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 — without importing your code. Building that cache does import it, once, in a detached subprocess: the first Tab in a fresh directory pays for it, and no keystroke after that. See completion.
Here is the whole project behind the recording below. No CLI framework, no argument parser, no completion script: three functions, and the annotations that say what their parameters are.
# tasks.py
from typing import Annotated, Literal
from footman import doc, suggest, task
from toolroom import git
def branches() -> list[str]:
"Every branch in this repo, asked of git rather than written down."
return git.branch(format="%(refname:short)").stdout.split()
@task
def deploy(
branch: Annotated[str, suggest(branches), doc("branch to ship")] = "main",
region: Annotated[Literal["eu", "us", "ap"], doc("region")] = "eu",
):
"Ship a branch to a region."
@task
def build(release: bool = False, jobs: int = 4):
"""Compile and bundle.
Args:
release: optimise and strip symbols
jobs: parallel compile jobs
"""
@task
def test(watch: bool = False):
"""Run the test suite.
Args:
watch: re-run on every file change
"""
Real sessions, not mock-ups: recorded from live shells on every docs build,
in all five shells footman supports, one frame per keypress. Nothing above is
typed out in full: every token on that line arrives by pressing
Tab, and the longest thing typed is two characters. --branch
completes to this repository's actual git branches, because its
candidates come from a function rather than a list someone wrote down; then
--region offers what the signature's Literal declares. The menus arrive
at keystroke speed because footman answers them from a cached manifest
instead of importing your project. How that works.
Each recording shows its shell at its best, which for three of them means one
line of setup: zsh's menu select, bash's show-all-if-ambiguous,
PowerShell's MenuComplete. Each shell's page
(zsh, bash,
PowerShell) says which line and why. What footman
supplies is the same everywhere: the candidates, their descriptions, and the
values behind them.
Annotations are how you get the most out of it, not the price of entry: a
plain def deploy(target, port=8000) is already a working command with a
positional, a typed option, and completion. Annotate when you want
more.
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, like every terminal image on
this site.)
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.52.0) if you build on it. What is covered, what is
internal, and what has to be true before 1.0 are written down in the
stability promise.
Latest release: 0.52.1 — 2026-09-06
Fixed¶
- The discovered built-in record is keyed by the environment it
describes, not the one that wrote it. v0.52.0 keyed it by the writer's
sys.prefix, which reproduced the very leak the key was added to prevent, one hop over:fm self.*discovers by asking the tool environment's own interpreter — that is the only thing that knows its entry points — so the answer is never about the process that typed the command. Run from a project's venv,fm self.installfiled the tool environment's packages under that venv, which then warned about every name it could not import.
The probe now reports its own sys.prefix alongside the names, and
write_discovered requires the prefix rather than defaulting to the
writer's. If you ran fm self.install on 0.52.0 from anywhere other
than a shell where fm resolved to the tool installation, run it once
more; the misfiled record names an environment that never had those
packages and is otherwise inert.
The full history lives in the changelog.
Why¶
duty gets a lot right — the run() capture model, the
decorator ergonomics — and footman keeps those ideas. It tries to improve on
the parts you meet every day:
- Completion answers from a cache instead of re-importing your whole project on every Tab. (The numbers, if you want them.)
- 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 one at a time. - A monorepo task cascade merges a
tasks.pyper directory, 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+ — the first Python with stdlib TOML reading, which
is what makes zero runtime dependencies possible for a tool configured
through pyproject.toml. Linux, macOS and
Windows are all first-class: CI runs the full suite on all three (five
Pythons each, free-threaded included) and drives real shell completion on
bash, zsh, fish, PowerShell and nushell.
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.
Calling your tools¶
Tasks mostly run other programs, and run("ruff check src --fix") is a
string your editor cannot help you with.
toolroom makes those calls
Python:
from footman import task
from toolroom import ruff
@task
def lint(fix: bool = False):
"Run ruff over the project."
ruff.check("src", fix=fix)
It wraps any command-line program, not a fixed list: keyword arguments
become flags, so toolroom.terraform("plan", var_file="prod.tfvars") runs
terraform whether or not toolroom has ever heard of terraform.
For a number of common tools it also ships type hints, generated from
each tool's own metadata, so your editor knows that ruff check takes
--fix and what that does. The hints only decide whether your editor can
help. They never decide whether a call works: a tool without them runs
exactly the same, and one whose flags have moved on costs you a stale
suggestion rather than a broken build.
It began as part of footman and was spun out as its own library for two reasons. It releases on its own schedule. And other people might want type hinted, documented command-line calls without having to use footman for anything.
footman does not depend on it and never imports it. Plain
run() is there regardless, and nothing on this page needs
toolroom.