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, and a DAG scheduler
that runs independent tasks in parallel by default (duty and invoke can't). 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.
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=Truereturns the code instead), honours--dry-run(prints the command instead of running it), and records a step for--json.- No
ctxneeded —run()andpassthrough()read the current task's context implicitly, sodef check():stays boilerplate-free. Declare a firstctx: Contextparameter 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 project path. 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 a specific tasks 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), and the global-option set. What's next:
- shell-native completion installers (
--install-completionfor bash/zsh/fish/pwsh/nushell) — today the resolver works viafm --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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file footman-0.2.0.tar.gz.
File metadata
- Download URL: footman-0.2.0.tar.gz
- Upload date:
- Size: 31.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6a289805a8e1e8bcc4c2cd90d1cc5b81f22528a24f7df7bc463693208fb6491e
|
|
| MD5 |
2b53daa457c195a75ca24d54473bfdd2
|
|
| BLAKE2b-256 |
e4e103372c3378d47eb5917b88465ebd045a8c22f7d6e37fa11b7ebc9962430e
|
Provenance
The following attestation bundles were made for footman-0.2.0.tar.gz:
Publisher:
release.yml on willemkokke/footman
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
footman-0.2.0.tar.gz -
Subject digest:
6a289805a8e1e8bcc4c2cd90d1cc5b81f22528a24f7df7bc463693208fb6491e - Sigstore transparency entry: 2186126409
- Sigstore integration time:
-
Permalink:
willemkokke/footman@aacd19047cd530844ac2274e72975f88527f15f4 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/willemkokke
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@aacd19047cd530844ac2274e72975f88527f15f4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file footman-0.2.0-py3-none-any.whl.
File metadata
- Download URL: footman-0.2.0-py3-none-any.whl
- Upload date:
- Size: 37.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59060d92b63b6af166b22f153c51fadd1bd3b0874bec0273bb00e7e322ac0c8d
|
|
| MD5 |
f8e9c3db227400d3c5a7182ba7891922
|
|
| BLAKE2b-256 |
1abeb2b59d7fc90c15cf622061ac89fe79bbf2f831dce62f33035e143df0fa5b
|
Provenance
The following attestation bundles were made for footman-0.2.0-py3-none-any.whl:
Publisher:
release.yml on willemkokke/footman
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
footman-0.2.0-py3-none-any.whl -
Subject digest:
59060d92b63b6af166b22f153c51fadd1bd3b0874bec0273bb00e7e322ac0c8d - Sigstore transparency entry: 2186126498
- Sigstore integration time:
-
Permalink:
willemkokke/footman@aacd19047cd530844ac2274e72975f88527f15f4 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/willemkokke
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@aacd19047cd530844ac2274e72975f88527f15f4 -
Trigger Event:
push
-
Statement type: