Skip to main content

A task runner with typed commands, nested groups, and instant shell completion.

Project description

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 ~19 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.

[!WARNING] Very early code. footman is alpha and moving fast — the public API, the decorator surface, the manifest format, and the CLI grammar can all change without notice or a deprecation cycle. Pin an exact version if you build on it.

Why

duty got a lot right — the ctx.run capture model, the tools wrappers, the decorator ergonomics — and footman keeps those ideas. Where it pushes is the parts that compound: completion that answers from a cache instead of re-importing your whole project on every TAB (~15× faster in practice), eager type and choice validation (including unions and dynamic value sets), native command groups, no ctx boilerplate in task signatures, a DAG scheduler that runs independent tasks in parallel by default (duty and invoke can't), and a monorepo task cascade that merges a tasks.py per folder from the repo root down to where you stand. A measured head-to-head against duty, invoke, poe, and typer lives in comparison/ — modern duty has real flags and chaining, so the gap is validation, ergonomics, and completion latency, not grammar.

Install

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

Requires Python 3.11+. Zero runtime dependencies.

Quick start

Write a tasks.py in your project root:

from footman import task, group

@task
def lint(fix: bool = False):
    "Run ruff over the project."
    ...

@task
def test(marker: str = "", *pytest_args):
    "Run the test suite (extra pytest args after --)."
    ...

docs = group("docs", help="Documentation")

@docs.task
def serve(port: int = 8000):
    "Serve the docs locally."
    ...

Then:

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

Signatures become CLIs

A function's signature is introspected into real CLI semantics:

Signature CLI
fix: bool = False flag --fix / --no-fix
mode: str = "loose" option --mode VALUE
env: Literal["dev","prod"] completable, eagerly-validated choices
count: int = 100 typed option, validated at parse time
target: str | int union — coerced by specificity (int before str)
paths: list[Path] | None repeatable option (--paths a --paths b)
items: Many[str | int] one or more values, each coerced
tags: Annotated[list[str], csv] repeatable, and splits --tags a,b,c
env: dict[str, int] --env KEY=VAL pairs (repeatable; csv-splittable)
labels: dict[str, list[int]] repeated key appends: --labels p=1 --labels p=2
project: Annotated[str, suggest(fn)] dynamic choices from fn(), cached + validated
template: Path (no default) required positional (exact arity)
*cmd: str variadic; also receives everything after --

Errors are product surface — they name the task, state the expectation, and propose the fix:

$ fm deploy check
fm: deploy: <env> must be one of dev|staging|prod — 'check' looks like the next
task; did you forget <env>?

Unions and one-or-many values

A parameter can accept a union of types; footman coerces in specificity order (most restrictive first, str last), so str | int turns "5" into 5 and "x" into "x". For one or more values use Many[T] (or list[T]) — always a list, element type may itself be a union:

@task
def build(targets: Many[str | int], jobs: int | str = "auto"):
    "Build one or more targets."
fm build core web 3        # targets = ["core", "web", 3]  (positional, juxtaposed)
fm build core --jobs 8     # jobs = 8 (int); --jobs auto -> "auto"

Multi-value options repeat the flag (--tag a --tag b) so values stay opaque (a value may contain commas). Opt into comma-splitting per option with csv:

@task
def build(tags: Annotated[list[str], csv] = ()):
    "..."
fm build --tags a,b,c        # ["a", "b", "c"]  — also works as --tags a --tags b

csv splits on , and nothing else; a value that must contain a comma uses the repeated-flag form. (This is deliberately shell-portable — a comma-joined token survives bash, zsh, and PowerShell intact, unlike a bare comma separator.)

Dictionaries

dict[K, V] becomes a repeatable KEY=VALUE option; keys and values are typed and validated like everything else, and it composes with csv:

@task
def deploy(env: Annotated[dict[str, int | str], csv] | None = None,
           ports: dict[str, list[int]] | None = None):
    ...
fm deploy --env DEBUG=1 --env HOST=prod          # {"DEBUG": 1, "HOST": "prod"}
fm deploy --env=DEBUG=1,HOST=prod                # same — csv splits the pairs
fm deploy --ports web=8080 --ports web=8443      # {"web": [8080, 8443]}  (repeat appends)

Values split on the first = (so a value may contain one); a scalar value's duplicate key is last-wins, and a dict[K, list[E]] appends on repeat. Under csv a value can't contain a comma — same opt-in trade as lists.

Dynamic completion

Some values change occasionally — the projects in a monorepo, environments, branches. Attach a completer with Annotated[T, suggest(fn)] (a bare callable works too):

from footman import task, suggest
from typing import Annotated

def projects() -> list[str]:
    return [p.name for p in Path("projects").iterdir() if p.is_dir()]

@task
def build(project: Annotated[str, suggest(projects)]):
    ...

footman runs projects() on the execution path — refreshing a cache the completion hot path serves — so TAB stays instant (no import of the framework or your code) while the candidates stay current. By default it is strict: the value is validated against a fresh call, with a did-you-mean hint:

$ fm build myprojet
fm: build: <project> must be one of api|core|web (got 'myprojet') — did you mean
'web'?

Pass suggest(fn, strict=False) for best-effort data that shouldn't block a run. A completer shared across parameters runs once per invocation.

Chaining and parallelism

fm format lint --fix test runs three tasks from one line — duty's muscle memory, but with real flags. The split is driven by the manifest, so it is deterministic; + is always available as an explicit boundary, and --dry-run prints the parsed plan:

$ fm --dry-run format lint --fix test
  globals: --dry-run
  -> format
  -> lint  --fix
  -> test

Independent tasks run in parallel by default. footman builds a DAG from the chain and each task's declared dependencies, then runs everything that isn't waiting on something else concurrently. Tasks are almost always I/O-bound (they shell out through run(), releasing the GIL), so threads give real wall-clock speedups without process isolation:

fm a b c            # three 1s tasks -> ~1.0s, not 3.0s
fm -s a b c         # -s/--sequential runs them one at a time -> ~3.0s

Output never interleaves: each task's stdout is buffered and flushed as one contiguous block when it finishes.

Dependencies with pre / post. Declare prerequisites and follow-ups on the task; footman schedules them (deduping shared deps, so a prerequisite pulled in twice runs once) and skips a task whose prerequisite failed:

@task(pre=[fmt, lint])      # fmt and lint run (concurrently) before check
def check(): ...

@task(post=[notify])        # notify runs after deploy succeeds
def deploy(): ...

Fan out from inside a task with parallel() — pass task functions directly, or thunks when you need arguments. It runs them concurrently, waits, and fails if any fail:

from footman import task, parallel

@task
def check():
    parallel(lambda: format(check=True), lint, typecheck, test)

Tasks run stop-on-first-failure by default; -k/--keep-going runs every independent branch even if one fails.

Monorepos

In a monorepo you rarely want one giant tasks file. footman collects every tasks.py from the repo root (the nearest .git above you) down to your current directory and merges them into one command set:

repo/            .git  pyproject.toml  tasks.py   →  build  test  lint
  services/
    api/         tasks.py                         →  serve  migrate  build*

Standing in services/api, fm sees build* (the local override), test, lint, serve, and migrate. The rules are the ones you'd guess:

  • a new name appends, a name already defined higher up is overridden by the folder nearest you, and a group present at both levels merges (its tasks overlaid the same way);
  • every task runs from the folder of the file that defined it — root's build always builds from repo/, api's serve from services/api/, wherever you invoke it. (run(cwd=…) still overrides per command.)

Completion is cached per directory, so <TAB> in services/api offers the merged set while the repo root offers only its own. -f/--tasks-file is the escape hatch: it loads exactly one file, no cascade.

Configuration

Behavioural settings are discovered by the same upward walk. footman reads [tool.footman] from pyproject.toml and a standalone footman.toml (whole-file), from the repo root down to your cwd — nearer files win, so a package can override repo-wide defaults:

# repo/pyproject.toml
[tool.footman]
tasks = "tasks.py"     # the filename to look for in the cascade
sequential = false     # run tasks one at a time by default

# repo/services/api/footman.toml   (no pyproject here — a standalone file)
sequential = true      # this package prefers serial runs

--config PATH points at a single TOML file that overrides everything else. Unknown keys are ignored, so a newer setting never breaks an older footman.

Running tools

Task bodies run tools through run() and the typed tools.* wrappers. run() captures output and stays quiet on success, replaying it only on failure — so a green run is calm and a red one shows exactly what broke:

from footman import task, run, tools

@task
def check():
    tools.ruff("check", "src", fix=False)   # subprocess (ruff is a binary)
    tools.pytest("-x")                        # in-process via pytest.main
    run("mkdocs build --strict")              # any command; a callable also works
  • In-process where possible — Python-native tools (pytest) skip the process spawn; binaries (ruff, basedpyright, uv) run as subprocesses. Either way the wrapper gives you typed, autocompletable options and a typo-proof command line.
  • run() takes a command (string or list) or a Python callable; it raises on a non-zero exit (nofail=True returns the code instead), honours --dry-run (prints the command instead of running it), and records a step for --json.
  • No ctx neededrun() and passthrough() read the current task's context implicitly, so def check(): stays boilerplate-free. Declare a first ctx: Context parameter only if you want the object; footman keeps it out of the CLI mapping:
from footman import Context

@task
def test(ctx: Context):
    tools.pytest(*ctx.passthrough)          # fm test -- -k mytest -x

Under --json, every run() becomes a structured step (command, code, duration, captured output) inside the task's entry.

Instant completion

Completion answers from a JSON manifest cached under your XDG cache dir, keyed by directory (so each folder of a monorepo caches its own merged cascade). The hot path is stdlib-only — it reads one file, parses JSON, and walks the tree; it never imports footman or your tasks. That is the whole latency story (measured cold-process on an M-series Mac):

variant mean
interpreter startup (floor) 14 ms
standalone resolver (baked-in path) 19 ms
python -m footman --complete 24 ms

The manifest is regenerated for free on any execution-path run (footman is importing your code anyway) and rewritten only when the command surface actually changed.

Run uv run python scripts/bench_completion.py to reproduce.

--json for CI and agents

$ fm --json test
[
  {"task": "test", "ok": true, "code": 0, "duration_ms": 812.4, "output": "...", "error": null}
]

Task output — including anything a subprocess writes — is captured into the payload, so stdout stays pure machine-readable JSON. No incumbent has this.

Global options

Global options bind to fm itself and go before the first task name (fm --json test, not fm test --json):

option effect
-V, --version print the version and exit
-l, --list list tasks (flat)
--tree list tasks (grouped by command group)
--where TASK print the task's source file:line
-n, --dry-run print the parsed plan without running
-s, --sequential run tasks one at a time (default is parallel)
-k, --keep-going run every segment even if one fails
-q, --quiet suppress the per-task summary
--timings show per-task durations
--json machine-readable results (captures task output)
-C, --directory PATH run as if launched from PATH
-f, --tasks-file PATH use one file, no cascade
--config PATH override config with a single TOML file

Accepted but not yet wired: --install-completion SHELL (prints guidance for now), a per-command --help (currently lists tasks), -v/--verbose, --no-color, and --refresh-manifest (the manifest already refreshes on every run).

Status

Alpha. The core is built and tested (~95% coverage): the registry, signature→CLI manifest, the completion hot path, the chain grammar (all six rules with taught errors), typed execution (unions, one-or-many, dict[K, V], csv, custom types via their constructors), dynamic completion, the run()/tools execution layer with capture and replay-on-failure, the DAG scheduler (parallel-by-default with pre/post dependencies, parallel(), and grouped non-interleaved output), the monorepo cascade (root-to-cwd task merge with defining-dir cwd and per-directory completion) and its config discovery ([tool.footman] / footman.toml / --config), and the global-option set. What's next:

  • shell-native completion installers (--install-completion for bash/zsh/fish/pwsh/nushell) — today the resolver works via fm --complete;
  • a live TTY progress spinner and richer tools.* coverage;
  • chain-aware completion.

See the design notes for the full roadmap. MIT licensed.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

footman-0.3.0.tar.gz (34.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

footman-0.3.0-py3-none-any.whl (41.6 kB view details)

Uploaded Python 3

File details

Details for the file footman-0.3.0.tar.gz.

File metadata

  • Download URL: footman-0.3.0.tar.gz
  • Upload date:
  • Size: 34.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for footman-0.3.0.tar.gz
Algorithm Hash digest
SHA256 67eb533a471266bb91b7f4088287afec4c5dcab49a4702bab785464136d85639
MD5 cfb434977ee4fa9ae333bdce689c8765
BLAKE2b-256 b21c05ba6c52bfff0eb74b9556a3d01c7314a1c1e35a1b0d660db3c53a11c08b

See more details on using hashes here.

Provenance

The following attestation bundles were made for footman-0.3.0.tar.gz:

Publisher: release.yml on willemkokke/footman

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file footman-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: footman-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 41.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for footman-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1c4adfa195a7f3ad82e073ef815b519fec8e261466d84470e933804393e902c8
MD5 c016184d2d2e676e5c3dfc0578f898d2
BLAKE2b-256 2bb1a8ddd5287d09cedd572a655867a68d9803c58f7156c79762c6b5048bb324

See more details on using hashes here.

Provenance

The following attestation bundles were made for footman-0.3.0-py3-none-any.whl:

Publisher: release.yml on willemkokke/footman

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page