xtr-recipes 3.0.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 (58) hide show
  1. xtr_recipes-3.0.0/LICENSE +21 -0
  2. xtr_recipes-3.0.0/PKG-INFO +319 -0
  3. xtr_recipes-3.0.0/README.md +297 -0
  4. xtr_recipes-3.0.0/pyproject.toml +181 -0
  5. xtr_recipes-3.0.0/pyproject.toml.orig +179 -0
  6. xtr_recipes-3.0.0/src/xtr_recipes/.agents/skills/xtr-recipes/SKILL.md +140 -0
  7. xtr_recipes-3.0.0/src/xtr_recipes/__init__.py +95 -0
  8. xtr_recipes-3.0.0/src/xtr_recipes/__main__.py +34 -0
  9. xtr_recipes-3.0.0/src/xtr_recipes/bundle_entry.py +41 -0
  10. xtr_recipes-3.0.0/src/xtr_recipes/bundle_planner.py +96 -0
  11. xtr_recipes-3.0.0/src/xtr_recipes/bundle_requirements.py +129 -0
  12. xtr_recipes-3.0.0/src/xtr_recipes/bundles_file.py +291 -0
  13. xtr_recipes-3.0.0/src/xtr_recipes/command/__init__.py +26 -0
  14. xtr_recipes-3.0.0/src/xtr_recipes/command/add_command.py +51 -0
  15. xtr_recipes-3.0.0/src/xtr_recipes/command/install_command.py +55 -0
  16. xtr_recipes-3.0.0/src/xtr_recipes/command/remove_command.py +51 -0
  17. xtr_recipes-3.0.0/src/xtr_recipes/command/show_command.py +138 -0
  18. xtr_recipes-3.0.0/src/xtr_recipes/command/sync_command.py +77 -0
  19. xtr_recipes-3.0.0/src/xtr_recipes/command_support.py +137 -0
  20. xtr_recipes-3.0.0/src/xtr_recipes/entry_point_recipe_source.py +97 -0
  21. xtr_recipes-3.0.0/src/xtr_recipes/exception/__init__.py +25 -0
  22. xtr_recipes-3.0.0/src/xtr_recipes/exception/bundles_not_editable_error.py +43 -0
  23. xtr_recipes-3.0.0/src/xtr_recipes/exception/invalid_manifest_error.py +40 -0
  24. xtr_recipes-3.0.0/src/xtr_recipes/exception/marked_block_error.py +41 -0
  25. xtr_recipes-3.0.0/src/xtr_recipes/exception/project_not_found_error.py +45 -0
  26. xtr_recipes-3.0.0/src/xtr_recipes/exception/recipe_not_installed_error.py +32 -0
  27. xtr_recipes-3.0.0/src/xtr_recipes/exception/recipes_error.py +15 -0
  28. xtr_recipes-3.0.0/src/xtr_recipes/marked_block_editor.py +252 -0
  29. xtr_recipes-3.0.0/src/xtr_recipes/notes_config.py +30 -0
  30. xtr_recipes-3.0.0/src/xtr_recipes/operation/__init__.py +43 -0
  31. xtr_recipes-3.0.0/src/xtr_recipes/operation/bundle_note.py +37 -0
  32. xtr_recipes-3.0.0/src/xtr_recipes/operation/delete_file.py +39 -0
  33. xtr_recipes-3.0.0/src/xtr_recipes/operation/keep_file.py +36 -0
  34. xtr_recipes-3.0.0/src/xtr_recipes/operation/move_file.py +46 -0
  35. xtr_recipes-3.0.0/src/xtr_recipes/operation/notes.py +43 -0
  36. xtr_recipes-3.0.0/src/xtr_recipes/operation/operation_interface.py +34 -0
  37. xtr_recipes-3.0.0/src/xtr_recipes/operation/plan.py +46 -0
  38. xtr_recipes-3.0.0/src/xtr_recipes/operation/put_block.py +49 -0
  39. xtr_recipes-3.0.0/src/xtr_recipes/operation/remove_block.py +46 -0
  40. xtr_recipes-3.0.0/src/xtr_recipes/operation/section.py +35 -0
  41. xtr_recipes-3.0.0/src/xtr_recipes/operation/write_bundles.py +43 -0
  42. xtr_recipes-3.0.0/src/xtr_recipes/operation/write_file.py +43 -0
  43. xtr_recipes-3.0.0/src/xtr_recipes/operation/write_lock.py +44 -0
  44. xtr_recipes-3.0.0/src/xtr_recipes/operation/write_new_file.py +47 -0
  45. xtr_recipes-3.0.0/src/xtr_recipes/planned_recipe.py +44 -0
  46. xtr_recipes-3.0.0/src/xtr_recipes/project.py +192 -0
  47. xtr_recipes-3.0.0/src/xtr_recipes/py.typed +0 -0
  48. xtr_recipes-3.0.0/src/xtr_recipes/recipe_config.py +156 -0
  49. xtr_recipes-3.0.0/src/xtr_recipes/recipe_content.py +31 -0
  50. xtr_recipes-3.0.0/src/xtr_recipes/recipe_loader.py +124 -0
  51. xtr_recipes-3.0.0/src/xtr_recipes/recipe_lock.py +175 -0
  52. xtr_recipes-3.0.0/src/xtr_recipes/recipe_planner.py +330 -0
  53. xtr_recipes-3.0.0/src/xtr_recipes/recipe_source_interface.py +27 -0
  54. xtr_recipes-3.0.0/src/xtr_recipes/recipe_survey.py +51 -0
  55. xtr_recipes-3.0.0/src/xtr_recipes/sync_draft.py +187 -0
  56. xtr_recipes-3.0.0/src/xtr_recipes/sync_options.py +29 -0
  57. xtr_recipes-3.0.0/src/xtr_recipes/sync_selection.py +93 -0
  58. xtr_recipes-3.0.0/src/xtr_recipes/synchronizer.py +296 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,319 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-recipes
3
+ Version: 3.0.0
4
+ Summary: Applies a package's use-in-an-application steps to a project from a committed lock, and undoes them when the package goes away.
5
+ Keywords: recipe,scaffold,configure,bundle,lockfile,console,xtr-dependency-injection
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: xtr-console>=3.0,<4
19
+ Requires-Dist: xtr-dependency-injection>=3.0,<4
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+
23
+ <div align="center">
24
+
25
+ # xtr-recipes
26
+
27
+ **Applies a package's use-in-an-application steps to a project, and undoes them when the package goes away.**
28
+
29
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
30
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
31
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
32
+
33
+ </div>
34
+
35
+ ---
36
+
37
+ ## Why?
38
+
39
+ Adding a package to an application is a short list of chores: list its bundle, write a
40
+ configuration file, set an environment variable, add an ignore line. Every package already
41
+ describes that list in its *Use in an application* section. A recipe is that description made
42
+ declarative, shipped beside the package, so one command does the chores — and, read backwards,
43
+ undoes them when the package is removed.
44
+
45
+ A recipe is declarative only: bundles to list, files to write, environment and ignore lines to
46
+ add, and notes to print. No code from a dependency ever runs. Because applying a recipe is a diff
47
+ against a committed lock file, it does not matter how a package arrived, and running it twice
48
+ changes nothing the second time.
49
+
50
+ ## Install
51
+
52
+ ```sh
53
+ uv add --dev xtr-recipes
54
+ ```
55
+
56
+ Requires Python 3.11+. It depends on [xtr-console](../xtr-console) for its command line and on
57
+ [xtr-dependency-injection](../xtr-dependency-injection) to read an application's bundle list. It
58
+ installs no runtime weight into the application: it is a tool a developer and a CI job run, not
59
+ a dependency the application boots.
60
+
61
+ ## Commands
62
+
63
+ Each command reads the project at `--project-dir`, defaulting to the nearest directory at or
64
+ above the current one that holds a `pyproject.toml`. The application package is
65
+ `[tool.xtr-recipes] app` when set, otherwise `[project].name` normalised to an import name; it
66
+ must resolve to `src/<app>/` or `<app>/`, or the command stops with a `ProjectNotFoundError`
67
+ naming the setting.
68
+
69
+ ```sh
70
+ uv run xtr-recipes recipes:sync # configure new, update changed, undo removed
71
+ uv run xtr-recipes recipes:sync --check # exit 1 if a sync would change anything (CI); writes nothing
72
+ uv run xtr-recipes recipes:sync --dry-run # print what it would do; writes nothing
73
+ uv run xtr-recipes recipes:show [<package>] # the project's recipes, or one recipe in full
74
+ uv run xtr-recipes recipes:install <package> [--force] # re-apply one; --force overwrites edited files
75
+ uv run xtr-recipes recipes:add <requirement> # uv add <requirement>, then sync afresh
76
+ uv run xtr-recipes recipes:remove <package> # uv remove <package>, then sync afresh
77
+ ```
78
+
79
+ ### `recipes:sync`
80
+
81
+ The whole run is planned before any of it is carried out, so three ways of asking share one code
82
+ path. The default prints the plan and applies it; `--dry-run` prints it and stops; `--check`
83
+ writes nothing and exits non-zero when the plan would change the project, which is what a
84
+ continuous integration job asks. A plan made only of headings, kept files and bundle notes has
85
+ something to say and nothing to do, so `--check` passes on it.
86
+
87
+ A sync does three things to the recipes of the project's direct dependencies
88
+ (`[project].dependencies` only; dependency groups are left out, and so is a transitive package
89
+ the application did not choose):
90
+
91
+ - **configure** a dependency not yet in the lock — write its files, add its env and ignore
92
+ blocks, list its bundle, print its notes;
93
+ - **update** a dependency whose recipe changed since the lock — re-render its files, apply the
94
+ bundle, env and ignore differences;
95
+ - **unconfigure** a locked package no longer depended on — delete the files it wrote, move an
96
+ edited or adopted one to `<file>.removed`, clear its blocks, remove its bundle entry, drop its
97
+ lock entry.
98
+
99
+ A plan reads as one block per package, under a heading naming the action:
100
+
101
+ ```console
102
+ configure xtr-messenger
103
+ write src/bookshop/config/messenger.py
104
+ write src/bookshop/.env
105
+ bundle MessengerBundle listed
106
+ steps:
107
+ - Name the transports in config/messenger.py and route your messages to them: a message routed nowhere is neither sent nor handled.
108
+ check:
109
+ - bookshop debug:bundles — messenger is listed and active
110
+ - bookshop debug:config messenger — the resolved transports and routing
111
+ run:
112
+ - bookshop messenger:consume <transport>
113
+ write src/bookshop/bundles.py
114
+ write xtr.lock
115
+ ```
116
+
117
+ The bundle list and the lock are each written once for the whole run, so they are the only
118
+ unindented steps besides the headings.
119
+
120
+ ### `recipes:show`
121
+
122
+ Writes nothing. Named without a package it lists every recipe the project has something to say
123
+ about, with its standing — `locked`, `not configured`, `outdated`, `removed`, or
124
+ `skipped: install <package>[di]` for a package installed without the extra that ships its bundle
125
+ class. Named with a package it prints that one recipe in full: its bundles, files, env keys,
126
+ ignore lines and notes, exactly as the recipe ships them.
127
+
128
+ ### `recipes:install`
129
+
130
+ Re-applies one package's recipe unconditionally and leaves the rest of the lock alone — the way
131
+ to restore a config file that went missing, which a sync would skip because the recipe is
132
+ unchanged. By default a file you have edited is left in place and the new content is written
133
+ beside it as `<file>.new`; `--force` overwrites the file instead.
134
+
135
+ ### `recipes:add` / `recipes:remove`
136
+
137
+ Convenience over two steps: `recipes:add` runs `uv add <requirement>` then syncs; `recipes:remove`
138
+ runs `uv remove <package>` then syncs. `uv` is found on `PATH` and run in the project directory;
139
+ a non-zero exit from it stops before the sync. The sync runs as a fresh process
140
+ (`uv run xtr-recipes recipes:sync`), because installing or removing has changed the environment
141
+ under the running interpreter.
142
+
143
+ ## The manifest
144
+
145
+ A recipe lives in the package that ships the bundle, at `src/xtr_<name>/recipe/`, versioned with
146
+ it. `recipe/` is a real package (an `__init__.py` with a docstring and `__all__: list[str] = []`),
147
+ so `uv build` puts its `manifest.toml` and `files/` tree in the wheel. The manifest is TOML, read
148
+ with `tomllib`:
149
+
150
+ ```toml
151
+ [bundles]
152
+ # "<module>:<Class>" = the environment flags it is listed with, exactly as in BUNDLES.
153
+ "xtr_messenger.bundle:MessengerBundle" = { all = true }
154
+
155
+ [files]
156
+ # destination under the application package = template under recipe/
157
+ "config/messenger.py" = "files/config/messenger.py.tmpl"
158
+
159
+ [env]
160
+ # written only when the key is absent from .env; "" means no default, so it is written commented out
161
+ MESSENGER_DSN = ""
162
+
163
+ [gitignore]
164
+ lines = []
165
+
166
+ [notes]
167
+ steps = []
168
+ check = ["<script> debug:bundles — messenger is listed and active"]
169
+ run = ["<script> messenger:consume <transport>"]
170
+ ```
171
+
172
+ - Every table is optional. An unknown table or key, a malformed `"<module>:<Class>"` target, or a
173
+ value of the wrong type raises `InvalidManifestError` naming the package and the key at fault.
174
+ - Templates are named `*.tmpl` so a file still holding placeholders is not imported, linted or
175
+ type-checked as part of the package. A template substitutes `${app}` — the application import
176
+ name — with `string.Template.substitute`; an unknown placeholder is an error, and a literal `$`
177
+ is written `$$`.
178
+ - A `<script>` token in a note is replaced with the application's first `[project.scripts]` name,
179
+ so a printed check reads as a command you can actually run; with no script declared the token is
180
+ left as it is.
181
+ - The three note lists stay apart because they are acted on differently: `steps` are changes to
182
+ application code a declarative recipe cannot make, `check` shows the package working, `run` puts
183
+ it to work. Notes are printed after a recipe is applied and never written to disk.
184
+ - A destination under `config/` also ensures `<app>/config/__init__.py` exists (a docstring and
185
+ `__all__: list[str] = []`), created if missing, adopted if present, never deleted.
186
+
187
+ Recipes are discovered through the `xtr_recipes` entry-point group, named after the bundle, the
188
+ same way bundles are found through `xtr_dependency_injection.bundles`:
189
+
190
+ ```toml
191
+ [project.entry-points."xtr_recipes"]
192
+ messenger = "xtr_messenger.recipe"
193
+ ```
194
+
195
+ ## The lock
196
+
197
+ `xtr.lock` sits in the project root, is committed, and is the record of what each recipe applied —
198
+ enough on its own to undo a recipe after `uv remove`, so it stores paths relative to the project
199
+ directory, `/`-separated. It is JSON with sorted keys, two-space indent and a trailing newline, so
200
+ it diffs cleanly; a sync rewrites it only when it actually changed.
201
+
202
+ ```json
203
+ {
204
+ "xtr-messenger": {
205
+ "recipe": "<sha256>",
206
+ "bundles": {"xtr_messenger.bundle:MessengerBundle": "listed"},
207
+ "files": {"src/bookshop/config/messenger.py": {"sha256": "…", "adopted": false}},
208
+ "env": ["MESSENGER_DSN"],
209
+ "gitignore": []
210
+ }
211
+ }
212
+ ```
213
+
214
+ - `recipe` is a sha256 over the manifest and every template in sorted path order — not the package
215
+ version. Every package shares one version, so hashing the content is what keeps a release bump
216
+ from "updating" every recipe and churning the lock. A sync re-applies a recipe only when this
217
+ hash moves.
218
+ - A bundle's state is `listed` when the sync added it, `adopted` when it was already in `BUNDLES`,
219
+ or `required` when another listed bundle requires it so the sync leaves it out.
220
+ - `files` records each written file's hash and whether it was adopted. The hash tells an untouched
221
+ file from one you have since edited; an adopted file is one that was already there when the
222
+ recipe first ran and so is never deleted on removal.
223
+ - `env` and `gitignore` hold only the keys and lines the sync wrote inside its own marked block.
224
+ A key or line that was already set outside the block is adopted, is not recorded, and is never
225
+ removed.
226
+
227
+ ## Bundles, env and ignore
228
+
229
+ `<app>/bundles.py` is regenerated, not patched. It is read with the standard library's `ast`:
230
+ each `from <module> import <Class>` maps a name, and the single `BUNDLES = {...}` assignment gives
231
+ the ordered entries with their flags from `ast.literal_eval`. A `BUNDLES` built any other way, a
232
+ key that is not an imported name, non-literal flags, or any statement besides the docstring,
233
+ imports, `__all__` and `BUNDLES` raises `BundlesNotEditableError` rather than deleting it. The file
234
+ is rewritten in a canonical form — the docstring exactly as written,
235
+ `from __future__ import annotations`, one import per entry in import order, `__all__ = ["BUNDLES"]`,
236
+ and the `BUNDLES` mapping — that passes `ruff check` and `ruff format --check`; an unchanged entry
237
+ set leaves the file alone. Comments on entries are not kept.
238
+
239
+ A recipe's bundle is **left out when another bundle in the final list requires it**, transitively,
240
+ hard or soft, through the `@required_bundle` declarations
241
+ [xtr-dependency-injection](../xtr-dependency-injection) reads. This is recomputed every sync, so a
242
+ bundle recorded as `required` becomes `listed` again once the bundle that required it is removed.
243
+ Only a requirer listed for every environment counts: one limited to `dev` and `test` would leave
244
+ its peers inactive in `prod`, so the recipe's bundle is listed anyway.
245
+ A bundle whose class cannot be imported — the package was installed without the `di` extra — is
246
+ skipped with a message naming the extra; the package is not locked, so the next sync retries once
247
+ the extra is installed. A package already configured whose bundle stops importing is left exactly
248
+ as the lock has it — a broken installation is no reason to undo what was applied.
249
+
250
+ Environment variables and ignore lines are written as marked blocks in the project's `.env` and
251
+ `.gitignore`:
252
+
253
+ ```
254
+ # >>> xtr-messenger
255
+ # MESSENGER_DSN=
256
+ # <<< xtr-messenger
257
+ ```
258
+
259
+ An env key with no default is written commented out, so the variable stays unset rather than being
260
+ set to the empty string — the difference between a clear "not set" error at boot and a value that
261
+ is silently wrong. A value you fill in inside the block is kept when the recipe is applied again.
262
+ Removing a package deletes its whole block and nothing else.
263
+
264
+ ## Adoption
265
+
266
+ The first sync of an existing project changes as little as it can. A bundle already in `BUNDLES`,
267
+ a config file already on disk, an env key already assigned, an ignore line already present is
268
+ recorded as adopted and left untouched. An adopted file is never overwritten by a later update and
269
+ never deleted on removal; an adopted env key or ignore line is never recorded and so never cleared.
270
+ When its package is removed, an edited or adopted config file still imports that package, so it is
271
+ renamed to `<file>.removed` (`.removed.1` and on when one is already there): the application keeps
272
+ loading, and your content is kept for you to delete or reuse.
273
+ A file you edit after the recipe wrote it is recognised by its hash: an update writes the new
274
+ content to `<file>.new` beside it and reports it, rather than overwriting your work. Use
275
+ `recipes:install <package> --force` to take the recipe's version instead.
276
+
277
+ ## In continuous integration
278
+
279
+ Run `recipes:sync --check` in CI. It writes nothing and exits non-zero when the committed
280
+ `xtr.lock`, bundle list, config files, `.env` or `.gitignore` have drifted from what the installed
281
+ recipes would produce — the sign that someone added a dependency without syncing, or edited a
282
+ generated file by hand.
283
+
284
+ ```sh
285
+ uv run xtr-recipes recipes:sync --check
286
+ ```
287
+
288
+ ## Shipping a recipe
289
+
290
+ A package that ships a bundle ships a recipe beside it. Add `recipe/__init__.py`, a
291
+ `recipe/manifest.toml`, any templates under `recipe/files/`, and the entry point:
292
+
293
+ ```toml
294
+ [project.entry-points."xtr_recipes"]
295
+ <name> = "xtr_<name>.recipe"
296
+ ```
297
+
298
+ Fill the manifest from the package's README *Use in an application* section, and nothing invented:
299
+ `[bundles]` from *Activate*, `[files]` from *Configure* (only when the README names an
300
+ `<app>/config/<name>.py` the application needs — a zero-config bundle ships no file), `[env]` from
301
+ *Environment*, `[gitignore]` from *Ignore*, and `[notes]` from the code changes, *Check* and *Run*
302
+ the recipe cannot do itself. The root repository test checks that every package advertising a
303
+ bundle also advertises a recipe of the same name, that its manifest parses, that each listed
304
+ bundle is one the package advertises, and that every template exists.
305
+
306
+ ## Development
307
+
308
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
309
+ `packages/xtr-recipes`; run the commands below from there.
310
+
311
+ ```sh
312
+ uv sync --all-extras
313
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
314
+ ```
315
+
316
+ ## License
317
+
318
+ MIT — see [LICENSE](LICENSE).
319
+ </content>
@@ -0,0 +1,297 @@
1
+ <div align="center">
2
+
3
+ # xtr-recipes
4
+
5
+ **Applies a package's use-in-an-application steps to a project, and undoes them when the package goes away.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
9
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
10
+
11
+ </div>
12
+
13
+ ---
14
+
15
+ ## Why?
16
+
17
+ Adding a package to an application is a short list of chores: list its bundle, write a
18
+ configuration file, set an environment variable, add an ignore line. Every package already
19
+ describes that list in its *Use in an application* section. A recipe is that description made
20
+ declarative, shipped beside the package, so one command does the chores — and, read backwards,
21
+ undoes them when the package is removed.
22
+
23
+ A recipe is declarative only: bundles to list, files to write, environment and ignore lines to
24
+ add, and notes to print. No code from a dependency ever runs. Because applying a recipe is a diff
25
+ against a committed lock file, it does not matter how a package arrived, and running it twice
26
+ changes nothing the second time.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ uv add --dev xtr-recipes
32
+ ```
33
+
34
+ Requires Python 3.11+. It depends on [xtr-console](../xtr-console) for its command line and on
35
+ [xtr-dependency-injection](../xtr-dependency-injection) to read an application's bundle list. It
36
+ installs no runtime weight into the application: it is a tool a developer and a CI job run, not
37
+ a dependency the application boots.
38
+
39
+ ## Commands
40
+
41
+ Each command reads the project at `--project-dir`, defaulting to the nearest directory at or
42
+ above the current one that holds a `pyproject.toml`. The application package is
43
+ `[tool.xtr-recipes] app` when set, otherwise `[project].name` normalised to an import name; it
44
+ must resolve to `src/<app>/` or `<app>/`, or the command stops with a `ProjectNotFoundError`
45
+ naming the setting.
46
+
47
+ ```sh
48
+ uv run xtr-recipes recipes:sync # configure new, update changed, undo removed
49
+ uv run xtr-recipes recipes:sync --check # exit 1 if a sync would change anything (CI); writes nothing
50
+ uv run xtr-recipes recipes:sync --dry-run # print what it would do; writes nothing
51
+ uv run xtr-recipes recipes:show [<package>] # the project's recipes, or one recipe in full
52
+ uv run xtr-recipes recipes:install <package> [--force] # re-apply one; --force overwrites edited files
53
+ uv run xtr-recipes recipes:add <requirement> # uv add <requirement>, then sync afresh
54
+ uv run xtr-recipes recipes:remove <package> # uv remove <package>, then sync afresh
55
+ ```
56
+
57
+ ### `recipes:sync`
58
+
59
+ The whole run is planned before any of it is carried out, so three ways of asking share one code
60
+ path. The default prints the plan and applies it; `--dry-run` prints it and stops; `--check`
61
+ writes nothing and exits non-zero when the plan would change the project, which is what a
62
+ continuous integration job asks. A plan made only of headings, kept files and bundle notes has
63
+ something to say and nothing to do, so `--check` passes on it.
64
+
65
+ A sync does three things to the recipes of the project's direct dependencies
66
+ (`[project].dependencies` only; dependency groups are left out, and so is a transitive package
67
+ the application did not choose):
68
+
69
+ - **configure** a dependency not yet in the lock — write its files, add its env and ignore
70
+ blocks, list its bundle, print its notes;
71
+ - **update** a dependency whose recipe changed since the lock — re-render its files, apply the
72
+ bundle, env and ignore differences;
73
+ - **unconfigure** a locked package no longer depended on — delete the files it wrote, move an
74
+ edited or adopted one to `<file>.removed`, clear its blocks, remove its bundle entry, drop its
75
+ lock entry.
76
+
77
+ A plan reads as one block per package, under a heading naming the action:
78
+
79
+ ```console
80
+ configure xtr-messenger
81
+ write src/bookshop/config/messenger.py
82
+ write src/bookshop/.env
83
+ bundle MessengerBundle listed
84
+ steps:
85
+ - Name the transports in config/messenger.py and route your messages to them: a message routed nowhere is neither sent nor handled.
86
+ check:
87
+ - bookshop debug:bundles — messenger is listed and active
88
+ - bookshop debug:config messenger — the resolved transports and routing
89
+ run:
90
+ - bookshop messenger:consume <transport>
91
+ write src/bookshop/bundles.py
92
+ write xtr.lock
93
+ ```
94
+
95
+ The bundle list and the lock are each written once for the whole run, so they are the only
96
+ unindented steps besides the headings.
97
+
98
+ ### `recipes:show`
99
+
100
+ Writes nothing. Named without a package it lists every recipe the project has something to say
101
+ about, with its standing — `locked`, `not configured`, `outdated`, `removed`, or
102
+ `skipped: install <package>[di]` for a package installed without the extra that ships its bundle
103
+ class. Named with a package it prints that one recipe in full: its bundles, files, env keys,
104
+ ignore lines and notes, exactly as the recipe ships them.
105
+
106
+ ### `recipes:install`
107
+
108
+ Re-applies one package's recipe unconditionally and leaves the rest of the lock alone — the way
109
+ to restore a config file that went missing, which a sync would skip because the recipe is
110
+ unchanged. By default a file you have edited is left in place and the new content is written
111
+ beside it as `<file>.new`; `--force` overwrites the file instead.
112
+
113
+ ### `recipes:add` / `recipes:remove`
114
+
115
+ Convenience over two steps: `recipes:add` runs `uv add <requirement>` then syncs; `recipes:remove`
116
+ runs `uv remove <package>` then syncs. `uv` is found on `PATH` and run in the project directory;
117
+ a non-zero exit from it stops before the sync. The sync runs as a fresh process
118
+ (`uv run xtr-recipes recipes:sync`), because installing or removing has changed the environment
119
+ under the running interpreter.
120
+
121
+ ## The manifest
122
+
123
+ A recipe lives in the package that ships the bundle, at `src/xtr_<name>/recipe/`, versioned with
124
+ it. `recipe/` is a real package (an `__init__.py` with a docstring and `__all__: list[str] = []`),
125
+ so `uv build` puts its `manifest.toml` and `files/` tree in the wheel. The manifest is TOML, read
126
+ with `tomllib`:
127
+
128
+ ```toml
129
+ [bundles]
130
+ # "<module>:<Class>" = the environment flags it is listed with, exactly as in BUNDLES.
131
+ "xtr_messenger.bundle:MessengerBundle" = { all = true }
132
+
133
+ [files]
134
+ # destination under the application package = template under recipe/
135
+ "config/messenger.py" = "files/config/messenger.py.tmpl"
136
+
137
+ [env]
138
+ # written only when the key is absent from .env; "" means no default, so it is written commented out
139
+ MESSENGER_DSN = ""
140
+
141
+ [gitignore]
142
+ lines = []
143
+
144
+ [notes]
145
+ steps = []
146
+ check = ["<script> debug:bundles — messenger is listed and active"]
147
+ run = ["<script> messenger:consume <transport>"]
148
+ ```
149
+
150
+ - Every table is optional. An unknown table or key, a malformed `"<module>:<Class>"` target, or a
151
+ value of the wrong type raises `InvalidManifestError` naming the package and the key at fault.
152
+ - Templates are named `*.tmpl` so a file still holding placeholders is not imported, linted or
153
+ type-checked as part of the package. A template substitutes `${app}` — the application import
154
+ name — with `string.Template.substitute`; an unknown placeholder is an error, and a literal `$`
155
+ is written `$$`.
156
+ - A `<script>` token in a note is replaced with the application's first `[project.scripts]` name,
157
+ so a printed check reads as a command you can actually run; with no script declared the token is
158
+ left as it is.
159
+ - The three note lists stay apart because they are acted on differently: `steps` are changes to
160
+ application code a declarative recipe cannot make, `check` shows the package working, `run` puts
161
+ it to work. Notes are printed after a recipe is applied and never written to disk.
162
+ - A destination under `config/` also ensures `<app>/config/__init__.py` exists (a docstring and
163
+ `__all__: list[str] = []`), created if missing, adopted if present, never deleted.
164
+
165
+ Recipes are discovered through the `xtr_recipes` entry-point group, named after the bundle, the
166
+ same way bundles are found through `xtr_dependency_injection.bundles`:
167
+
168
+ ```toml
169
+ [project.entry-points."xtr_recipes"]
170
+ messenger = "xtr_messenger.recipe"
171
+ ```
172
+
173
+ ## The lock
174
+
175
+ `xtr.lock` sits in the project root, is committed, and is the record of what each recipe applied —
176
+ enough on its own to undo a recipe after `uv remove`, so it stores paths relative to the project
177
+ directory, `/`-separated. It is JSON with sorted keys, two-space indent and a trailing newline, so
178
+ it diffs cleanly; a sync rewrites it only when it actually changed.
179
+
180
+ ```json
181
+ {
182
+ "xtr-messenger": {
183
+ "recipe": "<sha256>",
184
+ "bundles": {"xtr_messenger.bundle:MessengerBundle": "listed"},
185
+ "files": {"src/bookshop/config/messenger.py": {"sha256": "…", "adopted": false}},
186
+ "env": ["MESSENGER_DSN"],
187
+ "gitignore": []
188
+ }
189
+ }
190
+ ```
191
+
192
+ - `recipe` is a sha256 over the manifest and every template in sorted path order — not the package
193
+ version. Every package shares one version, so hashing the content is what keeps a release bump
194
+ from "updating" every recipe and churning the lock. A sync re-applies a recipe only when this
195
+ hash moves.
196
+ - A bundle's state is `listed` when the sync added it, `adopted` when it was already in `BUNDLES`,
197
+ or `required` when another listed bundle requires it so the sync leaves it out.
198
+ - `files` records each written file's hash and whether it was adopted. The hash tells an untouched
199
+ file from one you have since edited; an adopted file is one that was already there when the
200
+ recipe first ran and so is never deleted on removal.
201
+ - `env` and `gitignore` hold only the keys and lines the sync wrote inside its own marked block.
202
+ A key or line that was already set outside the block is adopted, is not recorded, and is never
203
+ removed.
204
+
205
+ ## Bundles, env and ignore
206
+
207
+ `<app>/bundles.py` is regenerated, not patched. It is read with the standard library's `ast`:
208
+ each `from <module> import <Class>` maps a name, and the single `BUNDLES = {...}` assignment gives
209
+ the ordered entries with their flags from `ast.literal_eval`. A `BUNDLES` built any other way, a
210
+ key that is not an imported name, non-literal flags, or any statement besides the docstring,
211
+ imports, `__all__` and `BUNDLES` raises `BundlesNotEditableError` rather than deleting it. The file
212
+ is rewritten in a canonical form — the docstring exactly as written,
213
+ `from __future__ import annotations`, one import per entry in import order, `__all__ = ["BUNDLES"]`,
214
+ and the `BUNDLES` mapping — that passes `ruff check` and `ruff format --check`; an unchanged entry
215
+ set leaves the file alone. Comments on entries are not kept.
216
+
217
+ A recipe's bundle is **left out when another bundle in the final list requires it**, transitively,
218
+ hard or soft, through the `@required_bundle` declarations
219
+ [xtr-dependency-injection](../xtr-dependency-injection) reads. This is recomputed every sync, so a
220
+ bundle recorded as `required` becomes `listed` again once the bundle that required it is removed.
221
+ Only a requirer listed for every environment counts: one limited to `dev` and `test` would leave
222
+ its peers inactive in `prod`, so the recipe's bundle is listed anyway.
223
+ A bundle whose class cannot be imported — the package was installed without the `di` extra — is
224
+ skipped with a message naming the extra; the package is not locked, so the next sync retries once
225
+ the extra is installed. A package already configured whose bundle stops importing is left exactly
226
+ as the lock has it — a broken installation is no reason to undo what was applied.
227
+
228
+ Environment variables and ignore lines are written as marked blocks in the project's `.env` and
229
+ `.gitignore`:
230
+
231
+ ```
232
+ # >>> xtr-messenger
233
+ # MESSENGER_DSN=
234
+ # <<< xtr-messenger
235
+ ```
236
+
237
+ An env key with no default is written commented out, so the variable stays unset rather than being
238
+ set to the empty string — the difference between a clear "not set" error at boot and a value that
239
+ is silently wrong. A value you fill in inside the block is kept when the recipe is applied again.
240
+ Removing a package deletes its whole block and nothing else.
241
+
242
+ ## Adoption
243
+
244
+ The first sync of an existing project changes as little as it can. A bundle already in `BUNDLES`,
245
+ a config file already on disk, an env key already assigned, an ignore line already present is
246
+ recorded as adopted and left untouched. An adopted file is never overwritten by a later update and
247
+ never deleted on removal; an adopted env key or ignore line is never recorded and so never cleared.
248
+ When its package is removed, an edited or adopted config file still imports that package, so it is
249
+ renamed to `<file>.removed` (`.removed.1` and on when one is already there): the application keeps
250
+ loading, and your content is kept for you to delete or reuse.
251
+ A file you edit after the recipe wrote it is recognised by its hash: an update writes the new
252
+ content to `<file>.new` beside it and reports it, rather than overwriting your work. Use
253
+ `recipes:install <package> --force` to take the recipe's version instead.
254
+
255
+ ## In continuous integration
256
+
257
+ Run `recipes:sync --check` in CI. It writes nothing and exits non-zero when the committed
258
+ `xtr.lock`, bundle list, config files, `.env` or `.gitignore` have drifted from what the installed
259
+ recipes would produce — the sign that someone added a dependency without syncing, or edited a
260
+ generated file by hand.
261
+
262
+ ```sh
263
+ uv run xtr-recipes recipes:sync --check
264
+ ```
265
+
266
+ ## Shipping a recipe
267
+
268
+ A package that ships a bundle ships a recipe beside it. Add `recipe/__init__.py`, a
269
+ `recipe/manifest.toml`, any templates under `recipe/files/`, and the entry point:
270
+
271
+ ```toml
272
+ [project.entry-points."xtr_recipes"]
273
+ <name> = "xtr_<name>.recipe"
274
+ ```
275
+
276
+ Fill the manifest from the package's README *Use in an application* section, and nothing invented:
277
+ `[bundles]` from *Activate*, `[files]` from *Configure* (only when the README names an
278
+ `<app>/config/<name>.py` the application needs — a zero-config bundle ships no file), `[env]` from
279
+ *Environment*, `[gitignore]` from *Ignore*, and `[notes]` from the code changes, *Check* and *Run*
280
+ the recipe cannot do itself. The root repository test checks that every package advertising a
281
+ bundle also advertises a recipe of the same name, that its manifest parses, that each listed
282
+ bundle is one the package advertises, and that every template exists.
283
+
284
+ ## Development
285
+
286
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
287
+ `packages/xtr-recipes`; run the commands below from there.
288
+
289
+ ```sh
290
+ uv sync --all-extras
291
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
292
+ ```
293
+
294
+ ## License
295
+
296
+ MIT — see [LICENSE](LICENSE).
297
+ </content>