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.Group
¶
A node in the command tree: named tasks and nested sub-groups.
default_task
property
¶
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__
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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:
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,--treeand group help (--allbrings it back), whilefm <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--jsonreports it marked rather than missing, because a machine is who calls it. Unset inherits the enclosing group's answer, sogroup("internal", hidden=True)hides a whole subtree and a child can still sayhidden=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
¶
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
¶
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
¶
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_*.
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
¶
footman.requires_dep
¶
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
¶
Gate a task on command-line tools being on PATH (shutil.which).
footman.requires_env
¶
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).
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".
env
property
¶
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".
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).
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.
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).
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.
footman.Argv
¶
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 forsh/bash/zsh..windows()— one string, quoted the wayCreateProcessparses.
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,
) -> 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: str) -> _StepBlock
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
¶
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
¶
Arguments after -- on the command line, for the running task.
footman.inherited
¶
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 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
¶
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
¶
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.
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
¶
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 | 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
¶
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
¶
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[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
¶
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, solist[int]andlist[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
¶
Shorthand for Annotated[T, stdout] on a return annotation:
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:
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
¶
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.
footman.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
¶
footman.console_lane
module-attribute
¶
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.
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.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.
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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.
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
¶
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
¶
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.
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
¶
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.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.
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.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
¶
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
¶
The merged [tool.footman] configuration.
results
instance-attribute
¶
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.
skipped
instance-attribute
¶
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.
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
¶
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.
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.
group
instance-attribute
¶
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.
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
¶
The task's leaf name, e.g. build. The whole spelling is
address — two tasks in different groups share this one.
path
property
¶
Every segment of the task's address, leaf last — ("forge", "dev",
"up"). address is the same thing as one string.
shadow_chain
property
¶
shadow_chain: tuple[Task, ...]
This task and every task it shadows, nearest (this one) first.
shadowed
property
¶
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.
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
¶
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/.
install
¶
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.Arg
module-attribute
¶
Arg = Annotated[_T, _arg]
An optional trailing positional, via Annotated-alias:
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
¶
footman.forward
module-attribute
¶
forward: Final[ForwardMarker] = ForwardMarker()
Forward this parameter to the tasks this task dispatches, via Annotated:
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
¶
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:
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
¶
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:
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:
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"
)
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
¶
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
¶
Shorthand for Annotated[Path, isfile]: require an existing file.
footman.IsDir
module-attribute
¶
Shorthand for Annotated[Path, isdir]: require an existing directory.
footman.matching
¶
Narrow a Path parameter's Tab completion to a glob:
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:
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:
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:
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:
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:
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:
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.
Docstrings¶
Standalone (stdlib-only, no footman imports) — reusable outside footman.
footman.docstrings.parse
¶
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
¶
Per-parameter help, keyed by the Python parameter name (not the
CLI spelling): {"fix": "apply safe fixes in place"}.
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.