Footman¶
A task runner with the soul of duty and the UX of typer: typed function signatures become real flags and positionals, modules become nested command groups, and shell completion answers from a cached manifest in ~25 ms — without importing your code.
fm lint --fix
fm format lint --fix test # a chain: three tasks, no separator
fm workspace mount --share <TAB> # main scratch archive
Ships two console scripts: footman and the two-letter fm. (That
screenshot is generated from the real CLI on every docs build — like every
terminal image on this site, it cannot drift from what footman actually
prints.)
Beta
footman is pre-1.0: the surface is settling, but minor versions may still
include breaking changes — always called out in the
changelog, never in a patch release. Pin the minor
(footman~=0.19.0) if you build on it. The written stability promise
lands with 1.0 — the road there is on the roadmap.
Latest release: 0.19.0 — 2026-07-23
Added¶
- Per-subtree keep-going scoping. The failure policy is now resolved per
node, not run-wide: a keep-going gate keeps its own prerequisites going with
it, while an independent task in the same run keeps its own policy. So
fm check deploy—checkkeep-going,deployfail-fast — surfaces everycheckfailure and still bailsdeployon the first, where before one task'skeep_going=Trueforced the whole run to keep going. A command-line-k/--fail-faststill overrides every scope at once, and a task's own (or.opts()-set) policy wins over one inherited from a gate above it. True fail-fast reaps only the fail-fast subprocess trees still in flight on a failure, so a keep-going task's long-running child keeps going while a doomed fail-fast branch dies at once. - Per-use option overrides —
.opts(). A task or runnable group carries an.opts(...)that overrides its orchestration options for one use without touching the registered task:pre=[fmt.opts(atomic=True)],pre=[lint.opts(keep_going=True)], or a body calldeploy.opts(atomic=True)("prod"). It takes the policy options —keep_going,atomic,interactive,progress,confirm,infinite— and rejects a task parameter with a taught error, because work goes in the call and policy rides beside it, the same splittools.*draw with their.opts(). Keep-going resolution now spans the whole dependency graph, so a declared or optedkeep_goingon apre/postprerequisite counts, not only a task named in the chain. As a side benefit,@tasknow forwards the wrapped function's signature in the type system (parameters and return type), where a decorated task used to be typedCallable[..., Any]— so a body call with a wrong or missing argument is now a type error, not silentlyAny. TaskViewround-out for finalizers. A@finalizehook'sTaskViewnow reads a task's owninggroup(orNoneat top level), its policy flags (keep_going,atomic,infinite,interactive,timed,confirm) and its cascade provenance —defining_dir(the folder it was defined in),shadowed(the task it overrides one cascade level up),shadow_chain(it and everything it shadows), andsource_file— so a finalizer can make decisions by where a task came from and what it overrode (e.g. gate every task defined under aninfra/folder). Newset_opts(**overrides)sets a task's policy for every use of it — the permanent, tree-wide counterpart to.opts(), taking the same options and rejecting a task parameter the same way. A command-line-k/--fail-faststill wins over a setkeep_going.
Changed¶
- Homebrew resolution for host-read tools on macOS (stub generation only).
The tools footman reads straight off the host — git, docker, uv, never
provisioned into an isolated prefix — resolve their Homebrew keg
(
opt/<name>/bin/<name>, which survivesbrew unlink) before falling back toPATH, so on macOS the stub describes the newest build (Homebrew git over Apple's older/usr/bin/git). Every provisioned tool (ruff, mkdocs, pytest, gh, …) still resolves on plainPATH, so aprovision --syncprefix and a venv win and no stale/opt/homebrew/binconsole-script shim can shadow them. Only<tool> --help/--versionparsing is affected; running atools.*task resolves onPATHas before.
Fixed¶
fm footman tools provision --syncno longer strips plugin flags. A provisioned prefix is a bare install, so reading pytest's stub from it dropped every--cov*flag (they come from pytest-cov). A driver can now name extra wheels to install alongside a tool —Provision(plugins=("pytest-cov",))— whichprovisionadds withuv --with, so the prefix holds a plugin-complete pytest and the sync reads its full flag surface.- A
v-prefixed0.xversion is read whole. The version a stub records is scraped from<tool> --version, and a tool that printedv0.23.1was recorded as0.23.1's tail,23.1, because the match required a word boundary thevremoved. It now reads0.23.1(markdownlint-cli2, and any tool that gluesvto a0.version).
The full history lives in the changelog.
Why¶
duty gets a lot right — the run() capture model, the tools wrappers, the
decorator ergonomics — and footman keeps those ideas. Where it pushes is the
parts that compound:
- Completion answers from a cache instead of re-importing your whole project on every Tab — ~15× faster, measured.
- Types and choices validate eagerly, including unions and dynamic value sets, with errors that teach.
- Modules become nested command groups, and task signatures carry no
ctxboilerplate. - Independent tasks run in parallel by default, scheduled from the chain and
each task's
pre/postdependencies — duty and invoke run these serially. - A monorepo task cascade merges a
tasks.pyper folder, from the repo root down to where you stand.
The receipts live in the comparison — a measured
head-to-head against duty, invoke, poe, and typer, every number reproducible
from the repo's comparison/ directory.
Install¶
Requires Python 3.11+. Zero runtime dependencies.
A first taste¶
Write a tasks.py in your project root:
from footman import task, group
@task
def lint(fix: bool = False):
"Run ruff over the project."
...
docs = group("docs", help="Documentation")
@docs.task(infinite=True)
def serve(port: int = 8000):
"Serve the docs locally."
...
Then:
Head to Getting started to go deeper.