netboot 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. netboot-0.2.0/.gitignore +53 -0
  2. netboot-0.2.0/AGENTS.md +85 -0
  3. netboot-0.2.0/CHANGELOG.md +455 -0
  4. {netboot-0.1.2 → netboot-0.2.0}/PKG-INFO +43 -31
  5. {netboot-0.1.2 → netboot-0.2.0}/README.md +32 -3
  6. netboot-0.2.0/benchmarks/README.md +61 -0
  7. {netboot-0.1.2 → netboot-0.2.0}/benchmarks/bench_netboot.py +25 -5
  8. netboot-0.2.0/benchmarks/results/netboot.json +24 -0
  9. netboot-0.2.0/docs/api.md +60 -0
  10. netboot-0.2.0/docs/changelog.md +1 -0
  11. {netboot-0.1.2 → netboot-0.2.0}/docs/cli.md +29 -9
  12. netboot-0.2.0/docs/configuration.md +127 -0
  13. {netboot-0.1.2 → netboot-0.2.0}/docs/extending.md +30 -2
  14. {netboot-0.1.2 → netboot-0.2.0}/docs/index.md +3 -1
  15. netboot-0.2.0/examples/README.md +56 -0
  16. netboot-0.2.0/examples/config/pixie.yaml +37 -0
  17. netboot-0.2.0/examples/plugins/recording.py +16 -0
  18. netboot-0.2.0/examples/templates/debian/boot.cfg +6 -0
  19. netboot-0.2.0/examples/templates/debian/install.ks.j2 +7 -0
  20. {netboot-0.1.2 → netboot-0.2.0}/mkdocs.yml +15 -0
  21. {netboot-0.1.2 → netboot-0.2.0}/pyproject.toml +36 -14
  22. netboot-0.2.0/src/netboot/AGENTS.md +393 -0
  23. netboot-0.2.0/src/netboot/__init__.py +44 -0
  24. netboot-0.2.0/src/netboot/_version.py +15 -0
  25. netboot-0.2.0/src/netboot/cmds/complete.py +30 -0
  26. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/cmds/initiate.py +12 -4
  27. netboot-0.2.0/src/netboot/content/__init__.py +182 -0
  28. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/dhcp.py +58 -10
  29. netboot-0.2.0/src/netboot/engine.py +720 -0
  30. netboot-0.2.0/src/netboot/logging.py +32 -0
  31. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/main.py +123 -25
  32. netboot-0.2.0/src/netboot/templates/__init__.py +200 -0
  33. netboot-0.2.0/src/netboot/templates/common.py +50 -0
  34. netboot-0.2.0/src/netboot/templates/jinja.py +24 -0
  35. netboot-0.2.0/src/netboot/templates/shell.py +85 -0
  36. netboot-0.2.0/src/netboot/utils/dicts.py +97 -0
  37. netboot-0.2.0/src/netboot/utils/misc.py +29 -0
  38. netboot-0.2.0/tests/conftest.py +49 -0
  39. netboot-0.2.0/tests/test_api_contracts.py +115 -0
  40. netboot-0.2.0/tests/test_cli.py +185 -0
  41. {netboot-0.1.2 → netboot-0.2.0}/tests/test_cli_discovery.py +26 -0
  42. netboot-0.2.0/tests/test_config_discovery.py +92 -0
  43. {netboot-0.1.2 → netboot-0.2.0}/tests/test_content.py +94 -0
  44. {netboot-0.1.2 → netboot-0.2.0}/tests/test_dhcp.py +9 -15
  45. netboot-0.2.0/tests/test_engine_robustness.py +250 -0
  46. {netboot-0.1.2 → netboot-0.2.0}/tests/test_hooks.py +30 -0
  47. {netboot-0.1.2 → netboot-0.2.0}/tests/test_lookup.py +87 -0
  48. netboot-0.2.0/tests/test_render.py +349 -0
  49. netboot-0.2.0/tests/test_target.py +72 -0
  50. netboot-0.2.0/tests/test_utils.py +71 -0
  51. netboot-0.1.2/.gitignore +0 -38
  52. netboot-0.1.2/CHANGELOG.md +0 -136
  53. netboot-0.1.2/docs/api.md +0 -28
  54. netboot-0.1.2/docs/configuration.md +0 -65
  55. netboot-0.1.2/src/netboot/AGENTS.md +0 -260
  56. netboot-0.1.2/src/netboot/__init__.py +0 -399
  57. netboot-0.1.2/src/netboot/cmds/complete.py +0 -22
  58. netboot-0.1.2/src/netboot/content/__init__.py +0 -88
  59. netboot-0.1.2/src/netboot/logging.py +0 -17
  60. netboot-0.1.2/src/netboot/templates/__init__.py +0 -123
  61. netboot-0.1.2/src/netboot/templates/common.py +0 -21
  62. netboot-0.1.2/src/netboot/templates/jinja.py +0 -18
  63. netboot-0.1.2/src/netboot/templates/shell.py +0 -32
  64. netboot-0.1.2/src/netboot/utils/dicts.py +0 -49
  65. netboot-0.1.2/src/netboot/utils/misc.py +0 -6
  66. netboot-0.1.2/tests/test_render.py +0 -58
  67. netboot-0.1.2/tests/test_review_fixes.py +0 -109
  68. netboot-0.1.2/tests/test_target.py +0 -37
  69. netboot-0.1.2/tests/test_utils.py +0 -34
  70. {netboot-0.1.2 → netboot-0.2.0}/LICENSE +0 -0
  71. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/__main__.py +0 -0
  72. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/cmds/__init__.py +0 -0
  73. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/py.typed +0 -0
  74. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/utils/__init__.py +0 -0
  75. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/utils/config.py +0 -0
  76. {netboot-0.1.2 → netboot-0.2.0}/src/netboot/utils/net.py +0 -0
@@ -0,0 +1,53 @@
1
+ # Dotfiles are opt-in, as a category: a new secret-bearing dotfile (.env,
2
+ # .envrc, .pypirc, .netrc) is ignored before anyone has to remember it.
3
+ /.*
4
+ !/.gitignore
5
+ !/.gitattributes
6
+ !/.github/
7
+
8
+ # Personal, per-machine overrides: never committed, never packaged (the
9
+ # manifest excludes them too -- a dotfile pattern does not match these names).
10
+ *.local.*
11
+
12
+ # Agent configs / private notes, for nested occurrences the rule above cannot
13
+ # reach. `.agents` is slashless on purpose: it is usually a symlink, which a
14
+ # directory-only `.agents/` would not match.
15
+ .agents
16
+ CLAUDE*
17
+ .claude
18
+
19
+ # Virtualenvs
20
+ .venv*/
21
+ .pyvenv/
22
+ venv/
23
+ env/
24
+
25
+ # Byte-compiled / optimized
26
+ __pycache__/
27
+ *.py[cod]
28
+ *$py.class
29
+
30
+ # Distribution / packaging
31
+ build/
32
+ dist/
33
+ *.egg-info/
34
+ *.egg
35
+ .eggs/
36
+
37
+ # Test / coverage / type-check caches
38
+ .pytest_cache/
39
+ .hypothesis/
40
+ .mypy_cache/
41
+ .ruff_cache/
42
+ .coverage*
43
+ htmlcov/
44
+ .tox/
45
+
46
+ # Docs site build output
47
+ /site/
48
+
49
+ # Editors / OS
50
+ .vscode/
51
+ .idea/
52
+ *.swp
53
+ .DS_Store
@@ -0,0 +1,85 @@
1
+ # netboot — working in this checkout
2
+
3
+ Contributor orientation. The **API** is documented in
4
+ [`src/netboot/AGENTS.md`](src/netboot/AGENTS.md), which ships inside the wheel —
5
+ read that to *use* the library, this to *work on* it.
6
+
7
+ ## Names
8
+
9
+ Deliberate, not drift: **`netboot`** is the library (distribution, import
10
+ package, `python -m netboot`), **`pixie`** is the CLI identity (console script,
11
+ prog name, `PIXIE_*` env vars, `pixie.yaml`), and **`Pixie*`** is the public
12
+ class prefix (`Pixie`, `PixieTarget`, `PixieContext`, `PixieEvent`). PXE is
13
+ pronounced "pixie". `netboot` remains the conventional variable name for an
14
+ engine instance, which is why command modules take `run(netboot, args, conf)`.
15
+
16
+ ## Layout
17
+
18
+ | Path | What |
19
+ | ---- | ---- |
20
+ | `src/netboot/` | the package; `__init__.py` holds the engine |
21
+ | `src/netboot/cmds/` | built-in CLI commands (`initiate`, `complete`) |
22
+ | `tests/` | pytest suite; nothing here touches the network |
23
+ | `benchmarks/` | micro-benchmarks + committed results ([README](benchmarks/README.md)) |
24
+ | `examples/` | a runnable config, templates and a DHCP plugin ([README](examples/README.md)) |
25
+ | `docs/` + `mkdocs.yml` | the documentation site |
26
+
27
+ ## Environments
28
+
29
+ Per-version venvs, gitignored: `.venv/<version>-<os>-<arch>/`. Develop on the
30
+ latest Python and also run the floor from `requires-python` (3.9) before
31
+ finishing a chunk of work.
32
+
33
+ ```sh
34
+ python -m venv .venv/3.14-nt-amd64
35
+ .venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,docs,config,http]"
36
+ ```
37
+
38
+ Install **every extra that has tests**: without `http` the repository-service
39
+ tests skip rather than run, and `pytest -rs` is how you see that happen.
40
+
41
+ ## Commands
42
+
43
+ ```sh
44
+ <venv>/python -m pytest -q -rs # the suite (skips are visible)
45
+ <venv>/python -m black src tests benchmarks # formatter; run before committing
46
+ <venv>/python -m mkdocs build --strict # docs must build clean
47
+ <venv>/python -m build # sdist + wheel
48
+ <venv>/python benchmarks/bench_netboot.py --save
49
+ ```
50
+
51
+ CI does not gate on `black`, so an unformatted commit is only caught by the
52
+ person who wrote it.
53
+
54
+ ## Line endings
55
+
56
+ LF everywhere, enforced by `.gitattributes` (`* text=auto eol=lf`).
57
+ `git ls-files --eol | grep w/crlf` must stay empty.
58
+
59
+ ## CI
60
+
61
+ Three workflows, one concern each — a release must never be the first time a
62
+ config is exercised, and the docs site must be redeployable without a release.
63
+
64
+ - `test.yml` — every supported Python on Linux plus the platform edges;
65
+ `workflow_dispatch` (with a `ref`), pushes, PRs, and `ci-*` tags.
66
+ - `release.yml` (`v*` tag) — test → build → GitHub release → PyPI (Trusted
67
+ Publishing, `skip-existing`). It runs a **non-deploying** docs gate, then
68
+ dispatches `docs.yml` for the tag: a release created with `GITHUB_TOKEN`
69
+ starts no workflow run, so `release: published` alone never deploys.
70
+ - `docs.yml` — owns every Pages deploy, self-enables Pages, and is the only
71
+ place that publishes the site.
72
+
73
+ Throwaway `ci-*` tags are fine to push; delete them afterwards, local and
74
+ remote. A `v*` tag is a publish and needs the owner's say-so for that release.
75
+
76
+ ## Releasing
77
+
78
+ Versions are PEP 440 in `pyproject.toml` and SemVer in tags/changelog — the two
79
+ syntaxes differ on purpose. Pre-1.0, **the minor slot means the documented API
80
+ broke**; new methods, new optional arguments and fixes are all patches, so a
81
+ `~=0.1.0` consumer gets them without re-reading anything.
82
+
83
+ Bump `version` in the same commit as the `CHANGELOG.md` entry, and keep the
84
+ changelog to what changed and what a reader must do about it. The release body
85
+ is scraped from the matching `## [x.y.z]` section, so that heading must exist.
@@ -0,0 +1,455 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - 2026-09-17
10
+
11
+ A correctness and hardening release from a full review of the code base.
12
+ Several documented behaviours changed, so read **Changed** before upgrading:
13
+ target lookup, `shell_quote`, shell-template placeholders, relative
14
+ `template_path` resolution and undefined template variables all behave
15
+ differently on purpose.
16
+
17
+ ### Added
18
+ - `PixieError`, `PixieLookupError` and `PixieConfigError`. Netboot's deliberate
19
+ failures are now distinguishable from bugs: lookups that fail or are
20
+ ambiguous raise `PixieLookupError` (still a `LookupError`) instead of a plain
21
+ `Exception`, and the messages name the target and what was configured.
22
+ - `netboot.logging.quiet_noisy_dependencies(insecure_warnings=False)`.
23
+ - `netboot[http]` extra. `http`/`https` repository services reach the network
24
+ through `requests`, which nothing installed: `pathlib_next[uri]` does not
25
+ depend on it, so `Repository.service("http")` failed with
26
+ `ModuleNotFoundError: No module named 'requests'` on any install that did not
27
+ happen to have it. Install `netboot[http]` for http-served repos; `file` and
28
+ `tftp` repos, and rendering, need nothing extra. Without the extra those calls
29
+ now raise `ImportError` naming it instead of failing inside the library.
30
+
31
+ ### Changed
32
+ - **A template variable with no value now raises by default.** The two engines
33
+ disagreed: the shell engine raised while Jinja rendered an empty string, so a
34
+ typo in a `.j2` file shipped a boot artifact with a blank where a kernel path
35
+ belonged, and the behaviour depended on the file's suffix. The new top-level
36
+ config key `templates_undefined` decides, and means the same in both engines:
37
+ `strict` (default) raises naming the variable, `lenient` renders an empty
38
+ string with a warning — the old Jinja behaviour — and `debug` leaves the
39
+ placeholder in the output. Set `templates_undefined: lenient` to keep
40
+ templates that relied on blanks working.
41
+
42
+ ### Security
43
+ - Config loading is hardened: the loader runs with `allow_commands=False` and
44
+ `sandbox=True`, so `{{ ... }}` interpolation in a config value renders in
45
+ jinja2's sandbox and a config document can no longer run commands. Previously
46
+ a value such as `{{ ''.__class__.__mro__[1].__subclasses__() }}` was evaluated
47
+ unsandboxed — and a netboot config is often assembled by `!include` from
48
+ inventory exports rather than written by hand. Ordinary interpolation is
49
+ unchanged; set `YACONFIGLIB_CONFINE_TO` to also restrict where `!include` may
50
+ read from.
51
+ - `shell_quote` now actually quotes. It wrapped values in `"` without escaping
52
+ anything, so a value containing `` ` ``, `$`, `"` or `;` was expanded or
53
+ executed by the shell that read the generated script — a password with `$` in
54
+ it was silently corrupted, and a value taken from an inventory could run
55
+ commands. It now defaults to POSIX single-quoting (`quote="'"`), escaping an
56
+ embedded `'` as `'\''`; `quote='"'` escapes `"`, `\`, `` ` `` and `$`, and any
57
+ other quote character raises `ValueError`. It also returns a `str` for a `str`
58
+ input instead of always a one-element list, so `{{ shell_quote(v) }}` renders
59
+ the value rather than `['"v"']`. Templates that relied on indexing the result
60
+ of a scalar call must drop the `[0]`.
61
+
62
+ ### Fixed
63
+ - Shell-engine templates render kickstart files. The engine substituted both
64
+ `%{NAME}` and a bare `%name`, and treated an unknown bare one as an error, so
65
+ any file containing `%packages`, `%pre`, `%post`, `%end` or `date +%Y` failed
66
+ to render at all. Only the documented braced form `%{NAME}` is substituted
67
+ now; a bare `%word` is literal text, including one that names a context
68
+ variable. `%%` still yields a literal `%` and an unknown `%{NAME}` still
69
+ raises. A template written with bare placeholders must brace them.
70
+ - A `StartPixieInit` hook's return value is what gets built. The result was
71
+ stored but the original config was read, so a hook written in the usual
72
+ non-mutating style (`return {**value, "targets": ...}`) had its targets
73
+ ignored while its `templates` took effect — the config ended up half applied.
74
+ A hook that returns `None` for `StartPixieInit` or `NewPixieObject` now raises
75
+ `PixieConfigError` naming the contract instead of failing obscurely later.
76
+ - A template name containing `..` is refused. Names are resolved against every
77
+ search root, so `../../etc/passwd` reached outside them.
78
+ - Arming DHCP is all or nothing. `pxe_init` stopped at the first backend that
79
+ raised, leaving the target armed on the earlier ones — it could boot an
80
+ installer from one server while another handed out its normal lease. The
81
+ already-armed backends are now rolled back before the error propagates.
82
+ `pxe_complete` takes the opposite approach: every backend is disarmed even if
83
+ one fails, and the first error is raised afterwards, instead of leaving the
84
+ rest armed.
85
+ - `Pixie.complete` no longer needs the target's image to exist. Retiring an
86
+ image from the config used to make the machines that used it impossible to
87
+ clean up, because building the context failed before DHCP could be disarmed.
88
+ - A global named `target`, `image`, `dhcpzone`, `repos` or `resources` no longer
89
+ replaces the render context's own field of that name; it is dropped with a
90
+ warning.
91
+ - A zone is chosen by longest prefix, not declaration order: with `10.0.0.0/16`
92
+ and `10.0.0.5/24` both configured, a host in the /24 now gets the /24.
93
+ - A `None` config entry (`targets: {host1:}` — valid YAML meaning "all
94
+ defaults") no longer crashes with `AttributeError`, and an entry that is
95
+ neither a mapping nor an object raises `PixieConfigError` naming it.
96
+ - `lookup_image` treats any falsy `match` result as "no match": a `match`
97
+ override returning `None` used to crash the sort.
98
+ - `DhcpZone` no longer normalises the caller's lists in place (they may be
99
+ shared through a config merge), drops an unparseable nameserver with a warning
100
+ instead of turning it into `None`, and `get_local_server` accepts the strings
101
+ config actually holds rather than raising on them. Two plugins claiming one
102
+ URI scheme now log a warning naming both.
103
+ - A `Pixie` subclass's annotated attribute keeps its class default when the
104
+ config does not mention it (it was overwritten with `None`), and an
105
+ `Optional[...]` annotation no longer crashes construction — its origin is not
106
+ a constructor, and on 3.9 it is not even a class.
107
+ - The `_id` retry when building a config object only catches "this class takes
108
+ no `_id`"; any other `TypeError` from the value class propagates instead of
109
+ being hidden behind a second construction attempt.
110
+ - `Pixie.globals` is really the deep copy the docs promised. The copy made in
111
+ `__init__` was immediately overwritten by the annotated-attribute loop, so
112
+ nested values stayed shared with the caller's config dict (mutating
113
+ `engine.globals["a"]["b"]` changed the caller's mapping) and any global whose
114
+ key started with `_` was silently dropped. Both are fixed: `_`-prefixed
115
+ globals are ordinary variable names and are kept.
116
+ - `Pixie.lookup_dhcpzone` returns `None` instead of raising `AttributeError`
117
+ when the target has no usable IP — the documented MAC-keyed target shape hit
118
+ this on every `initiate` that did not name a `dhcpzone`.
119
+ - Repository URLs are built correctly from an `address` that carries a port:
120
+ `mirror.example:8080` became a hostname with an escaped colon
121
+ (`mirror.example%3A8080`) instead of a host and a port. `[2001:db8::1]:8080`
122
+ works too, and a repo with no address warns instead of silently producing a
123
+ hostless `http:/path`.
124
+ - An `https` service keeps the configured name rather than substituting the
125
+ resolved IP, which broke certificate validation and name-based virtual hosts.
126
+ Other schemes still resolve, since a PXE client often has no DNS yet.
127
+ - `repo / "/sub"` extends the repository root instead of replacing it, and a
128
+ trailing slash no longer doubles up.
129
+ - A command module whose body is `main(netboot, args, conf)` gets netboot's
130
+ contract. Dispatch looked only at `module.run`, while duho resolves
131
+ `main` → `run` → `call`, so such a command was handed to duho's
132
+ single-argument dispatch and failed on the signature.
133
+ - Two context values that flatten to the same shell-template variable (a global
134
+ `target_ip` beside `target.ip`, or `domain` beside `DOMAIN`) now log a warning
135
+ naming both; the later one still wins, but silently building an artifact from
136
+ the wrong value is what this prevents.
137
+ - A relative `template_path` is resolved inside the template roots
138
+ (`config["templates"]`, `./templates` by default) rather than against the
139
+ process's working directory, so a config means the same thing whichever
140
+ directory `pixie` runs from. An image's or target's `template_path` still
141
+ *extends* the roots, which are searched last; absolute paths and URIs are used
142
+ as given. A config that wrote `template_path: [templates/debian]` to reach
143
+ `./templates/debian` should now write `[debian]`.
144
+ - Rendered artifacts keep the template's final newline. The Jinja engine dropped
145
+ it while the shell engine kept it, so the same content rendered differently
146
+ depending on the file's suffix, and a kickstart or iPXE script could end
147
+ without a newline.
148
+ - MAC-less targets no longer all look for `00-00-00-00-00-00.<name>` first: the
149
+ null MAC is skipped when building candidate template names, as the unset IP
150
+ already was. One stray file of that name used to apply to every such target.
151
+ - A `http://...` entry in `templates` or in an image's `template_path` stays a
152
+ URI instead of being turned into a directory named `http:` under the working
153
+ directory, so URI search paths are searched at all. A repository's `local`
154
+ path accepts a Windows drive path (`C:\\tftp`), which was previously read as
155
+ URI scheme `c` and then failed on every read.
156
+ - An edited shell template is picked up again: the loader assigned its freshness
157
+ *check function* to `is_up_to_date`, and a function object is always truthy,
158
+ so a cached template was never reloaded.
159
+ - `Loader` works without a `PixieContext` in the environment: it resolves a
160
+ plain template name instead of raising `AttributeError`.
161
+ - Template selection is deterministic. Within a search directory an exact
162
+ filename now wins, and several files sharing a stem (`boot` matching
163
+ `boot.j2`, `boot.sh`, `boot.j2.bak`) resolve lowest-name-first instead of in
164
+ whatever order the filesystem listed them — a leftover `.bak`/`.orig`/
165
+ `.rpmnew` copy could previously be rendered instead of the real template.
166
+ - Templates are read as UTF-8 rather than the machine's locale encoding, so the
167
+ same template tree renders identically on every host (on Windows a non-ASCII
168
+ template could raise `UnicodeDecodeError` or decode wrongly).
169
+ - An image with no `template_path` renders instead of raising `AttributeError`
170
+ from `PixieContext.searchpaths`; it now defaults to `[]` as targets already
171
+ did.
172
+ - A target whose hostname does not resolve no longer hangs the process.
173
+ `PixieTarget` construction retried the same DNS lookup in an endless loop
174
+ while the name stayed unresolved; because every target is built at startup,
175
+ one not-yet-in-DNS entry made every `pixie` command hang and flood the
176
+ resolver. Resolution is now a single lookup: it fills `ip` on success, and on
177
+ failure logs a warning and leaves `ip` unset. A malformed hostname is warned
178
+ about instead of aborting construction.
179
+
180
+ ### Changed
181
+ - `Pixie.lookup_target` matches exactly before it matches by prefix. It
182
+ previously returned the first target whose hostname *started with* the query,
183
+ so with targets `node10` and `node1` in that order, `pixie initiate node1`
184
+ acted on `node10`. Exact id, hostname, MAC and IP matches across the whole
185
+ table now win; a hostname prefix is the fallback, a query matching more than
186
+ one target raises `LookupError` (the CLI reports it and exits 1) instead of
187
+ choosing one, and an empty query matches nothing rather than the first entry.
188
+ MAC queries are parsed, so `AA-BB-CC-00-00-01` and `aabb.cc00.0001` now match
189
+ a target keyed `aa:bb:cc:00:00:01`; previously only the colon spelling did.
190
+ - `pathlib_next[uri]` floor raised to `>=0.9.9`. Measured: recursive
191
+ `!include` globs (`sub/**/*.yaml`) fail on pathlib_next 0.9.5+ unless
192
+ yaconfiglib is 0.12.0+, and yaconfiglib 0.12.0 itself requires
193
+ pathlib-next>=0.9.9 — so the old `>=0.9.0` claim described a combination that
194
+ cannot work. `tests/test_config_discovery.py` now fails below the floor
195
+ instead of passing quietly.
196
+ - `yaconfiglib` moves to the 0.12 series (`>=0.12.0,<0.13`). The APIs netboot
197
+ uses are unchanged; the previous `<0.12` ceiling made netboot uninstallable
198
+ alongside yaconfiglib 0.12.
199
+ - The package logger is named `netboot`, not `NETBOOT`, and `-v`/`-q` now reach
200
+ it: duho sets the level of the logger named after the parser, which never
201
+ matched the one netboot writes to, so the verbosity flags had no effect on
202
+ netboot's own output. Anything filtering on the old name must use `netboot`.
203
+ - Importing `netboot` no longer has logging side effects. It used to quiet
204
+ urllib3/paramiko and disable urllib3's `InsecureRequestWarning`
205
+ process-wide — a decision belonging to the application, not to a library, and
206
+ it imported urllib3 as a side effect of `import netboot`. The `pixie` CLI
207
+ calls `quiet_noisy_dependencies()`; embedders opt in.
208
+ - The CLI reports operator errors as one line and exits 2 instead of printing a
209
+ traceback: a missing or malformed config, a non-mapping config, an unknown
210
+ image or zone, or a sandbox refusal. `-v` still shows the traceback, and an
211
+ unexpected exception is still raised in full. `--version` and usage errors
212
+ say `pixie` rather than the class name `Pixie_`.
213
+ - An absolute Windows path is accepted for `--config`/`--baseconfig`/template
214
+ paths. `C:\\srv\\tftp` was parsed as URI scheme `c`, which then failed with
215
+ `NotImplementedError`; a single-letter scheme is now read as a drive letter.
216
+ - An empty or comment-only config loads as `{}` rather than raising
217
+ `AttributeError`, a scalar `templates:` is accepted as a one-element list, and
218
+ a command returning a non-int value is warned about and treated as success
219
+ rather than crashing after the command's work is done.
220
+ - License metadata is PEP 639: `license = "MIT"` plus `license-files`, and the
221
+ legacy `License :: OSI Approved :: MIT License` classifier is gone (building
222
+ now needs `hatchling>=1.27`). The wheel carries the licence at
223
+ `netboot-<version>.dist-info/licenses/LICENSE`.
224
+ - Packaging excludes `*.local.*` from both sdist and wheel. A personal override
225
+ such as `pixie.local.yaml` or `AGENTS.local.md` previously shipped, because a
226
+ dotfile pattern does not match a name that has no leading dot.
227
+ - The engine moved to `netboot.engine`, leaving the package root as a surface
228
+ (44 lines). `from netboot import Pixie` and every other documented import are
229
+ unchanged; `netboot.engine.Pixie` is the same object. `netboot._version`
230
+ holds the version lookup.
231
+ - `netboot.utils` no longer re-exports `argparse.Namespace` as
232
+ `netboot.utils.Namespace`, where it read as netboot's own config base. The
233
+ config base is `netboot.utils.config.Namespace`, as documented.
234
+ - `PixieContext.templates` is gone. It was an annotation only: nothing ever set
235
+ it, so reading it raised `AttributeError`. Search paths are
236
+ `PixieContext.searchpaths`.
237
+ - `jinja2` is now `>=3.0,<4`, previously unpinned. jinja2 2.x imports
238
+ `markupsafe.soft_unicode`, removed in MarkupSafe 2.1, so an unconstrained
239
+ resolve could install a pair that raises `ImportError` on `import jinja2`.
240
+
241
+ ### Documentation
242
+ - The shipped API header (`src/netboot/AGENTS.md`) is corrected where it did not
243
+ match the code: `hook`'s `value` is positional-only and every hook must return
244
+ a value; `Path` in a Jinja template is `pathlib_next.Path`, not the stdlib's;
245
+ `netboot.netutils` is an attribute, not an importable module path;
246
+ `PixieContext.resources` starts empty and is a hook's slot to fill; template
247
+ search tries each candidate *name* across all search paths (not each path
248
+ across all names); importing `netboot.logging` does import urllib3 and
249
+ disables its insecure-request warning process-wide.
250
+ - `docs/cli.md` no longer says `initiate` renders artifacts or that `--iscsi`
251
+ prepares an iSCSI LUN — both are hook extension points; `--help` said the same
252
+ and was corrected too. The `--config` row explains that a `.cfg`/`.ini` suffix
253
+ is parsed as INI, not YAML.
254
+ - `docs/extending.md` documents the hook contract: the positional signature, the
255
+ `dict` fourth argument, `netboot=None` for `NewPixieObject`, the prefixed
256
+ `PixieEvent` string values, and that a hook must return the value.
257
+ - `docs/configuration.md` example is runnable as written (the MAC-keyed target
258
+ names its zone) and says that a `dnsmasq://` backend has to be provided and
259
+ imported. The shell-template section documents that only `%{NAME}` substitutes.
260
+ - The public API is documented in the source, so the API Reference renders
261
+ prose rather than bare signatures: 41 docstrings added, leaving only dunders
262
+ undocumented (which the reference filters out anyway).
263
+ - The API Reference page covers the content, template, utility and CLI modules
264
+ as well as the engine, and renders members that have no docstring; the site
265
+ gains a Changelog page.
266
+ - README/docs install instructions name `netboot[config]`, which the `pixie`
267
+ CLI needs to read a config file, and the README's LICENSE links are absolute
268
+ so they resolve on PyPI.
269
+ - `examples/` holds a complete runnable setup — config, both template engines,
270
+ and a `DhcpServer` plugin that prints instead of arming real DHCP — and a
271
+ repo-root `AGENTS.md` covers layout, environments, commands, CI and releasing
272
+ for contributors. The README gains Development and Releasing sections.
273
+ - `benchmarks/` documents its metrics and schema and keeps results in the
274
+ tracked `benchmarks/results/`, written by a new `--save` flag, so a
275
+ before/after comparison survives in history. The metric that claimed to
276
+ measure a worst-case target scan actually measured an indexed hit; it is now
277
+ a pair, `lookup_target_by_id` and `lookup_target_scan_by_ip`.
278
+ - CI: the release workflow runs a non-deploying docs gate and dispatches the
279
+ docs workflow for the tag (a release created with `GITHUB_TOKEN` starts no
280
+ workflow run, so `release: published` alone never deployed), publishing uses
281
+ `skip-existing`, the test workflow drops to read-only permissions and also
282
+ runs on Windows and macOS, and docs redeploy when `src/`, `README.md` or
283
+ `CHANGELOG.md` change.
284
+
285
+ ## [0.1.3] - 2026-08-16
286
+
287
+ Dependency floor raise only — no library or CLI behaviour changes.
288
+
289
+ ### Changed
290
+ - The four internal dependencies are now pinned to the minor series they are
291
+ supported on, rather than floored at whatever patch happened to be current:
292
+ `duho>=0.5.0,<0.6`, `netimps>=0.2.1,<0.3`, `pathlib_next[uri]>=0.9.0,<0.10`
293
+ and `yaconfiglib>=0.11.1,<0.12`. Each floor is the minor that netboot
294
+ actually requires, and each ceiling stops the next pre-1.0 minor — where, by
295
+ these projects' own versioning rule, the documented API is allowed to break
296
+ — from being resolved unattended. `jinja2` is third-party and stays
297
+ unpinned.
298
+ - `duho` moves from `>=0.4.1` to the 0.5 series. 0.5.0 changed option parsing
299
+ for `list`/`set`/`tuple` fields to one value per flag occurrence, which is
300
+ the shape `--cmdspath` (`Arg[list[str], Extend(os.pathsep)]`) is now
301
+ written against. The `CMDS_PATH` layering that `_discover` leans on after
302
+ 0.1.2 stopped re-resolving `PIXIE_CMDS_PATH` itself predates this at 0.4.1,
303
+ so it needs no floor above `0.5.0`.
304
+ - `netimps` moves from `>=0.2.1` to `>=0.2.1,<0.3` — the floor stays above the
305
+ `.0` on purpose. `Host.try_ip` calls `resolve()` with no `rdtype`, and
306
+ auto-selection of that argument (`"ptr"` for an address literal, `"a"`
307
+ otherwise) is what 0.2.1 added; on 0.2.0 the same call resolves differently.
308
+ - `pathlib_next[uri]` moves from `>=0.8.2` to the 0.9 series.
309
+ `Repository.service()` constructs `pathlib_next.uri.Source` directly from
310
+ `str(target.try_ip())`, and that direct-construction path raised
311
+ `socket.gaierror` out of `Source.is_local()` for a bare IPv6-literal host
312
+ until 0.9.0 — the same release that stopped `Source` rendering its password
313
+ in `str()`/`repr()`, so a service URI carrying credentials no longer leaks
314
+ through a traceback frame. 0.9.0's one breaking change is to `PathSyncer`,
315
+ which netboot does not use.
316
+ - `yaconfiglib` moves from an unbounded `>=0.10.0` — which spanned two minor
317
+ series — to `>=0.11.1,<0.12`. This floor is also above the `.0` on purpose:
318
+ `load_config` builds `ConfigLoader(..., recursive=True)`, and that setting
319
+ was never forwarded to glob expansion until 0.11.1, so on 0.10.x and 0.11.0
320
+ an argument netboot passes does nothing. 0.11.1 also made `typed_merge`
321
+ read parametrized generics, which is what `Repository.services`
322
+ (`dict[str, UriPath]`) is annotated with.
323
+
324
+ ## [0.1.2] - 2026-08-16
325
+
326
+ ### Added
327
+ - Test coverage for behaviour that was documented but unasserted: image
328
+ best-match ordering and its `{}` fallback, DHCP-zone lookup by IP containment
329
+ and its write-back onto the target, the hook chain (value threading, import
330
+ strings, target substitution) and the `initialize`/`complete` event sequence,
331
+ and repository/resource URI assembly with `Host.try_ip`. The suite goes from
332
+ 27 tests to 71; no production behaviour changed.
333
+ - Python 3.14 is advertised (trove classifier) and tested: the push matrix runs
334
+ 3.9–3.14 and the release matrix's ceiling moves from 3.13 to 3.14.
335
+
336
+ ### Changed
337
+ - The `duho` floor is now `>=0.4.1`, the release that made `Env` consult
338
+ `os.environ` before a `pixie_env` companion module and turned `CMDS_PATH`
339
+ discovery into a layer merged on top of an explicit `commands=` list. netboot
340
+ now depends on both behaviours.
341
+ - `PIXIE_CMDS_PATH` is left to duho instead of being re-resolved by netboot,
342
+ which had discovered the same modules a second time. One consequence is
343
+ visible: on a name clash `PIXIE_CMDS_PATH` now wins over `--cmdspath`, where
344
+ before the option won.
345
+ - `black` is the project formatter: it joins the `dev` extra with
346
+ `[tool.black] target-version = ["py39"]` (the supported floor), and the tree
347
+ has been reformatted once to match.
348
+ - The `netimps` floor is now `>=0.2.1`. `>=0.0.1` predated the API netboot
349
+ actually calls: the optional-`dnspython` `resolve()` fallback chain (0.2.0),
350
+ its auto-selected `rdtype` (0.2.1), and the 0.2.1 `ping(src=...)` `NameError`
351
+ fix.
352
+
353
+ ### Documentation
354
+ - `docs/cli.md` no longer tells operators that `pixie_env` module defaults
355
+ outrank real `PIXIE_*` environment variables. duho 0.4.1 fixed that
356
+ inversion; the note now states the true order (explicit `env` values > real
357
+ `PIXIE_*` > module defaults).
358
+ - The shipped API header (`src/netboot/AGENTS.md`) no longer tells consumers the
359
+ `dns` extra is a no-op with `dnspython` arriving transitively — false since
360
+ netimps 0.2.0 made `dnspython` optional. `netboot[dns]` is the only thing that
361
+ installs the dnspython backend; without it `resolve()` falls back to netimps'
362
+ `system`/`nslookup` backends. `resolve`'s documented default `rdtype` is now
363
+ `None` (auto-selects `"ptr"` for address literals, `"a"` otherwise).
364
+ - README and the docs landing page no longer claim the IP/MAC/DNS helpers are
365
+ vendored in-tree as `netboot._netutils` — that module was deleted when
366
+ `netimps` was adopted; they now credit `netimps` and name the
367
+ `netboot.utils.net` re-export.
368
+ - The `netboot[dns]` extra row describes what it actually does since netimps
369
+ 0.2.0: it installs the `dnspython` resolver backend, and hostname targets
370
+ still resolve without it via the system/`nslookup` fallbacks.
371
+ - Fixed the `pathlib_next` repository link (`pathlib-next`, with a hyphen) and
372
+ dropped the "once published" install note left on the docs landing page.
373
+
374
+ ## [0.1.1] - 2026-07-21
375
+
376
+ Packaging/CI fixes only — no library or CLI behaviour changes.
377
+
378
+ ### Fixed
379
+ - Release runs no longer fail at GitHub Release creation: the release is pinned
380
+ to the tagged commit (`target_commitish`) instead of defaulting to the
381
+ repository's default branch, which broke note generation for a release object
382
+ still pointing at a pre-rename branch.
383
+ - The release workflow's docs job self-enables GitHub Pages (`enablement: true`
384
+ plus `pages: write`), matching `docs.yml`, so a docs-site problem no longer
385
+ turns an otherwise-successful release red.
386
+ - The docs-only workflow triggers on `main`; it was listening on `master`, a
387
+ branch this repo does not have, so it never ran on a docs change.
388
+
389
+ ### Documentation
390
+ - README: version/pythons/license/docs/CI badge row, and the install note names
391
+ the `pixie` command instead of saying "once published".
392
+ - README library example binds the engine to `pixie` rather than `netboot`,
393
+ which read as the package and left two calls referencing an undefined name.
394
+
395
+ ## [0.1.0] - 2026-07-21
396
+
397
+ First packaged release: the `netboot` library with the `pixie` command line.
398
+
399
+ ### Added
400
+ - Packaged as `netboot` (src layout, hatchling, `pixie` console script, `py.typed`).
401
+ Python 3.9+.
402
+ - PXE provisioning engine: `Pixie` with target/image/dhcpzone/repo lookup, a
403
+ render `PixieContext`, and an `initialize`/`complete` lifecycle.
404
+ - Event-hook system (`PixieEvent`, `Pixie(hooks=...)`) for customising lookup,
405
+ context construction and the init/complete lifecycle.
406
+ - Template rendering via a URI-aware Jinja2 loader plus a `%`-delimited shell
407
+ template engine, selecting sources by MAC / hostname / IP.
408
+ - Pluggable `DhcpServer` backends dispatched by URI scheme, discovered
409
+ recursively so plugin modules loaded via `--load-module` are honoured.
410
+ - `pixie` CLI built on `duho` (PXE is pronounced "pixie"; `netboot` is the
411
+ library/import package): `initiate` and `complete` commands with layered YAML
412
+ config (`config/pixie.yaml` via `yaconfiglib`), command discovery, and
413
+ `--load-module`/`--cmdspath`.
414
+ - App settings read through `duho.env.Env("pixie")`, so `PIXIE_*` variables
415
+ (notably `PIXIE_CMDS_PATH`) configure the CLI; the resolved accessor reaches
416
+ commands as `args._env_`.
417
+ - Config objects use `yaconfiglib`'s `TypedNamespace` (`_parse_<field>` coercers)
418
+ and `OpaqueMerge` (last-object-wins) so fully-built targets/zones with
419
+ factory-function field hints are merged as opaque values (requires
420
+ `yaconfiglib>=0.10.0`).
421
+ - Two-workflow CI (`test`, `Release`) and a MkDocs documentation site
422
+ (`docs/` + `mkdocs.yml`, API reference via mkdocstrings), plus a
423
+ `benchmarks/bench_netboot.py` micro-benchmark for the lookup/template hot paths.
424
+ - IP/MAC/DNS helpers vendored in-tree as `netboot._netutils`; DNS lookup of
425
+ hostname targets is the optional `netboot[dns]` extra (`dnspython`).
426
+
427
+ ### Changed
428
+ - Built on `duho` (args/command-discovery/app) rather than the in-house
429
+ `coquilib` layer; IP/MAC helpers are vendored in-tree; the `sys.path`
430
+ `vendor/` shim is gone. Version derives from installed package metadata.
431
+
432
+ ### Fixed
433
+ - Target resolution no longer no-ops when the id is an IP address (`self.ip`
434
+ self-assignment and a `resolve - True` typo).
435
+ - `utils.flatten` now produces a genuinely flat mapping and no longer raises
436
+ `TypeError` on nested lists, so shell-template rendering works.
437
+ - `DhcpServer(uri)` raises a clear error for an unknown scheme instead of
438
+ silently returning an inert base, and zone `dhcpservers` URIs are constructed
439
+ into backends.
440
+ - `make_context` no longer deletes `globals` off shared image/dhcpzone/target
441
+ objects, so a second target reusing an image keeps that image's globals.
442
+ - `_template_names` accepts the loader's template `**options`; template names
443
+ stringify the target IP and skip an unspecified address.
444
+ - Command dispatch introspects each module's `run` signature, so a user command
445
+ using duho's plain `run(args)` is dispatched via `duho.run_command`.
446
+ - Shell templates render `None` as empty instead of the literal `"None"`;
447
+ config value construction no longer swallows non-`TypeError` errors; repo
448
+ `joinpath` keeps `.local` a path so chained joins work.
449
+
450
+ [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.0...HEAD
451
+ [0.2.0]: https://github.com/jose-pr/netboot/compare/v0.1.3...v0.2.0
452
+ [0.1.3]: https://github.com/jose-pr/netboot/compare/v0.1.2...v0.1.3
453
+ [0.1.2]: https://github.com/jose-pr/netboot/compare/v0.1.1...v0.1.2
454
+ [0.1.1]: https://github.com/jose-pr/netboot/compare/v0.1.0...v0.1.1
455
+ [0.1.0]: https://github.com/jose-pr/netboot/releases/tag/v0.1.0