Skip to content

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:

@task
def test(*pytest_args: str):
    "Run the test suite."
    pytest(*pytest_args)
$ fm test -- -k "grammar and not slow" -x --lf

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:

$ fm format lint --fix test

--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()
    ])
$ fm image v3 --build-args PYTHON=3.13 --build-args DEBIAN=trixie

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:

# ~/.config/footman/config.toml
[fetch]
backend = "curl"

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())
[project.scripts]
acme = "acme_cli:main"

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.