netboot 0.2.4__tar.gz → 0.3.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 (84) hide show
  1. {netboot-0.2.4 → netboot-0.3.0}/AGENTS.md +9 -1
  2. {netboot-0.2.4 → netboot-0.3.0}/CHANGELOG.md +71 -2
  3. {netboot-0.2.4 → netboot-0.3.0}/PKG-INFO +26 -2
  4. {netboot-0.2.4 → netboot-0.3.0}/README.md +13 -1
  5. {netboot-0.2.4 → netboot-0.3.0}/docs/api.md +14 -0
  6. {netboot-0.2.4 → netboot-0.3.0}/docs/configuration.md +83 -4
  7. netboot-0.3.0/docs/extending.md +150 -0
  8. {netboot-0.2.4 → netboot-0.3.0}/examples/README.md +7 -5
  9. netboot-0.3.0/examples/templates/debian/boot.cfg.shtpl +8 -0
  10. {netboot-0.2.4 → netboot-0.3.0}/pyproject.toml +15 -1
  11. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/AGENTS.md +106 -15
  12. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/engine.py +5 -3
  13. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/templates/__init__.py +111 -10
  14. netboot-0.3.0/src/netboot/templates/common.py +171 -0
  15. netboot-0.3.0/src/netboot/templates/copy.py +76 -0
  16. netboot-0.3.0/src/netboot/templates/data.py +160 -0
  17. netboot-0.3.0/src/netboot/templates/external.py +220 -0
  18. netboot-0.3.0/src/netboot/templates/handlebars.py +54 -0
  19. netboot-0.3.0/src/netboot/templates/jinja.py +39 -0
  20. netboot-0.3.0/src/netboot/templates/liquid.py +59 -0
  21. netboot-0.3.0/src/netboot/templates/mako.py +131 -0
  22. netboot-0.3.0/src/netboot/templates/mustache.py +55 -0
  23. netboot-0.3.0/src/netboot/templates/shell.py +161 -0
  24. netboot-0.3.0/tests/test_external_engines.py +244 -0
  25. netboot-0.3.0/tests/test_mako.py +133 -0
  26. netboot-0.3.0/tests/test_optional_engines.py +206 -0
  27. {netboot-0.2.4 → netboot-0.3.0}/tests/test_render.py +22 -22
  28. netboot-0.3.0/tests/test_shell_template.py +257 -0
  29. netboot-0.3.0/tests/test_template_registry.py +150 -0
  30. netboot-0.2.4/docs/extending.md +0 -77
  31. netboot-0.2.4/examples/templates/debian/boot.cfg +0 -6
  32. netboot-0.2.4/src/netboot/templates/common.py +0 -50
  33. netboot-0.2.4/src/netboot/templates/jinja.py +0 -24
  34. netboot-0.2.4/src/netboot/templates/shell.py +0 -85
  35. {netboot-0.2.4 → netboot-0.3.0}/.gitignore +0 -0
  36. {netboot-0.2.4 → netboot-0.3.0}/LICENSE +0 -0
  37. {netboot-0.2.4 → netboot-0.3.0}/benchmarks/README.md +0 -0
  38. {netboot-0.2.4 → netboot-0.3.0}/benchmarks/bench_netboot.py +0 -0
  39. {netboot-0.2.4 → netboot-0.3.0}/benchmarks/results/netboot.json +0 -0
  40. {netboot-0.2.4 → netboot-0.3.0}/docs/changelog.md +0 -0
  41. {netboot-0.2.4 → netboot-0.3.0}/docs/cli.md +0 -0
  42. {netboot-0.2.4 → netboot-0.3.0}/docs/index.md +0 -0
  43. {netboot-0.2.4 → netboot-0.3.0}/examples/config/pixie.yaml +0 -0
  44. {netboot-0.2.4 → netboot-0.3.0}/examples/plugins/recording.py +0 -0
  45. {netboot-0.2.4 → netboot-0.3.0}/examples/templates/debian/install.ks.j2 +0 -0
  46. {netboot-0.2.4 → netboot-0.3.0}/mkdocs.yml +0 -0
  47. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/__init__.py +0 -0
  48. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/__main__.py +0 -0
  49. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/_version.py +0 -0
  50. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/cmds/__init__.py +0 -0
  51. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/cmds/complete.py +0 -0
  52. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/cmds/initiate.py +0 -0
  53. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/content/__init__.py +0 -0
  54. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/dhcp/__init__.py +0 -0
  55. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/dhcp/dhcpd.py +0 -0
  56. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/dhcp/dnsmasq.py +0 -0
  57. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/dhcp/kea.py +0 -0
  58. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/dhcp/options.py +0 -0
  59. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/dhcp/windhcp.py +0 -0
  60. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/logging.py +0 -0
  61. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/main.py +0 -0
  62. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/py.typed +0 -0
  63. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/utils/__init__.py +0 -0
  64. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/utils/config.py +0 -0
  65. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/utils/dicts.py +0 -0
  66. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/utils/misc.py +0 -0
  67. {netboot-0.2.4 → netboot-0.3.0}/src/netboot/utils/net.py +0 -0
  68. {netboot-0.2.4 → netboot-0.3.0}/tests/conftest.py +0 -0
  69. {netboot-0.2.4 → netboot-0.3.0}/tests/test_api_contracts.py +0 -0
  70. {netboot-0.2.4 → netboot-0.3.0}/tests/test_cli.py +0 -0
  71. {netboot-0.2.4 → netboot-0.3.0}/tests/test_cli_discovery.py +0 -0
  72. {netboot-0.2.4 → netboot-0.3.0}/tests/test_config_discovery.py +0 -0
  73. {netboot-0.2.4 → netboot-0.3.0}/tests/test_content.py +0 -0
  74. {netboot-0.2.4 → netboot-0.3.0}/tests/test_dhcp.py +0 -0
  75. {netboot-0.2.4 → netboot-0.3.0}/tests/test_dhcp_dhcpd.py +0 -0
  76. {netboot-0.2.4 → netboot-0.3.0}/tests/test_dhcp_dnsmasq.py +0 -0
  77. {netboot-0.2.4 → netboot-0.3.0}/tests/test_dhcp_kea.py +0 -0
  78. {netboot-0.2.4 → netboot-0.3.0}/tests/test_dhcp_options.py +0 -0
  79. {netboot-0.2.4 → netboot-0.3.0}/tests/test_dhcp_windhcp.py +0 -0
  80. {netboot-0.2.4 → netboot-0.3.0}/tests/test_engine_robustness.py +0 -0
  81. {netboot-0.2.4 → netboot-0.3.0}/tests/test_hooks.py +0 -0
  82. {netboot-0.2.4 → netboot-0.3.0}/tests/test_lookup.py +0 -0
  83. {netboot-0.2.4 → netboot-0.3.0}/tests/test_target.py +0 -0
  84. {netboot-0.2.4 → netboot-0.3.0}/tests/test_utils.py +0 -0
@@ -36,7 +36,15 @@ python -m venv .venv/3.14-nt-amd64
36
36
  ```
37
37
 
38
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.
39
+ tests skip rather than run, and `pytest -rs` is how you see that happen. The
40
+ `dev` extra pulls in the optional template engines' libraries (`mako`,
41
+ `python-liquid`, `pybars3`, `chevron`) for the same reason.
42
+
43
+ `tests/test_external_engines.py` needs **`ruby`** and **`puppet`**, which this
44
+ Windows box does not have: those cases skip here and run under WSL
45
+ (`FedoraLinux-44` has both). A change to `.erb`/`.epp` rendering is unverified
46
+ until the suite has run there — the engine-shape cases (registration, suffixes,
47
+ the missing-program error) do run everywhere.
40
48
 
41
49
  ## Commands
42
50
 
@@ -4,7 +4,75 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.3.0] - 2026-10-02
8
+
9
+ ### Changed
10
+ - **The shell template engine now claims the `.shtpl` suffix only** (it claimed
11
+ *every* suffix before, as the documented fallback). The suffix picks the engine
12
+ and the stem names the artifact, which is the convention `install.ks.j2`
13
+ already used: rename `boot.cfg` to `boot.cfg.shtpl` and `ctx.render("boot.cfg")`
14
+ keeps working unchanged. A file no engine claims is **copied as is** by the new
15
+ `CopyTemplate`, last in the default `template_types` — so a static `grub.cfg`
16
+ needs no engine, and a file that *does* carry `%{NAME}` placeholders ships them
17
+ unrendered until it is renamed. That one case logs a warning naming the rename.
18
+ Narrowing `template_types` so nothing claims a file raises
19
+ `netboot.templates.TemplateEngineError`, which names the file and every
20
+ engine's suffixes.
21
+
22
+ ### Added
23
+ - **Seven more template engines**, each claiming its own suffix and costing
24
+ nothing when unused: `MakoTemplate` (`.mako`, `netboot[mako]`),
25
+ `LiquidTemplate` (`.liquid`, `netboot[liquid]`), `HandlebarsTemplate`
26
+ (`.hbs`/`.handlebars`, `netboot[handlebars]`), `MustacheTemplate` (`.mustache`,
27
+ `netboot[mustache]`), and `ERBTemplate` (`.erb`) / `EppTemplate` (`.epp`),
28
+ which render through the system `ruby` and `puppet epp render` so an existing
29
+ template behaves as its author tested it (`PIXIE_RUBY` / `PIXIE_PUPPET` name
30
+ the program if it is not on `PATH`). Every engine is registered whether or not
31
+ its dependency is present — a claimed file reports what is missing instead of
32
+ being copied — and nothing is imported until a file claims it. What each engine
33
+ does with `templates_undefined` differs by library and is tabulated in the
34
+ configuration guide; handlebars and mustache cannot fail on an undefined name
35
+ at all.
36
+ - `template_data()` / `DataView` / `jsonable()` — a lazy mapping view of the
37
+ render context for engines that read data rather than evaluate Python
38
+ (`{{ target.hostname }}` beside `{{ ctx.target.hostname }}`), and its
39
+ JSON-serialisable form for the subprocess engines.
40
+ - `SubprocessTemplate` — the base for an engine that renders through another
41
+ language's own tooling: program lookup with an env override, a temporary
42
+ template and values file, a timeout, and the program's stderr in the error.
43
+ - `ShellTemplate` is configured by subclassing, through three class attributes:
44
+ `EXT` (a string or a sequence, dots optional, case-insensitive; `None` or
45
+ `"*"` claims any suffix), `DELIMITER` (default `"%"`) and `PATTERN` —
46
+ `"braced"` (default, `%{NAME}`), `"unbraced"` (`%NAME`) or `"all"`.
47
+ - POSIX default expressions in the braced form: `%{NAME:-fallback}` substitutes
48
+ the fallback when the value is unset *or* empty, `%{NAME-fallback}` only when
49
+ it is unset. One layer of `'`/`"` quotes is stripped, the fallback is literal
50
+ text, and a placeholder with a default never fails whatever
51
+ `templates_undefined` says. `:=`, `:?` and `:+` are not implemented and stay
52
+ literal text.
53
+ - `CopyTemplate` — the copy-as-is engine described above, working in **bytes**:
54
+ the loader no longer decodes a file before knowing which engine claims it, so
55
+ the copy is byte-exact for anything (CRLF, latin-1, a binary) and
56
+ `ctx.render()` returns `bytes` for such a file. `EXT` narrows it to chosen
57
+ suffixes; `Template.BINARY` is the flag any engine can set to be handed raw
58
+ bytes. A non-UTF-8 file claimed by a *text* engine now raises
59
+ `TemplateEngineError` naming the file, where it used to fail inside the read.
60
+ - `Loader.find_source()` — the byte-returning counterpart of `get_source()`
61
+ (which keeps jinja2's text contract).
62
+ - **A template-engine registry**: `register_template_type()` (bare or as a
63
+ decorator, with an optional `priority=`), `unregister_template_type()`,
64
+ `TEMPLATE_TYPES` and `by_priority()`. `Loader(template_types=None)` — the new
65
+ default — uses every registered engine, so a `--load-module` plugin is picked
66
+ up without rebuilding the list, and the list is snapshotted at construction.
67
+ - **`Template.PRIORITY`**, so engine selection does not depend on registration
68
+ order: engines are consulted highest first, `DEFAULT_PRIORITY` (0) for an
69
+ ordinary engine and `FALLBACK_PRIORITY` (-100) for `CopyTemplate`. A plugin is
70
+ registered after the shipped engines, so without this every plugin would sit
71
+ behind the catch-all and never see a file. Equal priorities keep the order they
72
+ were given, so an explicit `template_types` list still means what it says.
73
+ - `Template.EXT` and `JinjaTemplate.EXT`, so suffix ownership is one declarative
74
+ attribute per engine, plus `netboot.templates.template_extensions()` to read it
75
+ back normalised.
8
76
 
9
77
  ## [0.2.4] - 2026-09-28
10
78
 
@@ -563,7 +631,8 @@ First packaged release: the `netboot` library with the `pixie` command line.
563
631
  config value construction no longer swallows non-`TypeError` errors; repo
564
632
  `joinpath` keeps `.local` a path so chained joins work.
565
633
 
566
- [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.4...HEAD
634
+ [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.3.0...HEAD
635
+ [0.3.0]: https://github.com/jose-pr/netboot/compare/v0.2.4...v0.3.0
567
636
  [0.2.4]: https://github.com/jose-pr/netboot/compare/v0.2.3...v0.2.4
568
637
  [0.2.3]: https://github.com/jose-pr/netboot/compare/v0.2.2...v0.2.3
569
638
  [0.2.2]: https://github.com/jose-pr/netboot/compare/v0.2.1...v0.2.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: netboot
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: PXE provisioning management: render netboot artifacts and drive DHCP for targets
5
5
  Project-URL: Homepage, https://github.com/jose-pr/netboot/
6
6
  Project-URL: Issues, https://github.com/jose-pr/netboot/issues
@@ -35,11 +35,15 @@ Requires-Dist: pyyaml; extra == 'config'
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: black; extra == 'dev'
37
37
  Requires-Dist: build; extra == 'dev'
38
+ Requires-Dist: chevron; extra == 'dev'
38
39
  Requires-Dist: dnspython; extra == 'dev'
39
40
  Requires-Dist: hatchling; extra == 'dev'
41
+ Requires-Dist: mako; extra == 'dev'
42
+ Requires-Dist: pybars3; extra == 'dev'
40
43
  Requires-Dist: pypureomapi; extra == 'dev'
41
44
  Requires-Dist: pytest; extra == 'dev'
42
45
  Requires-Dist: pytest-cov; extra == 'dev'
46
+ Requires-Dist: python-liquid; extra == 'dev'
43
47
  Requires-Dist: pywinrm; extra == 'dev'
44
48
  Requires-Dist: pyyaml; extra == 'dev'
45
49
  Requires-Dist: requests; extra == 'dev'
@@ -52,10 +56,18 @@ Provides-Extra: docs
52
56
  Requires-Dist: mkdocs; extra == 'docs'
53
57
  Requires-Dist: mkdocs-material; extra == 'docs'
54
58
  Requires-Dist: mkdocstrings[python]; extra == 'docs'
59
+ Provides-Extra: handlebars
60
+ Requires-Dist: pybars3; extra == 'handlebars'
55
61
  Provides-Extra: http
56
62
  Requires-Dist: requests; extra == 'http'
57
63
  Provides-Extra: kea
58
64
  Requires-Dist: requests; extra == 'kea'
65
+ Provides-Extra: liquid
66
+ Requires-Dist: python-liquid; extra == 'liquid'
67
+ Provides-Extra: mako
68
+ Requires-Dist: mako; extra == 'mako'
69
+ Provides-Extra: mustache
70
+ Requires-Dist: chevron; extra == 'mustache'
59
71
  Provides-Extra: ssh
60
72
  Requires-Dist: pathlib-next[sftp]; extra == 'ssh'
61
73
  Provides-Extra: winrm
@@ -99,11 +111,21 @@ Optional extras:
99
111
  | `netboot[dns]` | The `dnspython` resolver backend (best coverage; without it hostname targets still resolve via the system/`nslookup` fallbacks) |
100
112
  | `netboot[http]` | `http`/`https` repository services (`requests`); `file`/`tftp` repos and rendering need nothing extra |
101
113
  | `netboot[kea]` | The `kea://` DHCP backend (`requests`) |
114
+ | `netboot[mako]` | `*.mako` templates (`mako`) |
115
+ | `netboot[liquid]` | `*.liquid` templates (`python-liquid`) |
116
+ | `netboot[handlebars]` | `*.hbs` / `*.handlebars` templates (`pybars3`) |
117
+ | `netboot[mustache]` | `*.mustache` templates (`chevron`) |
102
118
  | `netboot[dhcpd]` | The `dhcpd://` DHCP backend over OMAPI (`pypureomapi`) |
103
119
  | `netboot[winrm]` | `windhcp://` over WinRM (`pywinrm`); over ssh it needs nothing |
104
120
  | `netboot[ssh]` | `dnsmasq://` with remote `sftp://` paths (`pathlib_next[sftp]`) |
105
121
  | `netboot[docs]` | Build the documentation site (`mkdocs`) |
106
122
 
123
+ `*.erb` and `*.epp` templates need no extra, but do need `ruby` or `puppet`
124
+ installed: they render through the real program, so an existing template behaves
125
+ as its author tested it. `PIXIE_RUBY` / `PIXIE_PUPPET` name it when it is not on
126
+ `PATH`. Jinja2 (`.j2`) and the `%{NAME}` shell engine (`.shtpl`) are built in, and
127
+ a file no engine claims is copied unchanged.
128
+
107
129
  Built on [`duho`](https://github.com/jose-pr/duho) (CLI/args/command discovery),
108
130
  [`pathlib_next`](https://github.com/jose-pr/pathlib-next) (URI-aware paths),
109
131
  [`yaconfiglib`](https://github.com/jose-pr/yaconfiglib) (layered YAML), and
@@ -175,7 +197,9 @@ python -m venv .venv/3.14
175
197
  ```
176
198
 
177
199
  Install every extra that has tests — without `http` the repository-service tests
178
- skip instead of running, which `-rs` makes visible. Develop on the latest Python
200
+ skip instead of running, which `-rs` makes visible (`dev` carries the template
201
+ engines' libraries for the same reason; the `.erb`/`.epp` tests skip unless
202
+ `ruby`/`puppet` are installed). Develop on the latest Python
179
203
  and also run the floor (3.9) before finishing a piece of work. More detail, plus
180
204
  the layout and CI notes, in
181
205
  [`AGENTS.md`](https://github.com/jose-pr/netboot/blob/main/AGENTS.md).
@@ -35,11 +35,21 @@ Optional extras:
35
35
  | `netboot[dns]` | The `dnspython` resolver backend (best coverage; without it hostname targets still resolve via the system/`nslookup` fallbacks) |
36
36
  | `netboot[http]` | `http`/`https` repository services (`requests`); `file`/`tftp` repos and rendering need nothing extra |
37
37
  | `netboot[kea]` | The `kea://` DHCP backend (`requests`) |
38
+ | `netboot[mako]` | `*.mako` templates (`mako`) |
39
+ | `netboot[liquid]` | `*.liquid` templates (`python-liquid`) |
40
+ | `netboot[handlebars]` | `*.hbs` / `*.handlebars` templates (`pybars3`) |
41
+ | `netboot[mustache]` | `*.mustache` templates (`chevron`) |
38
42
  | `netboot[dhcpd]` | The `dhcpd://` DHCP backend over OMAPI (`pypureomapi`) |
39
43
  | `netboot[winrm]` | `windhcp://` over WinRM (`pywinrm`); over ssh it needs nothing |
40
44
  | `netboot[ssh]` | `dnsmasq://` with remote `sftp://` paths (`pathlib_next[sftp]`) |
41
45
  | `netboot[docs]` | Build the documentation site (`mkdocs`) |
42
46
 
47
+ `*.erb` and `*.epp` templates need no extra, but do need `ruby` or `puppet`
48
+ installed: they render through the real program, so an existing template behaves
49
+ as its author tested it. `PIXIE_RUBY` / `PIXIE_PUPPET` name it when it is not on
50
+ `PATH`. Jinja2 (`.j2`) and the `%{NAME}` shell engine (`.shtpl`) are built in, and
51
+ a file no engine claims is copied unchanged.
52
+
43
53
  Built on [`duho`](https://github.com/jose-pr/duho) (CLI/args/command discovery),
44
54
  [`pathlib_next`](https://github.com/jose-pr/pathlib-next) (URI-aware paths),
45
55
  [`yaconfiglib`](https://github.com/jose-pr/yaconfiglib) (layered YAML), and
@@ -111,7 +121,9 @@ python -m venv .venv/3.14
111
121
  ```
112
122
 
113
123
  Install every extra that has tests — without `http` the repository-service tests
114
- skip instead of running, which `-rs` makes visible. Develop on the latest Python
124
+ skip instead of running, which `-rs` makes visible (`dev` carries the template
125
+ engines' libraries for the same reason; the `.erb`/`.epp` tests skip unless
126
+ `ruby`/`puppet` are installed). Develop on the latest Python
115
127
  and also run the floor (3.9) before finishing a piece of work. More detail, plus
116
128
  the layout and CI notes, in
117
129
  [`AGENTS.md`](https://github.com/jose-pr/netboot/blob/main/AGENTS.md).
@@ -39,8 +39,22 @@ The engine and its context objects. Rendered from source via
39
39
 
40
40
  ::: netboot.templates.JinjaTemplate
41
41
 
42
+ ::: netboot.templates.MakoTemplate
43
+
44
+ ::: netboot.templates.LiquidTemplate
45
+
46
+ ::: netboot.templates.HandlebarsTemplate
47
+
48
+ ::: netboot.templates.MustacheTemplate
49
+
50
+ ::: netboot.templates.ERBTemplate
51
+
52
+ ::: netboot.templates.EppTemplate
53
+
42
54
  ::: netboot.templates.ShellTemplate
43
55
 
56
+ ::: netboot.templates.CopyTemplate
57
+
44
58
  ## Utilities
45
59
 
46
60
  ::: netboot.utils.dicts
@@ -176,11 +176,90 @@ not a mapping is an error naming the file.
176
176
 
177
177
  `templates` is a list of search paths (local or URI). For each render netboot looks
178
178
  for a file named by the target's MAC (`aa-bb-cc-...`), hostname, or IP — falling
179
- back to the bare template name. A `.j2` / `.jinja` / `.jinja2` file is rendered
180
- with Jinja2; anything else is rendered with the `%`-delimited shell engine, whose
181
- `%{UPPER_SNAKE}` placeholders come from the flattened context.
179
+ back to the bare template name.
180
+
181
+ **The suffix picks the engine, and the stem names the artifact.** A `.j2` /
182
+ `.jinja` / `.jinja2` file is rendered with Jinja2; a `.shtpl` file with the
183
+ `%`-delimited shell engine, whose `%{UPPER_SNAKE}` placeholders come from the
184
+ flattened context. Seven more engines ship for template trees that already exist
185
+ in another language:
186
+
187
+ | Engine | Suffixes | Needs | `templates_undefined` |
188
+ | ------ | -------- | ----- | --------------------- |
189
+ | `JinjaTemplate` | `.j2` `.jinja` `.jinja2` | — | all three |
190
+ | `MakoTemplate` | `.mako` | `netboot[mako]` | `strict`, `lenient`; `debug` behaves as `lenient` |
191
+ | `LiquidTemplate` | `.liquid` | `netboot[liquid]` | all three (`debug` names the variable in the output) |
192
+ | `HandlebarsTemplate` | `.hbs` `.handlebars` | `netboot[handlebars]` | **always lenient** — the library cannot fail |
193
+ | `MustacheTemplate` | `.mustache` | `netboot[mustache]` | **always lenient** (logs under `strict`) |
194
+ | `ERBTemplate` | `.erb` | `ruby` on `PATH` (or `PIXIE_RUBY`) | n/a — missing data is Ruby's `nil` |
195
+ | `EppTemplate` | `.epp` | `puppet` on `PATH` (or `PIXIE_PUPPET`) | n/a — missing data is Puppet's `undef` |
196
+ | `ShellTemplate` | `.shtpl` | — | all three |
197
+ | `CopyTemplate` | anything else | — | n/a — nothing is substituted |
198
+
199
+ Every optional engine is **registered whether or not its library is installed**,
200
+ so a `.liquid` file tells you to `pip install netboot[liquid]` instead of being
201
+ quietly copied. None of them is imported until a file claims it, so an install
202
+ that uses none pays nothing.
203
+
204
+ Jinja and mako evaluate Python, so a template reaches into `ctx` directly. The
205
+ data languages (liquid, handlebars, mustache) and the external ones (ERB, EPP)
206
+ get a **mapping view** of the same context instead: `{{ ctx.target.hostname }}`
207
+ and `{{ target.hostname }}` both resolve, an address or a path arrives as the
208
+ string a template would have printed, and the engine's own machinery
209
+ (`ctx._netboot_`, the renderer) is left out. ERB additionally sets each top-level
210
+ name as an instance variable (`@target`), and EPP as a parameter (`$target`).
211
+
212
+ ERB and EPP run the real `ruby` and `puppet` as a subprocess, with the context
213
+ marshalled to JSON — which is the point: an existing `.erb` renders the way its
214
+ author tested it, rather than the way a reimplementation guesses. Set `PIXIE_RUBY`
215
+ or `PIXIE_PUPPET` if the program is installed but not on `PATH`. Because a template is also found by its stem, the file behind
216
+ an artifact called `boot.cfg` is `boot.cfg.shtpl` and `ctx.render("boot.cfg")`
217
+ still finds it.
218
+
219
+ Anything else is **copied as is** — a static `grub.cfg`, an EFI binary or a
220
+ license file is a template that needs no engine. The copy engine works in
221
+ **bytes**: the loader never decodes the file, so the result is byte-exact
222
+ whatever it holds (CRLF line endings, latin-1 text, something that is not text at
223
+ all) and `ctx.render()` returns `bytes` for it rather than `str`.
224
+
225
+ Before 0.3.0 the shell engine claimed every suffix, so a file that *does* carry
226
+ `%{NAME}` placeholders now ships them unrendered: rename it to `*.shtpl` once.
227
+ That is the one mistake a copy can hide, so a copy that still finds placeholders
228
+ logs a warning naming the rename.
182
229
 
183
230
  Only the braced form is substituted, so a bare `%word` is left alone — a kickstart
184
231
  file keeps its `%packages`, `%pre`, `%post` and `%end` sections, and a script keeps
185
232
  `date +%Y`. Write `%%` for a literal `%` next to a brace, `%{NAME}` to substitute.
186
- An unknown `%{NAME}` is an error.
233
+ An unknown `%{NAME}` follows `templates_undefined` (above).
234
+
235
+ `%{NAME:-fallback}` renders `fallback` when `NAME` is unset **or empty**, and
236
+ `%{NAME-fallback}` only when it is unset — the two POSIX forms, so a template can
237
+ carry its own default instead of requiring the variable. One layer of `'`/`"`
238
+ quotes is stripped (`%{NAME:-'a default'}`), the fallback is literal text (no
239
+ nested placeholders, and no `}` inside it), and a placeholder that has a default
240
+ never fails whatever `templates_undefined` says. The other POSIX forms (`:=`,
241
+ `:?`, `:+`) are not implemented and stay literal text.
242
+
243
+ The engine is configured by subclassing, not by config, for a tree that uses
244
+ another convention:
245
+
246
+ ```python
247
+ from netboot.templates.shell import ShellTemplate
248
+
249
+ class DollarTemplate(ShellTemplate):
250
+ EXT = (".tpl", ".cfg") # a single string is fine; `None` claims any suffix
251
+ DELIMITER = "$"
252
+ PATTERN = "all" # 'braced' (default), 'unbraced', or 'all'
253
+ ```
254
+
255
+ Register the class with `netboot.templates.register_template_type` (see
256
+ [Extending](extending.md#custom-template-engines)) or pass it in
257
+ `Loader(..., template_types=[...])`. Engines are consulted in `PRIORITY` order,
258
+ highest first, so a registered engine is asked before the catch-all
259
+ `CopyTemplate` whatever order they were registered in. The default is every
260
+ registered engine: `JinjaTemplate`, `ShellTemplate`, then `CopyTemplate`. Narrow
261
+ it — dropping the copy engine, or giving it a suffix list of its own — and a file
262
+ nothing claims raises `netboot.templates.TemplateEngineError`, naming the file and
263
+ what each engine handles. A file that is not valid UTF-8 raises the same error when the engine
264
+ that claims it needs text; set `BINARY = True` on an engine to be handed the raw
265
+ bytes instead, as `CopyTemplate` does.
@@ -0,0 +1,150 @@
1
+ # Extending
2
+
3
+ ## Event hooks
4
+
5
+ Pass `hooks=[...]` to `Pixie(...)` — each entry is a callable or a
6
+ `"module.function"` import string. A hook
7
+
8
+ ```python
9
+ def my_hook(event, netboot, value, kwargs):
10
+ ...
11
+ return value
12
+ ```
13
+
14
+ is invoked for every `PixieEvent` and may transform the value flowing through it.
15
+ Events fire around object creation (`NewPixieObject`, `SetPixieProperty`,
16
+ `PixieInitiated`), lookup (`LookupTarget`, `FoundTarget`, `FoundTargetImage`,
17
+ `FoundTargetDhcpzone`), context construction (`PixieContextForTarget`), and the
18
+ init/complete lifecycle (`StartPixieInitialize` … `EndPixieComplete`). This is the
19
+ seam for customising how targets/images/zones are resolved and how the render
20
+ context is assembled.
21
+
22
+ ### The contract
23
+
24
+ - **Always return.** Every hook is handed the previous hook's return value, and
25
+ the last return value is what netboot uses. A hook that falls off the end
26
+ returns `None`, which is then what the engine gets — a lookup hook that
27
+ forgets to `return value` turns every lookup into "not found".
28
+ - **The signature is positional**, and `kwargs` arrives as a plain `dict` (the
29
+ fourth argument), not as `**kwargs`.
30
+ - **`netboot` is `None` for `NewPixieObject`**, which fires before the instance
31
+ exists; `value` there is the class about to be instantiated, and returning a
32
+ subclass is how you swap in your own.
33
+ - **`value` and `kwargs` differ per event.** `SetPixieProperty` gets a
34
+ `(name, value)` tuple plus `origin=`/`rawvalue=`; the lookup events get the
35
+ object found (or the query, for `LookupTarget`) plus `target=`; the lifecycle
36
+ events get the target, then the built context.
37
+ - **`PixieEvent` is a string enum whose values are prefixed** — the value of
38
+ `PixieEvent.LookupTarget` is the string `"PixieEvent.LookupTarget"`. Compare
39
+ against the enum member, not a bare name.
40
+
41
+ ```python
42
+ from netboot import PixieEvent
43
+
44
+ def only_on_lookup(event, netboot, value, kwargs):
45
+ if event is not PixieEvent.FoundTarget:
46
+ return value # pass everything else through
47
+ return value or fallback_target()
48
+ ```
49
+
50
+ ## Custom template engines
51
+
52
+ An engine is a class with two things: the suffixes it claims (`EXT`) and
53
+ `render()`. Register it and it joins the engines every `Loader` consults.
54
+
55
+ ```python
56
+ from netboot.templates import Template, register_template_type
57
+
58
+ @register_template_type
59
+ class MakoTemplate(Template):
60
+ EXT = ".mako" # a string, or a sequence of them
61
+
62
+ def __init__(self, template: str) -> None:
63
+ self._source = template
64
+
65
+ def render(self, **extras):
66
+ ctx = self._globals_["ctx"] # the PixieContext being rendered
67
+ return my_mako_render(self._source, ctx)
68
+ ```
69
+
70
+ Import the module before anything renders — `--load-module your.plugin`, the same
71
+ flag a DHCP backend plugin needs.
72
+
73
+ **Priority, not registration order, decides who gets a file.** Engines are
74
+ consulted highest `PRIORITY` first, and a plugin is necessarily registered
75
+ *after* the shipped engines — if order decided, every plugin would sit behind the
76
+ catch-all `CopyTemplate` and never see a file. So:
77
+
78
+ | `PRIORITY` | Meaning |
79
+ | ---------- | ------- |
80
+ | `DEFAULT_PRIORITY` (`0`) | an ordinary engine claiming its own suffixes — the default, and enough to outrank the copy engine |
81
+ | above `0` | override a shipped engine on a suffix it also claims (`priority=10` to take `.j2` from `JinjaTemplate`) |
82
+ | `FALLBACK_PRIORITY` (`-100`) | a last resort, where `CopyTemplate` sits |
83
+
84
+ Engines of *equal* priority keep the order they were given, so an explicit
85
+ `Loader(..., template_types=[...])` still means what it says. Set the priority on
86
+ the class, or at registration for a class you do not own:
87
+
88
+ ```python
89
+ register_template_type(SomeonesEngine, priority=10)
90
+ ```
91
+
92
+ `unregister_template_type(cls)` removes one again, and a `Loader` snapshots the
93
+ registry when it is built, so importing a plugin halfway through a run never
94
+ changes an engine already in use.
95
+
96
+ Other attributes on the contract: **`BINARY = True`** to be handed the file's raw
97
+ `bytes` instead of decoded text (and to return bytes, as `CopyTemplate` does),
98
+ and `EXT = None` to claim *every* suffix — which only makes sense together with a
99
+ low priority.
100
+
101
+ The shipped engines are the worked examples, and they cover the three shapes an
102
+ engine takes:
103
+
104
+ - **evaluates Python** (`JinjaTemplate`, `MakoTemplate`) — hand the engine `ctx`
105
+ itself and let the template reach into it.
106
+ - **reads data only** (`LiquidTemplate`, `HandlebarsTemplate`,
107
+ `MustacheTemplate`) — build the namespace with
108
+ `netboot.templates.template_data(self._globals_, extras)`, which returns a lazy
109
+ mapping view of the context. liquid cannot traverse attributes at all, so this
110
+ is not optional for that class of engine.
111
+ - **shells out** (`ERBTemplate`, `EppTemplate`) — subclass
112
+ `SubprocessTemplate`, set `COMMAND`, `ENV_VAR` and `EXT`, and implement
113
+ `command(program, template_path, values_path)`. The base marshals the context
114
+ with `jsonable()`, writes template and values into a temporary directory, runs
115
+ the program with a timeout, and turns a non-zero exit into a
116
+ `TemplateEngineError` carrying the program's own stderr.
117
+
118
+ An engine that needs an optional library imports it in a helper, not at module
119
+ scope, and raises `ImportError` naming the extra — that way the class can be
120
+ registered unconditionally, and a file it claims reports what is missing rather
121
+ than falling through to the copy engine.
122
+
123
+ ## Custom DHCP backends
124
+
125
+ `netboot.dhcp.DhcpServer` dispatches on the URI scheme of a zone's `dhcpservers`
126
+ entry: a subclass whose lowercased class name equals the scheme handles it.
127
+
128
+ ```python
129
+ from netboot.dhcp import DhcpServer
130
+
131
+ class dnsmasq(DhcpServer): # handles dnsmasq://...
132
+ def add_target(self, ctx):
133
+ ... # arm DHCP for ctx.target
134
+ def remove_target(self, ctx):
135
+ ... # disarm it
136
+ ```
137
+
138
+ netboot ships four backends — `netboot.dhcp.dnsmasq`, `.kea`, `.dhcpd` and
139
+ `.windhcp` — and they are the worked examples: one writes files (locally or over
140
+ `sftp://`), one speaks a REST API, one a binary protocol, and one runs
141
+ PowerShell over ssh or WinRM. A backend gets its client options from
142
+ `self.options_for(ctx)` and translates them; see the configuration guide for
143
+ what an operator writes.
144
+
145
+ Subclassing at any depth is honoured, so a backend may share an intermediate
146
+ base. Import your plugin module before the config builds the zones — pass
147
+ `--load-module your.plugin` (repeat or colon-separate for several) so the
148
+ `DhcpServer` subclass is registered when `dnsmasq://...` is resolved.
149
+
150
+ An unknown scheme raises a clear `ValueError` rather than silently doing nothing.
@@ -6,8 +6,8 @@ a laptop.
6
6
 
7
7
  ```
8
8
  config/pixie.yaml the config `pixie` discovers (./config/pixie.yaml)
9
- templates/debian/boot.cfg shell-engine template (%{NAME} placeholders)
10
- templates/debian/install.ks.j2 Jinja template (full context access)
9
+ templates/debian/boot.cfg.shtpl shell-engine template (%{NAME} placeholders)
10
+ templates/debian/install.ks.j2 Jinja template (full context access)
11
11
  plugins/recording.py a DhcpServer backend that prints instead of arming
12
12
  ```
13
13
 
@@ -44,9 +44,11 @@ Expected output for `initiate web01`: the backend line, then the rendered
44
44
  zone by containment, so it names its `dhcpzone` — otherwise zone lookup finds
45
45
  nothing. Either target can be selected by id, hostname, MAC or IP, in any MAC
46
46
  spelling.
47
- - **Both template engines.** `boot.cfg` has no Jinja suffix, so the shell engine
48
- renders it: `%{UPPER_SNAKE}` placeholders from the flattened context, and a
49
- bare `%` left alone (which is what lets a kickstart's `%packages` through).
47
+ - **Both template engines.** The suffix picks the engine and the stem names the
48
+ artifact: `boot.cfg.shtpl` is rendered by the shell engine and asked for as
49
+ `boot.cfg` — `%{UPPER_SNAKE}` placeholders from the flattened context, a bare
50
+ `%` left alone (which is what lets a kickstart's `%packages` through), and
51
+ `%{NAME:-fallback}` for a value the context may not carry.
50
52
  `install.ks.j2` is Jinja, with `ctx`, `shell_quote`, `Path` and `Uri` in scope.
51
53
  - **A real DHCP backend.** `config/pixie.yaml` carries a commented
52
54
  `dnsmasq://` entry beside the recording stub: uncomment it (and drop the stub)
@@ -0,0 +1,8 @@
1
+ # Shell-engine template: the `.shtpl` suffix is what picks the engine, and the
2
+ # rendered artifact is named by the stem (`boot.cfg`). Only %%{NAME} substitutes;
3
+ # a bare % is literal text, so this comment survives and so does a shell line
4
+ # like: date +%Y%m%d. %%{NAME:-fallback} fills in a value the context lacks.
5
+ default=install
6
+ label install
7
+ kernel %{IMAGE_GLOBALS_KERNEL}
8
+ append ip=%{TARGET_IP} hostname=%{TARGET_HOSTNAME} domain=%{DHCPZONE_DOMAIN}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "netboot"
7
- version = "0.2.4"
7
+ version = "0.3.0"
8
8
  authors = [{ name = "Jose A." }]
9
9
  description = "PXE provisioning management: render netboot artifacts and drive DHCP for targets"
10
10
  readme = "README.md"
@@ -52,6 +52,15 @@ dependencies = [
52
52
  [project.optional-dependencies]
53
53
  # Config-file loading for the CLI (YAML with !include).
54
54
  config = ["pyyaml"]
55
+ # The mako template engine (`*.mako`). netboot.templates registers the engine
56
+ # either way, so a .mako file without the extra raises ImportError naming it
57
+ # rather than falling through to the copy engine.
58
+ mako = ["mako"]
59
+ # Further optional template engines, same deal: the engine is always registered,
60
+ # the library is imported only when a file claims it.
61
+ liquid = ["python-liquid"]
62
+ handlebars = ["pybars3"]
63
+ mustache = ["chevron"]
55
64
  # http/https repository services. pathlib_next's uri scheme handler for
56
65
  # http(s) imports `requests` at module import, and nothing else installs it --
57
66
  # pathlib_next[uri] does not. Repos served over file/tftp, and rendering in
@@ -84,6 +93,11 @@ dev = [
84
93
  # Backend dependencies, so their tests run rather than skip.
85
94
  "pypureomapi",
86
95
  "pywinrm",
96
+ # Template engines behind an extra, so their tests run rather than skip.
97
+ "mako",
98
+ "python-liquid",
99
+ "pybars3",
100
+ "chevron",
87
101
  ]
88
102
  docs = ["mkdocs", "mkdocs-material", "mkdocstrings[python]"]
89
103