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 or comma-separated (--paths a,b) |
items: Many[str | int] |
one or more values, each coerced |
names: Annotated[list[str], nosplit] |
repeatable only — values may contain commas |
env: dict[str, int] |
--env KEY=VAL pairs (repeatable or comma-separated) |
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 work two ways, and collections comma-split by default:
repeat the flag (--tag a --tag b) or comma-separate one token (--tag a,b):
@task
def build(tags: list[str] = ()):
"..."
fm build --tags a,b,c # ["a", "b", "c"] — also works as --tags a --tags b
Splitting is on , and nothing else (deliberately shell-portable — a
comma-joined token survives bash, zsh, and PowerShell intact, unlike a bare
comma separator). When a value must contain a comma, mark the parameter
nosplit — then only the repeated flag adds items:
@task
def notify(lines: Annotated[list[str], nosplit] = ()):
"..."
Dictionaries
dict[K, V] becomes a repeatable KEY=VALUE option; keys and values are typed
and validated like everything else, and it comma-splits by default too:
@task
def deploy(env: dict[str, int | str] | 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 — commas split 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. Mark a
dict nosplit when a value may itself contain a comma.
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
buildalways builds fromrepo/,api'sservefromservices/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.
Rebrand it as your own CLI
fm and footman are just the default-branded instance of a public App.
Point your own console script at an App with your project's names and version,
and every message the user sees uses your branding — so an internal tool can
ship under its own name (say hse) while being footman underneath:
# hse/cli.py
from footman import App
app = App(name="HSE", prog="hse", version="1.4.0")
def main() -> None:
raise SystemExit(app.run())
# hse/pyproject.toml
[project.scripts]
hse = "hse.cli:main"
$ hse --version
HSE 1.4.0
$ hse nope
hse: expected a task name, got 'nope' (know: build, test, deploy)
name is the long/display name (the --version banner), prog the short
command name (error prefix and hints), version your own version (optional —
footman's is used otherwise). Tasks and completion are unchanged: hse
discovers the tasks.py cascade exactly like fm, and hse --complete stays
on the same stdlib-only fast path.
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 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
{
"schema": 1,
"results": [
{"task": "test", "ok": true, "code": 0, "duration_ms": 812.4, "output": "...", "steps": [], "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 |
|---|---|
-h, --help |
help for fm, a group, or a task |
-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 |
-v, --verbose |
replay captured run() output even on success |
--no-color |
disable ANSI colour |
--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 |
--help is the one global allowed anywhere before --: fm deploy --help
is a read-only help request, never an execution. fm --help documents the
runner and its globals, fm --help docs a group, fm --help deploy a task.
Accepted but not yet wired: --install-completion SHELL (prints guidance for
now) 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],
comma-splitting with nosplit, 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-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.5.0.tar.gz.
File metadata
- Download URL: footman-0.5.0.tar.gz
- Upload date:
- Size: 40.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fc17751d8fc110174014a9864403fcf3197fca36873e29dcc2a65322e6bcedac
|
|
| MD5 |
d0a37a393e4dbf3bcf1518eb3576da35
|
|
| BLAKE2b-256 |
71da7dcbbc7614c4bd295207d6833617cec28581598ce2f282f9a992c3b0eec7
|
Provenance
The following attestation bundles were made for footman-0.5.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.5.0.tar.gz -
Subject digest:
fc17751d8fc110174014a9864403fcf3197fca36873e29dcc2a65322e6bcedac - Sigstore transparency entry: 2187929743
- Sigstore integration time:
-
Permalink:
willemkokke/footman@1411b055861bb347632c2bc343f75467f0c1a95a -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/willemkokke
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1411b055861bb347632c2bc343f75467f0c1a95a -
Trigger Event:
push
-
Statement type:
File details
Details for the file footman-0.5.0-py3-none-any.whl.
File metadata
- Download URL: footman-0.5.0-py3-none-any.whl
- Upload date:
- Size: 48.6 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 |
15cf24edd51e5d5f93e530814da2f9a9a7b21537aebed654506e57721080a270
|
|
| MD5 |
52e402d9373b0e5ba33573d011e51ad1
|
|
| BLAKE2b-256 |
2e29ef884d55a6da191a55d4a43eeb4c06e255b29fad0f621e35573beb023671
|
Provenance
The following attestation bundles were made for footman-0.5.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.5.0-py3-none-any.whl -
Subject digest:
15cf24edd51e5d5f93e530814da2f9a9a7b21537aebed654506e57721080a270 - Sigstore transparency entry: 2187929761
- Sigstore integration time:
-
Permalink:
willemkokke/footman@1411b055861bb347632c2bc343f75467f0c1a95a -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/willemkokke
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1411b055861bb347632c2bc343f75467f0c1a95a -
Trigger Event:
push
-
Statement type: