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 — 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
    """

Animated: fm TAB lists every task with its summary, deploy --branch TAB completes to the repository's real git branches, and --region TAB offers the values the signature declares

Animated: fm TAB lists every task with its summary, deploy --branch TAB completes to the repository's real git branches, and --region TAB offers the values the signature declares

Animated: fm TAB lists every task with its summary, deploy --branch TAB completes to the repository's real git branches, and --region TAB offers the values the signature declares

Animated: fm TAB lists every task with its summary, deploy --branch TAB completes to the repository's real git branches, and --region TAB offers the values the signature declares

Animated: fm TAB lists every task with its summary, deploy --branch TAB completes to the repository's real git branches, and --region TAB offers the values the signature declares

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

fm --tree in a terminal: tasks grouped by command group, bold names, one-line help

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.install filed 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 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 one at a time.
  • A monorepo task cascade merges a tasks.py per 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

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

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:

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

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.