Changelog
All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.4.1 - 2026-10-22
Changed
DUHO_TRACEBACKis read with duho's own truthy values:0,false,no,offand an empty value no longer print a traceback on a reported error, where any non-empty value did. The status stays 2.- A command module's
rungets the engine (run(netboot, args, conf)) when duho'saccepts_positionalsays it takes three positional parameters. That differs from before only for arunwhose signature cannot be read (a builtin or other C callable), which is now dispatched as a plainrun(args), and for afunctools.wrapswrapper, whose own signature is read rather than the wrapped function's.
0.4.0 - 2026-10-19
Added
pixie render <target> NAME... [--out DIR]renders a target's artifacts to standard output or a directory, arming nothing.
Changed
- Configuration is loaded by
anycfg, the new name of yaconfiglib: netboot depends onanycfg>=0.14.0,<0.15andtplx>=0.2.0,<0.3instead ofyaconfiglibandjinja2, andnetboot[config]installsanycfg[yaml]. Includes are confined byANYCFG_CONFINE_TO;YACONFIGLIB_CONFINE_TOis not read, so rename it where you set it. A malformed file is still a one-line error, now namingfile:line:col.netboot.utils.config.Namespaceand the config classes build onanycfg.TypedNamespace/OpaqueMerge. - Every public module declares
__all__;netboot.utilsexportsNamespacetoo.PixieTarget,PixieImageandPixieContextare defined in private modules and imported, as before, fromnetbootornetboot.engine. netboot.dhcp.DhcpZoneisDHCPZone(also importable fromnetboot), as every other public name spells DHCP; rename the import.Pixie.lookup_imagereturnsNonewhen nothing matches, aslookup_targetandlookup_dhcpzonedo, rather than{}; aFoundTargetImagehook seesNonetoo.- The command line is
netboot.cli, one duho class per command (Initiate,Complete,Render,DhcpConfig); the console script isnetboot.cli:main, andnetboot.mainre-exportsmain,PixieArgs,Pixie_andparse_path.main(argv=None, *, name=None):argvis the first parameter, somain(["--version"])works; passname=by keyword.netboot.cmdsis gone:netboot.cmds.dhcp_config.recipeisnetboot.dhcp.condition_recipe,conditions_forisbuild_conditions, and a--cmdspathplugin withrun(netboot, args, conf)works as before. pixieno longer imports apixie_envsettings module fromsys.path, which underpython -m netbootmeant the working directory: running it in a directory runs no code from there. ExportPIXIE_*variables instead, or name a module with--load-module.- Building the engine does no DNS. A hostname-only target's
ipis looked up byPixieTarget.resolve_ip(), which the engine calls for the target a command acts on (and for every hostname-only target when an IP query matches no configured address), so an unresolvable target no longer slows every command. APixieTargetbuilt directly hasip == ""untilresolve_ip()is called. initialize(target, **options)andcomplete(target, **options)handoptionsto their Start/End hooks as kwargs:initiate --iscsinow reaches them askwargs["iscsi"].- A DHCP server's credentials are its own config entry's. A
dhcpserversentry is a URI or a mapping ofuri:and that backend's secrets (user,password; dhcpdkeyname,omapi_key), written in the config or taken from the environment there with{{ env.NAME }}, which config files can now use.PIXIE_WINDHCP_PASSWORD,PIXIE_WINDHCP_USERandPIXIE_DHCPD_OMAPI_KEYare no longer read, andnetboot.dhcp.WINDHCP_PASSWORD_ENV_VAR,WINDHCP_USER_ENV_VARandDHCPD_OMAPI_KEY_ENV_VARare gone: move each value into the entries of the servers that use it (password: "{{ env.PIXIE_WINDHCP_PASSWORD }}"keeps a variable you already set). An entry's secrets override a user or password in the URI.pixie dhcp-config --aspasses the prompted user and password to each server directly, overriding the URI's. - The errors are in
netboot.exceptions(PixieError,PixieConfigError,PixieLookupError), still re-exported fromnetboot. dhcp_options,dhcp_whenanddhcp_completeare read as what a target is served, a pydhcpDHCPLease:next-serverandboot-file-nameare its BOOTP header fieldssiaddrandfile, as they always reached the server, andfile,snameandsiaddrmay be written as such.bootfile-nameis option 67 itself and is now sent as an option.build_next_serveris replaced bybuild_lease(ctx, server). A Kea client class fromdhcp_whencarries the boot file as itsboot-file-namefield, not as option 67.- An option with no type of its own (
option-224, unlessdhcp.optionsdeclares one) takes its octets as hex text or{hex: ...}indhcp_options, as pydhcp'sDHCPOptions.from_mappingreads them. - The DHCP backends are
dhcpctl's. netboot depends onpydhcpctl>=0.1.0,<0.2(imported asdhcpctl), andnetboot.dhcpis a module that builds each target's reservation for it:DhcpZone,connect(uri),build_options,build_conditions,build_completion,reservation_for(ctx, server),armanddisarm. What a server is sent is unchanged, byte for byte, including the names netboot writes on it (# >>> netbootregions,netboot-<mac>Windows policies). Removed, with what to do: DhcpServerand its scheme-by-class-name dispatch: subclassdhcpctl.DHCPServerand calldhcpctl.register_backend(scheme, cls)from the--load-moduleplugin, or declare it in thedhcpctl.backendsentry point. A backend implementsadd(reservation)/remove(reservation)(wereadd_target(ctx)/remove_target(ctx)),keep(reservation)(waskeep_target(ctx, completion)), andextras(reservation, phase)(wasextras(ctx, phase)); the netboot context isreservation.context.netboot.dhcp.dnsmasq/.kea/.dhcpd/.windhcp: the backends aredhcpctl.backends.DnsmasqBackend,KeaBackend,DHCPDBackendandWinDHCPBackend; to override one, register the subclass withreplace=True.netboot.dhcp.options,.conditionsand.completion:GENERIC_OPTIONS,APPLY_TIME,DhcpOptions,split_query,DhcpCondition,MATCH_KEYS,DhcpCompletionandBOOT_OPTIONSare dhcpctl's (DHCPOptions,Condition,Completion, ...);build_options,build_conditions,build_completionandzone_defaultsare innetboot.dhcp.ConditionMissing/ConditionUnsupportedaredhcpctl.ConditionMissingError/ConditionUnsupportedError.windhcp.PASSWORD_ENV_VAR/USER_ENV_VARarenetboot.dhcp.WINDHCP_PASSWORD_ENV_VAR/WINDHCP_USER_ENV_VAR, anddhcpd.KEY_ENV_VARisDHCPD_OMAPI_KEY_ENV_VAR; the variables are the samePIXIE_*ones, now read only when the URI has no user or password of its own:windhcp://admin:pw@hostanddhcpd://keyname:key@hostwork. Logs, errors andpixie dhcp-configshow a server's URI with the password masked.netboot.DhcpServeris no longer exported. Thekea,dhcpd,winrmandsshextras install dhcpctl's of the same name. An unknown scheme, or a URI a backend cannot use, is aPixieConfigError; a server refusing is adhcpctl.ServerError, reported by the CLI as one line with status 2.PixieEvent.BuildDhcpOptionsnow fires once per server for every backend, including one that never asks for its options.- DHCP options are pydhcp's, through dhcpctl. Every IANA option is named by
its pydhcp name (
dns,bootfile-name,interface-mtu, ...) oroption-<n>, and the names netboot already took still work (LEGACY_OPTION_NAMES). A config declares more in a new top-leveldhcp.optionssection: an option of its own with a code and a pydhcp type, or an alias of a name or a code;dhcp_options,dhcp_whenanddhcp_completeall read them. Each value is read by the option's type when the config is applied, sorouter: gatewayis aPixieConfigErrornaming the image or target; a list option takes one address per item, so write several as a YAML list or a repeated URI key, not one comma-joined string. Kea is sent options by code, so a zone with a gateway or a lease time no longer sends Kea option names it does not define (router,lease-time). What to do in aBuildDhcpOptionshook: it receives pydhcp'sDHCPOptions, keyed by code, so writeoptions[DHCPOptionCode.ROUTER] = ...oroptions[3]where it wroteoptions["router"], or return a mapping of option names.next-serveris no longer an option a hook sees: it is the reservation's BOOTP field, still set fromdhcp_options: {next-server: ...}. - A server URI says only how to reach the server. Its query takes the
backend's connection settings and nothing else: DHCP options
(
?router=...),options_builder=andraw.<backend>=text are unknown-key errors. What to do: move options to the zone's settings or an image's or target'sdhcp_options; replace anoptions_builderwith a hook onPixieEvent.BuildDhcpOptions, which receives the same options plus the target and the server; for the iPXE chainload usedhcp_when:, and for other backend-native text register a backend subclass whoseextras(reservation, phase)returns it. - Template roots resolve against the config file's directory. Through the
CLI, a relative
templates:root is joined to the directory of the config file, as!includeis, and with notemplates:the root istemplatesbeside the config file (./config/templatesby default). The working directory's./templatesis no longer prepended. What to do: a config that relied on./templatesnames it, relative to the config file (the example config now saystemplates: ../templates). - The path layer is
pathapianduripath, notpathlib-next. netboot depends onpathapi>=0.1.0,<0.2andpathapi-uri>=0.1.0,<0.2(import packageuripath) and no longer installspathlib-nextoruritools. The paths netboot returns are their classes:Repository.service(),.get()and.localareuripath.URIPath(werepathlib_next.uri.UriPath),parse_path()returnspathapi.LocalPathorURIPath, and a template'sPathandUriglobals arepathapi.Pathanduripath.URIPath. What to do: import them from there in a command module, hook or template that usedpathlib_next; apathlib_nextpath handed to netboot is not aPath. The URLs netboot builds are the same, buturipathdiffers in these ways a hook or template can see: - A join keeps the query and fragment of the service URI it starts from
(
http://h/boot?sig=1joined withaishttp://h/boot/a?sig=1); it dropped both. Queryis no longer astr, andSource.from_str()/as_str()areSource.parse()/str().file:///xandfile:/xare different URIs. netboot still writes the service of.localasfile:/srv/tftp.yaconfiglibfloor moves to>=0.13.1,<0.14, the first release built onpathapi; 0.13.0 needspathlib-next. Configuration loading,!includeglobs and merging behave as before.- The
httpextra is removed.http/httpsrepository services are sent on the standard library byuripath, so they need no extra andRepository.service()no longer raises anImportErrornamingnetboot[http]. What to do: drop[http]from the install line (pip warns about the unknown extra and installs the rest).netboot[kea]still installsrequests; installpathapi-requestsyourself only to sendhttp/httpsservices through arequests.Session. - The
sshextra issftppath[uri,paramiko]>=0.1.0,<0.2(waspathlib_next[sftp]): remotednsmasq://paths aresftp://paths ofsftppath, over paramiko.SFTPPATH_BACKENDreplacesPATHLIB_NEXT_SFTP_BACKENDto choose paramiko or asyncssh; the old name is not read. duhofloor moves to>=0.7.5,<0.8(pre-1.0 minor, documented API break; 0.7.4 adds the unsorted multi-source command discovery that--cmdspathnow uses, 0.7.5 theexit="parse"mode ofduho.run). Thepixiecommand line is unchanged: same flags, exit statuses and output, with one exception in--help: the commands of one--cmdspathdirectory are listed by file name, then in the order a file defines them, where they were sorted by command name. The two differ only for a file that defines several commands or is named differently from its command.pixieruns throughduho.run(..., exit="parse")and reports operator errors through duho's_errors_/_report_error_: the same one-line log record and the same status 2 as before. New with it: Ctrl-C ends with status 130 and a broken pipe with status 1, both without a traceback; a plainrun(args)command's returned value (notNoneor anint) is printed to standard output, where it was handed tosys.exit; withDUHO_TRACEBACKset an operator error also prints its traceback to standard error, and the status stays 2.--help,--versionand a usage error still raiseSystemExitfrommain, as before.netimpsmoves to>=0.4.0,<0.5(pre-1.0 minor, documented API break). The network values netboot hands out are netimps', so its renames reach templates:target.mac.as_str("-")is nowtarget.mac.format("-").- Templates are rendered by
tplx(tplx[jinja]>=0.1.0,<0.2), with the same engines, suffixes, search order andtemplates_undefinedmodes; themako,liquid,handlebarsandmustacheextras install tplx's. The same templates render the same, with these differences: - An include goes through the per-target names and the image's
template_path, and a file of another engine is rendered by that engine and inserted: a Jinja{% include "net" %}ofnet.shtplworks, where it failed withAttributeError. mako, Liquid, Handlebars and Mustache includes and partials search the template roots too. - Every engine sees each context field at the top level (
target,image, ...) besidectx; Jinja and mako saw onlyctx. - A MAC address in a Liquid, Handlebars or Mustache template renders as
aa:bb:cc:dd:ee:ff, where it rendered asDataView(MACAddress(...)). - An
.epptemplate's output no longer gains a trailing newline the template did not write. - The shell engine leaves out
_-prefixed context names other than_id. - Errors are tplx's:
tplx.TemplateNotFound(wasjinja2.TemplateNotFound),tplx.UndefinedError(wasjinja2.UndefinedError, orKeyErrorfrom the shell engine),tplx.NoEngineError,tplx.EngineUnavailableErrorandtplx.RenderError(wasnetboot.templates.TemplateEngineError, or anImportErrornamingnetboot[mako]).pixiereports everytplx.TplxErroras one line and exits 2. -
PIXIE_RUBYandPIXIE_PUPPETare nowTPLX_RUBYandTPLX_PUPPET. -
shell_quoteisshlex.quote: a value with nothing special to the shell is no longer quoted ({{ shell_quote("web01") }}rendersweb01, was'web01'), and an embedded'is written'"'"'(was'\''). Both read back as the same word in a POSIX shell. Thequote=argument and its double-quote form are gone.
Removed
netboot.utils.flatten,netboot.utils.arr_getand thenetboot.utils.dictsmodule;shell_quoteandTare innetboot.utils.miscand still importable fromnetboot.utils.netboot.templates: usetplx(Loader, the engine classes,register_engineforregister_template_type,unregister_engine,registered_enginesforTEMPLATE_TYPES,DataEncoderfortemplate_data/jsonable). A plugin engine subclassestplx.Templateand implementsrender_values(values), wherevaluesholdsctxand the context fields.PixieContext._rendereris gone; the context'stplx.Loaderis_loader.netboot.utils.net,netboot.netutilsand the network namesnetboot.utilsre-exported (Host,IPAddress,IPInterface,IPNetwork,MACAddress,parse,try_parse,is_valid,is_wildcard,resolve,ping,get_interfaces,iter_addresses,normalize_host): import them fromnetimps. netboot'sHostsubclass goes with them, and with it.try_ip()(usehost.ip() or str(host)),.address(use.value, and build a newHostrather than assigning) andHost()with no argument (Host(None)).Repository.addressis anetimps.Host. A test that faked DNS throughnetboot.netutils.resolvepatchesnetimps.resolve.
Fixed
- A configuration mistake is one line and exit 2, not a traceback: a MAC,
address or network that does not parse (named
targets.<id>,dhcpzones.<id>), a collection that is not a mapping, a key that is not text, a--load-modulethat does not import, and a zone whose only DHCP servers could not be reached (no DHCP server armed ...). A configuredipthat is not an address is now that error instead of being dropped. - A numeric MAC is refused. YAML reads an unquoted all-digit MAC
(
mac: 52:54:00:12:34:56, or such a target id) as a base-60 number, which netboot armed as a different MAC; quote it. repo / "sub"keeps a service's query string last (http://h/boot/sub/a?sig=1, not.../a?sig=1/sub), and leaveslocalunset when the repository had none instead of making itfile:sub.- A full-URI repository service no longer resolves the repository's address (nor warns that it has none); a relative one resolves it once, through netimps' shared cache, rather than on every URL.
templates: dirgiven toPixiedirectly is one root, not one per character, andparse_pathreads every RFC 3986 scheme (svn+ssh://).pixie dhcp-configwith no target warns about a target whose zone cannot be found and covers the rest, where it stopped at the first; a recipe several targets share is printed once.--ason Windows with standard input fromNULfails at once instead of waiting on a prompt.- A global named like any member of the render context (
version,render,searchpaths, ...) is dropped with a warning, astargetandimagealready were, instead of replacing it or breaking the context. - A zone with no
dhcpserverswarns that nothing was armed. hooks: Nonemeans no hooks;ctx.resource(Resource)for a resource with nopathisNone.- A configuration key nothing reads (
targetz:, a zone'sgatway:) is logged as a warning naming it. Data for templates belongs underglobals; a key starting with_is never warned about. - With
DUHO_TRACEBACKset, a configuration error prints its traceback to standard error again: duho 0.7.6 leaves that to netboot, which now requiresduho>=0.7.6. dnsmasq://writes reservation and option files dnsmasq can read: each line is the value of--dhcp-host/--dhcp-optionwithout the key, which dnsmasq rejected before, so no reservation took effect. A dnsmasq extras fragment is written the same way (tag:web01,252,http://x).dnsmasq://in directory mode stops serving a completed target: dnsmasq keeps what--dhcp-hostsdirheld until SIGHUP, so completion now signalspidfile=(default/run/dnsmasq/dnsmasq.pid), or needsreload=when the dnsmasq is remote. A dnsmasq target served options with nooptsfile=is refused rather than armed without its boot file.dhcpd://keeps a completed target (the host is deleted and created again, as dhcpd refuses to overwrite one), serves aconditions=grouptarget its conditional boot file (the host's own no longer outranks the group's), and writes an option dhcpd has no name for in a form dhcpd parses.kea://keeps a completed target withreservation-update(or a delete and add), where it sent areservation-addKea refuses for an existing reservation, and finds a zone's subnet inside a Keashared-networksentry.windhcp://completing a target that is removed also removes its own policies (netboot-<mac>-<condition>,netboot-<mac>), which went on serving its boot file;conditions=sharedleaves an existing policy untouched;pixie dhcp-configprints the per-target policies target mode creates, every value quoted;method=netshkeeps a reservation through netsh and no longer fails its read-back for an option a condition serves.pixie initiateon a target that is already armed replaces its reservation on every DHCP server; dhcpd and Kea refused, and the Windows cmdlet threw.kea://conditions are a client class of the target's own, so an iPXE client is served the chainload and any other client the reservation's boot file; on a real Kea both were handed the same one.dhcp-configprints the two classes per target (<name>-<mac>andnetboot-<mac>). A Kea with no hosts database is armed with?target=memory.- Every DHCP option reaches dnsmasq, dhcpd and Kea in a form it reads: dhcpd refused the host's statements for most options netboot did not name, and dnsmasq read flags, number lists and octets as text.
windhcp://and a remotednsmasq://reload run through hostctl. ssh is asyncssh rather than the systemsshclient (it reads~/.ssh/configassshdoes), sowindhcp://over ssh now needsnetboot[ssh];?timeout=bounds one Windows script (default 120 s).windhcp://over its local and ssh transports runs the whole script and fails when any of it fails. It ran only the first line of each multi-line block and reported success, so a reservation could be reported armed and not exist.
0.3.2 - 2026-10-03
Added
pixie dhcp-config [target] [--apply] [--as USER]— prints the server-side configuration a config needs but netboot may not be able to create: the Windows policy and its user class, adhcpd.confgroup, a Keaclient-classesentry. Printing contacts nothing, so the output goes straight into a change ticket;--applyruns it and--asprompts for an admin password. It is the only command that prompts, becauseinitiateandcompleterun unattended and a prompt there would hang a pipeline. Run once per condition; routine arming then needs none of those rights.windhcp://takestransport=local— run PowerShell on this machine, withserver=pointing the cmdlets at the DHCP server, which is the ordinary RSAT shape and needs no sshd, WinRM or credential. An empty host (windhcp:///?server=dhcp01) defaults to it; a named host still uses a transport. The account for WinRM can now come from$PIXIE_WINDHCP_USERwhen the URI names none.dhcp_complete:— what a finished target's DHCP entry becomes. A machine that still receivesboot-file-nameafter its install boots the installer again;pixie completeremoved the reservation to prevent that, which also threw away the fixed address. Nowkeep: trueleaves the address and drops the boot options (firmware falls through to local disk), andoptions:serves something else instead — an iPXEsanbootscript, for firmware with no local-disk fallback. Router, DNS and subnet mask are never touched, and a kept entry also loses itsdhcp_whenmembership so the condition cannot keep serving it the installer. The default is unchanged. All four backends implement it; verified against a real Windows Server 2025 for each of the three outcomes.dhcp_when:— conditional options, declared once and translated per backend. A mapping keyed by name, where the name is the construct each server uses: a Windows scope policy, a dhcpd group or inlineif, a Kea client class. It layers zone → image → target by name, so two targets share one policy and a target can replace an image's condition.matchtakesuser-classandvendor-class;optionstakes the usual option names. This is the iPXE chainload said once:undionly.kpxefor a PXE ROM,boot.ipxefor iPXE.
On Windows a condition becomes two policies for that target — one matched on
its MAC plus the test, one on the MAC alone — and the overridden option is
kept off the reservation, because a reservation's options outrank every
policy and would otherwise make the condition dead while the configuration
looked right. conditions=shared gives the other shape: one policy per
condition for the whole scope, which an administrator can create once on a host
where netboot cannot create policies at all.
A backend that cannot create the construct says exactly what to run
(Add-DhcpServerv4Class/Add-DhcpServerv4Policy, a dhcpd.conf group, the
Kea client-classes JSON), and one that cannot express conditions at all
(dnsmasq://, because the tag needs a dhcp-match line in config netboot does
not own) refuses rather than arming a target whose chainload would never fire.
Either way the zone's other servers are armed, since arming is best effort.
- DhcpServer.ensure_condition() / .condition_recipe() /
.remove_condition_member() — the contract above, for a plugin backend.
Changed
netimps>=0.3.3(was>=0.3.1), and three things netboot hand-rolled are now the library's. No behaviour netboot documented changes, except where noted:- repository addresses are split by
normalize_host, so a malformed one (mirror.example:http,mirror.example:) now raisesPixieConfigErrornaming the repo instead of being treated as a hostname and handed to DNS as one.[v6]:portstill produceshttp://[2001:db8::1]:8080/...— the host comes back unbracketed andpathlib_nextbrackets it itself. - "the target has no address" is
is_wildcard, which also coversNoneand a%zonesuffix (::%eth0); the check it replaced knew0.0.0.0and::only, so a scoped unspecified address produced a template candidate named after it. - the
windhcp://netsh client id isMACAddress.hex()rather than formatting with separators and stripping them again. netboot.utils.net.Hostis now netimps'Host, subclassed rather than reimplemented. The local class claimedHostwas "a netboot concept"; netimps has had the same type since 0.2.0, written for the same reason, and this module's stated principle is to use the library's vocabulary. Nothing is removed:.try_ip(),.address(readable and writable) andHost()with no argument all behave as before, so this is not an API break.
What you gain for free: .ip() caches its result, failures included, so a
repo asked for several services resolves once where the old class resolved
every time (refresh=True retries). Also inherited: .value (the same text as
.address), .is_address (no DNS), and, on netimps 0.3.3, .fqdn.
Two behaviour differences worth knowing, neither of them a documented contract:
Host("a") == "a" is now True with matching hashes, where the old class
returned NotImplemented; and faking resolution in a test means patching
netimps.get_ip, which .ip() imports at call time, rather than netboot's
own resolve re-export.
0.3.1 - 2026-10-02
Fixed
windhcp://could not set a multi-valued option (two name servers, two NTP servers) with its default PowerShell method, and the failure took the rest of the reservation with it. The value was sent comma-joined, andSet-DhcpServerv4OptionValue -ValuetakesString[]: Windows answered "Parameters for option value to be set for option ID 6 do not match with option definition" and, underErrorActionPreference = 'Stop', every option after it was abandoned — so the target got a router and no boot file. Values are now sent as a list. Present since the backend shipped in 0.2.0; found by running it against a real Windows Server 2025 DHCP server rather than reading the script.
Added
DhcpServer.extras(ctx, phase)— one documented extension point for backend-native fragments netboot does not model: an extra statement, an extra command, or a condition such as the iPXE chainload (serve the iPXE binary to a PXE ROM, the script to iPXE). It defaults to the configuredraw.<backend>fragments, and a subclass overrides it to decide per target. All four shipped backends consume it, andraw.<backend>.remove=is new: a fragment that belongs to teardown rather than to arming. Forwindhcp://a fragment may be a PowerShell line or a{"Args": [...], "Ignore": bool}netsh command, which works under eithermethod.method=netshverifies what the server actually applied. netsh exits 0 and prints its success line even when it rejected what it was told — measured: adomain-name-serversoption with one unreachable address keeps the other, says "not a valid DNS Server", says "Command completed successfully", exits 0. So after applying, netboot reads the state back withnetsh ... dump(the one netsh output that is command syntax rather than localised prose) and fails naming any option that is missing or short of values. The cmdlet method needs none of this: Windows raises there by itself.windhcp://takesmethod=powershell|netsh. The default is unchanged (theDhcpServercmdlets);method=netshdrivesnetsh dhcp server ...instead, for a host where that PowerShell module is not installed. It is independent oftransport=, and netsh is invoked from PowerShell with each argument an array element, so no value is spliced into a command line. Success is$LASTEXITCODErather than netsh's localised success line, a removal tolerates a reservation that is not there, option data types are declared per option (unmapped ones asSTRING), and a multi-valued option becomes one argument per value.
0.3.0 - 2026-10-02
Changed
- The shell template engine now claims the
.shtplsuffix only (it claimed every suffix before, as the documented fallback). The suffix picks the engine and the stem names the artifact, which is the conventioninstall.ks.j2already used: renameboot.cfgtoboot.cfg.shtplandctx.render("boot.cfg")keeps working unchanged. A file no engine claims is copied as is by the newCopyTemplate, last in the defaulttemplate_types— so a staticgrub.cfgneeds no engine, and a file that does carry%{NAME}placeholders ships them unrendered until it is renamed. That one case logs a warning naming the rename. Narrowingtemplate_typesso nothing claims a file raisesnetboot.templates.TemplateEngineError, which names the file and every engine's suffixes.
Added
- Seven more template engines, each claiming its own suffix and costing
nothing when unused:
MakoTemplate(.mako,netboot[mako]),LiquidTemplate(.liquid,netboot[liquid]),HandlebarsTemplate(.hbs/.handlebars,netboot[handlebars]),MustacheTemplate(.mustache,netboot[mustache]), andERBTemplate(.erb) /EppTemplate(.epp), which render through the systemrubyandpuppet epp renderso an existing template behaves as its author tested it (PIXIE_RUBY/PIXIE_PUPPETname the program if it is not onPATH). Every engine is registered whether or not its dependency is present — a claimed file reports what is missing instead of being copied — and nothing is imported until a file claims it. What each engine does withtemplates_undefineddiffers by library and is tabulated in the configuration guide; handlebars and mustache cannot fail on an undefined name at all. template_data()/DataView/jsonable()— a lazy mapping view of the render context for engines that read data rather than evaluate Python ({{ target.hostname }}beside{{ ctx.target.hostname }}), and its JSON-serialisable form for the subprocess engines.SubprocessTemplate— the base for an engine that renders through another language's own tooling: program lookup with an env override, a temporary template and values file, a timeout, and the program's stderr in the error.ShellTemplateis configured by subclassing, through three class attributes:EXT(a string or a sequence, dots optional, case-insensitive;Noneor"*"claims any suffix),DELIMITER(default"%") andPATTERN—"braced"(default,%{NAME}),"unbraced"(%NAME) or"all".- POSIX default expressions in the braced form:
%{NAME:-fallback}substitutes the fallback when the value is unset or empty,%{NAME-fallback}only when it is unset. One layer of'/"quotes is stripped, the fallback is literal text, and a placeholder with a default never fails whatevertemplates_undefinedsays.:=,:?and:+are not implemented and stay literal text. CopyTemplate— the copy-as-is engine described above, working in bytes: the loader no longer decodes a file before knowing which engine claims it, so the copy is byte-exact for anything (CRLF, latin-1, a binary) andctx.render()returnsbytesfor such a file.EXTnarrows it to chosen suffixes;Template.BINARYis the flag any engine can set to be handed raw bytes. A non-UTF-8 file claimed by a text engine now raisesTemplateEngineErrornaming the file, where it used to fail inside the read.Loader.find_source()— the byte-returning counterpart ofget_source()(which keeps jinja2's text contract).- A template-engine registry:
register_template_type()(bare or as a decorator, with an optionalpriority=),unregister_template_type(),TEMPLATE_TYPESandby_priority().Loader(template_types=None)— the new default — uses every registered engine, so a--load-moduleplugin is picked up without rebuilding the list, and the list is snapshotted at construction. Template.PRIORITY, so engine selection does not depend on registration order: engines are consulted highest first,DEFAULT_PRIORITY(0) for an ordinary engine andFALLBACK_PRIORITY(-100) forCopyTemplate. A plugin is registered after the shipped engines, so without this every plugin would sit behind the catch-all and never see a file. Equal priorities keep the order they were given, so an explicittemplate_typeslist still means what it says.Template.EXTandJinjaTemplate.EXT, so suffix ownership is one declarative attribute per engine, plusnetboot.templates.template_extensions()to read it back normalised.
0.2.4 - 2026-09-28
Changed
duhofloor moves to>=0.6.0,<0.7(pre-1.0 minor, documented API break). None of that break reaches netboot:app()'s renamed internal dispatch dest, the stricter--loglevel, thePATHSEPenv var rename to<PREFIX>PATHSEP, and RunPath ordering are all surfaces netboot's CLI does not touch, so nothing changes besides the pin.
0.2.3 - 2026-09-23
Fixed
- One DHCP server being offline no longer fails
pixie initiateorpixie complete. Arming is meant to be best effort, but since 0.2.0 it stopped at the first failing server and rolled the others back.initiatenow tries every server in the zone, logs each failure as a warning, and fails only when no server could be armed.completelogs a failing server as a warning and still exits 0, so read the log if you need to know every server was disarmed.
0.2.2 - 2026-09-21
netboot moves to netimps 0.3.1 and yaconfiglib 0.13. If a plugin, hook or
template compares target.mac with text, read the MACAddress entry below.
Changed
- Dependencies move to their new series:
netimps>=0.3.1,<0.4andyaconfiglib>=0.13.0,<0.14. The old ceilings made netboot uninstallable beside either release. netimps 0.3.0 is skipped on purpose: it raised fromresolve()when no resolver was reachable, which would have failed every command at startup, and 0.3.1 answers[]again. netboot uses none of the yaconfiglib APIs 0.13.0 removed. MACAddressno longer equals astr(netimps 0.3.0, re-exported asnetboot.utils.net.MACAddress).target.mac == "aa:bb:cc:dd:ee:ff"is nowFalse— in a plugin, a hook or a Jinja template alike. Compare withMACAddress.try_parse(text) == target.mac. In exchange a MAC is found in a set or dict whichever spelling built it. netboot's own target lookup already compared parsed MACs and is unaffected. A MAC that mixes separators (00-11:22-33:44-55) is now rejected, andaa.bb.cc.dd.ee.ffis accepted.resolve()keeps trying backends after an empty answer, so a hostname target known only to the hosts file or NSS now gets its IP on Windows and macOS. A name that exists nowhere costs two or three lookups instead of one.
0.2.1 - 2026-09-17
netboot ships DHCP backends. Additive: nothing documented changed behaviour,
and a config that named no dhcpservers scheme netboot implements works as
before.
Fixed
- A repository
addressof the formhost:portno longer sends the whole string to DNS. The port is split off first, somirror.example:8080resolvesmirror.example— the old lookup could only fail, and did so at different speeds on different machines.
Added
netboot.dhcpis a package, and DHCP backends carry client options. Adhcpserversentry is still a plain URI string; its query string now holds the options the server should give the client (?router=10.0.0.1), alongside each backend's own connection settings.netboot.dhcp.optionsmodels them:GENERIC_OPTIONSfor the names netboot translates,option-<n>for anything else,raw.<backend>=for untranslated backend-native text, andoptions_builder=my.mod.fnplus the newPixieEvent.BuildDhcpOptionsfor building them from the context. Merge order, later winning: zone defaults, the connection query,image.dhcp_options,target.dhcp_options, the callback, the hook.- A dnsmasq backend (
dnsmasq://), the first netboot ships. It writesdhcp-host/dhcp-option/dhcp-bootentries throughpathlib_nextpaths, so the same configuration serves a local dnsmasq or one reached oversftp://(netboot[ssh]).hostsfile/optsfilemay be a directory (one file per target — preferred, and with--dhcp-hostsdir/--dhcp-optsdirdnsmasq re-reads them with no signal at all) or a file, where netboot edits only its own marked region and never touches a line it did not write.reload=runs a command, over ssh when the URI names a host; omitting it is fine for a watched directory and an error for a file, which dnsmasq never re-reads by itself. - A Kea backend (
kea://,keas://for https). It sendsreservation-addandreservation-delto the control agent, mappingboot-file-name,next-serverandhost-nameonto reservation fields and everything else ontooption-data. Thesubnet-idcomes from the zone (subnet_id:) or is discovered once by matching the zone's network againstconfig-get; when neither works netboot refuses rather than guessing, because a reservation in the wrong subnet silently never matches. Aresult: 2from Kea is reported with the likely cause (host_cmds not loaded, or a read-only hosts backend), and an existing reservation for the same MAC is refused rather than overwritten. Needsnetboot[kea]. - An ISC dhcpd backend (
dhcpd://) over OMAPI, the only way to give dhcpd a reservation without rewriting and reloading its config. Options are rendered into the host'sstatements, which is dhcpd config source: text values are quoted with"and\escaped, a value meant to be an address must look like one, and a newline is refused outright — a value cannot end its statement and start another. The OMAPI secret comes fromkeyfile=or$PIXIE_DHCPD_OMAPI_KEY, never from the URI. Needsnetboot[dhcpd]. Note that a host added over OMAPI does not survive a dhcpd restart by itself: that is dhcpd's design, not netboot forgetting it. - A Windows DHCP Server backend (
windhcp://), driving theDhcpServerPowerShell module over ssh (the default, no dependency — it uses the systemsshclient, so your config, keys, agent and jump hosts apply) or WinRM (netboot[winrm]). The transport host and the DHCP server are separate:server=becomes-ComputerNamewhen the cmdlets should act elsewhere. The scope is the zone's network address, resolved when the target is applied and overridable per zone. No value is interpolated into the script: parameters travel as a JSON payload PowerShell parses, so a target name or option value cannot become a statement. - New extras, each carrying exactly one backend's dependency:
netboot[kea],netboot[dhcpd],netboot[winrm], andnetboot[ssh]for dnsmasq oversftp://. A local dnsmasq andwindhcp://over ssh need none. Verified on a clean wheel install: importingnetboot.dhcploads no backend dependency, those two backends construct, and the other two raiseImportErrornaming their extra. - Anything that varies per target is refused in a connection string and resolved
when the target is applied instead:
subnet_id/scopebelong to the zone,boot-file-name/next-server/tftp-server-nameto the image or target. The error names where each belongs.from netboot.dhcp import DhcpServer, DhcpZoneis unchanged, and a backend module is imported only when a config names its scheme.
0.2.0 - 2026-09-17
A correctness and hardening release from a full review of the code base.
Several documented behaviours changed, so read Changed before upgrading:
target lookup, shell_quote, shell-template placeholders, relative
template_path resolution and undefined template variables all behave
differently on purpose.
Added
PixieError,PixieLookupErrorandPixieConfigError. Netboot's deliberate failures are now distinguishable from bugs: lookups that fail or are ambiguous raisePixieLookupError(still aLookupError) instead of a plainException, and the messages name the target and what was configured.netboot.logging.quiet_noisy_dependencies(insecure_warnings=False).netboot[http]extra.http/httpsrepository services reach the network throughrequests, which nothing installed:pathlib_next[uri]does not depend on it, soRepository.service("http")failed withModuleNotFoundError: No module named 'requests'on any install that did not happen to have it. Installnetboot[http]for http-served repos;fileandtftprepos, and rendering, need nothing extra. Without the extra those calls now raiseImportErrornaming it instead of failing inside the library.
Changed
- A template variable with no value now raises by default. The two engines
disagreed: the shell engine raised while Jinja rendered an empty string, so a
typo in a
.j2file shipped a boot artifact with a blank where a kernel path belonged, and the behaviour depended on the file's suffix. The new top-level config keytemplates_undefineddecides, and means the same in both engines:strict(default) raises naming the variable,lenientrenders an empty string with a warning — the old Jinja behaviour — anddebugleaves the placeholder in the output. Settemplates_undefined: lenientto keep templates that relied on blanks working.
Security
- Config loading is hardened: the loader runs with
allow_commands=Falseandsandbox=True, so{{ ... }}interpolation in a config value renders in jinja2's sandbox and a config document can no longer run commands. Previously a value such as{{ ''.__class__.__mro__[1].__subclasses__() }}was evaluated unsandboxed — and a netboot config is often assembled by!includefrom inventory exports rather than written by hand. Ordinary interpolation is unchanged; setYACONFIGLIB_CONFINE_TOto also restrict where!includemay read from. shell_quotenow actually quotes. It wrapped values in"without escaping anything, so a value containing`,$,"or;was expanded or executed by the shell that read the generated script — a password with$in it was silently corrupted, and a value taken from an inventory could run commands. It now defaults to POSIX single-quoting (quote="'"), escaping an embedded'as'\'';quote='"'escapes",\,`and$, and any other quote character raisesValueError. It also returns astrfor astrinput instead of always a one-element list, so{{ shell_quote(v) }}renders the value rather than['"v"']. Templates that relied on indexing the result of a scalar call must drop the[0].
Fixed
- Shell-engine templates render kickstart files. The engine substituted both
%{NAME}and a bare%name, and treated an unknown bare one as an error, so any file containing%packages,%pre,%post,%endordate +%Yfailed to render at all. Only the documented braced form%{NAME}is substituted now; a bare%wordis literal text, including one that names a context variable.%%still yields a literal%and an unknown%{NAME}still raises. A template written with bare placeholders must brace them. - A
StartPixieInithook's return value is what gets built. The result was stored but the original config was read, so a hook written in the usual non-mutating style (return {**value, "targets": ...}) had its targets ignored while itstemplatestook effect — the config ended up half applied. A hook that returnsNoneforStartPixieInitorNewPixieObjectnow raisesPixieConfigErrornaming the contract instead of failing obscurely later. - A template name containing
..is refused. Names are resolved against every search root, so../../etc/passwdreached outside them. - Arming DHCP is all or nothing.
pxe_initstopped at the first backend that raised, leaving the target armed on the earlier ones — it could boot an installer from one server while another handed out its normal lease. The already-armed backends are now rolled back before the error propagates.pxe_completetakes the opposite approach: every backend is disarmed even if one fails, and the first error is raised afterwards, instead of leaving the rest armed. Pixie.completeno longer needs the target's image to exist. Retiring an image from the config used to make the machines that used it impossible to clean up, because building the context failed before DHCP could be disarmed.- A global named
target,image,dhcpzone,reposorresourcesno longer replaces the render context's own field of that name; it is dropped with a warning. - A zone is chosen by longest prefix, not declaration order: with
10.0.0.0/16and10.0.0.5/24both configured, a host in the /24 now gets the /24. - A
Noneconfig entry (targets: {host1:}— valid YAML meaning "all defaults") no longer crashes withAttributeError, and an entry that is neither a mapping nor an object raisesPixieConfigErrornaming it. lookup_imagetreats any falsymatchresult as "no match": amatchoverride returningNoneused to crash the sort.DhcpZoneno longer normalises the caller's lists in place (they may be shared through a config merge), drops an unparseable nameserver with a warning instead of turning it intoNone, andget_local_serveraccepts the strings config actually holds rather than raising on them. Two plugins claiming one URI scheme now log a warning naming both.- A
Pixiesubclass's annotated attribute keeps its class default when the config does not mention it (it was overwritten withNone), and anOptional[...]annotation no longer crashes construction — its origin is not a constructor, and on 3.9 it is not even a class. - The
_idretry when building a config object only catches "this class takes no_id"; any otherTypeErrorfrom the value class propagates instead of being hidden behind a second construction attempt. Pixie.globalsis really the deep copy the docs promised. The copy made in__init__was immediately overwritten by the annotated-attribute loop, so nested values stayed shared with the caller's config dict (mutatingengine.globals["a"]["b"]changed the caller's mapping) and any global whose key started with_was silently dropped. Both are fixed:_-prefixed globals are ordinary variable names and are kept.Pixie.lookup_dhcpzonereturnsNoneinstead of raisingAttributeErrorwhen the target has no usable IP — the documented MAC-keyed target shape hit this on everyinitiatethat did not name adhcpzone.- Repository URLs are built correctly from an
addressthat carries a port:mirror.example:8080became a hostname with an escaped colon (mirror.example%3A8080) instead of a host and a port.[2001:db8::1]:8080works too, and a repo with no address warns instead of silently producing a hostlesshttp:/path. - An
httpsservice keeps the configured name rather than substituting the resolved IP, which broke certificate validation and name-based virtual hosts. Other schemes still resolve, since a PXE client often has no DNS yet. repo / "/sub"extends the repository root instead of replacing it, and a trailing slash no longer doubles up.- A command module whose body is
main(netboot, args, conf)gets netboot's contract. Dispatch looked only atmodule.run, while duho resolvesmain→run→call, so such a command was handed to duho's single-argument dispatch and failed on the signature. - Two context values that flatten to the same shell-template variable (a global
target_ipbesidetarget.ip, ordomainbesideDOMAIN) now log a warning naming both; the later one still wins, but silently building an artifact from the wrong value is what this prevents. - A relative
template_pathis resolved inside the template roots (config["templates"],./templatesby default) rather than against the process's working directory, so a config means the same thing whichever directorypixieruns from. An image's or target'stemplate_pathstill extends the roots, which are searched last; absolute paths and URIs are used as given. A config that wrotetemplate_path: [templates/debian]to reach./templates/debianshould now write[debian]. - Rendered artifacts keep the template's final newline. The Jinja engine dropped it while the shell engine kept it, so the same content rendered differently depending on the file's suffix, and a kickstart or iPXE script could end without a newline.
- MAC-less targets no longer all look for
00-00-00-00-00-00.<name>first: the null MAC is skipped when building candidate template names, as the unset IP already was. One stray file of that name used to apply to every such target. - A
http://...entry intemplatesor in an image'stemplate_pathstays a URI instead of being turned into a directory namedhttp:under the working directory, so URI search paths are searched at all. A repository'slocalpath accepts a Windows drive path (C:\\tftp), which was previously read as URI schemecand then failed on every read. - An edited shell template is picked up again: the loader assigned its freshness
check function to
is_up_to_date, and a function object is always truthy, so a cached template was never reloaded. Loaderworks without aPixieContextin the environment: it resolves a plain template name instead of raisingAttributeError.- Template selection is deterministic. Within a search directory an exact
filename now wins, and several files sharing a stem (
bootmatchingboot.j2,boot.sh,boot.j2.bak) resolve lowest-name-first instead of in whatever order the filesystem listed them — a leftover.bak/.orig/.rpmnewcopy could previously be rendered instead of the real template. - Templates are read as UTF-8 rather than the machine's locale encoding, so the
same template tree renders identically on every host (on Windows a non-ASCII
template could raise
UnicodeDecodeErroror decode wrongly). - An image with no
template_pathrenders instead of raisingAttributeErrorfromPixieContext.searchpaths; it now defaults to[]as targets already did. - A target whose hostname does not resolve no longer hangs the process.
PixieTargetconstruction retried the same DNS lookup in an endless loop while the name stayed unresolved; because every target is built at startup, one not-yet-in-DNS entry made everypixiecommand hang and flood the resolver. Resolution is now a single lookup: it fillsipon success, and on failure logs a warning and leavesipunset. A malformed hostname is warned about instead of aborting construction.
Changed
Pixie.lookup_targetmatches exactly before it matches by prefix. It previously returned the first target whose hostname started with the query, so with targetsnode10andnode1in that order,pixie initiate node1acted onnode10. Exact id, hostname, MAC and IP matches across the whole table now win; a hostname prefix is the fallback, a query matching more than one target raisesLookupError(the CLI reports it and exits 1) instead of choosing one, and an empty query matches nothing rather than the first entry. MAC queries are parsed, soAA-BB-CC-00-00-01andaabb.cc00.0001now match a target keyedaa:bb:cc:00:00:01; previously only the colon spelling did.pathlib_next[uri]floor raised to>=0.9.9. Measured: recursive!includeglobs (sub/**/*.yaml) fail on pathlib_next 0.9.5+ unless yaconfiglib is 0.12.0+, and yaconfiglib 0.12.0 itself requires pathlib-next>=0.9.9 — so the old>=0.9.0claim described a combination that cannot work.tests/test_config_discovery.pynow fails below the floor instead of passing quietly.yaconfiglibmoves to the 0.12 series (>=0.12.0,<0.13). The APIs netboot uses are unchanged; the previous<0.12ceiling made netboot uninstallable alongside yaconfiglib 0.12.- The package logger is named
netboot, notNETBOOT, and-v/-qnow reach it: duho sets the level of the logger named after the parser, which never matched the one netboot writes to, so the verbosity flags had no effect on netboot's own output. Anything filtering on the old name must usenetboot. - Importing
netbootno longer has logging side effects. It used to quiet urllib3/paramiko and disable urllib3'sInsecureRequestWarningprocess-wide — a decision belonging to the application, not to a library, and it imported urllib3 as a side effect ofimport netboot. ThepixieCLI callsquiet_noisy_dependencies(); embedders opt in. - The CLI reports operator errors as one line and exits 2 instead of printing a
traceback: a missing or malformed config, a non-mapping config, an unknown
image or zone, or a sandbox refusal.
-vstill shows the traceback, and an unexpected exception is still raised in full.--versionand usage errors saypixierather than the class namePixie_. - An absolute Windows path is accepted for
--config/--baseconfig/template paths.C:\\srv\\tftpwas parsed as URI schemec, which then failed withNotImplementedError; a single-letter scheme is now read as a drive letter. - An empty or comment-only config loads as
{}rather than raisingAttributeError, a scalartemplates:is accepted as a one-element list, and a command returning a non-int value is warned about and treated as success rather than crashing after the command's work is done. - License metadata is PEP 639:
license = "MIT"pluslicense-files, and the legacyLicense :: OSI Approved :: MIT Licenseclassifier is gone (building now needshatchling>=1.27). The wheel carries the licence atnetboot-<version>.dist-info/licenses/LICENSE. - Packaging excludes
*.local.*from both sdist and wheel. A personal override such aspixie.local.yamlorAGENTS.local.mdpreviously shipped, because a dotfile pattern does not match a name that has no leading dot. - The engine moved to
netboot.engine, leaving the package root as a surface (44 lines).from netboot import Pixieand every other documented import are unchanged;netboot.engine.Pixieis the same object.netboot._versionholds the version lookup. netboot.utilsno longer re-exportsargparse.Namespaceasnetboot.utils.Namespace, where it read as netboot's own config base. The config base isnetboot.utils.config.Namespace, as documented.PixieContext.templatesis gone. It was an annotation only: nothing ever set it, so reading it raisedAttributeError. Search paths arePixieContext.searchpaths.jinja2is now>=3.0,<4, previously unpinned. jinja2 2.x importsmarkupsafe.soft_unicode, removed in MarkupSafe 2.1, so an unconstrained resolve could install a pair that raisesImportErroronimport jinja2.
Documentation
- The shipped API header (
src/netboot/AGENTS.md) is corrected where it did not match the code:hook'svalueis positional-only and every hook must return a value;Pathin a Jinja template ispathlib_next.Path, not the stdlib's;netboot.netutilsis an attribute, not an importable module path;PixieContext.resourcesstarts empty and is a hook's slot to fill; template search tries each candidate name across all search paths (not each path across all names); importingnetboot.loggingdoes import urllib3 and disables its insecure-request warning process-wide. docs/cli.mdno longer saysinitiaterenders artifacts or that--iscsiprepares an iSCSI LUN — both are hook extension points;--helpsaid the same and was corrected too. The--configrow explains that a.cfg/.inisuffix is parsed as INI, not YAML.docs/extending.mddocuments the hook contract: the positional signature, thedictfourth argument,netboot=NoneforNewPixieObject, the prefixedPixieEventstring values, and that a hook must return the value.docs/configuration.mdexample is runnable as written (the MAC-keyed target names its zone) and says that adnsmasq://backend has to be provided and imported. The shell-template section documents that only%{NAME}substitutes.- The public API is documented in the source, so the API Reference renders prose rather than bare signatures: 41 docstrings added, leaving only dunders undocumented (which the reference filters out anyway).
- The API Reference page covers the content, template, utility and CLI modules as well as the engine, and renders members that have no docstring; the site gains a Changelog page.
- README/docs install instructions name
netboot[config], which thepixieCLI needs to read a config file, and the README's LICENSE links are absolute so they resolve on PyPI. examples/holds a complete runnable setup — config, both template engines, and aDhcpServerplugin that prints instead of arming real DHCP — and a repo-rootAGENTS.mdcovers layout, environments, commands, CI and releasing for contributors. The README gains Development and Releasing sections.benchmarks/documents its metrics and schema and keeps results in the trackedbenchmarks/results/, written by a new--saveflag, so a before/after comparison survives in history. The metric that claimed to measure a worst-case target scan actually measured an indexed hit; it is now a pair,lookup_target_by_idandlookup_target_scan_by_ip.- CI: the release workflow runs a non-deploying docs gate and dispatches the
docs workflow for the tag (a release created with
GITHUB_TOKENstarts no workflow run, sorelease: publishedalone never deployed), publishing usesskip-existing, the test workflow drops to read-only permissions and also runs on Windows and macOS, and docs redeploy whensrc/,README.mdorCHANGELOG.mdchange.
0.1.3 - 2026-08-16
Dependency floor raise only — no library or CLI behaviour changes.
Changed
- The four internal dependencies are now pinned to the minor series they are
supported on, rather than floored at whatever patch happened to be current:
duho>=0.5.0,<0.6,netimps>=0.2.1,<0.3,pathlib_next[uri]>=0.9.0,<0.10andyaconfiglib>=0.11.1,<0.12. Each floor is the minor that netboot actually requires, and each ceiling stops the next pre-1.0 minor — where, by these projects' own versioning rule, the documented API is allowed to break — from being resolved unattended.jinja2is third-party and stays unpinned. duhomoves from>=0.4.1to the 0.5 series. 0.5.0 changed option parsing forlist/set/tuplefields to one value per flag occurrence, which is the shape--cmdspath(Arg[list[str], Extend(os.pathsep)]) is now written against. TheCMDS_PATHlayering that_discoverleans on after 0.1.2 stopped re-resolvingPIXIE_CMDS_PATHitself predates this at 0.4.1, so it needs no floor above0.5.0.netimpsmoves from>=0.2.1to>=0.2.1,<0.3— the floor stays above the.0on purpose.Host.try_ipcallsresolve()with nordtype, and auto-selection of that argument ("ptr"for an address literal,"a"otherwise) is what 0.2.1 added; on 0.2.0 the same call resolves differently.pathlib_next[uri]moves from>=0.8.2to the 0.9 series.Repository.service()constructspathlib_next.uri.Sourcedirectly fromstr(target.try_ip()), and that direct-construction path raisedsocket.gaierrorout ofSource.is_local()for a bare IPv6-literal host until 0.9.0 — the same release that stoppedSourcerendering its password instr()/repr(), so a service URI carrying credentials no longer leaks through a traceback frame. 0.9.0's one breaking change is toPathSyncer, which netboot does not use.yaconfiglibmoves from an unbounded>=0.10.0— which spanned two minor series — to>=0.11.1,<0.12. This floor is also above the.0on purpose:load_configbuildsConfigLoader(..., recursive=True), and that setting was never forwarded to glob expansion until 0.11.1, so on 0.10.x and 0.11.0 an argument netboot passes does nothing. 0.11.1 also madetyped_mergeread parametrized generics, which is whatRepository.services(dict[str, UriPath]) is annotated with.
0.1.2 - 2026-08-16
Added
- Test coverage for behaviour that was documented but unasserted: image
best-match ordering and its
{}fallback, DHCP-zone lookup by IP containment and its write-back onto the target, the hook chain (value threading, import strings, target substitution) and theinitialize/completeevent sequence, and repository/resource URI assembly withHost.try_ip. The suite goes from 27 tests to 71; no production behaviour changed. - Python 3.14 is advertised (trove classifier) and tested: the push matrix runs 3.9–3.14 and the release matrix's ceiling moves from 3.13 to 3.14.
Changed
- The
duhofloor is now>=0.4.1, the release that madeEnvconsultos.environbefore apixie_envcompanion module and turnedCMDS_PATHdiscovery into a layer merged on top of an explicitcommands=list. netboot now depends on both behaviours. PIXIE_CMDS_PATHis left to duho instead of being re-resolved by netboot, which had discovered the same modules a second time. One consequence is visible: on a name clashPIXIE_CMDS_PATHnow wins over--cmdspath, where before the option won.blackis the project formatter: it joins thedevextra with[tool.black] target-version = ["py39"](the supported floor), and the tree has been reformatted once to match.- The
netimpsfloor is now>=0.2.1.>=0.0.1predated the API netboot actually calls: the optional-dnspythonresolve()fallback chain (0.2.0), its auto-selectedrdtype(0.2.1), and the 0.2.1ping(src=...)NameErrorfix.
Documentation
docs/cli.mdno longer tells operators thatpixie_envmodule defaults outrank realPIXIE_*environment variables. duho 0.4.1 fixed that inversion; the note now states the true order (explicitenvvalues > realPIXIE_*> module defaults).- The shipped API header (
src/netboot/AGENTS.md) no longer tells consumers thednsextra is a no-op withdnspythonarriving transitively — false since netimps 0.2.0 madednspythonoptional.netboot[dns]is the only thing that installs the dnspython backend; without itresolve()falls back to netimps'system/nslookupbackends.resolve's documented defaultrdtypeis nowNone(auto-selects"ptr"for address literals,"a"otherwise). - README and the docs landing page no longer claim the IP/MAC/DNS helpers are
vendored in-tree as
netboot._netutils— that module was deleted whennetimpswas adopted; they now creditnetimpsand name thenetboot.utils.netre-export. - The
netboot[dns]extra row describes what it actually does since netimps 0.2.0: it installs thednspythonresolver backend, and hostname targets still resolve without it via the system/nslookupfallbacks. - Fixed the
pathlib_nextrepository link (pathlib-next, with a hyphen) and dropped the "once published" install note left on the docs landing page.
0.1.1 - 2026-07-21
Packaging/CI fixes only — no library or CLI behaviour changes.
Fixed
- Release runs no longer fail at GitHub Release creation: the release is pinned
to the tagged commit (
target_commitish) instead of defaulting to the repository's default branch, which broke note generation for a release object still pointing at a pre-rename branch. - The release workflow's docs job self-enables GitHub Pages (
enablement: truepluspages: write), matchingdocs.yml, so a docs-site problem no longer turns an otherwise-successful release red. - The docs-only workflow triggers on
main; it was listening onmaster, a branch this repo does not have, so it never ran on a docs change.
Documentation
- README: version/pythons/license/docs/CI badge row, and the install note names
the
pixiecommand instead of saying "once published". - README library example binds the engine to
pixierather thannetboot, which read as the package and left two calls referencing an undefined name.
0.1.0 - 2026-07-21
First packaged release: the netboot library with the pixie command line.
Added
- Packaged as
netboot(src layout, hatchling,pixieconsole script,py.typed). Python 3.9+. - PXE provisioning engine:
Pixiewith target/image/dhcpzone/repo lookup, a renderPixieContext, and aninitialize/completelifecycle. - Event-hook system (
PixieEvent,Pixie(hooks=...)) for customising lookup, context construction and the init/complete lifecycle. - Template rendering via a URI-aware Jinja2 loader plus a
%-delimited shell template engine, selecting sources by MAC / hostname / IP. - Pluggable
DhcpServerbackends dispatched by URI scheme, discovered recursively so plugin modules loaded via--load-moduleare honoured. pixieCLI built onduho(PXE is pronounced "pixie";netbootis the library/import package):initiateandcompletecommands with layered YAML config (config/pixie.yamlviayaconfiglib), command discovery, and--load-module/--cmdspath.- App settings read through
duho.env.Env("pixie"), soPIXIE_*variables (notablyPIXIE_CMDS_PATH) configure the CLI; the resolved accessor reaches commands asargs._env_. - Config objects use
yaconfiglib'sTypedNamespace(_parse_<field>coercers) andOpaqueMerge(last-object-wins) so fully-built targets/zones with factory-function field hints are merged as opaque values (requiresyaconfiglib>=0.10.0). - Two-workflow CI (
test,Release) and a MkDocs documentation site (docs/+mkdocs.yml, API reference via mkdocstrings), plus abenchmarks/bench_netboot.pymicro-benchmark for the lookup/template hot paths. - IP/MAC/DNS helpers vendored in-tree as
netboot._netutils; DNS lookup of hostname targets is the optionalnetboot[dns]extra (dnspython).
Changed
- Built on
duho(args/command-discovery/app) rather than the in-housecoquiliblayer; IP/MAC helpers are vendored in-tree; thesys.pathvendor/shim is gone. Version derives from installed package metadata.
Fixed
- Target resolution no longer no-ops when the id is an IP address (
self.ipself-assignment and aresolve - Truetypo). utils.flattennow produces a genuinely flat mapping and no longer raisesTypeErroron nested lists, so shell-template rendering works.DhcpServer(uri)raises a clear error for an unknown scheme instead of silently returning an inert base, and zonedhcpserversURIs are constructed into backends.make_contextno longer deletesglobalsoff shared image/dhcpzone/target objects, so a second target reusing an image keeps that image's globals._template_namesaccepts the loader's template**options; template names stringify the target IP and skip an unspecified address.- Command dispatch introspects each module's
runsignature, so a user command using duho's plainrun(args)is dispatched viaduho.run_command. - Shell templates render
Noneas empty instead of the literal"None"; config value construction no longer swallows non-TypeErrorerrors; repojoinpathkeeps.locala path so chained joins work.