Cookbook¶
Recipes, not reference: each one is a real shape you can paste and bend.
They assume the getting started basics: tasks are
typed functions in tasks.py, run() executes commands, and the CLI is
derived from the signatures.
Everyday shapes¶
The gate¶
Every repo deserves one command that answers "is this fine?". Give the
independent checks to parallel() and let the machine use its cores:
from footman import task, parallel
from toolroom import basedpyright, pytest, ruff
@task
def lint(fix: bool = False):
"Lint with ruff."
ruff.check("src", "tests", fix=fix)
@task
def typecheck():
"Type-check with basedpyright."
basedpyright()
@task
def test(*pytest_args: str):
"Run the test suite."
pytest(*pytest_args)
@task
def check():
"Lint, typecheck, and test, in parallel."
# A call with arguments goes in the block form; bare tasks ride along.
with parallel():
lint(fix=False)
typecheck()
test()
fm check fans out across cores, keeps every task's output in one
uninterleaved block, and, once it has seen a few runs, shows a progress
bar that actually knows how long your gate takes. Wire it into CI as-is:
the same command, the same exit codes.
Hand a tool its own flags¶
A *args parameter receives everything after --, verbatim, with no
quoting gymnastics, no flag collisions with footman's own:
Anything before -- still belongs to footman (fm -q test -- -x), so
both grammars stay whole. A task can also read the raw list itself with
footman.passthrough().
One chain, each task with its own flags¶
Options bind to the task named just before them, and chains need no separators:
--fix is lint's, because it follows lint. Independent tasks in a
chain run in parallel by default; -s serialises the whole run (and
reaches parallel() calls inside task bodies too), -k keeps going past
failures, -j=2 caps the width.
Choices that teach¶
A Literal is a validated choice list, a completion menu, and a
did-you-mean in one annotation:
from typing import Literal
@task
def deploy(target: Literal["dev", "staging", "prod"]):
"Ship to an environment."
$ fm deploy produ
fm: deploy: <target> must be one of dev|staging|prod (got 'produ') — did you mean 'prod'?
Exit code 64, nothing executed, and the fix is in the message.
Typed inputs & validation¶
The belt-and-braces deploy¶
Markers stack. Each one validates eagerly, before anything runs, and each failure is a taught error, not a traceback:
from pathlib import Path
from typing import Annotated
from footman import task, run
from footman.params import between, check, env, isfile
def semver(value: str) -> None:
import re
if not re.fullmatch(r"\d+\.\d+\.\d+", value):
raise ValueError(f"expected MAJOR.MINOR.PATCH, got {value!r}")
@task
def deploy(
config: Annotated[Path, isfile],
version: Annotated[str, check(semver)],
workers: Annotated[int, between(1, 32)] = 4,
target: Annotated[str, env("DEPLOY_ENV")] = "staging",
):
"Roll out."
run(f"./rollout.sh {target} {version} --config {config} -j {workers}")
config must name an existing file; version goes through your own
validator (raise ValueError with a message written for the person at
the prompt); workers is bounds-checked; and target falls back to
$DEPLOY_ENV before its default, so CI sets the variable, humans say
--target=prod, and both flow through the same validation.
Validate one input against another¶
A check validator that declares a second parameter also receives the
siblings, the parameters to its left at their effective values (provided,
or their default), coerced and read-only. That turns a static bound into a
dynamic, cross-field one: a new version checked against the current release of
the package named in an earlier argument, looked up at run time.
from typing import Annotated
from footman import task
from footman.params import check
def current_version(name: str) -> str: ... # your lookup (pyproject, git…)
def newer(version: str, current: str) -> bool: ... # your comparison; none bundled
def newer_than_current(version, params):
current = current_version(params["name"])
if not newer(version, current):
raise ValueError(f"{version} is not newer than {current}")
@task
def release(name: str, version: Annotated[str, check(newer_than_current)]):
"Cut a release, but only forward."
...
fm release core 1.4.0 binds name before validating version, so the bound
is dynamic, which a hard-coded > 1.0.0 can't express. Reaching for the
current version keeps footman zero-dependency: you bring the lookup and the
comparison, footman turns your ValueError into a taught error. The first
parameter's check sees an empty dict; a sibling left at its default shows that
default (so your check never re-hardcodes it); a plain one-argument check is
unchanged. A *args parameter is in there too, as a tuple under its own name.
Left only, and footman says so if you forget. A parameter declared after
this one has no value yet, so asking for it is an error with the fix in it
rather than a silent None:
'version' may only read parameters declared before it, and 'channel' comes
after — so it has no value yet. Move 'channel' above 'version' in the signature.
The same view, and the same rule, is what
default(fn) reads when it
declares one parameter.
TAB completes your git branches¶
suggest() attaches a completer, and footman runs it fresh when you complete
the value (in a bounded subprocess), so Tab offers current branches,
never a stale snapshot:
from typing import Annotated
from footman import task, run
from footman.params import suggest
from toolroom import docker
def branches() -> list[str]:
import subprocess # inside the body, so importing tasks.py stays cheap
out = subprocess.run(
["git", "branch", "--format=%(refname:short)"],
capture_output=True, text=True,
)
return out.stdout.split()
@task
def review(branch: Annotated[str, suggest(branches)]):
"Check out and gate a branch."
run(f"git switch {branch}")
run("fm check")
fm review <TAB> offers real branches. suggest is strict by default:
a typo'd branch is refused against a fresh call, so pass
suggest(branches, strict=False) when the values are hints, not law.
KEY=VALUE options¶
A dict parameter speaks the --name=KEY=VALUE dialect, repeatable,
with taught errors for malformed pairs:
@task
def image(tag: str, build_args: dict[str, str] | None = None):
"Build the container image."
docker.build(".", tag=tag, build_arg=[
f"{k}={v}" for k, v in (build_args or {}).items()
])
Variadic in front, required option behind¶
A keyword-only parameter (after *) is an option, and without a
default, a required one. So a task can take an open list of inputs
positionally and still demand a named output:
from pathlib import Path
@task
def bundle(*entries: str, out: Path):
"Bundle entry points into one artifact."
run(f"./bundle.sh {' '.join(entries)} -o {out}")
$ fm bundle web api worker --out=dist/app.tar
$ fm bundle web
fm: bundle: missing required option(s): --out
Orchestration & tools¶
Dependencies that dedup¶
pre and post build a DAG; a dependency shared by several tasks runs
once per invocation:
from footman import run, task
@task
def proto():
"Generate protobuf stubs."
run("buf generate")
@task(pre=[proto])
def build():
"Compile the service."
run("cargo build --release")
@task(pre=[proto])
def docs():
"Render the API docs."
run("./render-docs.sh")
@task
def notify():
"Announce the finished train."
run("./notify.sh done")
@task(pre=[build, docs], post=[notify])
def release():
"The whole train."
fm build docs runs proto exactly once, then both dependents in
parallel. A failed dependency skips its dependents loudly, never
silently, because a check that quietly dropped lint is how CI learns
to lie.
A build matrix¶
The block form fans the same task over arguments; keep_going collects
every failure instead of stopping at the first:
from footman import parallel, run, task
TARGETS = ("linux-x86_64", "linux-arm64", "darwin-arm64")
@task
def build(target: str):
"Compile one target."
run(f"cargo zigbuild --target {target}")
@task
def matrix():
"Compile every target."
with parallel(keep_going=True) as p:
for t in TARGETS:
build(t)
if any(p): # the block is its list of codes
raise SystemExit(1)
-j caps the fan-out's width from the command line; the timing history
keys on it, so -j=2 runs learn their own duration.
An endless dev server¶
Some tasks end when you say so, not when they finish. Mark them
infinite and footman stops pretending otherwise:
@task(infinite=True)
def serve(port: int = 8000):
"Run the dev server until Ctrl-C."
run(f"uvicorn app:api --reload --port {port}")
infinite=True implies progress=False (a duration that never arrives
is not history), the status line yields to a one-time hint (serve runs
until you stop it — Ctrl-C) and listings carry the same note:
serve Run the dev server until Ctrl-C. (runs until Ctrl-C). Ctrl-C
itself cancels cleanly: the run reports interrupted and exits 130, no
traceback. (This recipe used progress=False the day it was written;
infinite exists because Willem read the recipe and wanted the display
to say how the story ends. Cookbooks feed the kitchen too.)
Tools you never declared¶
Every executable on PATH is already a tool. Attribute access chains
subcommands; keyword arguments translate mechanically (detach=True →
--detach, lists repeat, trailing _ escapes Python keywords):
from toolroom import docker, mkdocs, terraform
@task
def up(detach: bool = True):
"Start the stack."
docker.compose.up(detach=detach)
@task
def plan(out: str = "tf.plan"):
"Terraform plan, saved."
terraform.plan(out=out, input_=False)
@task
def site():
"Build the docs."
mkdocs.build(strict=True) # in-process: no interpreter spawn
Two extras worth knowing: any tool's installed_version() for the
rare version-dependent branch, and the off sentinel
(strict=off → --no-strict) for negating a flag a tool turns on
by default. And on macOS, in-process is sometimes the only correct
option, because SIP strips DYLD_* from subprocesses, so a tool needing
Homebrew's native libraries only works inside the process.
Working directory & environment¶
A task rooted somewhere else¶
The cwd is policy, not an accident: root a task anywhere on the ladder, and suffix one call without ceremony.
from footman import task, run
@task(cwd="root", rel="services/api")
def deploy():
"Deploy the api service, wherever fm was invoked from."
run("docker compose up -d")
@task
def bundle():
"Build the web bundle from a subdirectory of this task's own dir."
run("npm run build", rel="web") # <task cwd>/web, this call only
The task that anchors one branch on the root¶
A task whose contract is invocation-relative can still reach the repo
root for one branch of its work. project_root() is what cwd="root"
anchors on — the highest tasks file's directory in the cascade — readable
from any body, whatever the task's own cwd policy says:
from footman import project_root, task, run
@task
def audit(path: str = ".", affected: bool = False):
"Audit a path as given, or with --affected the whole tree."
target = project_root() if affected else path
run(f"pytest {target}", shell=False)
The distinction doing the work: the task's cwd is policy, resolved once
per task, while where the cascade roots is data, carried alongside
whichever policy won. No git rev-parse required, and no VCS assumed:
the root is the cascade's, which is why it exists even in a repo git has
never seen. Outside a project it answers None.
The legacy task that owns the process¶
A helper that genuinely chdirs, or drives a library that only reads the process state, declares it and gets the real globals, safely:
import footman
from footman import task, run
@task(serial=True)
def legacy_build():
"One serial task at a time; the parallel pool keeps running around it."
with footman.chdir(rel="vendor"): # a real chdir, legal here
run("make")
The serial lane costs less than it sounds for tools that parallelise themselves (pytest with xdist, build systems): they saturate the machine on their own, so serialising the task forgoes almost nothing.
The benchmark that needs a quiet machine¶
exclusive=True is the real full drain, with nothing else in flight:
@task(exclusive=True)
def bench():
"Timings mean nothing with a build running next door."
run("pytest tests/bench --benchmark-only")
Environment for a child, not the world¶
Writes to os.environ in a parallel task scope to the task: its children
see them, siblings never do. The deliberate spellings say it out loud:
@task
def publish(ctx):
ctx.env["TWINE_NON_INTERACTIVE"] = "1" # every child of this task
run("twine upload dist/*", shell=True, env={"TWINE_VERBOSE": "1"}) # one call
The release flow that asks once¶
ask() parameters front-load: every question is asked before anything
runs, so you answer and walk away, and a value on the command line, in the
environment, or in a default means no question at all.
from typing import Annotated
from footman import ask, task, run
@task(confirm="Publish to PyPI?")
def release(version: Annotated[str, ask()]):
"fm release → asks version up front, confirms, then runs unattended."
run(f"uv version {version}", shell=False)
run("uv build")
run("uv publish")
Monorepos¶
Monorepo: root gate, leaf overrides¶
tasks.py files cascade from the repo root down to wherever you stand;
nearer definitions win, and every task runs from the directory that defined
it:
repo/
tasks.py # check, format, release: the shared surface
svc/api/tasks.py # serve, plus its own `check` override
tools/legacy/footman.toml # `uv = false`: run in the parent's env
From svc/api, fm check is the override; fm -C=../.. check is the
root's. A deep directory can adjust behaviour with a two-line
footman.toml, and the configuration ladder reaches
everywhere the cascade does.
A tasks file that carries its own dependencies¶
A tasks file can declare what it needs, inline, with a PEP 723 header. Then it needs no project at all: drop it in any directory and run it:
# /// script
# requires-python = ">=3.11"
# dependencies = ["footman", "httpx"]
# ///
from footman import task
@task
def health(url: str = "https://example.com"):
"Check a deployment is up."
import httpx
print(httpx.get(url).status_code)
fm health builds that script environment once (uv does the work) and runs
inside it; every later run is a warm cache hit. Name any file with
-f=deploy.py and the same rule applies to that file's header. Because
uv reads the block natively, everything it understands works: a
requires-python, a [tool.uv.sources] pointing a dependency at a git
ref or a local path.
The full rules, in order. A project that pins footman wins outright —
inside one, the script block is ignored (worth seeing under -v, never a
warning), because the lockfile already declared what fm means there. The
handoff only fires where the cascade found exactly one file, and only when
that file's block declares dependencies. The opt-outs: FOOTMAN_NO_UV=1
disables both uv handoffs for a shell, and uv = false under
[tool.footman] does the same per project; with either set, or with no uv
on the PATH, the file simply runs as-is in the current environment.
The project handoff also covers the environment going stale. With the
project's venv active but out of date — a dependency locked after the last
sync — an import fails exactly where a missing package would; rather than
report the mystery, the run hands itself to uv run --project, which
syncs and retries, and a failure after that is real and surfaces
unchanged. Under the opt-outs (or with no uv to hand off to) the error
instead ends with the fix: run uv sync.
List footman itself among the dependencies: the file imports it, so the script environment has to contain it. That is also the one thing footman refuses, because the environment provably could not run the file.
Two touches make it a command in its own right:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["footman", "httpx"]
# ///
import footman
from footman import task
@task
def health(url: str = "https://example.com"): ...
if __name__ == "__main__":
footman.main(__file__)
chmod +x deploy.py, and ./deploy.py health --url=… runs from any
directory, with the same tasks, the same options and the same --help, and no
runner installed at all.
Checked into a project, nothing changes for people working in it: a
project whose lockfile pins footman owns its runs, so the header is
ignored there (-v mentions it). The file is portable and at
home in the repo.
Extend an inherited task instead of replacing it¶
Overriding by name usually means and also, not instead of.
inherited() is footman's super(): inside an overriding task it hands
you the task you shadow, as the plain function it is.
# svc/api/tasks.py; the repo root also defines `check`
from footman import inherited, run, task
@task
def check(fix: bool = False, contracts: bool = True):
"The shared gate, plus this service's contracts."
inherited()(fix=fix) # the root's check, arguments forwarded
if contracts:
run("./verify-contracts.sh")
Here the forwarding is spelled out on purpose. inherited() calls a task you
shadow, and the two signatures are genuinely independent (this leaf added
--contracts, which the root has never heard of) so you pass what you mean and
can change it on the way through: inherited()(fix=False) runs the root's gate
without letting it rewrite files. And being an ordinary call, it finishes before
your next line; write parallel(inherited(), extra_checks) when you'd rather
it didn't.
(For the other direction, threading a value down to a task's pre/post
prerequisites or a runnable group's surfaces, the
forward marker
does it declaratively. inherited() stays the explicit form for the case it's
built for: calling the specific task you shadow, and choosing what it gets.)
It chains all the way up: a mid-level check that calls inherited()
reaches the root's, and the leaf's call reaches the mid's. Two commands
answer "what am I overriding, and what does it take?":
$ fm --where=check
/repo/svc/api/tasks.py:6
/repo/svc/tasks.py:4 (shadowed)
/repo/tasks.py:9 (shadowed)
$ fm --help check
...
shadows /repo/svc/tasks.py:4 — inherited() calls it
fm check [--fix]
That last line is the forwarding call, spelled out. Calling
inherited() in a task that shadows nothing is a taught error, not a
None to trip over.
Progress, data & fetching¶
A bar that knows exactly where it is¶
Some work knows its own progress (23 of 150 migrations, bytes of a download) and that beats any timing history. Report it and the live bar fills from the truth:
from pathlib import Path
from footman import task, track, progress
def load_records() -> list: ... # your own work, whatever shape it takes
def apply(record): ...
def build_index(path): ...
@task
def migrate():
"Apply pending migrations."
for record in track(load_records()): # total from len()
apply(record)
@task
def index(path: Path):
"Rebuild the search index."
for done, total in build_index(path):
progress(done, total) # the explicit form
Counted beats estimated, so a reporting task is exact on its first run, where the estimator would still be gathering samples. The full story, covering the live status line, the timing history and the off switches, is on Progress & timing.
Fetch and cache a toolchain¶
fetch() downloads into footman's own cache: the same directory
FOOTMAN_CACHE_DIR moves and the cache collector tends, so vendored
artifacts for deleted projects clean themselves up.
from pathlib import Path
from footman import fetch, parallel, step, task
TOOLCHAIN = {
"protoc": ("https://example.com/protoc-27.tar.gz", "9f86d081884c…"),
"buf": ("https://example.com/buf-1.34.tar.gz", "2c26b46b68ff…"),
}
@task
def vendor():
"Fetch the pinned toolchain, in parallel."
# step(fetch) lifts the helper into an owned item, so each download
# gets its own receipt and its own worker.
parallel(*(
step(fetch)(url, sha256=digest, into=Path("vendor") / name)
for name, (url, digest) in TOOLCHAIN.items()
))
A second run revalidates with the server (ETag / If-None-Match)
instead of re-downloading; a 304 costs one round trip and keeps
"cached" true. sha256= refuses anything that arrived wrong, which is
what makes the task reproducible. And a fetch is a step: --dry-run
prints it without touching the network, recording() asserts on it in
tests, --json carries it, and it appears in the step lines beside your
run() calls. Byte counts feed the progress bar for free.
The default backend is stdlib urllib: zero dependencies, and the only
one that can report bytes as they arrive. Behind a corporate proxy whose
TLS store Python can't see, name the system curl once and every project
follows:
httpx and requests are available when named; auto picks the best
importable one if you'd rather. It isn't the default on purpose, because a
download that silently changes engine when an unrelated dependency
appears would change its TLS and proxy behaviour with it.
Tasks that return data¶
Return a dict and --json carries it verbatim under returned: your
task's own machine surface, no printing-and-parsing:
@task
def coverage() -> dict:
"Measure test coverage."
run("pytest --cov=app --cov-report=json -q")
import json
percent = json.load(open("coverage.json"))["totals"]["percent_covered"]
return {"percent": round(percent, 2)}
$ fm --json coverage | jq '.items[0].returned.percent'
94.2
$ fm --json coverage | jq -e '.items[0].returned.percent >= 90' > /dev/null \
|| echo "coverage regression"
Path, Enum, datetime, UUID, Decimal, dataclasses, and sets all
serialise symmetrically with what footman coerces in; an int return
stays what it always was, an exit code.
Machine use, testing & branding¶
The coding-agent loop¶
Footman treats agents as users in their own right, and the loop is the same one you'd teach a new colleague: discover, validate, run, read the receipt.
fm --json --list # the full catalog: tasks, params, types, docs
fm --json --dry-run deploy prod # rehearse: bodies run, footman's work is faked
fm --json deploy prod # one envelope: results, output, returned
A refusal is machine-readable too ({"error": {"code": 64, "message":
"…did you mean 'prod'?"}}) so an agent can read the fix out of the
message the same way a human does. Two hooks close the loop for Claude
Code (this repo runs both on the agent that builds footman itself): an
edit-time hook running fm format lint after every Python edit, and a
stop-gate refusing to let the session end until fm check passes. The
paste-ready versions live on the AI agents page, next to
the CLAUDE.md snippet that teaches the grammar in six lines.
Test your tasks like code¶
Tasks are plain functions, so plain calls already work. recording()
asserts which commands would run without running them, and the pytest
fixtures scaffold whole projects:
from footman import recording
from tasks import deploy
def test_deploy_passes_the_workers_flag():
with recording() as steps:
deploy(config="app.toml", version="1.2.3", workers=8)
assert steps[0].command.endswith("-j 8")
def test_release_refuses_bad_versions(fm_project):
fm = fm_project('''
from typing import Annotated
from footman import task
from footman.params import check
def semver(v):
import re
if not re.fullmatch(r"\\d+\\.\\d+\\.\\d+", v):
raise ValueError("expected MAJOR.MINOR.PATCH")
@task
def release(version: Annotated[str, check(semver)]): ...
''')
result = fm.invoke("release not-a-version")
assert result.exit_code == 64
assert "MAJOR.MINOR.PATCH" in result.stderr
The fm, fm_project, and fm_record fixtures auto-load; pytest is
never a footman dependency; only pytest itself imports the plugin. The
whole story: Testing your tasks.
Ship your own CLI¶
A branded tool is footman with your name on it: same grammar, same completion, same docs machinery, answering as itself:
# acme_cli.py
from footman import App
app = App(name="Acme", prog="acme", version="1.4.0")
def main() -> None:
raise SystemExit(app.run())
acme --install-completion installs for acme; errors say acme:; and
acme docs.page (the plugin overlays your own docs group) documents
your task surface under your prog. The details: Custom CLI.