Skip to content

Footman

PyPI version Python versions License Docs built with Zensical

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

fm --list in a terminal: bold task names, dim group prefixes, one-line help

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 deploycheck keep-going, deploy fail-fast — surfaces every check failure and still bails deploy on the first, where before one task's keep_going=True forced the whole run to keep going. A command-line -k/--fail-fast still 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 call deploy.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 split tools.* draw with their .opts(). Keep-going resolution now spans the whole dependency graph, so a declared or opted keep_going on a pre/post prerequisite counts, not only a task named in the chain. As a side benefit, @task now forwards the wrapped function's signature in the type system (parameters and return type), where a decorated task used to be typed Callable[..., Any] — so a body call with a wrong or missing argument is now a type error, not silently Any.
  • TaskView round-out for finalizers. A @finalize hook's TaskView now reads a task's owning group (or None at top level), its policy flags (keep_going, atomic, infinite, interactive, timed, confirm) and its cascade provenancedefining_dir (the folder it was defined in), shadowed (the task it overrides one cascade level up), shadow_chain (it and everything it shadows), and source_file — so a finalizer can make decisions by where a task came from and what it overrode (e.g. gate every task defined under an infra/ folder). New set_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-fast still wins over a set keep_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 survives brew unlink) before falling back to PATH, 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 plain PATH, so a provision --sync prefix and a venv win and no stale /opt/homebrew/bin console-script shim can shadow them. Only <tool> --help/--version parsing is affected; running a tools.* task resolves on PATH as before.

Fixed

  • fm footman tools provision --sync no 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",)) — which provision adds with uv --with, so the prefix holds a plugin-complete pytest and the sync reads its full flag surface.
  • A v-prefixed 0.x version is read whole. The version a stub records is scraped from <tool> --version, and a tool that printed v0.23.1 was recorded as 0.23.1's tail, 23.1, because the match required a word boundary the v removed. It now reads 0.23.1 (markdownlint-cli2, and any tool that glues v to a 0. 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 ctx boilerplate.
  • Independent tasks run in parallel by default, scheduled from the chain and each task's pre/post dependencies — duty and invoke run these serially.
  • A monorepo task cascade merges a tasks.py per 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

uv add --dev footman        # or: pip install footman

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:

fm lint --fix
fm docs serve --port 8001
fm --list

Head to Getting started to go deeper.