bakefile is a Python task runner I wrote, and the previous post in this series covered the four patterns its repos are built on: composition over configuration, Template Method, a uniform interface, and workflows shared as packages. This post is the supporting cast those patterns run on: machine setup, publish credentials, configuration and the command line, the interpreter that runs the bakebook itself. None of it is glamorous, and every team that has grown past one repo does some of it by hand, differently in every repo. Here each piece ships once as inherited library code on the Spaces, and no bakefile in this post writes a line of it. The sections run from the everyday to the deep, starting with the command every fresh clone starts with.
Setup: one command, fresh clone to dev-ready repo
The dev loops in the patterns post all assumed a dev-ready repo. Start one step earlier. bake setup-dev turns a freshly cloned repo into a dev-ready environment in one command: nothing installed, nothing synced, and the command is the same in every repo in this post, because it lives on BaseSpace with everything else. The command runs three stages, each with one job:
- Platform tools (macOS only today): Homebrew is brought up to date, and mise itself is installed through it. On any other platform the stage degrades to a preview instead of failing.
- Tools (platform agnostic): each Space declares the tools its stack needs as a plain set on a hook (
uv,bun,pre-commitfor the base; a Rust Space unions incargo-audit,cargo-sort,rustup, the samesuper()set-union move every Space uses).setup-devasks mise what it already has, diffs that against the declared set, declares only the missing ones into the repo’smise.toml, then lets mise install everything declared. The tool list is data, declared once per Space, not a setup script anyone rewrites per repo. The diff is read-only, so evenbake -n setup-devpreviews the real missing tools. - Project (platform agnostic): clean stale artifacts,
pre-commit install, then a frozen lockfile sync (uv sync --frozenin a Python repo; the JavaScript Astro repo hangsbun install --frozen-lockfileoff the same_setup_projecthook). Frozen is the point: a fresh clone gets exactly the lockfile’s world, not whatever the registries serve today.
The Tools stage is also why the manifest never rots. In the JavaScript Astro repo, mise.toml was never written by hand; setup-dev added each line the first time a task needed the tool, and the committed file turns every tool change into a reviewable diff:
# mise.toml in the JavaScript Astro repo, written by setup-dev, never by hand
[tools]
bun = "latest"
"pipx:bakefile" = "latest"
# + actionlint, pre-commit, toml-sort, zerv-version, uv; abridged
bake update runs the same three stages in the upgrade direction: platform tools, then declared tools, then the project’s lockfiles.
flowchart TB
bake(["bake setup-dev"]) --> p["1. Platform tools<br/><em>installs mise</em>"]
bake -. "-f" .-> t
bake -. "-ff" .-> r
p --> t["2. Tools<br/><em>mise installs the rest</em>"]
t --> r["3. Project<br/><em>installs project deps</em>"]bake setup-dev, and where the -f counting grammar cutsThe stages are skippable by counting. -f skips the platform stage, -ff skips platform and tools, and the project stage always runs. The same counting grammar threads through the other lifecycle commands (bake update -ff upgrades only the project layer, bake assert-setup-dev -f skips the tests), so the flag means the same thing everywhere: how much staged work to skip, never which stage to change. That is the uniform-interface promise again, one level down. The interface (setup-dev, -f, -ff) is identical in every repo; the meaning (which tools, which lockfile) is whatever the composed Spaces say it is.
The consistency reaches CI too. No workflow in this repo installs a tool by hand: a setup-mise step runs mise-action, pinned to a commit SHA:
- name: setup-mise
uses: jdx/mise-action@c2a8761 # v4.3.0
The action reads the same mise.toml and installs the same tools the second stage would. Local machine and CI runner converge on one toolchain from one committed file, and only the project stage is something CI does on its own, through its own cache and install steps.
Secrets: a token vault with reactive refresh
If setup is the plumbing every team does by hand, secrets are the plumbing every team does by hand badly: a token in .env, a token pasted into CI settings, a token in a shell rc file, each copy aging in place. This section is the deep one, and it builds from the bottom: a cache class first, then the thin layer a bakebook adds on top. No bakefile writes a line of either.
The bottom layer is RefreshableCache, a cache that knows how to refill itself, and it is a plain library class: nothing in it imports bakefile, it knows nothing about bakebooks or tasks, and it could be dropped into any Python project as is. It pairs a stored value with the fetch function that produces it, either a plain callable or a small FetchFn object: a read that finds nothing (or, if the key carries a TTL, finds an expired entry) calls the fetch function, stores the result, and returns it; a read that hits comes back untouched.
For a structured source like a cloud secret manager, the FetchFn form earns its keep: a small frozen dataclass that carries its own parameters, here a project id and a secret id. A plain callable would work too, but the class gives the parameters a name and a home:
Note
The abstract
FetchFnclass ships; the GCP-shaped subclass is the reader’s to write. The fetch functions that ship with the library source their values locally, a cloud secret manager is the extension point the hook was designed for.
@dataclass(frozen=True)
class GcpSecretFetch(FetchFn[str]):
project_id: str
secret_id: str
def __call__(self) -> str:
return access_secret_version(self.project_id, self.secret_id)
Storage is pluggable: an in-process MemoryCache, a KeyringCache backed by the OS keychain, a NullCache that disables caching entirely, and a ChainedCache that stacks them, reading from the first backend that has the value and writing to every one.
So far that is an ordinary cache. The interesting part is what happens when a cached value goes stale, and the answer is not a timer. Consumers wrap their use of the value in catch_refresh and decide for themselves what “bad” means; the cache stays out of the way until the value proves itself bad:
cache = MemoryCache(
key="api-token",
# key is FetchFn's own first field, and must match the cache key:
fetch_fn=GcpSecretFetch(
key="api-token", project_id="my-project", secret_id="api-token"
),
)
@cache.catch_refresh
def call_api():
token = cache.get() # hit: cached value; miss: run the fetch fn, then store
response = call_api_with(token)
if response.status == 403:
raise cache.RefreshNeededError # proved bad: delete, retry, refetch
return response
When the wrapped call raises RefreshNeededError, usually because a remote service rejected the credential, the decorator deletes the entry and lets the error fly. Tenacity catches it and re-runs the call, which reads through the cache again, misses, and triggers a fresh fetch on the way back in. One extra attempt by default; if the second try fails the same way, the original error is re-raised, not a retry library’s wrapper around it. That is reactive refresh: the value does not rot silently until a human notices, because the first failed call heals it.
The top layer, SecretUtils, is the mixin every Space composes in, and it is the first line of code in this section that knows bakefile exists; it turns the cache into a vault. A key exists only because the Space’s get_secret_fetch_fns() hook declared a fetch function under that name, and a namespace hook keeps different packages out of each other’s keychain. The vault chains memory in front of the OS keychain, so in practice bake secret set <key> <value> once per machine puts the value in the keychain instead of a dotfile. The bake secret command group (list, get, set, del, refresh) manages all of it from the shell, and touching an untracked key fails with a pointer back to secret list.
Using it means declaring keys on the Space and nothing else. A Space mixes in the class (the [str | None] parameter types the cached value; None because a secret can be unset), names its namespace, and returns one fetch function per tracked key; the GcpSecretFetch class from above slots straight in, and from then on every task reads secrets like any other inherited call:
class ApiSpace(SecretUtils[str | None], BaseSpace):
def get_secret_namespace(self) -> str:
return "my-project"
def get_secret_fetch_fns(self) -> tuple[FetchFn[str | None], ...]:
return (
GcpSecretFetch(
key="api-token", project_id="my-project", secret_id="api-token"
),
)
def deploy(self):
token = self.get_secret("api-token") # keychain hit, or fetch + store
self.ctx.run(
"deploy-tool --prod",
env={"API_TOKEN": token}, # child process only, never your shell
)
And where a consumer can tell a stale value from a fresh one, the same catch_refresh wrap from above slots around get_secret() unchanged; the first consumer in this post that needs it is publish, next.
Publish: the version bump you never see
The folk ritual for publishing a package: edit the version in pyproject.toml or Cargo.toml, build, publish, then remember to revert the file. Any bakebook built on BaseLibSpace gets bake publish, which turns the ritual into a scope, in pseudo code (zerv, post 3, computes the version):
with bumped_version(): # zerv computes the version, or --version pins it
build()
publish()
# leaving the scope restores the 0.0.0 placeholder, success or crash
The write happens at the with line itself, not inside build(): assigning the version is a property assignment whose setter runs uv version (or the cargo equivalent), so the manifest is rewritten to the real number before the build reads it, and rewritten back to the placeholder when the scope exits.
This is not a demo path, either: bake publish is the same interface in the two repos I publish most, the Python library and zerv, version computed by zerv behind the scene either way.
Between releases the manifest does not carry a real version at all: pyproject.toml and Cargo.toml sit at a placeholder 0.0.0, and the git tag is the single source of truth. The scope above is where the placeholder becomes the real number and back again; read more in “The version line that never changes”.
The same scope drives more than package publishes. The version bump context lives on the base Space, so any build or deploy task can wrap itself in it: in a Docker-image Space shared across my service repos, bake build builds the image and pushes it to its registry under the computed version, bake deploy hands it to the environment, and bake bd runs the pair with the version computed once, by zerv, and threaded through both. The placeholder trick repeats too: a build at 0.0.0 ships as latest, a real build takes the real number. The uniform-interface promise again, one artifact later.
Nothing to revert by hand. A republish of an already-shipped version comes back as a skip, not a failure. And the token needs one line: pass --token or set an environment variable, or let the vault from the previous section fetch it once, into its keychain cache, and every later run reads the cached value, refreshed by the reactive loop if the registry ever rejects it, or by hand with bake secret refresh; none of those, the task dry-runs with a dummy token.
Environments: one bakefile, many targets
Deploy-shaped repos rarely have one target. ENV=prod bake deploy wants a different bakebook than ENV=dev bake deploy, and the folk answer is an if-statement inside every task. Here it is a grouping class instead: per-environment bakebooks declared as class attributes, the environment variable picks the instance, and the task names never move:
class MyBakebooks(EnvBakebooks[BaseMyBakebook]):
dev = DevBakebook()
prod = ProdBakebook()
bakebook = MyBakebooks.get() # resolve $ENV (dotenv supported), return one bakebook
Two guards make the group hard to get wrong. The attribute name must equal the bakebook’s own env value, so dev = ProdBakebook() dies at class definition, not as a mystery at deploy time. And the group is parameterized by the bakebooks’ common base type, so every environment exposes the same task surface: give one environment’s bakebook a task the others lack, and the type check asks where the rest are.
The fleet-scale version, per-environment bakebooks repeated across dozens of services, shows up in the env-mechanism bullet of the patterns post’s At scale section.
Config: the bakebook is a pydantic model
Sooner or later a task needs config: a host, a retry count, a token. The folk pattern is os.environ at the top of the task, a string parsed and defaulted wherever the value is read. A bakebook skips all of it, because a bakebook is also a pydantic Settings model, which makes every field a typed property tasks read straight off self:
class MyBakebook(BaseSpace):
api_host: str = "https://api.example.com"
retries: int = 3
api_token: SecretStr | None = None # typed as a secret, masked below
def check(self):
for _ in range(self.retries): # an int, not the string "3"
ping(self.api_host)
The declaration needs no extra code and no extra file, and a field named api_host reads API_HOST from the environment (or a .env file), parsed into the declared type, the default filling in when neither source has it. The type is the point. Plain environment variables are strings all the way down, so every consumer re-parses and re-validates them by hand; here retries reaches the task as an int or not at all, and a bad value fails at read time with pydantic’s error naming the field. And because every base class is a real pydantic model, fields layer exactly the way tasks do: a Space can declare fields of its own, and they merge into every bakebook that composes it.
The bakefile env family is that same contract read from the other side: pydantic supplies the typed values (and the SecretStr marker the masking keys on), and a layer of bakefile code turns the model into a shell surface:
bakefile env api_host # prints one value
bakefile env -- ./deploy.sh # injects API_HOST, RETRIES, API_TOKEN
bakefile env api_host -- ./deploy.sh # injects a subset
bakefile env api_token # ********** (add -s to reveal)
Injection is uppercased and shell-quoted, so values with spaces or quotes survive the trip, the child process gets the values and the caller’s shell never does, and the command’s exit code propagates (an unknown binary is a 127, like a plain exec). Secret-typed fields come masked unless -s asks for them in the open, so a command pasted into an issue for debugging does not paste the token.
The inverse direction is eval "$(bakefile export sh)": the same values emitted as shell assignments, ready for the current shell to swallow:
$ bakefile export sh
export API_HOST='https://api.example.com'
export RETRIES=3
export API_TOKEN='**********'
Secrets stay masked here too, so pasting the command into a CI step or a shell profile does not leak the token into a log. With dotenv, JSON, and YAML as the other export formats, one source of truth covers whichever consumer needs it: a task, a subshell, a CI step.
Commands: the CLI is typer
Config is the pydantic half of the class; the command half is typer. Every public method becomes a command on a real typer.Typer app, and the @command() decorator takes typer’s own arguments (help, short_help, epilog, rich_help_panel), so a bake command is documented, parsed, and errored exactly the way a typer command is. The method signature is the CLI interface: parameters become options through typer’s usual Annotated convention, and typer converts the values to the declared types before the task runs. From this site’s bakefile:
@command(help="Serve the site locally with drafts included.")
def serve(
self,
preview: Annotated[bool, typer.Option("--preview", "-p")] = False,
drafts: Annotated[bool, typer.Option("--drafts/--no-drafts")] = True,
) -> None: ...
The help panel is not written anywhere; it falls out of typer, rendered:
$ bake serve --help
Usage: bake serve [OPTIONS]
Serve the site locally with drafts included.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --preview -p │
│ --drafts --no-drafts [default: drafts] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
The top level works the same way. bake --help is typer’s own overview panel: the runner’s global flags (-n, -c, --verbose) in an options table, then one line per command with its short help, so a fresh clone advertises its whole interface, inherited tasks included, before you read a line of its bakefile. (The commands docs cover @command() end to end.)
If you know typer (or its engine, click), you already know how to write a bake command; the learning curve for the CLI surface is zero, and what typer gives its commands arrives with it: type conversion, defaults, the rich-rendered --help behind bake itself and the bake secret group, and shell completion. Failure is typer’s too: a task that rejects its input raises typer.Exit(code=1), and the exit code propagates to the shell like any CLI’s would.
Bootstrap: which Python runs the bakefile
The last helper stays invisible until it breaks, so it goes last: a bakefile is a Python script with dependencies (bakefile[lib] at minimum), and whatever Python the shell hands over usually has none of them. The runner does not ask you to care. It resolves the right environment (the bakefile’s own PEP 723 header for a standalone script, the project’s uv.lock otherwise), syncs it frozen, and hands itself over to that interpreter before the bakebook class ever loads. When in doubt about which interpreter won, or debugging a task that picked up the wrong dependency, bakefile which answers:
$ bakefile which
Invoked Python: bakefile 0.0.77 from ~/.local/pipx/bakefile/bin/python (python 3.14.7)
Reinvoked Python: bakefile 0.0.77 from ~/.cache/uv/environments-v2/bakefile-1cf853ca0015a158/bin/python3 (python 3.14.7)
Two Pythons, one story: the invoked one is whatever the shell started, the reinvoked one is the environment uv resolved from the bakefile’s declaration. The bakebook-loading commands re-exec themselves under the reinvoked Python (a marker prevents the handoff from looping), so bake lint runs in exactly the world the bakefile declared, whether the machine’s default Python is 3.9, 3.13, or managed by mise. For a standalone script the declaration is a PEP 723 header with its lock committed beside it, so the script pins its own dependency world the way an application would. That is how the JavaScript Astro repo of the patterns post runs: no pyproject.toml, and none needed.
Small shocks, one list
Three mechanics did not earn a section of their own and deserve more than silence.
Dry-run: one flag, every task
Dry-run is one global flag, not a per-task option: -n threads through every command in this post, and every task previews the same way. The flag reaches task code too, so a task can word its own preview. This is the shape of the Python library’s clean task (abridged):
class MyCleanUtils(Bakebook):
@command(help="Clean gitignored files with optional exclusions")
def clean(self):
results = self.ctx.run("git clean -fdX -n") # under -n: echoes, skips
if self.ctx.dry_run: # readable in task code, not just runner magic
console.echo("this is dry run")
Under -n, the ctx.run line echoes and skips, and the branch fires:
$ bake -n clean
❯ git clean -fdX -n
this is dry run
Everything through self.ctx.run is dry-run aware without a single check; self.ctx.dry_run is there for the wording and the steps that need it. Where a step is read-only, it can opt into running for real under -n: the tool-manifest diff in the Setup section does exactly that, so the preview shows the actually-missing tools instead of a simulation of them.
Chain: several commands, one invocation
bake -c lint test runs the remaining arguments as separate commands, in order, in one invocation, and stops at the first failure. The pre-push ritual collapses to one line, no wrapper task and no bake lint && bake test reloading the bakebook for each step.
The parallel runner primitive
The last mechanic is the runner underneath the patterns post’s At scale section, a ParallelCliTaskRunner: it runs a command across many directories in parallel, captures each child’s output, and re-sorts it to task order at the end, so a parallel run reads like a sequential one. Each child gets its own directory and its own .venv on the path, and the parent’s virtualenv is cleared before spawning, so a child can never load the wrong bakebook through an inherited environment. The pieces are public API, and the consumer side is a list comprehension (abridged from the bakefile repo’s own bakefile):
from bake import CliTask, ParallelCliTaskRunner, spawn_env
tasks = [
CliTask(
name=service.name,
command=["bake", "test"],
cwd=service,
env=spawn_env(
service, prepend_venv=True
), # child's .venv on PATH, VIRTUAL_ENV cleared
)
for service in services
]
ParallelCliTaskRunner(tasks, dry_run=self.ctx.dry_run).run()
The fan-out this enables is told in the At scale section of the patterns post; here it stays one example.
Final thoughts
Every section above is work most teams already do, just by hand and differently per repo: a setup script pasted in the README, a token aging in a dotfile, a version bumped and forgotten, an if-statement inside the deploy task. None of it is clever, and that is the point. The patterns post showed what its repos look like from the task level; this post dropped a level to show where those tasks come from, and the answer was the same every time: library code, shipped once on the Spaces, inherited by every repo, so a bakefile stays a few lines of composition.
The payoff is the uniform interface. bake setup-dev through bake publish mean the same thing in every repo in this series because none of the repos wrote them. A fresh repo inherits the whole supporting cast with one class line, and an improvement to any piece of it, a new secret backend, a new export format, a faster sync, lands in every repo on the next version bump. The supporting cast never gets written again; it only gets better for everyone.
If you landed here first: the patterns post is the main course, the four patterns this machinery runs, the zerv post covers the version computation behind bake publish, and the branching post covers the other half of the daily loop. The why-bakefile page makes the tool’s own case, if you want it from the source.






