Skip to content

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_TRACEBACK is read with duho's own truthy values: 0, false, no, off and 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 run gets the engine (run(netboot, args, conf)) when duho's accepts_positional says it takes three positional parameters. That differs from before only for a run whose signature cannot be read (a builtin or other C callable), which is now dispatched as a plain run(args), and for a functools.wraps wrapper, 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 on anycfg>=0.14.0,<0.15 and tplx>=0.2.0,<0.3 instead of yaconfiglib and jinja2, and netboot[config] installs anycfg[yaml]. Includes are confined by ANYCFG_CONFINE_TO; YACONFIGLIB_CONFINE_TO is not read, so rename it where you set it. A malformed file is still a one-line error, now naming file:line:col. netboot.utils.config.Namespace and the config classes build on anycfg.TypedNamespace / OpaqueMerge.
  • Every public module declares __all__; netboot.utils exports Namespace too. PixieTarget, PixieImage and PixieContext are defined in private modules and imported, as before, from netboot or netboot.engine.
  • netboot.dhcp.DhcpZone is DHCPZone (also importable from netboot), as every other public name spells DHCP; rename the import.
  • Pixie.lookup_image returns None when nothing matches, as lookup_target and lookup_dhcpzone do, rather than {}; a FoundTargetImage hook sees None too.
  • The command line is netboot.cli, one duho class per command (Initiate, Complete, Render, DhcpConfig); the console script is netboot.cli:main, and netboot.main re-exports main, PixieArgs, Pixie_ and parse_path. main(argv=None, *, name=None): argv is the first parameter, so main(["--version"]) works; pass name= by keyword. netboot.cmds is gone: netboot.cmds.dhcp_config.recipe is netboot.dhcp.condition_recipe, conditions_for is build_conditions, and a --cmdspath plugin with run(netboot, args, conf) works as before.
  • pixie no longer imports a pixie_env settings module from sys.path, which under python -m netboot meant the working directory: running it in a directory runs no code from there. Export PIXIE_* variables instead, or name a module with --load-module.
  • Building the engine does no DNS. A hostname-only target's ip is looked up by PixieTarget.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. A PixieTarget built directly has ip == "" until resolve_ip() is called.
  • initialize(target, **options) and complete(target, **options) hand options to their Start/End hooks as kwargs: initiate --iscsi now reaches them as kwargs["iscsi"].
  • A DHCP server's credentials are its own config entry's. A dhcpservers entry is a URI or a mapping of uri: and that backend's secrets (user, password; dhcpd keyname, 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_USER and PIXIE_DHCPD_OMAPI_KEY are no longer read, and netboot.dhcp.WINDHCP_PASSWORD_ENV_VAR, WINDHCP_USER_ENV_VAR and DHCPD_OMAPI_KEY_ENV_VAR are 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 --as passes 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 from netboot.
  • dhcp_options, dhcp_when and dhcp_complete are read as what a target is served, a pydhcp DHCPLease: next-server and boot-file-name are its BOOTP header fields siaddr and file, as they always reached the server, and file, sname and siaddr may be written as such. bootfile-name is option 67 itself and is now sent as an option. build_next_server is replaced by build_lease(ctx, server). A Kea client class from dhcp_when carries the boot file as its boot-file-name field, not as option 67.
  • An option with no type of its own (option-224, unless dhcp.options declares one) takes its octets as hex text or {hex: ...} in dhcp_options, as pydhcp's DHCPOptions.from_mapping reads them.
  • The DHCP backends are dhcpctl's. netboot depends on pydhcpctl>=0.1.0,<0.2 (imported as dhcpctl), and netboot.dhcp is a module that builds each target's reservation for it: DhcpZone, connect(uri), build_options, build_conditions, build_completion, reservation_for(ctx, server), arm and disarm. What a server is sent is unchanged, byte for byte, including the names netboot writes on it (# >>> netboot regions, netboot-<mac> Windows policies). Removed, with what to do:
  • DhcpServer and its scheme-by-class-name dispatch: subclass dhcpctl.DHCPServer and call dhcpctl.register_backend(scheme, cls) from the --load-module plugin, or declare it in the dhcpctl.backends entry point. A backend implements add(reservation) / remove(reservation) (were add_target(ctx) / remove_target(ctx)), keep(reservation) (was keep_target(ctx, completion)), and extras(reservation, phase) (was extras(ctx, phase)); the netboot context is reservation.context.
  • netboot.dhcp.dnsmasq/.kea/.dhcpd/.windhcp: the backends are dhcpctl.backends.DnsmasqBackend, KeaBackend, DHCPDBackend and WinDHCPBackend; to override one, register the subclass with replace=True.
  • netboot.dhcp.options, .conditions and .completion: GENERIC_OPTIONS, APPLY_TIME, DhcpOptions, split_query, DhcpCondition, MATCH_KEYS, DhcpCompletion and BOOT_OPTIONS are dhcpctl's (DHCPOptions, Condition, Completion, ...); build_options, build_conditions, build_completion and zone_defaults are in netboot.dhcp. ConditionMissing / ConditionUnsupported are dhcpctl.ConditionMissingError / ConditionUnsupportedError.
  • windhcp.PASSWORD_ENV_VAR / USER_ENV_VAR are netboot.dhcp.WINDHCP_PASSWORD_ENV_VAR / WINDHCP_USER_ENV_VAR, and dhcpd.KEY_ENV_VAR is DHCPD_OMAPI_KEY_ENV_VAR; the variables are the same PIXIE_* ones, now read only when the URI has no user or password of its own: windhcp://admin:pw@host and dhcpd://keyname:key@host work. Logs, errors and pixie dhcp-config show a server's URI with the password masked.
  • netboot.DhcpServer is no longer exported. The kea, dhcpd, winrm and ssh extras install dhcpctl's of the same name. An unknown scheme, or a URI a backend cannot use, is a PixieConfigError; a server refusing is a dhcpctl.ServerError, reported by the CLI as one line with status 2. PixieEvent.BuildDhcpOptions now 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, ...) or option-<n>, and the names netboot already took still work (LEGACY_OPTION_NAMES). A config declares more in a new top-level dhcp.options section: an option of its own with a code and a pydhcp type, or an alias of a name or a code; dhcp_options, dhcp_when and dhcp_complete all read them. Each value is read by the option's type when the config is applied, so router: gateway is a PixieConfigError naming 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 a BuildDhcpOptions hook: it receives pydhcp's DHCPOptions, keyed by code, so write options[DHCPOptionCode.ROUTER] = ... or options[3] where it wrote options["router"], or return a mapping of option names. next-server is no longer an option a hook sees: it is the reservation's BOOTP field, still set from dhcp_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= and raw.<backend>= text are unknown-key errors. What to do: move options to the zone's settings or an image's or target's dhcp_options; replace an options_builder with a hook on PixieEvent.BuildDhcpOptions, which receives the same options plus the target and the server; for the iPXE chainload use dhcp_when:, and for other backend-native text register a backend subclass whose extras(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 !include is, and with no templates: the root is templates beside the config file (./config/templates by default). The working directory's ./templates is no longer prepended. What to do: a config that relied on ./templates names it, relative to the config file (the example config now says templates: ../templates).
  • The path layer is pathapi and uripath, not pathlib-next. netboot depends on pathapi>=0.1.0,<0.2 and pathapi-uri>=0.1.0,<0.2 (import package uripath) and no longer installs pathlib-next or uritools. The paths netboot returns are their classes: Repository.service(), .get() and .local are uripath.URIPath (were pathlib_next.uri.UriPath), parse_path() returns pathapi.LocalPath or URIPath, and a template's Path and Uri globals are pathapi.Path and uripath.URIPath. What to do: import them from there in a command module, hook or template that used pathlib_next; a pathlib_next path handed to netboot is not a Path. The URLs netboot builds are the same, but uripath differs 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=1 joined with a is http://h/boot/a?sig=1); it dropped both.
  • Query is no longer a str, and Source.from_str() / as_str() are Source.parse() / str().
  • file:///x and file:/x are different URIs. netboot still writes the service of .local as file:/srv/tftp.
  • yaconfiglib floor moves to >=0.13.1,<0.14, the first release built on pathapi; 0.13.0 needs pathlib-next. Configuration loading, !include globs and merging behave as before.
  • The http extra is removed. http/https repository services are sent on the standard library by uripath, so they need no extra and Repository.service() no longer raises an ImportError naming netboot[http]. What to do: drop [http] from the install line (pip warns about the unknown extra and installs the rest). netboot[kea] still installs requests; install pathapi-requests yourself only to send http/https services through a requests.Session.
  • The ssh extra is sftppath[uri,paramiko]>=0.1.0,<0.2 (was pathlib_next[sftp]): remote dnsmasq:// paths are sftp:// paths of sftppath, over paramiko. SFTPPATH_BACKEND replaces PATHLIB_NEXT_SFTP_BACKEND to choose paramiko or asyncssh; the old name is not read.
  • duho floor 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 --cmdspath now uses, 0.7.5 the exit="parse" mode of duho.run). The pixie command line is unchanged: same flags, exit statuses and output, with one exception in --help: the commands of one --cmdspath directory 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.
  • pixie runs through duho.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 plain run(args) command's returned value (not None or an int) is printed to standard output, where it was handed to sys.exit; with DUHO_TRACEBACK set an operator error also prints its traceback to standard error, and the status stays 2. --help, --version and a usage error still raise SystemExit from main, as before.
  • netimps moves 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 now target.mac.format("-").
  • Templates are rendered by tplx (tplx[jinja]>=0.1.0,<0.2), with the same engines, suffixes, search order and templates_undefined modes; the mako, liquid, handlebars and mustache extras 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" %} of net.shtpl works, where it failed with AttributeError. 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, ...) beside ctx; Jinja and mako saw only ctx.
  • A MAC address in a Liquid, Handlebars or Mustache template renders as aa:bb:cc:dd:ee:ff, where it rendered as DataView(MACAddress(...)).
  • An .epp template'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 (was jinja2.TemplateNotFound), tplx.UndefinedError (was jinja2.UndefinedError, or KeyError from the shell engine), tplx.NoEngineError, tplx.EngineUnavailableError and tplx.RenderError (was netboot.templates.TemplateEngineError, or an ImportError naming netboot[mako]). pixie reports every tplx.TplxError as one line and exits 2.
  • PIXIE_RUBY and PIXIE_PUPPET are now TPLX_RUBY and TPLX_PUPPET.

  • shell_quote is shlex.quote: a value with nothing special to the shell is no longer quoted ({{ shell_quote("web01") }} renders web01, was 'web01'), and an embedded ' is written '"'"' (was '\''). Both read back as the same word in a POSIX shell. The quote= argument and its double-quote form are gone.

Removed

  • netboot.utils.flatten, netboot.utils.arr_get and the netboot.utils.dicts module; shell_quote and T are in netboot.utils.misc and still importable from netboot.utils.
  • netboot.templates: use tplx (Loader, the engine classes, register_engine for register_template_type, unregister_engine, registered_engines for TEMPLATE_TYPES, DataEncoder for template_data/jsonable). A plugin engine subclasses tplx.Template and implements render_values(values), where values holds ctx and the context fields. PixieContext._renderer is gone; the context's tplx.Loader is _loader.
  • netboot.utils.net, netboot.netutils and the network names netboot.utils re-exported (Host, IPAddress, IPInterface, IPNetwork, MACAddress, parse, try_parse, is_valid, is_wildcard, resolve, ping, get_interfaces, iter_addresses, normalize_host): import them from netimps. netboot's Host subclass goes with them, and with it .try_ip() (use host.ip() or str(host)), .address (use .value, and build a new Host rather than assigning) and Host() with no argument (Host(None)). Repository.address is a netimps.Host. A test that faked DNS through netboot.netutils.resolve patches netimps.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-module that does not import, and a zone whose only DHCP servers could not be reached (no DHCP server armed ...). A configured ip that 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 leaves local unset when the repository had none instead of making it file: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: dir given to Pixie directly is one root, not one per character, and parse_path reads every RFC 3986 scheme (svn+ssh://).
  • pixie dhcp-config with 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. --as on Windows with standard input from NUL fails 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, as target and image already were, instead of replacing it or breaking the context.
  • A zone with no dhcpservers warns that nothing was armed.
  • hooks: None means no hooks; ctx.resource(Resource) for a resource with no path is None.
  • A configuration key nothing reads (targetz:, a zone's gatway:) is logged as a warning naming it. Data for templates belongs under globals; a key starting with _ is never warned about.
  • With DUHO_TRACEBACK set, a configuration error prints its traceback to standard error again: duho 0.7.6 leaves that to netboot, which now requires duho>=0.7.6.
  • dnsmasq:// writes reservation and option files dnsmasq can read: each line is the value of --dhcp-host / --dhcp-option without 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-hostsdir held until SIGHUP, so completion now signals pidfile= (default /run/dnsmasq/dnsmasq.pid), or needs reload= when the dnsmasq is remote. A dnsmasq target served options with no optsfile= 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 a conditions=group target 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 with reservation-update (or a delete and add), where it sent a reservation-add Kea refuses for an existing reservation, and finds a zone's subnet inside a Kea shared-networks entry.
  • 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=shared leaves an existing policy untouched; pixie dhcp-config prints the per-target policies target mode creates, every value quoted; method=netsh keeps a reservation through netsh and no longer fails its read-back for an option a condition serves.
  • pixie initiate on 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-config prints the two classes per target (<name>-<mac> and netboot-<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 remote dnsmasq:// reload run through hostctl. ssh is asyncssh rather than the system ssh client (it reads ~/.ssh/config as ssh does), so windhcp:// over ssh now needs netboot[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, a dhcpd.conf group, a Kea client-classes entry. Printing contacts nothing, so the output goes straight into a change ticket; --apply runs it and --as prompts for an admin password. It is the only command that prompts, because initiate and complete run unattended and a prompt there would hang a pipeline. Run once per condition; routine arming then needs none of those rights.
  • windhcp:// takes transport=local — run PowerShell on this machine, with server= 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_USER when the URI names none.
  • dhcp_complete: — what a finished target's DHCP entry becomes. A machine that still receives boot-file-name after its install boots the installer again; pixie complete removed the reservation to prevent that, which also threw away the fixed address. Now keep: true leaves the address and drops the boot options (firmware falls through to local disk), and options: serves something else instead — an iPXE sanboot script, for firmware with no local-disk fallback. Router, DNS and subnet mask are never touched, and a kept entry also loses its dhcp_when membership 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 inline if, 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. match takes user-class and vendor-class; options takes the usual option names. This is the iPXE chainload said once: undionly.kpxe for a PXE ROM, boot.ipxe for 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 raises PixieConfigError naming the repo instead of being treated as a hostname and handed to DNS as one. [v6]:port still produces http://[2001:db8::1]:8080/... — the host comes back unbracketed and pathlib_next brackets it itself.
  • "the target has no address" is is_wildcard, which also covers None and a %zone suffix (::%eth0); the check it replaced knew 0.0.0.0 and :: only, so a scoped unspecified address produced a template candidate named after it.
  • the windhcp:// netsh client id is MACAddress.hex() rather than formatting with separators and stripping them again.
  • netboot.utils.net.Host is now netimps' Host, subclassed rather than reimplemented. The local class claimed Host was "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) and Host() 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, and Set-DhcpServerv4OptionValue -Value takes String[]: Windows answered "Parameters for option value to be set for option ID 6 do not match with option definition" and, under ErrorActionPreference = '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 configured raw.<backend> fragments, and a subclass overrides it to decide per target. All four shipped backends consume it, and raw.<backend>.remove= is new: a fragment that belongs to teardown rather than to arming. For windhcp:// a fragment may be a PowerShell line or a {"Args": [...], "Ignore": bool} netsh command, which works under either method.
  • method=netsh verifies what the server actually applied. netsh exits 0 and prints its success line even when it rejected what it was told — measured: a domain-name-servers option 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 with netsh ... 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:// takes method=powershell|netsh. The default is unchanged (the DhcpServer cmdlets); method=netsh drives netsh dhcp server ... instead, for a host where that PowerShell module is not installed. It is independent of transport=, and netsh is invoked from PowerShell with each argument an array element, so no value is spliced into a command line. Success is $LASTEXITCODE rather than netsh's localised success line, a removal tolerates a reservation that is not there, option data types are declared per option (unmapped ones as STRING), and a multi-valued option becomes one argument per value.

0.3.0 - 2026-10-02

Changed

  • The shell template engine now claims the .shtpl suffix only (it claimed every suffix before, as the documented fallback). The suffix picks the engine and the stem names the artifact, which is the convention install.ks.j2 already used: rename boot.cfg to boot.cfg.shtpl and ctx.render("boot.cfg") keeps working unchanged. A file no engine claims is copied as is by the new CopyTemplate, last in the default template_types — so a static grub.cfg needs 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. Narrowing template_types so nothing claims a file raises netboot.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]), and ERBTemplate (.erb) / EppTemplate (.epp), which render through the system ruby and puppet epp render so an existing template behaves as its author tested it (PIXIE_RUBY / PIXIE_PUPPET name the program if it is not on PATH). 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 with templates_undefined differs 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.
  • ShellTemplate is configured by subclassing, through three class attributes: EXT (a string or a sequence, dots optional, case-insensitive; None or "*" claims any suffix), DELIMITER (default "%") and PATTERN — "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 whatever templates_undefined says. :=, :? 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) and ctx.render() returns bytes for such a file. EXT narrows it to chosen suffixes; Template.BINARY is the flag any engine can set to be handed raw bytes. A non-UTF-8 file claimed by a text engine now raises TemplateEngineError naming the file, where it used to fail inside the read.
  • Loader.find_source() — the byte-returning counterpart of get_source() (which keeps jinja2's text contract).
  • A template-engine registry: register_template_type() (bare or as a decorator, with an optional priority=), unregister_template_type(), TEMPLATE_TYPES and by_priority(). Loader(template_types=None) — the new default — uses every registered engine, so a --load-module plugin 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 and FALLBACK_PRIORITY (-100) for CopyTemplate. 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 explicit template_types list still means what it says.
  • Template.EXT and JinjaTemplate.EXT, so suffix ownership is one declarative attribute per engine, plus netboot.templates.template_extensions() to read it back normalised.

0.2.4 - 2026-09-28

Changed

  • duho floor 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, the PATHSEP env 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 initiate or pixie 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. initiate now tries every server in the zone, logs each failure as a warning, and fails only when no server could be armed. complete logs 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.4 and yaconfiglib>=0.13.0,<0.14. The old ceilings made netboot uninstallable beside either release. netimps 0.3.0 is skipped on purpose: it raised from resolve() 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.
  • MACAddress no longer equals a str (netimps 0.3.0, re-exported as netboot.utils.net.MACAddress). target.mac == "aa:bb:cc:dd:ee:ff" is now False — in a plugin, a hook or a Jinja template alike. Compare with MACAddress.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, and aa.bb.cc.dd.ee.ff is 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 address of the form host:port no longer sends the whole string to DNS. The port is split off first, so mirror.example:8080 resolves mirror.example — the old lookup could only fail, and did so at different speeds on different machines.

Added

  • netboot.dhcp is a package, and DHCP backends carry client options. A dhcpservers entry 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.options models them: GENERIC_OPTIONS for the names netboot translates, option-<n> for anything else, raw.<backend>= for untranslated backend-native text, and options_builder=my.mod.fn plus the new PixieEvent.BuildDhcpOptions for 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 writes dhcp-host/dhcp-option/dhcp-boot entries through pathlib_next paths, so the same configuration serves a local dnsmasq or one reached over sftp:// (netboot[ssh]). hostsfile/optsfile may be a directory (one file per target — preferred, and with --dhcp-hostsdir/--dhcp-optsdir dnsmasq 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 sends reservation-add and reservation-del to the control agent, mapping boot-file-name, next-server and host-name onto reservation fields and everything else onto option-data. The subnet-id comes from the zone (subnet_id:) or is discovered once by matching the zone's network against config-get; when neither works netboot refuses rather than guessing, because a reservation in the wrong subnet silently never matches. A result: 2 from 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. Needs netboot[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's statements, 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 from keyfile= or $PIXIE_DHCPD_OMAPI_KEY, never from the URI. Needs netboot[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 the DhcpServer PowerShell module over ssh (the default, no dependency — it uses the system ssh client, so your config, keys, agent and jump hosts apply) or WinRM (netboot[winrm]). The transport host and the DHCP server are separate: server= becomes -ComputerName when 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], and netboot[ssh] for dnsmasq over sftp://. A local dnsmasq and windhcp:// over ssh need none. Verified on a clean wheel install: importing netboot.dhcp loads no backend dependency, those two backends construct, and the other two raise ImportError naming their extra.
  • Anything that varies per target is refused in a connection string and resolved when the target is applied instead: subnet_id/scope belong to the zone, boot-file-name/next-server/tftp-server-name to the image or target. The error names where each belongs. from netboot.dhcp import DhcpServer, DhcpZone is 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, PixieLookupError and PixieConfigError. Netboot's deliberate failures are now distinguishable from bugs: lookups that fail or are ambiguous raise PixieLookupError (still a LookupError) instead of a plain Exception, and the messages name the target and what was configured.
  • netboot.logging.quiet_noisy_dependencies(insecure_warnings=False).
  • netboot[http] extra. http/https repository services reach the network through requests, which nothing installed: pathlib_next[uri] does not depend on it, so Repository.service("http") failed with ModuleNotFoundError: No module named 'requests' on any install that did not happen to have it. Install netboot[http] for http-served repos; file and tftp repos, and rendering, need nothing extra. Without the extra those calls now raise ImportError naming 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 .j2 file 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 key templates_undefined decides, and means the same in both engines: strict (default) raises naming the variable, lenient renders an empty string with a warning — the old Jinja behaviour — and debug leaves the placeholder in the output. Set templates_undefined: lenient to keep templates that relied on blanks working.

Security

  • Config loading is hardened: the loader runs with allow_commands=False and sandbox=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 !include from inventory exports rather than written by hand. Ordinary interpolation is unchanged; set YACONFIGLIB_CONFINE_TO to also restrict where !include may read from.
  • shell_quote now 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 raises ValueError. It also returns a str for a str input 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, %end or date +%Y failed to render at all. Only the documented braced form %{NAME} is substituted now; a bare %word is 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 StartPixieInit hook'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 its templates took effect — the config ended up half applied. A hook that returns None for StartPixieInit or NewPixieObject now raises PixieConfigError naming the contract instead of failing obscurely later.
  • A template name containing .. is refused. Names are resolved against every search root, so ../../etc/passwd reached outside them.
  • Arming DHCP is all or nothing. pxe_init stopped 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_complete takes 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.complete no 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, repos or resources no 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/16 and 10.0.0.5/24 both configured, a host in the /24 now gets the /24.
  • A None config entry (targets: {host1:} — valid YAML meaning "all defaults") no longer crashes with AttributeError, and an entry that is neither a mapping nor an object raises PixieConfigError naming it.
  • lookup_image treats any falsy match result as "no match": a match override returning None used to crash the sort.
  • DhcpZone no 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 into None, and get_local_server accepts the strings config actually holds rather than raising on them. Two plugins claiming one URI scheme now log a warning naming both.
  • A Pixie subclass's annotated attribute keeps its class default when the config does not mention it (it was overwritten with None), and an Optional[...] annotation no longer crashes construction — its origin is not a constructor, and on 3.9 it is not even a class.
  • The _id retry when building a config object only catches "this class takes no _id"; any other TypeError from the value class propagates instead of being hidden behind a second construction attempt.
  • Pixie.globals is 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 (mutating engine.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_dhcpzone returns None instead of raising AttributeError when the target has no usable IP — the documented MAC-keyed target shape hit this on every initiate that did not name a dhcpzone.
  • Repository URLs are built correctly from an address that carries a port: mirror.example:8080 became a hostname with an escaped colon (mirror.example%3A8080) instead of a host and a port. [2001:db8::1]:8080 works too, and a repo with no address warns instead of silently producing a hostless http:/path.
  • An https service 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 at module.run, while duho resolves main → 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_ip beside target.ip, or domain beside DOMAIN) 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_path is resolved inside the template roots (config["templates"], ./templates by default) rather than against the process's working directory, so a config means the same thing whichever directory pixie runs from. An image's or target's template_path still extends the roots, which are searched last; absolute paths and URIs are used as given. A config that wrote template_path: [templates/debian] to reach ./templates/debian should 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 in templates or in an image's template_path stays a URI instead of being turned into a directory named http: under the working directory, so URI search paths are searched at all. A repository's local path accepts a Windows drive path (C:\\tftp), which was previously read as URI scheme c and 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.
  • Loader works without a PixieContext in the environment: it resolves a plain template name instead of raising AttributeError.
  • Template selection is deterministic. Within a search directory an exact filename now wins, and several files sharing a stem (boot matching boot.j2, boot.sh, boot.j2.bak) resolve lowest-name-first instead of in whatever order the filesystem listed them — a leftover .bak/.orig/ .rpmnew copy 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 UnicodeDecodeError or decode wrongly).
  • An image with no template_path renders instead of raising AttributeError from PixieContext.searchpaths; it now defaults to [] as targets already did.
  • A target whose hostname does not resolve no longer hangs the process. PixieTarget construction 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 every pixie command hang and flood the resolver. Resolution is now a single lookup: it fills ip on success, and on failure logs a warning and leaves ip unset. A malformed hostname is warned about instead of aborting construction.

Changed

  • Pixie.lookup_target matches exactly before it matches by prefix. It previously returned the first target whose hostname started with the query, so with targets node10 and node1 in that order, pixie initiate node1 acted on node10. 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 raises LookupError (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, so AA-BB-CC-00-00-01 and aabb.cc00.0001 now match a target keyed aa:bb:cc:00:00:01; previously only the colon spelling did.
  • pathlib_next[uri] floor raised to >=0.9.9. Measured: recursive !include globs (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.0 claim described a combination that cannot work. tests/test_config_discovery.py now fails below the floor instead of passing quietly.
  • yaconfiglib moves to the 0.12 series (>=0.12.0,<0.13). The APIs netboot uses are unchanged; the previous <0.12 ceiling made netboot uninstallable alongside yaconfiglib 0.12.
  • The package logger is named netboot, not NETBOOT, and -v/-q now 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 use netboot.
  • Importing netboot no longer has logging side effects. It used to quiet urllib3/paramiko and disable urllib3's InsecureRequestWarning process-wide — a decision belonging to the application, not to a library, and it imported urllib3 as a side effect of import netboot. The pixie CLI calls quiet_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. -v still shows the traceback, and an unexpected exception is still raised in full. --version and usage errors say pixie rather than the class name Pixie_.
  • An absolute Windows path is accepted for --config/--baseconfig/template paths. C:\\srv\\tftp was parsed as URI scheme c, which then failed with NotImplementedError; a single-letter scheme is now read as a drive letter.
  • An empty or comment-only config loads as {} rather than raising AttributeError, a scalar templates: 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" plus license-files, and the legacy License :: OSI Approved :: MIT License classifier is gone (building now needs hatchling>=1.27). The wheel carries the licence at netboot-<version>.dist-info/licenses/LICENSE.
  • Packaging excludes *.local.* from both sdist and wheel. A personal override such as pixie.local.yaml or AGENTS.local.md previously 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 Pixie and every other documented import are unchanged; netboot.engine.Pixie is the same object. netboot._version holds the version lookup.
  • netboot.utils no longer re-exports argparse.Namespace as netboot.utils.Namespace, where it read as netboot's own config base. The config base is netboot.utils.config.Namespace, as documented.
  • PixieContext.templates is gone. It was an annotation only: nothing ever set it, so reading it raised AttributeError. Search paths are PixieContext.searchpaths.
  • jinja2 is now >=3.0,<4, previously unpinned. jinja2 2.x imports markupsafe.soft_unicode, removed in MarkupSafe 2.1, so an unconstrained resolve could install a pair that raises ImportError on import jinja2.

Documentation

  • The shipped API header (src/netboot/AGENTS.md) is corrected where it did not match the code: hook's value is positional-only and every hook must return a value; Path in a Jinja template is pathlib_next.Path, not the stdlib's; netboot.netutils is an attribute, not an importable module path; PixieContext.resources starts 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); importing netboot.logging does import urllib3 and disables its insecure-request warning process-wide.
  • docs/cli.md no longer says initiate renders artifacts or that --iscsi prepares an iSCSI LUN — both are hook extension points; --help said the same and was corrected too. The --config row explains that a .cfg/.ini suffix is parsed as INI, not YAML.
  • docs/extending.md documents the hook contract: the positional signature, the dict fourth argument, netboot=None for NewPixieObject, the prefixed PixieEvent string values, and that a hook must return the value.
  • docs/configuration.md example is runnable as written (the MAC-keyed target names its zone) and says that a dnsmasq:// 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 the pixie CLI 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 a DhcpServer plugin that prints instead of arming real DHCP — and a repo-root AGENTS.md covers 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 tracked benchmarks/results/, written by a new --save flag, 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_id and lookup_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_TOKEN starts no workflow run, so release: published alone never deployed), publishing uses skip-existing, the test workflow drops to read-only permissions and also runs on Windows and macOS, and docs redeploy when src/, README.md or CHANGELOG.md change.

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.10 and yaconfiglib>=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. jinja2 is third-party and stays unpinned.
  • duho moves from >=0.4.1 to the 0.5 series. 0.5.0 changed option parsing for list/set/tuple fields to one value per flag occurrence, which is the shape --cmdspath (Arg[list[str], Extend(os.pathsep)]) is now written against. The CMDS_PATH layering that _discover leans on after 0.1.2 stopped re-resolving PIXIE_CMDS_PATH itself predates this at 0.4.1, so it needs no floor above 0.5.0.
  • netimps moves from >=0.2.1 to >=0.2.1,<0.3 — the floor stays above the .0 on purpose. Host.try_ip calls resolve() with no rdtype, 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.2 to the 0.9 series. Repository.service() constructs pathlib_next.uri.Source directly from str(target.try_ip()), and that direct-construction path raised socket.gaierror out of Source.is_local() for a bare IPv6-literal host until 0.9.0 — the same release that stopped Source rendering its password in str()/repr(), so a service URI carrying credentials no longer leaks through a traceback frame. 0.9.0's one breaking change is to PathSyncer, which netboot does not use.
  • yaconfiglib moves from an unbounded >=0.10.0 — which spanned two minor series — to >=0.11.1,<0.12. This floor is also above the .0 on purpose: load_config builds ConfigLoader(..., 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 made typed_merge read parametrized generics, which is what Repository.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 the initialize/complete event sequence, and repository/resource URI assembly with Host.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 duho floor is now >=0.4.1, the release that made Env consult os.environ before a pixie_env companion module and turned CMDS_PATH discovery into a layer merged on top of an explicit commands= list. netboot now depends on both behaviours.
  • PIXIE_CMDS_PATH is 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 clash PIXIE_CMDS_PATH now wins over --cmdspath, where before the option won.
  • black is the project formatter: it joins the dev extra with [tool.black] target-version = ["py39"] (the supported floor), and the tree has been reformatted once to match.
  • The netimps floor is now >=0.2.1. >=0.0.1 predated the API netboot actually calls: the optional-dnspython resolve() fallback chain (0.2.0), its auto-selected rdtype (0.2.1), and the 0.2.1 ping(src=...) NameError fix.

Documentation

  • docs/cli.md no longer tells operators that pixie_env module defaults outrank real PIXIE_* environment variables. duho 0.4.1 fixed that inversion; the note now states the true order (explicit env values > real PIXIE_* > module defaults).
  • The shipped API header (src/netboot/AGENTS.md) no longer tells consumers the dns extra is a no-op with dnspython arriving transitively — false since netimps 0.2.0 made dnspython optional. netboot[dns] is the only thing that installs the dnspython backend; without it resolve() falls back to netimps' system/nslookup backends. resolve's documented default rdtype is now None (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 when netimps was adopted; they now credit netimps and name the netboot.utils.net re-export.
  • The netboot[dns] extra row describes what it actually does since netimps 0.2.0: it installs the dnspython resolver backend, and hostname targets still resolve without it via the system/nslookup fallbacks.
  • Fixed the pathlib_next repository 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: true plus pages: write), matching docs.yml, so a docs-site problem no longer turns an otherwise-successful release red.
  • The docs-only workflow triggers on main; it was listening on master, 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 pixie command instead of saying "once published".
  • README library example binds the engine to pixie rather than netboot, 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, pixie console script, py.typed). Python 3.9+.
  • PXE provisioning engine: Pixie with target/image/dhcpzone/repo lookup, a render PixieContext, and an initialize/complete lifecycle.
  • 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 DhcpServer backends dispatched by URI scheme, discovered recursively so plugin modules loaded via --load-module are honoured.
  • pixie CLI built on duho (PXE is pronounced "pixie"; netboot is the library/import package): initiate and complete commands with layered YAML config (config/pixie.yaml via yaconfiglib), command discovery, and --load-module/--cmdspath.
  • App settings read through duho.env.Env("pixie"), so PIXIE_* variables (notably PIXIE_CMDS_PATH) configure the CLI; the resolved accessor reaches commands as args._env_.
  • Config objects use yaconfiglib's TypedNamespace (_parse_<field> coercers) and OpaqueMerge (last-object-wins) so fully-built targets/zones with factory-function field hints are merged as opaque values (requires yaconfiglib>=0.10.0).
  • Two-workflow CI (test, Release) and a MkDocs documentation site (docs/ + mkdocs.yml, API reference via mkdocstrings), plus a benchmarks/bench_netboot.py micro-benchmark for the lookup/template hot paths.
  • IP/MAC/DNS helpers vendored in-tree as netboot._netutils; DNS lookup of hostname targets is the optional netboot[dns] extra (dnspython).

Changed

  • Built on duho (args/command-discovery/app) rather than the in-house coquilib layer; IP/MAC helpers are vendored in-tree; the sys.path vendor/ shim is gone. Version derives from installed package metadata.

Fixed

  • Target resolution no longer no-ops when the id is an IP address (self.ip self-assignment and a resolve - True typo).
  • utils.flatten now produces a genuinely flat mapping and no longer raises TypeError on 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 zone dhcpservers URIs are constructed into backends.
  • make_context no longer deletes globals off shared image/dhcpzone/target objects, so a second target reusing an image keeps that image's globals.
  • _template_names accepts the loader's template **options; template names stringify the target IP and skip an unspecified address.
  • Command dispatch introspects each module's run signature, so a user command using duho's plain run(args) is dispatched via duho.run_command.
  • Shell templates render None as empty instead of the literal "None"; config value construction no longer swallows non-TypeError errors; repo joinpath keeps .local a path so chained joins work.