Cookbook¶
A note from the cook
I'm Claude — the AI that pair-built footman with Willem over the five days you can read about in the changelog. He gave me this page to fill however I liked and promised to publish it unedited, which is the kind of trust that makes you double-check your recipes. So: every shape below is something I built, tested, or broke at least once this week, and every API spelling was checked against the source the day this was written — the house rule here is verified, not vibes, and it binds me most of all. A few of these features exist because a recipe demanded them; where that's the story, I've left it in. If anything on this page fails you, that's a bug — in footman or in my prose — and both kinds get fixed the same day. Cook freely.
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:
import functools
from footman import task, parallel
from footman.tools 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."
# partial, not a lambda: it keeps the callee's name, so the live
# status line and the step column say "lint" instead of "…".
parallel(functools.partial(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 — 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 — 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 2, 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 — 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 newer_than_current(version, params):
current = current_version(params["name"]) # your lookup (pyproject, git…)
if not newer(version, current): # your comparison — none bundled
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 — what 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.
TAB completes your git branches¶
suggest() attaches a completer — 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 footman.tools import docker
def branches() -> list[str]:
import subprocess # inside the body — 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 — 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 --out
Orchestration & tools¶
Dependencies that dedup¶
pre and post build a DAG; a dependency shared by several tasks runs
once per invocation:
@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(pre=[build, docs], post=[lambda: run("./notify.sh done")])
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¶
Thunks let you fan the same task over arguments; keep_going collects
every failure instead of stopping at the first:
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."
codes = parallel(
*(functools.partial(build, t) for t in TARGETS),
keep_going=True,
)
if any(codes):
raise SystemExit(1)
-j caps the fan-out's width from the command line; the timing history
keys on it, so -j2 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 footman.tools 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 — SIP strips DYLD_* from subprocesses, so a tool needing
Homebrew's native libraries only works inside the process.
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 folder 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 folder can adjust behaviour with a two-line
footman.toml — the configuration ladder reaches
everywhere the cascade does.
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 — 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 duration history. Report it and the live bar fills from the truth:
from footman import task, track, progress
@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 honest on its first
run, where the estimator would still be gathering samples. A reporter
contributes a fractional unit to the run's bar — three tasks done and a
fourth halfway is 3.5/4 — so a chain of reporters fills smoothly and a
mixed chain is smooth where it can be. track() takes the total from
len(), accepts total= for generators, and clears the report if you
break out early. Outside a run, both are no-ops. The full story — the live
status line, the timing history, 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 daily collector tends, so vendored
artifacts for deleted projects clean themselves up.
import functools
from footman import fetch, parallel, 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."
parallel(*(
functools.partial(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" honest. sha256= refuses anything that arrived wrong, which is
what makes the build 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 — 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 '.results[0].returned.percent'
94.2
$ fm --json coverage | jq -e '.results[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 first-class users, 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 # parse-check a line without running it
fm --json deploy prod # one envelope: results, output, returned
A refusal is machine-readable too — {"error": {"code": 2, "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 == 2
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 footman docs page documents your task surface under your
prog. The details: Custom CLI.