Skip to main content

xtr-recipes

Applies a package's use-in-an-application steps to a project, and undoes them when the package goes away.

python 3.11+ typed license MIT

Why?

Adding a package to an application is a short list of chores: list its bundle, write a configuration file, set an environment variable, add an ignore line. Every package already describes that list in its Use in an application section. A recipe is that description made declarative, shipped beside the package, so one command does the chores — and, read backwards, undoes them when the package is removed.

A recipe is declarative only: bundles to list, files to write, environment and ignore lines to add, and notes to print. No recipe step and no template is ever executed. The one thing imported is the bundle class a recipe lists — and only from a module under the declaring distribution's own import package, never an arbitrary module a manifest names — so its @required_bundle declarations can be read to decide whether listing it is redundant; nothing else a dependency ships is imported or run. Because applying a recipe is a diff against a committed lock file, it does not matter how a package arrived, and running it twice changes nothing the second time.

Install

uv add --dev xtr-recipes

Requires Python 3.11+. It depends on xtr-console for its command line and on xtr-dependency-injection to read an application's bundle list. It installs no runtime weight into the application: it is a tool a developer and a CI job run, not a dependency the application boots.

Commands

Each command reads the project at --project-dir, defaulting to the nearest directory at or above the current one that holds a pyproject.toml. The application package is [tool.xtr-recipes] app when set, otherwise [project].name normalised to an import name; it must resolve to src/<app>/ or <app>/, or the command stops with a ProjectNotFoundError naming the setting.

uv run xtr-recipes recipes:sync                    # configure new, update changed, undo removed
uv run xtr-recipes recipes:sync --check            # exit 1 if a sync would change anything (CI); writes nothing
uv run xtr-recipes recipes:sync --dry-run          # print what it would do; writes nothing
uv run xtr-recipes recipes:show [<package>]        # the project's recipes, or one recipe in full
uv run xtr-recipes recipes:install <package> [--force]   # re-apply one; --force overwrites edited files
uv run xtr-recipes recipes:add <requirement>       # uv add <requirement>, then sync afresh
uv run xtr-recipes recipes:remove <package>        # uv remove <package>, then sync afresh

recipes:sync

The whole run is planned before any of it is carried out, so three ways of asking share one code path. The default prints the plan and applies it; --dry-run prints it and stops; --check writes nothing and exits non-zero when the plan would change the project, which is what a continuous integration job asks. A plan made only of headings, kept files and bundle notes has something to say and nothing to do, so --check passes on it.

A sync does three things to the recipes of the project's direct dependencies ([project].dependencies only; dependency groups are left out, and so is a transitive package the application did not choose):

  • configure a dependency not yet in the lock — write its files, add its env and ignore blocks, list its bundle, print its notes;
  • update a dependency whose recipe changed since the lock — re-render its files, apply the bundle, env and ignore differences;
  • unconfigure a locked package no longer depended on — delete the files it wrote, move an edited or adopted one to <file>.removed, clear its blocks, remove its bundle entry, drop its lock entry.

A plan reads as one block per package, under a heading naming the action:

configure xtr-messenger
  write src/bookshop/config/messenger.py
  write .env
  bundle MessengerBundle listed
  steps:
    - Name the transports in config/messenger.py and route your messages to them: a message routed nowhere is neither sent nor handled.
  check:
    - bookshop debug:bundles — messenger is listed and active
    - bookshop debug:config messenger — the resolved transports and routing
  run:
    - bookshop messenger:consume <transport>
write src/bookshop/bundles.py
write xtr.lock

The bundle list and the lock belong to the whole run — each written once, or deleted once when nothing is left to put in it — so they are the only unindented steps besides the headings.

recipes:show

Writes nothing. Named without a package it lists every recipe the project has something to say about, with its standing — locked, not configured, outdated, removed, or skipped: the bundle class does not load for a package whose bundle class cannot be imported. Named with a package it prints that one recipe in full: its bundles, files, env keys, ignore lines and notes, exactly as the recipe ships them.

A package the project no longer installs but the lock still records — removed from the dependencies and not yet synced away — is read from the lock instead: its recorded bundles, files, env keys and ignore lines, so what a sync is about to undo can still be inspected. Only a package with neither an installed recipe nor a lock entry is a RecipeNotInstalledError.

recipes:install

Re-applies one package's recipe unconditionally and leaves the rest of the lock alone — the way to restore a config file that went missing, which a sync would skip because the recipe is unchanged. By default a file you have edited is left in place and the new content is written beside it as <file>.new; --force overwrites the file instead.

recipes:add / recipes:remove

Convenience over two steps: recipes:add runs uv add <requirement> then syncs; recipes:remove runs uv remove <package> then syncs. uv is found on PATH and run in the project directory; a non-zero exit from it stops before the sync. The sync runs as a fresh process (uv run xtr-recipes recipes:sync), because installing or removing has changed the environment under the running interpreter.

The manifest

A recipe lives in the package that ships the bundle, at src/xtr_<name>/recipe/, versioned with it. recipe/ is a real package (an __init__.py with a docstring and __all__: list[str] = []), so uv build puts its manifest.toml and files/ tree in the wheel. The manifest is TOML, read with tomllib:

[bundles]
# "<module>:<Class>" = the environment flags it is listed with, exactly as in BUNDLES.
"xtr_messenger.bundle:MessengerBundle" = { all = true }

[files]
# destination under the application package = template under recipe/
"config/messenger.py" = "files/config/messenger.py.tmpl"

[env]
# written only when the key is absent from .env; "" means no default, so it is written commented out
MESSENGER_DSN = ""

[gitignore]
lines = []

[notes]
steps = []
check = ["<script> debug:bundles — messenger is listed and active"]
run   = ["<script> messenger:consume <transport>"]
  • Every table is optional. An unknown table or key, a malformed "<module>:<Class>" target, or a value of the wrong type raises InvalidManifestError naming the package and the key at fault.
  • Templates are named *.tmpl so a file still holding placeholders is not imported, linted or type-checked as part of the package. A template substitutes ${app} — the application import name — with string.Template.substitute; an unknown placeholder is an error, and a literal $ is written $$.
  • A <script> token in a note is replaced with the application's first [project.scripts] name, so a printed check reads as a command you can actually run; with no script declared the token is left as it is.
  • The three note lists stay apart because they are acted on differently: steps are changes to application code a declarative recipe cannot make, check shows the package working, run puts it to work. Notes are printed after a recipe is applied and never written to disk.
  • A destination under config/ also ensures every package between config and the file exists — <app>/config/__init__.py, and <app>/config/routes/__init__.py for a routes file — each a docstring and __all__: list[str] = [], created if missing, adopted if present, never deleted.

Recipes are discovered through the xtr_recipes entry-point group, named after the bundle, the same way bundles are found through xtr_dependency_injection.bundles:

[project.entry-points."xtr_recipes"]
messenger = "xtr_messenger.recipe"

The lock

xtr.lock sits in the project root, is committed, and is the record of what each recipe applied — enough on its own to undo a recipe after uv remove, so it stores paths relative to the project directory, /-separated. It is JSON with sorted keys, two-space indent and a trailing newline, so it diffs cleanly; a sync rewrites it only when it actually changed.

{
  "xtr-messenger": {
    "recipe": "<sha256>",
    "bundles": {"xtr_messenger.bundle:MessengerBundle": "listed"},
    "files": {"src/bookshop/config/messenger.py": {"sha256": "…", "adopted": false}},
    "env": ["MESSENGER_DSN"],
    "gitignore": []
  }
}
  • recipe is a sha256 over the manifest and every template in sorted path order — not the package version. Every package shares one version, so hashing the content is what keeps a release bump from "updating" every recipe and churning the lock. A sync re-applies a recipe only when this hash moves.
  • A bundle's state is listed when the sync added it, adopted when it was already in BUNDLES, or required when another listed bundle requires it so the sync leaves it out.
  • files records each written file's hash and whether it was adopted. The hash tells an untouched file from one you have since edited; an adopted file is one that was already there when the recipe first ran and so is never deleted on removal.
  • env and gitignore hold only the keys and lines the sync wrote inside its own marked block. A key or line that was already set outside the block is adopted, is not recorded, and is never removed.

Bundles, env and ignore

<app>/bundles.py is regenerated, not patched. It is read with the standard library's ast: each from <module> import <Class> maps a name, and the single BUNDLES = {...} assignment gives the ordered entries with their flags from ast.literal_eval. A BUNDLES built any other way, a key that is not an imported name, non-literal flags, or any statement besides the docstring, imports, __all__ and BUNDLES raises BundlesNotEditableError rather than deleting it. The file is rewritten in a canonical form — the docstring exactly as written, from __future__ import annotations, one import per entry in import order, __all__ = ["BUNDLES"], and the BUNDLES mapping — that passes ruff check and ruff format --check; an unchanged entry set leaves the file alone. Comments on entries are not kept.

A recipe's bundle is left out when another bundle in the final list requires it, transitively, hard or soft, through the @required_bundle declarations xtr-dependency-injection reads. This is recomputed every sync, so a bundle recorded as required becomes listed again once the bundle that required it is removed. Only a requirer listed for every environment counts: one limited to dev and test would leave its peers inactive in prod, so the recipe's bundle is listed anyway. A bundle whose class cannot be imported — a broken installation, a module it needs missing — is skipped with a message saying so; the package is not locked, so the next sync retries once the bundle loads. A package already configured whose bundle stops importing is left exactly as the lock has it — a broken installation is no reason to undo what was applied.

Environment variables and ignore lines are written as marked blocks in the project's .env and .gitignore:

# >>> xtr-messenger
# MESSENGER_DSN=
# <<< xtr-messenger

An env key with no default is written commented out, so the variable stays unset rather than being set to the empty string — the difference between a clear "not set" error at boot and a value that is silently wrong. A value you fill in inside the block is kept when the recipe is applied again. Removing a package deletes its whole block and nothing else.

Adoption

The first sync of an existing project changes as little as it can. A bundle already in BUNDLES, a config file already on disk, an env key already assigned, an ignore line already present is recorded as adopted and left untouched. An adopted file is never overwritten by a later update and never deleted on removal; an adopted env key or ignore line is never recorded and so never cleared. When its package is removed, an edited or adopted config file still imports that package, so it is renamed to <file>.removed (.removed.1 and on when one is already there): the application keeps loading, and your content is kept for you to delete or reuse. A file you edit after the recipe wrote it is recognised by its hash: an update writes the new content to <file>.new beside it and reports it, rather than overwriting your work. Use recipes:install <package> --force to take the recipe's version instead.

In continuous integration

Run recipes:sync --check in CI. It writes nothing and exits non-zero when the committed xtr.lock, bundle list, config files, .env or .gitignore have drifted from what the installed recipes would produce — the sign that someone added a dependency without syncing, or edited a generated file by hand.

uv run xtr-recipes recipes:sync --check

Shipping a recipe

A package that ships a bundle ships a recipe beside it. Add recipe/__init__.py, a recipe/manifest.toml, any templates under recipe/files/, and the entry point:

[project.entry-points."xtr_recipes"]
<name> = "xtr_<name>.recipe"

Fill the manifest from the package's README Use in an application section, and nothing invented: [bundles] from Activate, [files] from Configure (only when the README names an <app>/config/<name>.py the application needs — a zero-config bundle ships no file), [env] from Environment, [gitignore] from Ignore, and [notes] from the code changes, Check and Run the recipe cannot do itself. The root repository test checks that every package advertising a bundle also advertises a recipe of the same name, that its manifest parses, that each listed bundle is one the package advertises, and that every template exists.

Development

Developed in the python-xtr monorepo, under packages/xtr-recipes; run the commands below from there.

uv sync
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest

License

MIT — see LICENSE.

Metadata

Release files for xtr-recipes 4.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xtr-recipes 4.1.0
File Size Uploaded
xtr_recipes-4.1.0.tar.gz 66.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xtr-recipes 4.1.0
File Interpreter ABI Platform
xtr_recipes-4.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 154.4 kB

Release files / xtr_recipes-4.1.0.tar.gz

Download URL xtr_recipes-4.1.0.tar.gz
Size 66.0 kB
Tags Source
SHA-256 checksum
How to use checksums
54dc19af5c81b438e1adcaad18983124bbada2587bcd6278d0b7d64b843e3610
BLAKE2b-256 checksum
How to use checksums
75c70723f01f6d62fb68d254c8ae5bf8a019a85564276b4be502688f791f7c0d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release files / xtr_recipes-4.1.0-py3-none-any.whl

Download URL xtr_recipes-4.1.0-py3-none-any.whl
Size 88.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cce19ff4865596837d7379beb305474ce5f2df17b66351cf720b1a966e8577f0
BLAKE2b-256 checksum
How to use checksums
500fb551de9eef906c9ca65b866035171845b1562502f07a5c9758ad2892bd44
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

4.1.0 This release

2 release files

4.0.4

2 release files

4.0.3

2 release files

4.0.2

2 release files

3.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page