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.
- xtr_recipes-3.0.0/LICENSE +21 -0
- xtr_recipes-3.0.0/PKG-INFO +319 -0
- xtr_recipes-3.0.0/README.md +297 -0
- xtr_recipes-3.0.0/pyproject.toml +181 -0
- xtr_recipes-3.0.0/pyproject.toml.orig +179 -0
- xtr_recipes-3.0.0/src/xtr_recipes/.agents/skills/xtr-recipes/SKILL.md +140 -0
- xtr_recipes-3.0.0/src/xtr_recipes/__init__.py +95 -0
- xtr_recipes-3.0.0/src/xtr_recipes/__main__.py +34 -0
- xtr_recipes-3.0.0/src/xtr_recipes/bundle_entry.py +41 -0
- xtr_recipes-3.0.0/src/xtr_recipes/bundle_planner.py +96 -0
- xtr_recipes-3.0.0/src/xtr_recipes/bundle_requirements.py +129 -0
- xtr_recipes-3.0.0/src/xtr_recipes/bundles_file.py +291 -0
- xtr_recipes-3.0.0/src/xtr_recipes/command/__init__.py +26 -0
- xtr_recipes-3.0.0/src/xtr_recipes/command/add_command.py +51 -0
- xtr_recipes-3.0.0/src/xtr_recipes/command/install_command.py +55 -0
- xtr_recipes-3.0.0/src/xtr_recipes/command/remove_command.py +51 -0
- xtr_recipes-3.0.0/src/xtr_recipes/command/show_command.py +138 -0
- xtr_recipes-3.0.0/src/xtr_recipes/command/sync_command.py +77 -0
- xtr_recipes-3.0.0/src/xtr_recipes/command_support.py +137 -0
- xtr_recipes-3.0.0/src/xtr_recipes/entry_point_recipe_source.py +97 -0
- xtr_recipes-3.0.0/src/xtr_recipes/exception/__init__.py +25 -0
- xtr_recipes-3.0.0/src/xtr_recipes/exception/bundles_not_editable_error.py +43 -0
- xtr_recipes-3.0.0/src/xtr_recipes/exception/invalid_manifest_error.py +40 -0
- xtr_recipes-3.0.0/src/xtr_recipes/exception/marked_block_error.py +41 -0
- xtr_recipes-3.0.0/src/xtr_recipes/exception/project_not_found_error.py +45 -0
- xtr_recipes-3.0.0/src/xtr_recipes/exception/recipe_not_installed_error.py +32 -0
- xtr_recipes-3.0.0/src/xtr_recipes/exception/recipes_error.py +15 -0
- xtr_recipes-3.0.0/src/xtr_recipes/marked_block_editor.py +252 -0
- xtr_recipes-3.0.0/src/xtr_recipes/notes_config.py +30 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/__init__.py +43 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/bundle_note.py +37 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/delete_file.py +39 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/keep_file.py +36 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/move_file.py +46 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/notes.py +43 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/operation_interface.py +34 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/plan.py +46 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/put_block.py +49 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/remove_block.py +46 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/section.py +35 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/write_bundles.py +43 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/write_file.py +43 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/write_lock.py +44 -0
- xtr_recipes-3.0.0/src/xtr_recipes/operation/write_new_file.py +47 -0
- xtr_recipes-3.0.0/src/xtr_recipes/planned_recipe.py +44 -0
- xtr_recipes-3.0.0/src/xtr_recipes/project.py +192 -0
- xtr_recipes-3.0.0/src/xtr_recipes/py.typed +0 -0
- xtr_recipes-3.0.0/src/xtr_recipes/recipe_config.py +156 -0
- xtr_recipes-3.0.0/src/xtr_recipes/recipe_content.py +31 -0
- xtr_recipes-3.0.0/src/xtr_recipes/recipe_loader.py +124 -0
- xtr_recipes-3.0.0/src/xtr_recipes/recipe_lock.py +175 -0
- xtr_recipes-3.0.0/src/xtr_recipes/recipe_planner.py +330 -0
- xtr_recipes-3.0.0/src/xtr_recipes/recipe_source_interface.py +27 -0
- xtr_recipes-3.0.0/src/xtr_recipes/recipe_survey.py +51 -0
- xtr_recipes-3.0.0/src/xtr_recipes/sync_draft.py +187 -0
- xtr_recipes-3.0.0/src/xtr_recipes/sync_options.py +29 -0
- xtr_recipes-3.0.0/src/xtr_recipes/sync_selection.py +93 -0
- 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>
|