Skip to content

API reference

Auto-generated from the source via mkdocstrings. Everything here is importable straight from the footman package (from footman import task, run, App).

Defining tasks

footman.task module-attribute

task: Final[TaskDecorator] = root.task

footman.group module-attribute

group: Final[GroupFactory] = root.group

footman.Group

A node in the command tree: named tasks and nested sub-groups.

default_task property

default_task: Task | None

The default action — what a bare fm <group> runs.

Derived, never stored: the default is the child task named default (@group.default ↔ fm lint.default), so default-ness has one spelling, survives forks and grafts by construction, and there is no pointer to desync.

__call__

__call__(*args: Any, **kwargs: Any) -> Any

Run this group's default action — the imperative mirror of a bare fm <group> and of pre=[group].

A runnable group (one with an @group.default) is callable from a task body the way a task is: a check task can call lint(fix=fix). It runs the default's action synchronously and in order — a custom body as written, or, for an empty-body default, the group's own tasks, each handed the arguments it declares (partial reach, by name). Like every body call it forwards arguments explicitly and runs to completion before the next statement; prerequisites and parallelism stay the scheduler's job — reach for a real chain, pre=, or parallel() for those.

default

default(fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]
default(
    fn: None = None,
    *,
    pre: Sequence[Task] = (),
    post: Sequence[Task] = (),
    progress: bool = True,
    infinite: bool = False,
    shared: bool | None = None,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
    timeout: float | None = None,
    retries: int = 0,
    cwd: str | Path = "",
    rel: str | Path = "",
    serial: bool = False,
    exclusive: bool = False,
    hidden: bool | None = None,
    expose: str | None = None,
) -> Callable[[Callable[_P, _R_co]], TaskFn[_P, _R_co]]
default(
    fn: Task | None = None,
    *,
    pre: Sequence[Task] = (),
    post: Sequence[Task] = (),
    progress: bool = True,
    infinite: bool = False,
    shared: bool | None = None,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
    timeout: float | None = None,
    retries: int = 0,
    cwd: str | Path = "",
    rel: str | Path = "",
    serial: bool = False,
    exclusive: bool = False,
    hidden: bool | None = None,
    expose: str | None = None,
) -> Task | Callable[[Task], Task]

Register fn as this group's default action — what a bare fm <group> runs, and what the group returns when called.

Usable bare (@group.default) or parameterised (@group.default(keep_going=True)). It takes the same orchestration options as @task — pre/post, progress, infinite, confirm, interactive, keep_going, atomic — with no name (the group already names it).

The function's signature is the group's whole CLI surface, positionals included: fm lint src/ hands src/ to the default the way any task takes an argument. A nested task always keeps its own dotted address (fm lint.python), so a bare word after the group is unambiguously the default's value — when a value happens to equal a child's name, the run carries a one-line stderr note pointing at the dotted spelling.

An empty-body default fans the group's own tasks out in parallel, so interactive=True on one is rejected — there is no single body to own the terminal. Give the default a real body to make it interactive.

The default registers as the child task named default — a fixed, well-known name, not the function's own (which stays private, so renaming it is free). The decorator you wrote is the address you type: @lint.default ↔ fm lint.default; bare fm lint stays the idiomatic spelling, the way GET / serves /index.html. The name is the mechanism — @group.default is sugar for a task named default.

group

group(
    name: str,
    help: str = "",
    hidden: bool | None = None,
    expose: str | None = None,
) -> Group

Create and register a nested command group, returning it.

hidden=True keeps the whole subtree out of the human listings (--list, --tree, help — --all shows them) while leaving every address in it callable and completable — see @task(hidden=…).

expose="project_only" says the whole subtree belongs to a project: outside one it is not listed, not completed, and refused by name. "global_only" says the opposite, "always" says both. A child may still answer for itself — the same tri-state hidden has.

opts

opts(**overrides: Unpack[TaskOpts]) -> TaskFn[..., Any]

Per-use option overrides for this group's default action, the same .opts() a task has — pre=[lint.opts(keep_going=True)]. Overrides ride the group's default when it runs (bare, as a pre=, or called). The static type is a task reference with an untracked signature — Group doesn't carry its default's parameters the way TaskFn carries a task's.

post_task

post_task(fn: _F) -> _F

Register the after-each-task hook: post_task(inv, task, result).

The task-finished event: once a request's ladder opened, it fires when the request concludes — an execution whatever its outcome, a bind refusal, or a request satisfied by an execution the run already performed (result.state == "shared"). It fires whether or not your plugin registered a pre, and however any pre fared. Posts unwind in reverse plugin order, so the first plugin in speaks last.

result reads everything (ok, code, returned, error, duration, output, steps) and writes one thing: set_returned, which rewrites the reported value — the summary and the --json envelope — never what a dependent or a body caller received. A raising hook fails an otherwise-green task, named: a reporter that crashed must not pass silently.

@footman.post_task
def close_span(inv, task, result):
    task.state.span.end(ok=result.ok)

post_tasks

post_tasks(fn: _F) -> _F

Register the once-per-invocation closing hook: post_tasks(inv).

The run report's moment, on the main thread, after every task has concluded and before the summary or the --json envelope prints — so a rewrite a hook makes through a result view is what gets reported. The invocation now carries the whole story:

@footman.post_tasks
def digest(inv):
    failed = [r for r in inv.results if not r.ok]
    slack.post(f"{len(failed)} failed of {len(inv.results)}, "
               f"{inv.total_ms:.0f} ms")

inv.results is every row, in the order the work was created, as result views — executions, shared rows, refusals, and skipped nodes (inv.skipped is that subset; a post_task-only reporter never sees what never ran, which is why this moment exists). Under --json anything a hook prints goes to stderr — the envelope owns stdout. Hooks run in cascade order; a raising hook is named and fails the invocation rather than passing silently.

pre_bind

pre_bind(fn: _F) -> _F

Register the before-binding hook: pre_bind(inv, task).

The earliest per-task moment: it fires before the task's parameters are bound, so what it writes into task.env reaches env() fallbacks, coercion, and check(fn) validators — the one moment a plugin can influence what the body will be handed:

@footman.pre_bind
def credentials(inv, task):
    task.env["DEPLOY_TOKEN"] = vault.read("deploy")

Because nothing is bound yet, task.args is not readable here — read the values in pre_task, the post-bind moment. Binding happens once per request: a request that then joins work the run already performed still bound first, so pre_bind may fire for a request whose row ends up shared.

pre_task

pre_task(fn: _F) -> _F

Register the before-each-task hook: pre_task(inv, task).

Runs on the task's worker thread — in parallel across tasks, for every request: a chain segment, a prerequisite, a fan-out member, a body call. Only the body is shared: a request satisfied by an execution the run already performed still gets the pair, opened here and closed by post_task with its shared row — so pairing never depends on sharing, and only a reporter that cares reads result.state. It fires after binding, so task.args holds the real values the body would receive:

@footman.pre_task
def open_span(inv, task):
    task.state.span = tracer.start(task.name, dict(task.args))

inv is the frozen invocation — read it freely, it cannot change under you. Per-plugin scratch goes on task.state (private to your plugin, one namespace per execution, delivered back to your post_task); per-task environment goes in task.env (the task's own overlay — never os.environ, which is shared with every parallel sibling). A raising hook fails the task like a failed prerequisite, named.

The return value is reserved: a future power for a pre that supplies the task's result and skips the body. Today a returned value is noted and ignored — state belongs on task.state.

pre_tasks

pre_tasks(fn: _F) -> _F

Register the once-per-invocation hook, run before anything else.

It happens after the whole tasks.py cascade is assembled and before availability gates, the manifest, and any task — the one single-threaded moment where the invocation is still editable. It is handed the Invocation:

@footman.pre_tasks
def gate_deploys(inv):
    audit = inv.tasks["audit"]
    for t in inv.tasks:
        if t.name.startswith("deploy"):
            t.add_pre(audit)

Its edits are part of the plan, never a runtime surprise: an added pre runs and shows in --dry-run, a disabled task drops out of listings and completion. Setting os.environ here is ordinary code — it is pre-DAG and single-threaded, so everything downstream sees it, including requires_env gates and env() parameter fallbacks. (Per-task environment is a different lane: ctx.env.)

Hooks run in cascade order — root's first, the folder nearest your cwd last, each seeing the previous ones' edits — the same "local overrides global" precedence the task cascade uses. Read and edit each task through TaskView, never the private _footman_* attributes.

It also runs in the detached child that rebuilds the completion manifest, so a gate that depends on what a hook sets is baked into completion too. That is why it must be quick and quiet, and why tree edits derive from files, config and environment — never from inv.cli, which the child does not have.

task

task(fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]
task(
    fn: None = None,
    *,
    name: str = "",
    pre: Sequence[Task] = (),
    post: Sequence[Task] = (),
    progress: bool = True,
    infinite: bool = False,
    shared: bool | None = None,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
    timeout: float | None = None,
    retries: int = 0,
    cwd: str | Path = "",
    rel: str | Path = "",
    serial: bool = False,
    exclusive: bool = False,
    hidden: bool | None = None,
    expose: str | None = None,
    lanes: Sequence[Lane] = (),
    uses: Sequence[GlobalOption] = (),
) -> Callable[[Callable[_P, _R_co]], TaskFn[_P, _R_co]]
task(
    fn: Task | None = None,
    *,
    name: str = "",
    pre: Sequence[Task] = (),
    post: Sequence[Task] = (),
    progress: bool = True,
    infinite: bool = False,
    shared: bool | None = None,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
    timeout: float | None = None,
    retries: int = 0,
    cwd: str | Path = "",
    rel: str | Path = "",
    serial: bool = False,
    exclusive: bool = False,
    hidden: bool | None = None,
    expose: str | None = None,
    lanes: Sequence[Lane] = (),
    uses: Sequence[GlobalOption] = (),
) -> Task | Callable[[Task], Task]

Register a function as a task.

Usable bare (@task) or parameterised (@task(name="build")) to override the command name. pre/post declare dependency tasks (by reference) that run before/after this one — the scheduler runs independent prerequisites in parallel:

@task(pre=[format, lint, typecheck, test])
def check(): ...

Availability gating lives in the @requires decorators — stack @requires, @requires_dep, @requires_tool, or @requires_env above @task to list a task as unavailable (with a reason) where it can't run, rather than hide it.

Three different things, worth keeping apart:

  • hidden=True — out of the listings, callable as ever. The task drops out of --list, --tree and group help (--all brings it back), while fm <name> runs it exactly as before, Tab completes it, and the did-you-mean index knows it: a listing is prose about the project, completion is help with a name you are already typing. For the tasks a machine calls and a human never types: a CI entry point, a release step another task drives. It is presentation only — prerequisites still run it, a group's empty-body fan-out still includes it, and --json reports it marked rather than missing, because a machine is who calls it. Unset inherits the enclosing group's answer, so group("internal", hidden=True) hides a whole subtree and a child can still say hidden=False.
  • @requires… — listed with a reason it can't run here.
  • if sys.platform == "darwin": @task ... — plain Python, and the task does not exist: nothing to list, nothing to call, no address at all. Reach for it when the task is meaningless on this machine, not when it is merely uninteresting to type.

progress=False marks a task whose duration has no rhyme or reason (a REPL, a watcher, a network fetch): any run containing it never records timing history and never shows a determinate progress bar — the indeterminate pulse still does.

infinite=True marks a task that runs until stopped — a dev server, a follow-mode tail. It implies progress=False, and the run swaps the status line for a one-time hint that Ctrl-C is how this ends. Listings and help carry the same note.

shared=False says this task is never shared: every request for it runs. A run normally performs one execution per task and arguments, whoever asks — pre=[build] then build() in a body is one build — which is wrong for work whose whole point is to happen again, like a notification or a timestamp. One rule covers every spelling, so nobody has to remember whether a task was reached by declaration or by call: two dependents each get their own run, just as two calls do.

Sharing is a property of the request, resolved by a ladder: this reference's own .opts(shared=…), then the task's declaration, then whatever asked for it, then shared. It propagates down — an unshared request asks unshared for everything it needs, or the promise would be a half-truth — which is worth knowing before reaching for it, because one shared=False unshares that task's whole subtree. A step that genuinely is reusable pins itself with shared=True, which beats an inherited answer.

.opts(shared=False) asks for one unshared run without changing the task, on a call or on a declared edge alike. Such a run gets its own value but never rewrites what the run already remembers: the first result stands, so later shared requests stay stable.

confirm="…" gates the task on a yes/no answer asked before the task and its prerequisites run — deny and the task (and its subtree) is skipped; --yes auto-answers it. interactive=True hands the task the real terminal — no output capture, sole stdio — so its body can prompt or run a REPL; it can't run under --json, and because it owns the terminal, a run that contains an interactive task goes fully sequential — that task and everything else, one at a time.

cwd= roots the task's working directory: a policy token ("root" — the highest cascade file's directory, "taskfile" — the file the task was defined in (the default), "asinvoked" — a pinned snapshot of the launch directory, "unmanaged" — footman stays out entirely) or an absolute path. rel= appends a relative suffix to the resolved base. ctx.cwd and every run()/tools subprocess follow it.

wrap_bind

wrap_bind(fn: _F) -> _F

Register a two-yield wrapper spanning bind, body and all: sugar over pre_bind + pre_task + post_task, one generator per request.

The first yield sits at the bind boundary and receives the bound arguments; the second sits around the body and receives the ResultView:

@footman.wrap_bind
def audit(inv, task):
    started = clock()
    try:
        bound = yield      # after binding: the real values
        result = yield     # after the body: the outcome
    finally:
        log(task.name, clock() - started)

A failed bind arrives as the failure raised at the first yield, so a try/finally (or except) around it closes the span even then — the one span wrap_task cannot close. Everything else is the pair's rules, per request.

wrap_task

wrap_task(fn: _F) -> _F

Register a one-yield wrapper around the body: sugar over the pair.

Write the pre half, result = yield, then the post half — locals carry your state, and a try/finally around the yield closes even when the task fails:

@footman.wrap_task
def span(inv, task):
    s = tracer.start(task.name, dict(task.args))
    result = yield
    s.end(ok=result.ok)

It enters at the pre_task moment and is resumed with the ResultView at post_task — one engine, both spellings, so the rules are the pair's rules: per request (a request satisfied by an execution the run already performed is resumed with its shared row), unwound in reverse plugin order, a raising half failing the task, named. The one thing it never sees is a task that failed to bind — its anchor moment never fires; observe the bind boundary with @wrap_bind, which enters there.

footman.expose

expose(where: str) -> Callable[[_F], _F]

Say where a task is offered: @expose("global_only").

The decorator spelling of @task(expose=…), for authors who prefer the gates above the signature — it sets the same one answer, so stacking it twice is a contradiction rather than an addition, unlike @requires_*.

@task
@expose("global_only")
def new(name: str): ...

Availability gates

Stack these above @task to list a task as unavailable (with a reason) where it can't run. Every availability gate is evaluated live, and all failures are collected.

footman.requires

requires(
    predicate: Callable[[], object], *, reason: str = ""
) -> Callable[[_F], _F]

Gate a task on a live predicate — available only while it is truthy.

The generic gate the three specialisations build on. A predicate that raises reads as unavailable, the exception named:

@task
@requires(lambda: Path("config.toml").exists(), reason="needs config.toml")
def publish(): ...

footman.requires_dep

requires_dep(
    *modules: str, reason: str = ""
) -> Callable[[_F], _F]

Gate a task on Python modules being importable (find_spec, no import).

Keep the real import in the body; this only checks availability, so a missing optional dependency lists as a clean reason, never an import crash.

footman.requires_tool

requires_tool(
    *commands: str, reason: str = ""
) -> Callable[[_F], _F]

Gate a task on command-line tools being on PATH (shutil.which).

footman.requires_env

requires_env(
    *names: str, reason: str = ""
) -> Callable[[_F], _F]

Gate a task on environment variables being set (in os.environ).

Running commands

footman.run

run(
    cmd: str | list[str] | Callable[..., Any],
    *args: Any,
    nofail: bool = False,
    recorded: bool = True,
    capture: bool = True,
    input: str | None = None,
    timeout: float | None = None,
    title: str | None = None,
    pre_record: Callable[[ResultView], None] | None = None,
    env: dict[str, str] | None = None,
    color: str = "auto",
    cwd: str | Path | None = None,
    rel: str | Path | None = None,
    encoding: str | None = "utf-8",
    shell: bool | str = False,
    strict: bool = False,
    clean: bool = False,
    _show: Invocation | None = None,
) -> Result

Run a command or a Python callable in the current task's context.

Subprocess output is decoded as UTF-8 by default; pass encoding= for a tool that speaks another code page, or encoding=None for the locale default. Ignored for callables (in-process, no bytes boundary).

input= feeds the child's standard input and closes it — the one lane a payload can't take through argv (uv pip install -r - reads its requirements there). A string, encoded the way the capture is decoded (encoding=). The read side of the process boundary is a task parameter marked stdin; this is the write side, so a task can sit in the middle of a pipeline without a shell between it and either neighbour. An in-process tool has no standard input to feed — input= on one is a taught TypeError.

cwd= roots this one call somewhere other than the task's directory; rel= appends a relative suffix to the base in force (ctx.cwd, or the explicit cwd=), so run("npm run build", rel="web") is the ergonomic spelling of cwd=ctx.cwd / "web". cwd="unmanaged" opts this one call out instead — the task-level token, per call: a subprocess spawns inheriting the live process cwd, an in-process callable runs under it — for a call that touches no paths while its task keeps ctx.cwd everywhere else.

color= decides what this one child emits: "always" forces the colour variables into its environment, "never" writes NO_COLOR and removes any inherited force variables (a tool reading mere presence would honour one straight past NO_COLOR), and the default "auto" follows the run's own decision. Explicit beats ambient, so color="always" holds under an exported NO_COLOR. It merges on top of env= when both are given, and the in-process lane's overlay reads it too — so a per-call colour reaches both halves of a tool's colour, its flags and its environment, through one door. footman's own chrome (the step lines) stays on the run-wide decision: this is about the child's bytes, not the frame around them.

recorded=False runs this call off the record. A call is a step by default: it earns a receipt line, a row in --json, a recording() entry, and its output joins the task's block. Some calls aren't part of the task's story, though — they are how a task knows something (git rev-parse HEAD in a release task). Those return their Result and report nothing: no record, no receipt, no output replayed.

Everything else still applies, because the call is not unmanaged — only unreported. It runs in ctx.cwd with ctx.env, inside the task's lane, with colour resolved the same way, it is terminated with the rest on a fail-fast, and it still raises unless nofail=True. It also executes under recording(), where a step would be faked — unless the recording carries a scripted answer for it (recording(answers=…)), in which case the script wins: a value read is not the story being recorded, and faking it would corrupt the story that is — the real steps downstream would record whatever a blank answer produced.

pre_record= reviews the record before it is sealed. Some tools speak exit codes that need interpreting — djlint --reformat exits 1 when it changed files, which is success for a formatting gate. The reviewer receives the draft (ResultView) after the work ran: it reads what was captured and may set title and code; the receipt, the record, and the raise-on-nonzero decision all read what the review leaves behind, so the call site writes no nofail=. A reviewer that raises fails the call with its own error — a broken reviewer is a broken gate — and the record keeps what the work honestly produced. Dry-run never reviews (nothing ran, nothing captured), and a recorded=False call has no record to review (a note, not an error).

In-process work is a step now: run() runs commands. Lift a callable instead — @step / step(fn, title=…) builds an item that earns a receipt, and with step("…"): records a block where it stands. (toolroom keeps its in-process lane through its own private channel.)

_show is an internal channel from toolroom's bridge: a structured view of the call, so the shown command line can be normalised and role-coloured while execution runs whatever the tool needs. An explicit title still wins; a direct run([...]) is unaffected.

footman.Result

Bases: int

The outcome of one run() call — and the value run() returns.

A Result is the exit code: it subclasses int, so code = run(...), if run(...), and run(...) == 0 all keep working. It also carries the captured output, split by stream, and the command that produced it — so run("git rev-parse HEAD").stdout.strip() reads the hash without the stderr noise glued on. stdout/stderr are separated for both subprocess and in-process runs; a streamed run (capture=False) leaves them empty.

address property

address: str

The record's tree-derived name: the requester's path, this record's label, and an ordinal once a label repeats among siblings — counted in request order as written, so it is deterministic across runs and hosts. Empty outside a managed context.

audit property

audit: tuple[AuditEntry, ...]

The verdict's provenance: every lifecycle moment that acted on it, in execution order — the body entry with what the work itself produced, a review entry per pre_record reviewer. Empty for a record that executed nothing (a dry-run plan line).

code property

code: int

The exit code (0 is success) — the same value the Result itself is.

command property

command: str

The command line that ran, normalised for reading — options in separated form, values shell-quoted. What recording() asserts against, and what a dependent reads back.

cwd property

cwd: Path | None

The directory this call would have run in — the task's resolved one, or the call's cwd=/rel= — kept on a recorded step only, like env. None on a record that ran, and for a call that opted out with cwd="unmanaged".

duration property

duration: float

Wall-clock seconds the step took.

env property

env: dict[str, str] | None

The environment this call would have run in — the task's own, or the env= handed to the call — kept on a recorded step only, so a recording() can assert what a command would have seen (assert "UV_TOOL_DIR" not in steps[0].env). None on a record that ran: a live environment can hold secrets, and a record travels into --json and the report.

failed_at property

failed_at: str | None

The lifecycle moment the failure came from, None on success — a derived reading of the audit: the last moment that wrote the verdict. A red tool reviewed green reads None (the record succeeded); a green tool failed by its reviewer reads "review".

ok property

ok: bool

Whether the command succeeded (exit code 0).

output property

output: str

stdout then stderr, concatenated — a convenience for "show me everything". NOT interleaved in real time (each stream is captured whole); when the order across the two streams matters, read stdout and stderr separately.

raw property

raw: str

The exact command line executed, shell-quoted — the bytes footman handed the tool, which may spell an option --flag=value where command shows --flag value. What --verbose prints. Equal to command when there is nothing to normalise.

shown property

shown: str

command as it is safe to print — every argument that arrived as a Secret replaced by ***, everything else identical.

Equal to command unless the call carried a secret, so a record is the same string on both sides in the ordinary case. Every surface footman shows a command line on reads this one: the step line, the --verbose announce, --json, a profile span, the message a RunFailed carries. The record keeps the real value, because a dependent, recording(), and the caller who passed it all read command and none of them are a display.

started property

started: float | None

When the step began, on the run's monotonic clock — the same clock a task row's started reads, so a step places inside its task's span. None for a record that never ran (a dry-run's rehearsal).

stderr property

stderr: str

Captured standard error; empty when the step streamed instead.

stdout property

stdout: str

Captured standard output; empty when the step streamed instead.

timed_out property

timed_out: bool

Whether timeout= expired and footman killed the tree. The code is 124 — the shell convention — and stdout/stderr hold whatever the command managed to say first, which on a hang is the only clue there is.

work_code property

work_code: int | None

The code the record carried when the failing moment began — a derived reading of the audit, None on success or when nothing came before the failure. A green build failed in review keeps its 0 here, visible rather than inferred.

to_argv

to_argv() -> Argv

What this call ran, as the tokens it was spawned from.

.raw shows the command line quoted for the machine footman is standing on, which is right for a --verbose line and wrong the moment the string is sent somewhere else. This returns the same command as an Argv — raw tokens, re-quotable for whichever shell will actually parse them:

r = git.commit(m="ship 1.2.0")
r.to_argv()          # ["git", "commit", "-m", "ship 1.2.0"]
r.to_argv().posix()  # "git commit -m 'ship 1.2.0'"

Named apart from toolroom's .argv builders on purpose: this one describes a call that has already run, so git.push().argv(…) — meaning to build a command line — is an AttributeError rather than a push that quietly happened. For the same reason the two can differ by one token: what ran may carry a forced-colour switch (git's -c color.ui=always) that a line built to be sent elsewhere does not.

footman.ResultView

A step's record, in the review window: the draft a pre_record reviewer receives after the work ran and before the record is sealed.

The verdict is still open — title and code are plain writable attributes, and the raise-on-nonzero decision reads the code the review leaves behind, so "fail by this tool's definition of failure" needs no nofail= at the call site. Everything the run captured is read-only: review sees what the run kept, never edits it — an uncaptured (capture=False) call reviews the code alone. ok derives from code, so the two can never disagree.

code property writable

code: int

The verdict. What the review leaves here is the step's exit code — the raise decision and the receipt both read it. The audit records whether a reviewer wrote it or left it alone.

command property

command: str

The normalised command line as it stood before review.

duration property

duration: float

Wall-clock seconds the work took — machinery-owned, read-only.

ok property

ok: bool

Whether the code, as it stands, is success — derives from code.

output property

output: str

stdout then stderr, concatenated — the same convenience the sealed Result offers.

raw property

raw: str

The exact command line executed — the review can read what really ran while retitling what the report shows.

returned property

returned: Any

What the body returned, when this draft is a task's row — None by circumstance elsewhere (a subprocess step has no return value).

stderr property

stderr: str

Captured standard error; empty when the call streamed instead.

stdout property

stdout: str

Captured standard output; empty when the call streamed instead.

title property writable

title: str

The receipt's label. Starts as the shown command (or the call's title=); what the review leaves here is what the report shows.

set_returned

set_returned(value: Any) -> None

Rewrite the reported value — the summary line and the --json envelope — never what a dependent or a body caller received: that was snapshotted the moment the body handed it over. The review window is this write's home: reviewed, attributed in the audit, before the record seals.

footman.AuditEntry

Bases: NamedTuple

One entry of a record's audit: a lifecycle moment that acted on the verdict — who acted, and what they set.

The body entry is always present and carries what the work itself produced; a review entry names the reviewer, with code None when it was involved without writing the verdict (a reviewer that only set the title). Entries are in execution order.

actor instance-attribute

actor: str

Who acted: the command line for the body, the hook's name for a reviewer.

code instance-attribute

code: int | None

The code this moment left, or None for involvement without a verdict write.

moment instance-attribute

moment: str

Where in the record's lifecycle this happened: "body" for the work itself, "review" for a pre_record reviewer.

footman.Argv

Bases: list[str]

A command line built but not run — what Result.to_argv() hands back (and what a builder like toolroom's git.push.argv(force=True) makes).

An ordinary list[str] everywhere in Python: it indexes, slices, iterates, compares and prints exactly like the list it is, and run(cmd) spawns it as-is. Always the raw tokens — the one shape of a command with no shell in it. Passing the tokens on is plain Python: run(cmd) runs them, a wrapper takes them splatted (uv.run("--", *cmd)), and stdlib helpers take the list directly (shlex.join(cmd)).

The moment the line is about to cross into a shell, name that shell:

  • .posix() — one string, shlex-quoted for sh/bash/zsh.
  • .windows() — one string, quoted the way CreateProcess parses.

Naming it is the caller's job because footman cannot know it: the same handle can target different hosts across calls, the OS does not determine the shell, and the payload may reach no shell at all. The quoting is the destination's, never this machine's — a line built on Windows for a Linux box still comes back POSIX-quoted (which is why neither method is the local-platform _shell_quote).

Lives here beside Result because run() accepts one and Result.to_argv() returns one. toolroom carries its own twin of this class on purpose — the seam between the packages speaks plain list[str], so neither ever imports the other's.

posix

posix() -> str

This command as one string, quoted for a POSIX shell.

What an ssh payload wants: ssh joins its remaining arguments with spaces and hands the remote shell one string to re-split, so the only argument boundaries that survive are the ones quoted into it.

cmd = git.commit.argv(m="ship 1.2.0")
ssh("deploy@host", cmd.posix())

Quoting a built line that already carries a .posix() payload quotes it once more, which is exactly what each further hop needs.

windows

windows() -> str

This command as one string, quoted the way CreateProcess parses.

The Windows counterpart of .posix(), for a payload headed to a Windows box — subprocess.list2cmdline quoting, which cmd and PowerShell both read. Chosen by the caller, never sniffed from the machine footman happens to be standing on.

footman.RunFailed

Bases: Exception

A run() command exited non-zero (and nofail was not set).

The message names the command the way every other surface does — with any Secret argument redacted — because a failure line is a display, and the one place it most reliably lands is somebody's CI log. The record it carries, .result, is untouched: .result.command is the real command line, for the handler that reads rather than prints.

footman.parallel

parallel(*, keep_going: bool = False) -> Fanout
parallel(
    *calls: WorkItem[Any]
    | StepFn[..., Any]
    | TaskFn[..., Any]
    | Group,
    keep_going: bool = False,
) -> list[Result]
parallel(
    *calls: WorkItem[Any]
    | StepFn[..., Any]
    | TaskFn[..., Any]
    | Group,
    keep_going: bool = False,
) -> Fanout | list[Result]

Run tasks and steps concurrently; wait; fail if any fail.

Each one runs in a child of the current context with its own output buffer, flushed atomically on completion so concurrent output never interleaves. Pass task functions directly (parallel(lint, typecheck)) and built step items for everything else — parallel(convert(images)), or parallel(step(fn)(args)) for a function you didn't write. A bare zero-argument maker is welcome too: parallel(clean) builds and runs clean().

Nothing anonymous runs: footman only schedules, records, and safely cancels work it owns, and a bare callable is a stranger — no name for the report, no place in the plan, no way to stop it cleanly. The lift is one word, and it buys the step a receipt.

With no arguments it is a block instead, and the calls inside it are the fan-out — written as ordinary calls, with their values afterwards:

with parallel() as p:
    build("web")
    p(step(shutil.rmtree)(tmp, ignore_errors=True))
web, cleaned = p.results

Inside the block a task call is queued, so it has no value there (using one is a taught error); a built step item joins through p(item); everything runs when the block ends, under the same rules — sharing, hooks, -s/-j.

footman.step

step(
    target: Callable[P, Generator[None, ResultView, R]],
    /,
    *,
    title: str | None = None,
) -> StepFn[P, R]
step(
    target: Callable[P, R], /, *, title: str | None = None
) -> StepFn[P, R]
step(target: str) -> _StepBlock
step(
    target: Any = None, /, *, title: str | None = None
) -> Any

One name, three positions: @step on a def, with step("title"): over a block, step(fn, title=…) around a function you didn't write.

Decorator and expression positions are the same expression, so both return the maker — calling it builds the item; nothing runs until something executes it (parallel(), or calling the built item).

footman.pre_record

pre_record(
    hook: Callable[[Any], None],
) -> Callable[[_F], _F]

Attach a reviewer to a task: @pre_record(fn) stacked on the def.

The reviewer receives the task's record while it is still a draft — after the body concluded, before the record is sealed, observed, or reported. It reads the draft (a ResultView) and may set title and code; the row's verdict follows what the review leaves behind, and the record's audit names the reviewer and what it did.

Reviewers run from the inside out: the hook written closest to the function sees the draft first, and each one above it sees what the previous reviewers left. Stacks above or below @task alike — the decorated function (and a TaskFn when stacked above the lifter) keeps its static type. A reviewer that raises fails the task with its own error: a broken reviewer is a broken gate.

footman.passthrough

passthrough() -> list[str]

Arguments after -- on the command line, for the running task.

footman.inherited

inherited() -> Callable[..., Any]

The task this one shadows in the cascade — footman's super().

A nearer tasks.py overriding a task by name usually wants to extend it, not replace it. Call this inside the overriding task's body to get the task it shadows, then call that like the plain function it is:

# svc/api/tasks.py — the root also defines `check`
@task
def check(fix: bool = False, contracts: bool = True):
    inherited()(fix=fix)          # arguments are forwarded explicitly
    if contracts:
        run("./verify-contracts.sh")

Forwarding is deliberately manual: the two signatures are independent (a leaf usually adds a parameter), so automatic forwarding could only drop arguments silently or fail at run time — where spelling the call out shows you the mismatch as you type it. Being an ordinary call, it also runs to completion before the next statement — and composes with parallel(inherited(), extra) when you want otherwise.

fm --where <task> lists the whole shadow chain; fm --help <task> shows the inherited task's options, so you can read the forwarding call straight off it.

Failing on purpose

footman.fail

fail(reason: str = '', *, code: int = 1) -> NoReturn

Fail the current task with a reason (and exit code, default 1).

The blessed way to stop a task deliberately: fail("no open PR to act on"), or fail("reserved branch", code=3) to pick the exit code too. A function, not a raise, on purpose — a task lives in your repo under your linter, and raise SomeError("a literal") trips flake8-errmsg (EM101) and tryceratops (TRY003) at the call site, every failure. A call trips neither, the same reason sys.exit() and pytest.fail() are functions. return N still spells a bare code; sys.exit(...) still works — fail() is just the footman-native one, with a reason and a code together.

fail() means failure, so code=0 — success — is a contradiction it refuses rather than a corner it interprets: return 0 (or a plain return) spells stopping early with success.

footman.Failed

Bases: Exception

A task chose to fail — the exception footman.fail() raises.

A deliberate stop with a reason (and optional exit code): the user-facing sibling of RunFailed (which is a command's failure). Carries .reason and .code; footman renders the reason verbatim — no type prefix — in the failure line and the --json error field. Exported so a task can except footman.Failed:, but fail() is the blessed way to raise it.

footman.TimedOut

Bases: Failed

A task's @task(timeout=…) deadline expired.

A Failed, so except footman.Failed: keeps catching it and the reason renders verbatim in the failure line and the --json error field.

Carries .timeout (the declared seconds) and .after — what became of the work: stopped (footman issued a stop before the body returned) or completed (no stop was issued and the body finished on its own, just late). An open set: TaskResult.after_deadline explains which further values are designed and why they are not shipped.

The message never pretends the work did not happen. Where the body completed, it says so and says what the body's own verdict was, then states that the deadline governs — the receipt tells the whole truth while the task still fails on its contract (notes/20260807-timeout-and-retry.md).

Progress

footman.progress

progress(done: int, total: int = 0) -> None

Report this task's own progress: done of total units.

Some work knows exactly how far along it is — 23 of 150 migrations, bytes of a download — and that is better evidence than any duration history. A reporting task's counts drive the live bar directly (counted beats estimated), so the bar is honest on the very first run, where the estimator would still be guessing.

@task
def migrate():
    for i, record in enumerate(records, 1):
        apply(record)
        progress(i, len(records))

A total of 0 (or less) clears the report, returning the run to its estimate. Outside a run, or with no live status line, this is a no-op — plain calls and captured runs cost nothing.

footman.track

track(
    iterable: Iterable[_T], total: int | None = None
) -> Iterator[_T]

Iterate iterable, reporting progress as it goes.

The ergonomic form of progress(): the total comes from len() when the iterable has one, or from total when you know it for a generator. Without either, iteration still works — the run simply keeps whatever progress it had.

@task
def migrate():
    for record in track(load_records()):
        apply(record)

Profiling from inside a task

footman.section

section(name: str) -> _SectionTimer

Time a section of the running task — with footman.section("resolve"):.

Records onto the task's own timeline, subdividing its span; nested blocks nest. For work that overlaps — several waits in flight at once — use stream(), where overlap is legal.

footman.mark

mark(name: str) -> None

Record an instant on the running task's timeline — a moment worth a label, no duration.

footman.stream

stream(name: str) -> Stream

A named parallel timeline under the running task — see Stream.

footman.Stream

A named parallel timeline of sections under the running task.

Made in the task body (ci = footman.stream("ci")); the handle remembers its task, so a helper thread the body spawned may record through it. Sections on a stream may overlap — that is what a stream is for.

section

section(name: str) -> _SectionTimer
section(
    name: str,
    *,
    start: datetime | float,
    end: datetime | float,
) -> None
section(
    name: str,
    *,
    start: datetime | float | None = None,
    end: datetime | float | None = None,
) -> _SectionTimer | None

A section on this stream: bracketing (with ci.section("poll"):) or retroactive — ci.section("build", start=t0, end=t1) records a window learned after the fact (a CI check's real run, reported by its API), placed by wall clock.

footman.Section dataclass

One recorded interval of a task's time — what the --json envelope carries and a profile renders. stream is "" for the task's own timeline (these nest and never overlap, by construction); a named stream is its own parallel timeline, where overlap is legal. A mark() is a section with no duration.

Asking the person running it

footman.prompt

prompt(
    message: str = ...,
    *,
    default: str | None = ...,
    secret: bool = ...,
) -> str
prompt(
    message: str = ...,
    *,
    type: type[_T],
    default: _T | None = ...,
    secret: bool = ...,
) -> _T
prompt(
    message: str = ...,
    *,
    type: Any,
    default: Any = ...,
    secret: bool = ...,
) -> Any
prompt(
    message: str = "",
    *,
    type: Any = str,
    default: Any = None,
    secret: bool = False,
) -> Any

Ask the person running the task for a line of input.

A bare input() doesn't work in a task: its prompt goes to stdout, which footman buffers per task so parallel output never interleaves (and --json stays one envelope), so the prompt is swallowed and the task looks hung. prompt() writes to the real terminal on stderr instead — never captured — and serialises concurrent prompts.

Usable only inside an @task(interactive=True) task; called in an ordinary task body it raises a taught error naming the two fixes. Off a terminal, under --no-input or --dry-run, or when it would otherwise block, it returns default if given, else raises — an unattended run fails loudly, and a rehearsal is unattended by nature. For a value a script must supply, take it as a task parameter (a CLI flag) instead.

type= reuses the parameter type language as the validator: the answer runs through the same coercion pipeline a flag does — int, Path, an enum, Literal[…], a union, or Annotated[…] carrying between() / check() / exists — and a bad answer is taught and re-asked rather than raised. An empty answer takes default (returned as given, uncoerced), and an unattended run takes it the same way. One value per prompt: collections, and the markers that decide how a parameter gets filled (ask(), env(), suggest(), …), are refused by name — declare a parameter, or reach for select().

secret=True hides the typing (getpass) and returns a Secret, so the answer redacts in tracebacks and structured output the same way ask(secret=True) does — hiding a value while it is typed and then printing it in the first traceback would be a strange kind of secret. A default returned unattended is wrapped too: where the value came from doesn't change what it is. A secret is text by nature, so secret=True with a non-str type= is refused — ask(secret=True) on a typed parameter is that spelling.

footman.confirm

confirm(message: str, *, default: bool = False) -> bool

Ask a yes/no question. --yes auto-answers yes; Enter alone takes default; off a terminal or under --no-input/--dry-run the answer is default. Guarded like prompt() — interactive tasks only.

footman.select

select(
    message: str,
    options: Sequence[str],
    *,
    multiple: Literal[True],
    default: list[str] = ...,
) -> list[str]
select(
    message: str,
    options: Sequence[str],
    *,
    multiple: Literal[False] = False,
    default: str = ...,
) -> str
select(
    message: str,
    options: Sequence[str | tuple[str, _V]],
    *,
    multiple: Literal[True],
    default: list[str | _V] = ...,
) -> list[str | _V]
select(
    message: str,
    options: Sequence[str | tuple[str, _V]],
    *,
    multiple: Literal[False] = False,
    default: str | _V = ...,
) -> str | _V
select(
    message: str,
    options: Sequence[Any],
    *,
    multiple: bool = False,
    default: Any = _UNSET,
) -> Any

Let the person pick from a runtime-computed list — the one interactive case a flag can't cover, because the options aren't known until the task runs (which changed packages to release, which stale branches to delete).

options are strings, or (label, value) pairs to show one thing and return another. multiple=True returns the chosen subset as a list; otherwise one value is returned. Guarded like prompt() (interactive tasks only), and off a terminal or under --no-input/--dry-run it returns default, or raises if none was given.

footman.attended

attended() -> bool

Whether a prompt would reach a human: stdin is a real terminal and the run was not declared unattended (--no-input, --dry-run).

The question prompt()/confirm()/select() already answer for themselves by degrading to their defaults — attended() is for changing the shape of a run instead: skip an optional wizard outright, take the quiet path in CI. --yes deliberately does not count as unattended: it auto-answers confirm() gates but forbids nothing.

Readable anywhere, but it licenses nothing: a mid-body prompt still requires @task(interactive=True), because owning the terminal under parallelism is a separate question from someone being there to answer.

footman.tty

tty() -> bool

Whether this run's output lands on a live terminal, colour policy aside: the real stdout is a terminal (on Windows, one that interprets ANSI) and output is not captured into a --json envelope. NO_COLOR and --color do not change the answer — a person with colour turned off is still watching, so gate pagers and live displays here and colour on colored(). False outside a run: the scheduler stamps it per task.

footman.colored

colored() -> bool

Whether this task's own output should dress for colour — the same never/always/auto cascade footman's own chrome follows: --color=never (or NO_COLOR, or a dumb terminal) says no, --color=always forces colour even into a pipe (but never into a --json envelope), and auto follows the terminal.

The process boundary

stdin binds to typed parameters, and a Stdout[T] return owns stdout. The full contract lives on Pipelines and JSON output.

footman.Stdin module-attribute

Stdin = Annotated[_T, stdin]

Shorthand for Annotated[T, stdin], in the Arg/Forward mould:

def review(diff: Stdin[str] = ""): ...

The bare-marker form only — the call forms (stdin("field"), stdin(lines=True)) are spelled inside Annotated, like env("VAR").

footman.stdin

Bind this parameter from the process's standard input, via Annotated:

def review(diff: Annotated[str, stdin] = ""): ...
# git diff | fm review     AND     fm review < changes.patch

The annotation decides how the bytes are interpreted: str reads the stream as UTF-8 text (universal newlines), bytes reads it raw. Two call forms refine that:

  • stdin("prompt") binds one top-level field of a JSON document on stdin — the value flows through the same coercion and validation as a CLI token.
  • stdin(lines=True) binds a list parameter from lines of text — each line is coerced like one CLI token, so list[int] and list[Path] work as they would as repeated flags.

Precedence is CLI > stdin > env > default > prompt, so an explicit option always wins and one signature serves both fm process file.txt and cat file.txt | fm process. The stream is read once, fully, at the boundary — never inside a task body, so the parallel-stdin guard is never in play — and the same payload serves every parameter that asks. A terminal on stdin means "not provided": the parameter falls back to its default, and a required parameter refuses with a taught message rather than blocking on a read.

footman.Stdout module-attribute

Stdout = Annotated[_T, stdout]

Shorthand for Annotated[T, stdout] on a return annotation:

def wordcount(text: Stdin[str] = "") -> Stdout[int]: ...

Stdout[dict | None] is the house spelling for a filter that sometimes has nothing to say (marker outermost, like NoSplit[list[X] | None]); Stdout[dict] | None means the same thing. See stdout.

footman.stdout module-attribute

stdout: Final[StdoutMarker] = StdoutMarker()

Declare that a task's return value is the document on stdout, via the return annotation:

@task
def status() -> Stdout[dict]: ...
# fm status | jq .branch

The task is then a Unix filter by declaration — no flag at any call site. The return type decides the bytes, mirroring stdin: Stdout[str] emits the string verbatim (plus a trailing newline), Stdout[bytes] writes raw bytes, anything structured is JSON — pretty-printed on a terminal, one compact line into a pipe, dataclasses and Secret redaction handled by the same encoder --json uses — so a Secret inside a structured document serialises as ***, and a task that means to emit one says token.reveal(). A Stdout[str] built by formatting is unaffected: string operations on a Secret yield a plain str, which is how the print-a-credential filter (eval "$(fm env-export)") works without a switch to disarm redaction everywhere else.

The rules that keep it honest: an explicit --json wins (the document rides inside results[].returned); only the addressed task emits (a declaring task reached as a pre=/post= dependency or a fan-out member is suppressed, not refused); two declaring tasks in one chain is a plan-time refusal; None means empty stdout, exit 0; a failed task emits nothing; everything that is not the document — prints, run() output — replays on stderr. A bare -> int stays the exit-code channel; declaring Stdout[int] makes the int the document instead. A body call is unaffected: status() from another task just returns the value.

The working directory & lanes

footman.cwd

cwd() -> Path

The current task's working directory, always concrete.

ctx.cwd as the policy ladder resolved it — the blessed base for a task body's own path arithmetic (footman.cwd() / "dist"), instead of relative paths against the process cwd, which belongs to no one in a parallel run. Outside a run it is simply the process cwd.

footman.chdir

chdir(
    target: str | Path | None = None,
    *,
    rel: str | Path | None = None,
) -> Generator[None]

Really change the process directory — inside a serial/exclusive task.

The sugar for bodies that own the globals: the default target is the task's own ctx.cwd; arguments follow the marker grammar exactly — a policy token (root, taskfile, asinvoked) or an absolute path, with rel= for a relative suffix; a bare relative path is the same taught error the markers give. Performs a real os.chdir, keeps ctx.cwd in sync (so a nested run() roots where the block does), restores both on exit. In a parallel task it is a taught error — the cwd belongs to no one there.

footman.Lane

A serialised claim on one named resource: one holder at a time, contending only with claimants of the SAME lane — unrelated work runs untouched (nothing drains; that is exclusive=, a different regime).

Made by footman.lane() and claimed by handing the handle to a task's or a step maker's lanes=: the import system is the registry, so a misspelt lane is an undefined name, and sharing a resource across modules is importing its binding. footman itself ships exactly two — cwd_lane and console_lane — built with the same call.

name instance-attribute

name: str = name

The resource's one name — what the live line shows a wait on.

reason instance-attribute

reason: str | None = reason

Optional documentation on the declaration.

footman.lane

lane(name: str, *, reason: str | None = None) -> Lane

Declare a named resource to serialise on. One binding per resource: re-declaring a taken name is a refusal naming both sites — reuse is spelled by importing the handle, never by re-declaring. (The same site re-executing — a module re-imported — is the same declaration.)

footman.cwd_lane module-attribute

cwd_lane: Lane = make_lane(
    "cwd", reason="the one real working directory"
)

footman.console_lane module-attribute

console_lane: Lane = make_lane(
    "console", reason="the one terminal"
)

Where a task keeps things

Two folders footman writes, both created on access. cache_dir() is derived data the collector sweeps by age; data_dir() is durable and machine-local — credentials, tokens, generated assets — and is never collected. The rest are places footman reads, created by nobody but you: config_dir() and config_file() are your user-level settings, user_tasks_file() the personal rung riding every project, and project_root() the top of this invocation's cascade (None outside a project). fm self.path is the same set from the command line. Where each one lands is the CLI's business, not the task's; see Custom CLIs.

footman.cache_dir

cache_dir() -> Path

This CLI's cache directory, created if it isn't there yet.

Derived data, safe to delete: footman's collector sweeps this folder by age, so put nothing here you would mind losing.

(footman.cache_dir() / "index.json").write_text(payload)

Where it lands is the branded CLI's business — its own folder, or ~/.cache/<name> by default. A task asks for the kind of folder it wants and gets one that exists.

footman.data_dir

data_dir() -> Path

This CLI's data directory, created if it isn't there yet.

Durable and machine-local — credentials, tokens, generated assets. The collector never touches it, which is the whole difference from cache_dir(). Created owner-only (0o700), like ~/.ssh, because credentials are exactly what it is documented to hold.

(footman.data_dir() / "credentials.json").write_text(token)

footman.config_dir

config_dir() -> Path

This CLI's config directory — your writing, never footman's.

The user-level config file and the user-level tasks file live here and travel together. Unlike cache_dir() and data_dir() this one is not created on demand: an empty config directory says something different from a missing one, and inventing it would be footman writing where only you should.

footman.config_file

config_file() -> Path

The user-level config file — ~/.config/<brand>/config.toml by default. May not exist; a runner with no config is the ordinary case.

footman.user_tasks_file

user_tasks_file() -> Path

The user-level tasks file: the cascade's outermost writable rung, riding every project. May not exist.

footman.project_root

project_root() -> Path | None

The top of this invocation's task cascade, or None outside a project.

The same answer --json reports and the root cwd policy resolves against — never a guess from the current directory, which may be anywhere beneath it.

Which CLI is running

For output that has to call the CLI back — a generated CI workflow, a README line — so a branded runner emits its own name instead of fm. prog() is the command someone types, dist() the package that ships it (None when the brand never declared one). Inside a task body ctx.prog says the same and is the better reach: it is the invocation's own answer rather than process state.

footman.prog

prog() -> str

The command this CLI is invoked as — fm, or a branded CLI's own.

For output that has to call the CLI back: a generated CI workflow, a README line, a task that writes prose naming the command someone will type. A branded runner must emit its own name, or the file it writes tells the reader to run a command they do not have.

(root / ".github/workflows/ci.yml").write_text(
    f"      - run: {footman.prog()} check\n"
)

Inside a task body, prefer ctx.prog — declare a ctx: Context parameter and read it. That is the invocation's own answer rather than process state, and it is what footman's own taskdocs plugin uses. This accessor exists for the code that never receives a context: a plugin building its tree at mount time, a helper called from anywhere.

footman.dist

dist() -> str | None

The distribution that ships this CLI, or None if it never said.

The name someone would pip install — what the uv handoffs reason about, and what self.install puts on a PATH. App(dist=…) sets it; stock footman answers "footman".

None is a real answer, not a gap: declaring a distribution is opt-in for a branded CLI, and saying "footman" for a brand that never declared one would name the wrong package.

Fetching

footman.fetch

fetch(
    url: str,
    *,
    into: Path | str | None = None,
    sha256: str = "",
    backend: str = "",
    refresh: bool = False,
) -> Path

Download url (cached), returning the path to the local file.

A second call for the same URL revalidates with the server (ETag / Last-Modified) rather than re-downloading; a 304 Not Modified costs one round trip and keeps "cached" honest. Pass refresh to skip revalidation and fetch unconditionally.

@task
def deps():
    "Fetch the toolchain."
    archive = fetch(TOOLCHAIN_URL, sha256="9f86d0…")
    run(["tar", "-xzf", archive, "-C", "vendor"])

into copies the cached file to a path of your choosing (and returns that path). sha256 verifies what arrived and refuses a mismatch — the way to make a build reproducible. backend overrides the configured one for this call.

Under --dry-run nothing is downloaded: the step is recorded and the would-be cache path returned, so a plan can be inspected safely.

footman.FetchError

Bases: Exception

A download failed, or arrived wrong (checksum, missing backend).

The task context

given() answers whether the caller supplied a parameter or footman filled it in — the difference between asking for the default and having no opinion, which the value alone cannot tell you.

footman.Context dataclass

State for one running task: environment, flags, passthrough, output.

address class-attribute instance-attribute

address: str = ''

This context's place in the run's tree — the path of requests that led here. Every record made under it derives its own address from this one.

answers class-attribute instance-attribute

answers: Any = None

The scripted answers a recording(answers=…) block carries — the normalised table, shared by reference with every child context so an ordered sequence is consumed in one place. None outside a scripted recording; a raw mapping handed to Context(answers=…) is normalised on first use.

assume_yes class-attribute instance-attribute

assume_yes: bool = False

--yes: every confirm() gate auto-answers yes, for CI and scripts.

atomic class-attribute instance-attribute

atomic: bool = False

@task(atomic=True): this task's subprocesses opt out of fail-fast's kill — they run to completion so a mid-write can't be truncated. Total on purpose: the child is unregistered and not group-isolated, so a stop signal to footman's own pid (timeout, docker stop) ends the run and leaves the child to finish; a terminal's Ctrl-C reaches it only because the kernel signals the whole foreground group itself.

cwd class-attribute instance-attribute

cwd: Path | None = None

Where run() executes — resolved once per task by the policy ladder (.opts(cwd=) / @task(cwd=) / config cwd, default taskfile), so it is concrete inside a run. A preset value (tests, use_context) wins. None means unresolved (plain calls outside a footman run).

cwd_policy class-attribute instance-attribute

cwd_policy: str = ''

[tool.footman] cwd — the run-wide default policy token (or absolute path) the ladder bottoms out on. Empty means taskfile.

cwd_unmanaged class-attribute instance-attribute

cwd_unmanaged: bool = False

cwd="unmanaged": footman stays out — subprocesses spawn with cwd=None (inherit the live process cwd) even though ctx.cwd records the process cwd at task start for the body to read.

deadline class-attribute instance-attribute

deadline: float | None = None

@task(timeout=…), resolved to a time.perf_counter() instant when the body starts — an instant rather than a duration because everything downstream asks "how long is left?", and a duration would have to be re-based at each hand-off.

It is the fail-fast event scoped to one task: past it, no new work starts, in-flight subprocess trees are terminated, and generator steps unwind at their next checkpoint. Inherited by the steps and run() calls a body makes, so a subprocess is bounded by whatever the task has left rather than by its own timeout alone.

dry_run class-attribute instance-attribute

dry_run: bool = False

--dry-run: run() prints and records the command, executes nothing, and reports success.

env class-attribute instance-attribute

env: dict[str, str] = field(
    default_factory=lambda: _globals.base_env()
)

This task's environment — a complete one, not a diff.

Starts as a copy of the run's pinned environment, and is what os.environ answers from inside the task, what every child footman spawns receives, and what run(env=…) replaces. Because it is a whole value rather than an overlay, del os.environ["FOO"] is ordinary: it removes the key from this task's environment and from the children it goes on to spawn, while a sibling's copy is untouched.

err_sink class-attribute instance-attribute

err_sink: TextIO | None = None

Where this task's stderr goes. At task level it is the same buffer as sink (so the atomic parallel flush keeps stdout/stderr in order); a run() capturing an in-process callable temporarily points the two at separate buffers to split the step's streams for its Result.

fetch_backend class-attribute instance-attribute

fetch_backend: str = ''

[fetch] backend from the config ladder — which engine fetch() downloads with. Empty means the default (stdlib urllib).

fn class-attribute instance-attribute

fn: Any = None

The running task's own function — what inherited() reads to find the task this one shadows. None outside a run.

force_color class-attribute instance-attribute

force_color: bool = False

--color=always (or FORCE_COLOR): colour even when output is not a terminal — so run() forces the tools it spawns to colour and the shown command line paints, for a pipe into less -R. Gated off under capture (--json), where ANSI would corrupt the envelope. Never sets the live cursor affordances tty governs — those still need a real terminal.

given class-attribute instance-attribute

given: frozenset[str] = frozenset()

The parameter names the caller supplied — named on the command line, passed as a keyword by a body call, or answered at an ask() prompt. What the given() function reads.

Its complement is everything footman inferred rather than was told: an env() fallback, a default(fn), the declared default. That split is the whole point — a value and the fact that someone asked for it are two different things, and only the second can distinguish --profile (write the default file) from no --profile at all (write nothing).

Empty outside a run, and never inherited: a child task's set is its own, stamped where its context is built.

in_task class-attribute instance-attribute

in_task: bool = False

True while a task body runs (the scheduler sets it around the call), so the interactive primitives tell a guarded mid-body call from the framework's own up-front ask() resolution.

interactive class-attribute instance-attribute

interactive: bool = False

@task(interactive=True): the task owns the real terminal — output is not captured and it holds sole stdio, so its body may prompt or run a REPL. Mid-body prompt()/confirm()/select() are allowed only here.

invoked_dir class-attribute instance-attribute

invoked_dir: str = ''

The asinvoked token's target: the process cwd where fm was launched, pinned as a snapshot at startup.

jobs class-attribute instance-attribute

jobs: int = 0

The effective parallel width (-j/--jobs, config jobs, or the cores-minus-one default) — caps parallel() pools in task bodies. 0 means unset (plain calls outside a run): no cap.

keep_going class-attribute instance-attribute

keep_going: bool = False

This task's resolved (per-subtree) failure policy, tagged onto the subprocesses it spawns so a fail-fast failure elsewhere reaps only the fail-fast trees in a mixed run, sparing a keep-going task's.

machine_read class-attribute instance-attribute

machine_read: bool = False

--json: the envelope is the report. footman's own receipt chrome — the step line, the replayed output block, the audit trail — is left out of the buffer, because every one of those already has a field on the step's own row. Task output still lands there: a body's prints are what the buffer is for.

name_width class-attribute instance-attribute

name_width: int = 0

The widest sibling task name, so step-line columns align.

no_color class-attribute instance-attribute

no_color: bool = False

--no-color / --color=never (or NO_COLOR): never emit ANSI styling.

no_input class-attribute instance-attribute

no_input: bool = False

--no-input: never prompt — a required prompt errors instead of asking, so an unattended run fails loudly rather than hanging.

notes class-attribute instance-attribute

notes: list[Any] = field(default_factory=list)

The notes this task fired (_notes.Note records) — what the level system resolved, where each came from, and what the boundary verdict and the --json envelope read. A task's own, never inherited.

passthrough class-attribute instance-attribute

passthrough: list[str] = field(default_factory=list)

Everything after -- on the command line, verbatim.

prog class-attribute instance-attribute

prog: str = 'fm'

The invoking CLI's command name — a branded CLI's own prog, so tasks (the taskdocs plugin, say) can speak the brand's name.

quiet class-attribute instance-attribute

quiet: bool = False

--quiet: suppress step lines and the per-task summary.

root_dir class-attribute instance-attribute

root_dir: str = ''

The root token's target: the highest cascade task file's directory (files[0].parent), pinned by discovery. Empty outside a real run.

sections class-attribute instance-attribute

sections: list[Section] = field(default_factory=list)

Task-authored profiling: what section(), stream() and mark() recorded while this task ran, on the run's clock. Rides the task's result row; a parallel() child's records fold into its requester's, the way steps do.

sequential class-attribute instance-attribute

sequential: bool = False

The user asked for one-at-a-time (-s or config) — parallel() honours it too. Deliberately not set by the scheduler's own single-node routing, which is presentation, not a request to serialise task bodies.

serial_active class-attribute instance-attribute

serial_active: bool = False

This task holds (or inherited, through a fan-out) the serial or exclusive lane: it owns the real process globals — the environ router, the Popen injection, and the os guards all pass through. Set by the executor around a serial=/exclusive= body; parallel() children inherit it, because a lineage extends a hold.

shared class-attribute instance-attribute

shared: bool = True

Whether an execution the run has already performed may satisfy this request. False means it gets its own run, and so does everything it asks for, unless that task declares its own answer. Resolved per node by the scheduler and per call by the cell layer — the sharing twin of keep_going's per-subtree policy.

shell_default class-attribute instance-attribute

shell_default: str = ''

[shell] default from the config ladder — what run(shell=True) resolves to. Empty means posix (a POSIX shell everywhere: bash, then sh).

sink class-attribute instance-attribute

sink: TextIO | None = None

Where this task's stdout goes: a capture buffer in buffered (parallel) mode, None for the real stdout (live mode).

steps class-attribute instance-attribute

steps: list[Result] = field(default_factory=list)

Every run() this task made, in order — what recording() and the --json envelope read.

task class-attribute instance-attribute

task: str = ''

Who is running, for the step lines' name column: the scheduler sets the dotted task name, parallel() its child's name. Empty outside runs.

terminal class-attribute instance-attribute

terminal: bool = False

The run's real stdout is a terminal (on Windows, one that interprets ANSI) and output is not captured into a --json envelope — tty with the colour policy left out. What the tty() reader answers: NO_COLOR turns tty off because dressed output would be wrong, but the person is still there.

timeout_declared class-attribute instance-attribute

timeout_declared: float | None = None

The seconds the author wrote in @task(timeout=…), kept beside the resolved deadline so a failure line can name the declared number rather than the remainder that happened to be left when a call started.

tty class-attribute instance-attribute

tty: bool = False

Output dresses for a terminal (colour, marks). Live in-place rewrites additionally require output to be uncaptured.

unit_pending class-attribute instance-attribute

unit_pending: bool = False

A live-progress unit is already counted for this call, and unclaimed: parallel() counts every child it is handed, then the first task request inside that child claims the unit instead of counting a second one. A thunk that runs no task keeps it; a thunk that is only a wrapper around one call (the lambda: build("web") spelling) shows as the single piece of work it is. Cleared on claim, so the requests after it — and anything the callee itself asks for — count their own.

verbose class-attribute instance-attribute

verbose: bool = False

--verbose: replay captured run() output even on success.

child

child(label: str, **overrides: Any) -> Context

One birth for every child context — a body callee's, a parallel() child's — so the sites can never drift on which fields reset.

Identity is fresh: the address and sibling labels, the step and section records, the unit flag. The environment is a COPY — a child's writes are its own, which is the router's "children see it, siblings don't" made structural. Everything else inherits until overrides says otherwise. given deliberately inherits here: the futures layer stamps the callee's own set where binding computes it, and a step child has no binding to compute one.

overdue

overdue() -> bool

Has this task's deadline passed? False when there is no deadline.

time_left

time_left() -> float | None

Seconds until this task's deadline, or None when it has none.

Clamped at zero rather than going negative: callers hand it to subprocess.communicate(timeout=…) and to their own comparisons, and a negative bound reads as "no bound" to some of them. Zero means the deadline has passed and the caller should not start the work.

footman.given

given(name: str) -> bool

Whether the caller supplied name, as opposed to footman inferring it.

@task
def build(*, profile: Path = Path("build-profile.json")):
    if given("profile"):   # `fm build --profile` writes the default file;
        trace_to(profile)  # `fm build` writes nothing at all

True when the parameter was named on the command line (with or without a value — --profile alone means its default, and means it on purpose), passed as a keyword by a body call, or answered at an ask() prompt. False when the value came from an env() fallback, a default(fn), or the declared default, because nobody asked for those.

That distinction is the only way to tell "the default one, please" from "no opinion", which a value alone cannot express: both hand you the same path.

name is the parameter name, not its flag spelling — dry_run, not --dry-run. Naming something the running task has no parameter for is an error rather than a silent False, since a typo would otherwise read as a perfectly ordinary "not given".

footman.use_context

use_context(
    ctx: Context | None = None,
) -> Generator[Context]

Install ctx as the current run context for the duration of the block.

The public seam for calling tasks from other Python code — tests included: run() and hosted toolroom calls inside the block read this context instead of a fresh default. footman.testing.recording builds on it.

with use_context(Context(env={"CI": "1"})) as ctx:
    deploy()
assert ctx.steps[0].code == 0

Composing tasks

footman.include

include(
    source: str | ModuleType | Group,
    /,
    *,
    into: str | Group | None = None,
    only: tuple[str, ...] | list[str] = (),
    exclude: tuple[str, ...] | list[str] = (),
    override: bool = False,
) -> Group

Mount a task tree from an importable module — modules only; for an installed package's advertised tasks use plugin().

include("shared_tasks")                    # everything, at root
include("shared_tasks", only=["lint"])     # cherry-pick a child
include("mytasks.lint")                    # one group out of a module
include("mkdocs_helpers.tasks", into="docs")  # namespaced: fm docs.…

The longest importable prefix is imported under a registry capture (the provider's decorators can't leak into your tree); the rest of the string walks the captured tree. Collisions are loud (RegistrationError) unless override=True; your own definitions silently win. Included tasks run from your file's directory — a shared lint task lints this project. A Group or imported module may be passed programmatically (tests, generated trees). Returns the group grafted into.

footman.plugin

plugin(
    source: str,
    /,
    *,
    into: str | Group | None = None,
    only: tuple[str, ...] | list[str] = (),
    exclude: tuple[str, ...] | list[str] = (),
    override: bool = False,
) -> Group

Mount a task tree from an installed package's footman.tasks entry point — entry points only; for your own modules use include().

plugin("acme.devkit")                      # the import *: every group, top level
plugin("acme.devkit.lint")                 # from acme_devkit import lint
plugin("acme.ci", exclude=["deploy"])      # everything but one child
plugin("footman.docs", into="footman")     # built-ins are ordinary plugins
plugin("acme.linters.default", into="lint")  # adopt a provider's default

The longest installed entry-point name is the identity (consumed at resolve time, retained as provenance); the rest of the string walks the advertised tree. The mounted node lands under its own name — into= (a dotted address, created on demand) is the consumer's placement, and there is no rename. Returns the group grafted into.

footman.capture

capture() -> Generator[Group]

Redirect module-level @task/group registration into a fresh tree.

The seam include() uses to import a provider module without letting its decorators land in the current registry: root.tasks/root.groups are swapped for fresh dicts for the duration and the captured tree is yielded. Reentrant — a provider may itself include() another provider.

The invocation, and editing the discovered tree

@pre_tasks runs a hook once per invocation, over the fully-merged cascade and before anything else — see Hooks & plugin options. It is handed the Invocation, whose tasks is a Tasks view; iterating or indexing that yields a TaskView that reads and edits one task. The per-task pair — @pre_task and @post_task — runs around every execution; see Around every task.

footman.pre_tasks module-attribute

pre_tasks: Final[HookRegistrar] = root.pre_tasks

footman.pre_bind module-attribute

pre_bind: Final[HookRegistrar] = root.pre_bind

footman.pre_task module-attribute

pre_task: Final[HookRegistrar] = root.pre_task

footman.post_task module-attribute

post_task: Final[HookRegistrar] = root.post_task

footman.post_tasks module-attribute

post_tasks: Final[HookRegistrar] = root.post_tasks

footman.GlobalOption

A plugin's own global option — --env-file=PATH beside --jobs=N.

Constructing one is registering it: a module-level singleton in the provider, stamped with the module that defined it, riding the same carriage as lifecycle hooks — so it reaches a run only when its owner is mounted, and an unmounted owner's option is an unknown option, taught. Long-form only, and the value is =-attached like every option's.

ENV_FILE = GlobalOption("env-file", Path, help="load this .env file")

A bool annotation makes it a flag; anything else takes a value, coerced and validated through the same pipeline as a task parameter — Literal choices, Path file completion, Annotated[..., suggest(...)] all work, because the manifest describes it with the same machinery. Read it anywhere in-run as OPT.value (frozen after parse); cross-plugin use is an ordinary import of the singleton.

A mention with no value is legal — --profile beside --profile=out.json — and carries presence rather than a value: .value is then whatever the option would have had anyway, and .given says someone asked. That is what lets one declared value cover three outcomes (off, the default file, a named file) where a second declared value used to be needed.

config=True gives the option a project-config rung — the one ladder, CLI > env() > config > default(fn) > declared — reading the key named like the option from this provider's own section under the brand table's reserved plugins. child: [tool.footman.plugins.acme-devkit] for the acme.devkit entry point (the dot becomes a hyphen, because TOML's dot is its nesting operator). config="key" names the key instead, for a flag renamed around a collision — flag and key are different namespaces, and only the flag's is shared. The section is the entry point's, de-dotted; footman.config_section("...") names it explicitly where there is nothing to derive from (an include()d module) or the derivation reads wrong.

given property

given: bool

Whether this option was named on the command line, with or without a value — the twin of value, and the half a value cannot express.

PROFILE = GlobalOption("profile", Path, default=Path("fm-profile.json"))
if PROFILE.given:            # `--profile` writes the default file,
    write_trace(PROFILE.value)   # no `--profile` writes nothing

Three outcomes from one declared value: absent, named, named with a value. value answers what, given answers whether anyone asked — and an env fallback fills the first without ever touching the second, so a caller who wants the environment alone to count can say so, and one who does not can say that instead.

value property

value: Any

The parsed value for this run — the flag's presence, the option's coerced value, or the default when the line didn't carry it. Frozen after parse; reading it outside a run is a taught error, because there is no invocation to have carried it.

footman.config_section

config_section(name: str) -> None

Name this provider module's config section — the <name> of [tool.footman.plugins.<name>] — instead of the entry-point derivation.

Module-level, beside the module's GlobalOptions. For an include()d module (no entry point to derive from), or a derivation that reads wrong or ugly. The name is claimed at discovery, so two providers naming one section refuse loudly, never resolve by order.

footman.wrap_task module-attribute

wrap_task: Final[HookRegistrar] = root.wrap_task

footman.wrap_bind module-attribute

wrap_bind: Final[HookRegistrar] = root.wrap_bind

footman.Invocation

What one fm line is doing, handed to every lifecycle hook.

Editable only at pre_tasks; every later moment sees the same object with writes refused, so what a task-time hook reads is what the run decided.

cli instance-attribute

cli: dict[str, Any] = dict(cli or {})

The global options this line carried. Empty in the manifest refresh child, which has no command line — so a tree edit that reads this would be reading nothing there, which is exactly why it must not.

config instance-attribute

config: dict[str, Any] = dict(config or {})

The merged [tool.footman] configuration.

cwd instance-attribute

cwd: str = cwd

Where fm was invoked.

results instance-attribute

results: tuple[Any, ...] = ()

The run's rows, in the order the work was created, as result views — filled for post_tasks (the run itself writes past the freeze; hooks read). Executions, shared rows, refusals and skipped nodes alike.

root instance-attribute

root: str = root

The cascade root: the directory of the highest tasks.py.

skipped instance-attribute

skipped: tuple[Any, ...] = ()

The skipped subset of results — what never ran because something it needed failed.

tasks instance-attribute

tasks: Tasks | None = tasks

The merged command tree, as a Tasks view — the same surface a TaskView edit uses. Editable at pre_tasks, when a hook may disable a task, add a prerequisite, or read provenance.

total_ms instance-attribute

total_ms: float = 0.0

Wall-clock for the whole run, the envelope's total_ms — filled for post_tasks.

freeze

freeze() -> None

Close the invocation for writes — called once, before tasks run.

footman.Tasks

A hook's view of the merged command tree: iterate every task, or look one up by its address, each as a TaskView.

get

get(name: str) -> TaskView | None

The task at name — its full dotted address — or None.

Addresses are unique by construction (a name is a task or a group at each level, never both), so this either finds one task or finds none: there is nothing here to be ambiguous about. A bare leaf is not an address — get("build") finds a top-level build, never docs.build. To search by leaf, iterate: two tasks may answer to one, and picking between them is the caller's to make, not footman's to guess.

A runnable group answers whatever its default answers, so get("lint") is get("lint.default") — the bare group name is that action's other spelling on the command line, and lookup would otherwise be the one place that isn't true.

footman.TaskView

A hook's handle on one task: read its wiring, its policy flags, and its cascade provenance (where it was defined, what it overrode), and edit it here — never through the private _footman_* attributes.

address property

address: str

The task's full dotted address, exactly as the command line spells it: forge.dev.up. Unique across the tree — a name is a task or a group at each level, never both — so this is the key to hold a task by, and what Tasks.get looks up.

atomic property

atomic: bool

Whether the task's subprocesses opt out of fail-fast's kill.

confirm property

confirm: str

The @task(confirm="…") prompt gating the task, or "" if none.

defining_dir property

defining_dir: str | None

The folder the task was defined in, or None when the cascade did not tag it (a plugin- or include()-composed task, not a cascade file). Use it to act on tasks from one subtree of a monorepo.

disabled property

disabled: str | None

Why the task is unavailable here, or None if it can run.

fn instance-attribute

fn = fn

The task function itself — the escape hatch past the view.

group instance-attribute

group = group

The group this task lives in, or None for a top-level task — its .name is the group's command-line spelling (e.g. docs).

hidden property

hidden: bool | None

The task's own hidden= answer, or None when it inherits one.

Read the declaration, not the resolved answer: a hook that hides a group wants to know whether this task overrode it.

infinite property

infinite: bool

Whether the task runs until stopped (@task(infinite=True)).

interactive property

interactive: bool

Whether the task owns the real terminal (@task(interactive=True)).

keep_going property

keep_going: bool | None

The task's declared failure policy (@task(keep_going=…)), or None when it left the choice to the command line / the built-in default.

mounted_from property

mounted_from: str | None

Which provider this task came from — the footman.tasks entry-point identity for plugin(), the module name for include() — or None when the tasks file defines it itself, which is the honest answer: the project owns it.

Language-neutral by construction: it names a provider, not a file, so it keeps answering for a task that is one day not a Python function. A group carries the same field only when it was grafted whole; the moment a mount composes into a group that already exists, the group is shared and answers None while every task under it still answers exactly. Read ownership here.

name instance-attribute

name = name

The task's leaf name, e.g. build. The whole spelling is address — two tasks in different groups share this one.

path property

path: tuple[str, ...]

Every segment of the task's address, leaf last — ("forge", "dev", "up"). address is the same thing as one string.

post property

post: tuple[Task, ...]

The tasks that run after this one.

pre property

pre: tuple[Task, ...]

The prerequisites that run before this task.

shadow_chain property

shadow_chain: tuple[Task, ...]

This task and every task it shadows, nearest (this one) first.

shadowed property

shadowed: Task | None

The task this one overrides — same name, one cascade level up — or None if it shadows nothing.

source_file property

source_file: str | None

The file the task's function is defined in, or None when it can't be located (a built-in or dynamically constructed function).

timed property

timed: bool

Whether the task records timing history / shows a determinate bar — @task(progress=False) (and infinite/interactive) opt out.

add_post

add_post(*tasks: TaskView | Task) -> None

Append post-tasks (views or functions), skipping any already set.

add_pre

add_pre(*tasks: TaskView | Task) -> None

Prepend prerequisites (views or functions), skipping any already set.

disable

disable(reason: str) -> None

Mark the task unavailable — listed with reason, refused if run.

set_opts

set_opts(**overrides: Unpack[TaskOpts]) -> None

Set orchestration options on the task permanently, for every use — the discovery-time counterpart to a per-use .opts(). Takes the same options (TaskOpts, the whole .opts() set) and rejects a task parameter with the same taught error; the difference is that it edits the registered task rather than a per-use proxy, so a hook can set a policy across a whole class of tasks. A command-line -k/--fail-fast still wins over a set keep_going.

Custom CLI

footman.App

A branded footman CLI — call run from your console-script entry.

One App serves a process: run points module-level state (the brand, its locations) at this brand and deliberately never restores it — a process runs one brand, and run may not return at all (the uv handoff re-execs). Driving two brands from one process is supported only through footman.testing.Runner, which saves and restores around each invocation.

run

run(argv: list[str] | None = None) -> int

Resolve and run the CLI, returning the process exit code.

Handles the stdlib-only --complete hot path before importing the framework, so completion stays fast even through a custom entry point.

footman.Brand dataclass

The names a CLI shows the user, and where it keeps its things.

name is the long / display name (the --version banner); prog is the short command name (the error prefix and hints); version is your version string; tasks_file is the filename your users write tasks in (config tasks still overrides it per project).

dist is the distribution that ships your CLI — the name a user would pip install. It is what the handoffs reason about: which package a project's lockfile must pin, and which one a tasks file carrying its own PEP 723 dependencies must declare. Left None (the default for a custom brand), both handoffs simply stay out of your way.

cache_dir and data_dir are the two folders this CLI keeps things in, and you place them, so footman never guesses at your product's layout:

acme_home = Path(os.environ.get("ACME_HOME", Path.home() / ".acme"))
cache_dir=acme_home / ".cache" / "acme-cli",
data_dir=acme_home / "acme-cli",

They are not anchored to each other — put them wherever each belongs in your world. Cache is derived data, safe to delete, and the collector sweeps it; data is durable and machine-local (credentials, tokens, generated assets) and is never collected. footman refuses to start if the two name the same directory, because that would point the collector at things it must not delete.

<PREFIX>_CACHE_DIR and <PREFIX>_DATA_DIR override them at run time, which is how two installations run side by side under different identities. Left unset with those variables unset, both fall back to XDG — ~/.cache/acme and ~/.local/share/acme.

Task authors never see any of this: they call footman.cache_dir() and footman.data_dir() and get a directory that exists.

env_prefix is the namespace for every variable this CLI reads, and defaults to prog uppercased: the command is acme, so the variables are ACME_*. A branded CLI reads only its own prefix, so a stray FOOTMAN_CACHE_DIR can never move someone else's product.

Set it explicitly for either of two reasons. A terse command makes a poor variable: footman's own is fm, but its variables are FOOTMAN_*, because FOOTMAN_CACHE_DIR tells a reader who has never heard of the tool what it belongs to and FM_CACHE_DIR tells them nothing. Or the namespace collides with your product's own — keeping it clear is yours to arrange.

config_name names the standalone config file (acme.toml), the table inside pyproject.toml ([tool.acme]), and the user-level config corner (~/.config/acme/), from one field so the three cannot drift apart. It defaults to prog — the machine word your users already type, the same rule the env prefix follows — never the display name, which is free text ([tool."Acme DevKit"] is nothing anyone would write, and a capitalised table is one nobody would find). Set it when the identity your users should write isn't the command: footman's own command is fm, but its config is footman.toml, pinned for the same reason its variables are FOOTMAN_*. The user-level config file is not placed by the brand: it stays at ~/.config/acme/config.toml, where a user looks for their own settings, with <PREFIX>_CONFIG naming another.

builtin class-attribute instance-attribute

builtin: tuple[str, ...] = ()

The brand's own task surface where there is no project — a tuple of footman.tasks entry-point names, mounted as the base of the tree exactly when discovery finds no project task files. The ladder is project > user > built-in: a project ignores the base outright (its tasks file mounts what it wants — nothing is privileged, nothing lost), and the user tasks file overlays it. Strings, never live objects: the detached refresh child rebuilds manifests in a bare process, and a name rides argv where an object cannot. Curate a small global surface — what does this CLI offer someone with no project? — rather than aiming it at the everyday tasks, which belong where their probes work.

config_stem property

config_stem: str

The config identity — config_name, else prog: the acme of acme.toml, [tool.acme] and ~/.config/acme/.

prefix property

prefix: str

The environment-variable namespace — env_prefix, else from prog.

config_file

config_file() -> str

This brand's standalone config filename — acme.toml.

env

env(suffix: str) -> str

This brand's spelling of an environment variable: ACME_CACHE_DIR.

install

install() -> None

Point the process's locations at this brand — the one call the real entry (App.run) and the embedded harness (Runner.invoke) both make, so the two can never configure different worlds (they once each listed the fields by hand, and grew apart by three).

footman.main

main(tasks_file: str | None = None) -> None

Console-script entry for footman and fm.

tasks_file makes a tasks file its own command. Ending a file with

if __name__ == "__main__":
    footman.main(__file__)

turns it into a runnable script — ./deploy.py build — reading its own tasks whatever the directory, which is what pairs with a PEP 723 header and a #!/usr/bin/env -S uv run --script shebang. An explicit -f on the command line still wins.

Typed-parameter helpers

footman.Many module-attribute

Many = list

footman.Arg module-attribute

Arg = Annotated[_T, _arg]

An optional trailing positional, via Annotated-alias:

@task
def files(pattern: Arg[str] = "*"): ...

fm files src fills the positional; a bare fm files runs on the default. The grammar is deterministic and greedy: when a bare word follows, it is the value — never re-interpreted as the next task, no name-peeking — and capped at one token. To run the task argument-less ahead of another, say so with the explicit boundary: fm files + build. An Arg needs a default (absence must mean something), takes at most one token (use a list parameter for many), and must trail every required positional.

Arg is the one exception to the rule that sorts every other parameter: the default decides — no default makes a required positional, a default makes an option you pass by name. Arg says "keep me a positional even though I have a default", which is why it needs the greedy-but-capped grammar above. The rules it is bending are laid out in the typing guide.

footman.Forward module-attribute

Forward = Annotated[_T, forward]

Shorthand for Annotated[T, forward], like Many[T] is for a list:

@task(pre=[build, lint])
def check(fix: Forward[bool] = False): ...

Forward[bool] expands to Annotated[bool, forward] — the same marker, less noise on a signature full of forwarded parameters. See forward.

footman.forward module-attribute

forward: Final[ForwardMarker] = ForwardMarker()

Forward this parameter to the tasks this task dispatches, via Annotated:

@task(pre=[build, lint])
def check(fix: Annotated[bool, forward] = False): ...

A forward-marked parameter's value is passed to every task this one dispatches — its pre/post prerequisites, and a runnable group's fan-out — that declares a parameter of the same name; tasks that don't declare it run on their own defaults. The forwarded value overrides the callee's default, chains through callees that re-declare the marker, and may satisfy a defaultless parameter — the same courtesy ask() and stdin extend, so a receiver never needs a fake default just to be reachable. Presence travels beside the value, so given() reads the same sentence in the dispatcher and in what it dispatched.

footman.NoSplit module-attribute

NoSplit = Annotated[_T, nosplit]

Shorthand for Annotated[T, nosplit]: NoSplit[list[str]] opts a collection out of comma-splitting (see nosplit).

footman.nosplit module-attribute

nosplit: Final[NoSplitMarker] = NoSplitMarker()

Opt a list/dict parameter OUT of comma-splitting, via Annotated:

def build(names: Annotated[list[str], nosplit] = ()): ...

By default a collection parameter splits a single token on commas (--tag a,b,c -> ["a", "b", "c"]) in addition to the repeatable form (--tag a --tag b). Mark it nosplit when a value may itself contain a comma: then only the repeated flag adds items and --name "a,b" stays the literal "a,b". NoSplit[list[str]] is the terser spelling of the same thing.

footman.Hidden module-attribute

Hidden = Annotated[_T, hidden]

Shorthand for Annotated[T, hidden]: Hidden[str] keeps a parameter out of the listings while it still binds and completes (see hidden).

footman.hidden module-attribute

hidden: Final[HiddenMarker] = HiddenMarker()

Keep a parameter out of the listings a human reads, via Annotated:

def deploy(target: str, legacy: Annotated[str, hidden] = ""): ...

It means on a parameter what hidden=True means on a task. --help does not list it and the synthesised example does not use it, but it still binds, still completes, and --all reveals it. The manifest marks it rather than dropping it, so an agent reading --describe still learns it exists — hiding and completing are different questions, and a long machine-facing flag is the one you most want spelled for you.

For a deprecated flag kept working but unadvertised, a debug switch, or a flag a wrapper script passes. Hidden[str] is the terser spelling.

footman.suggest

Attach a dynamic completer to a parameter, via Annotated:

def build(project: Annotated[str, suggest(list_projects)]): ...

list_projects() -> list[str] returns the candidate values. footman runs it on the execution path — refreshing a cache the completion hot path serves — and, when strict (the default), validates the supplied value against a fresh call. The wrapper is required: a bare callable in Annotated is refused, so a marker that happens to be callable can never be mistaken for a completer.

footman.exists module-attribute

exists: Final[PathRequirement] = PathRequirement(
    "exists", "exists"
)

Require a Path parameter to name something that exists on disk:

def rm(target: Annotated[Path, exists]): ...

Validated eagerly (at parse time) with a taught error. See also isfile and isdir.

footman.isfile module-attribute

isfile: Final[PathRequirement] = PathRequirement(
    "file", "isfile"
)

Require a Path parameter to name an existing file (see exists).

footman.isdir module-attribute

isdir: Final[PathRequirement] = PathRequirement(
    "dir", "isdir"
)

Require a Path parameter to name an existing directory (see exists).

footman.Exists module-attribute

Exists = Annotated[Path, exists]

Shorthand for Annotated[Path, exists] — target: Exists requires the path to exist. Type-fixed to Path; use Annotated directly for a list[Path].

footman.IsFile module-attribute

IsFile = Annotated[Path, isfile]

Shorthand for Annotated[Path, isfile]: require an existing file.

footman.IsDir module-attribute

IsDir = Annotated[Path, isdir]

Shorthand for Annotated[Path, isdir]: require an existing directory.

footman.matching

Narrow a Path parameter's Tab completion to a glob:

def load(env_file: Annotated[Path, matching(".env*")] = Path(".env")): ...

footman cannot complete paths itself — it answers from a cached manifest and never touches the filesystem — so a path value is handed to the shell's own file completion. This is what it hands along: the pattern the shell filters by, so --env-file offers .env and .env.local rather than every file in the directory.

Directories are always offered whatever the pattern says, or a match one level down would be unreachable. The glob matches the file's name (*.json, not **/*.json) — the vocabulary all five shells share.

Completion only. It narrows what Tab shows; it does not reject a value typed anyway — that is check()'s job, or exists/isfile's. A completion filter that quietly became validation would refuse the perfectly good path someone pasted.

footman.between

Inclusive numeric bounds for an int/float parameter:

def test(jobs: Annotated[int, between(1, 32)] = 4): ...

Validated eagerly with a taught error (--jobs must be between 1 and 32). Either bound may be None for open-ended ranges. A bare range in Annotated also works for ints, with Python's half-open semantics (range(0, 8) accepts 0 through 7).

footman.env

Fall back to an environment variable when the option isn't given:

def deploy(target: Annotated[str, env("DEPLOY_ENV")] = "staging"): ...

Precedence is CLI > $DEPLOY_ENV > default. The env value flows through the same coercion and validation as a command-line token. Only valid on a parameter with a default — an env fallback makes it optional, so it needs somewhere to fall. A body call that omits the parameter reads the same ladder; a value passed explicitly wins over env, however the task was asked for.

footman.check

A custom validator, run after coercion; raise ValueError to reject:

def tag(version: Annotated[str, check(semver)]): ...

The callable receives the coerced value (each element, for collections). Its ValueError message is shown to the user, so write it for them.

Declare a second parameter to also receive the siblings — the parameters to this one's left at their effective values (a provided value, else the parameter's own default), coerced and read-only (empty for the first parameter) — so a check can validate against another input:

def newer(v, params):
    current = current_version(params["name"])   # the package named earlier
    if Version(v) <= current:
        raise ValueError(f"must be newer than {current}")

def release(name: str, version: Annotated[str, check(newer)]): ...

footman.default

Compute a parameter's default when the task runs, via Annotated:

def install(shell: Annotated[str, default(detect_shell)] = ""): ...

A Python default is evaluated once, at import — fine for a constant, wrong for anything that depends on the machine, the environment or the clock. default(fn) calls fn() at bind time instead, so --help and the run agree and both are current.

It sits in the ladder just above the Python default — CLI value > env > default(fn) > the declared default — and, like env(), it needs a declared default to sit on: a plain Python call of the task, outside any run, has to keep working.

Declare one positional argument and it receives the sibling parameters resolved so far — the same courtesy check(fn) gets, and for the same reason: a default is often a function of the inputs beside it.

def cast(*, shell: str = "zsh",
         title: Annotated[str, default(lambda p: f"{p['shell']} · x")] = ""): ...

Read-only, and only what is to its left in the signature. The view holds effective values — what each parameter will actually be — and only a left one has that yet, which is also why a cycle cannot be written down: the signature fixes a total order and binding walks it. Reaching rightwards is a taught error, through p["x"] and p.get("x") alike. --help cannot show such a default — there is no invocation to read — so it shows none rather than one it would have to invent.

The value is used as it comes back, not coerced: fn() returns a real object, and coercion exists because the command line only has strings. It still runs the annotation's validators, so a default(fn) that would be refused as a typed value is refused here too rather than smuggled in.

reads_siblings instance-attribute

reads_siblings: bool = _takes_an_argument(fn)

Whether fn takes the sibling view. Decided once, here, because both the binder and the manifest need the answer and the manifest cannot import the binder — asking the marker beats asking the same question twice in two modules. Inspected rather than probed by call, so a real arity error raised inside fn is never mistaken for the no-argument form.

footman.doc

Help text for one parameter, via Annotated:

def lint(fix: Annotated[bool, doc("apply fixes in place")] = False): ...

One line, written for the person at the prompt. It shows in fm --help <task>, as the option's description in shells that render one (zsh, fish, nushell, PowerShell tooltips), and in the fm --json --list catalog.

footman.ask

Prompt for a parameter's value when it isn't supplied, via Annotated:

def release(version: Annotated[str, ask()]): ...

A parameter marked ask() is prompted for when the command line does not give it and no env fills it — the answer runs through the same coercion and validation as a CLI token, re-asking on a bad value. Precedence is CLI > env > prompt (offering the default) > the default.

A declared default becomes the offer: the prompt shows it, Enter accepts it, and where nobody can be asked — off a terminal, under --no-input, in --json — it is simply used. So ask() is safe on any parameter: a person gets asked, an unattended run gets the default. Without a default there is no other answer, so those cases error naming the flag rather than hanging.

Naming the option bare (--version) skips the question: the caller has already said "the declared one", and asking again would be footman not listening.

secret=True hides the input (getpass) and never shows the default, though Enter still accepts it; prompt="…" overrides the question text.

footman.Secret

Bases: str

A string that redacts everywhere it is shown.

Answers to ask(secret=True) and prompt(secret=True) arrive as Secret, and a parameter annotated Secret coerces into one whatever its value came from — a flag, an env() fallback, a default. It is a real str for the body (comparisons, formatting — the caller's business), but its repr — what tracebacks, logs, and debuggers print — is Secret('***'), and structured surfaces serialise it as ***: the --json envelope, a Stdout[…] document, baked manifest defaults.

Redaction covers the places footman shows a value, not the bytes a task deliberately writes. print(token) and f"TOKEN={token}" emit the real thing, because every string operation on a Secret yields a plain str — which is what makes a task that must print a secret (an export … line for eval) work without a switch to disarm.

reveal() is that unwrap said out loud, for where it isn't implicit:

@task
def creds(token: Secret) -> Stdout[dict]:
    return {"token": token.reveal()}   # meant to leave; not redacted

Because it is a method, every intentional exposure is greppable — an audit list a run-wide "don't redact" flag could never give you.

reveal

reveal() -> str

The real value as a plain str, deliberately un-redacted.

Reach for it where a Secret would otherwise survive into a structured surface that redacts — a dict, a dataclass field, a Stdout[…] document — and you mean the value to be there.

Docstrings

Standalone (stdlib-only, no footman imports) — reusable outside footman.

footman.docstrings.parse

parse(text: str | None) -> Docstring

Parse text (a raw or already-cleaned docstring) into its pieces.

footman.docstrings.Docstring dataclass

The parsed pieces of one docstring.

Shaped to grow additively (returns, raises, …) without breaking reusers; absent pieces are empty, never None.

long class-attribute instance-attribute

long: str = ''

Prose between the summary and the first section (Args: and friends), structure preserved; empty when there is none.

params class-attribute instance-attribute

params: dict[str, str] = field(default_factory=dict)

Per-parameter help, keyed by the Python parameter name (not the CLI spelling): {"fix": "apply safe fixes in place"}.

returns class-attribute instance-attribute

returns: str = ''

The Returns: prose, joined to one line — what the value means, beside whatever schema the return annotation declares.

summary class-attribute instance-attribute

summary: str = ''

The first line — the one-liner listings and completion menus show.

Markdown export

Pure functions over manifest tree nodes — see Your tasks, documented for the task-level surface.

footman.markdown.render_page

render_page(
    tree: dict[str, Any],
    *,
    path: tuple[str, ...] = (),
    heading: int = 1,
    flavor: str = "plain",
    prog: str = "fm",
) -> str

One markdown document for the node at path (empty = whole tree).

footman.markdown.render_site

render_site(
    tree: dict[str, Any],
    *,
    path: tuple[str, ...] = (),
    flavor: str = "plain",
    prog: str = "fm",
) -> dict[str, str]

A linked set of files for the node at path: index.md per group (name, help, a table of children with relative links), one file per task. Keys are POSIX-relative paths.

Testing

footman.Runner

Drive a footman CLI in-process, capturing output and results.

Pass a branded App to test a custom CLI (Runner(App(prog="acme"))) — error prefixes, --version, and hints then use that brand, exactly as they would for real users.

invoke

invoke(
    args: str | list[str],
    *,
    tasks: Path | Group | None = None,
    cwd: Path | None = None,
    stdin: str | bytes | None = None,
    answers: Mapping[Any, Any] | None = None,
) -> InvokeResult

Run one command line and return everything it produced.

args is a string (shlex-split) or an argv list. tasks overrides discovery: a Path routes through --tasks-file, a Group skips discovery entirely (an in-memory tree, no files needed). Without it, the normal tasks.py cascade from cwd applies. stdin is what a pipe would have delivered — a str is encoded UTF-8 — and its absence means "a terminal": the invocation never reads the test harness's real stream, so stdin-bound parameters see exactly what the test says and nothing else. answers scripts the world the invocation rehearses in — the same table recording(answers=…) takes — and implies --dry-run, so the CLI runs end to end against scripted commands. Never raises on task failure — the code is in the Result; KeyboardInterrupt passes through.

footman.testing.InvokeResult dataclass

Everything one Runner.invoke produced.

Named apart from the run-step Result (footman.Result, what run() returns): this is the outcome of a whole CLI invocation — its exit code, captured streams, and per-task TaskResults.

footman.recording

recording(
    *,
    answers: Mapping[Any, Any] | None = None,
    **overrides: Any,
) -> Generator[list[Result]]

Capture the commands a block would run() — silently, not executing.

Yields the live step list; each run() call inside the block — hosted toolroom calls included — appends a Result instead of executing, and answers with a blank success. Keyword overrides go to the underlying Context (e.g. env={...}).

answers= scripts the answers instead. Keys are command prefixes — "uv tool list", or a tuple of tokens — matched against the recorded command (Result.command), longest match first. Values: a str is stdout with exit 0, an int is an exit code, a Result sets code and both streams, an exception instance is raised by the call (a missing binary), and a list answers consecutive matches in order and refuses by name when it runs dry. Unmatched calls keep the blank success. A non-zero answer takes the real failing lane — returned under nofail=True, raised as RunFailed otherwise — and a matched call is answered even when it opted out of the record with recorded=False. A recorded step also keeps the env and cwd it would have run with.

toolroom's answers() is the same table applied one layer down, at the bridge's own seam; nested inside a recording, it wins and the record sees nothing.