Chaining & parallelism¶
How a command line becomes a plan, what runs concurrently versus one at a
time, and how you steer it. Independent tasks run in parallel by default;
-s, -j, and -k control the concurrency, and a few rules decide when
footman falls back to sequential. The fine print (one execution per
request, body calls, sharing, steps of your own) lives on
The execution model.
The examples on this page share a small cast of stand-in tasks:
from footman import task
@task
def fmt(): ...
@task
def lint(): ...
@task
def typecheck(): ...
@task
def test(): ...
@task
def notify(): ...
Chaining¶
fm format lint --fix test runs three tasks from one line: duty's muscle
memory, but with real flags. The split is driven by the manifest, so it is
deterministic; + is always available as an explicit boundary, and --dry-run
rehearses the chain, so bodies run with their bound values while footman's
own work is faked into receipts, so the split shows itself in what each
task would have run:
Parallel by default¶
Independent tasks run in parallel by default. That is the concurrency
model. Footman builds a dependency graph (a DAG, with no cycles allowed, and
a cycle is a taught error) from the chain and each task's declared dependencies,
then runs everything that isn't waiting on something else concurrently. Tasks
spend most of their life waiting on subprocesses, and a run() call releases
Python's interpreter lock while it waits, so threads give real wall-clock
speedups without process isolation:
fm a b c # three 1s tasks -> ~1.0s, not 3.0s
fm -s a b c # -s/--sequential runs them one at a time -> ~3.0s
In the measured comparison, this default is most of the story: the gap on a real gate is architecture, not dispatch speed.
Two flags size the concurrency, and they reach both engines: the scheduler
and a parallel() inside a task body.
-s/--sequentialruns one task at a time, with no concurrency anywhere.-j/--jobs=Ncaps the width; unset, footman uses one less than your core count, never below two.
Set either permanently as sequential or jobs in [tool.footman]. A run
stops on the first failure; -k/--keep-going runs every independent branch even
if one fails, and a task whose prerequisite failed is skipped.
Output never interleaves
Each task's stdout is buffered and flushed as one contiguous block when it finishes, so concurrent tasks never scramble each other's lines. The block guarantee is about stdout; the run summary and the live status line are stderr commentary, so redirecting stdout captures task output alone.
Failing a task¶
A task succeeds unless it says otherwise. Four ways to say otherwise, most to least deliberate:
fail("reason")— the blessed way to stop with an explanation. The reason prints on the failure line and lands in the--jsonerrorfield, verbatim;fail("…", code=3)picks the exit code too. It is a function, not araise, so it stays clean under a strict linter (flake8-errmsg'sEM101, tryceratops'TRY003flag a string literal in araise), the same reasonsys.exit()andpytest.fail()are functions.return N— a bare non-zero exit code, no message.return 0(or falling off the end) is success.sys.exit("reason")/sys.exit(2)— the stdlib idiom, honoured: a string reason surfaces likefail()'s, an int is the code.- raise any exception — a crash. Its type and message show
(
RuntimeError: …), signalling a bug rather than a chosen stop; the exit code is 1. Anything leaving the body counts,BaseExceptionincluded, with two reserved:KeyboardInterruptandGeneratorExitare the process being stopped and a frame being torn down, not the task failing, so they end the run rather than one task.
from footman import task, fail
def open_pr() -> bool: ... # your own lookup
@task
def release(armed: bool = False):
if not open_pr():
fail("no open PR for setup — run `fm create repo` first")
...
A run() command that exits non-zero raises RunFailed on your behalf (unless
nofail=True), so fm mirrors the command's own code and you rarely raise
that one yourself. To catch a deliberate fail(), except footman.Failed:.
When a task fails: fail-fast & keep-going¶
A run is fail-fast by default: the first failure stops it. New tasks don't
start, and the sibling subprocesses still running are terminated: the whole
tree, each child and its own children, so a tool's workers (pytest-xdist,
make -j, a script's background jobs) die with it rather than orphaning. So a
doomed run dies at once instead of waiting out a long test suite. The kill is
SIGTERM, escalating to SIGKILL after a short grace if a tool ignores it. A task
cut off this way reports as cancelled, not failed, and the exit code is the
genuine failure's, never a kill signal. Ctrl-C reaps in-flight trees the same
way.
--keep-going/-k runs every independent branch regardless, so you see every
failure in one pass. --fail-fast forces the default back when a task declares
otherwise. Which wins is three-state: command line > declared > built-in.
fm checkkeeps going, by its own declaration.fm --fail-fast checkoverrides it for this run.- A task that declares nothing gets the built-in fail-fast.
The policy is scoped per subtree, not run-wide. A keep-going gate keeps its
own prerequisites going with it, while an independent task in the same run keeps
its own policy, so fm check deploy, with check keep-going and deploy
fail-fast, surfaces every check failure and still bails deploy on the first
one. A command-line -k/--fail-fast overrides every scope at once; a task's
own (or .opts()-set) policy always wins over one inherited from a gate above
it, so an explicit fail-fast prerequisite stays a fail-fast boundary. The kill is
scoped too: a failure reaps the fail-fast subprocess trees still in flight but
leaves a keep-going task's child running.
Three escape hatches for the kill:
@task(atomic=True)opts a task's subprocesses out: they run to completion, so a formatter rewriting a file can't be truncated mid-write. The opt-out is total on purpose: a stop signal sent to footman itself (timeout,docker stop, a CI cancel) ends the run and leaves an atomic child to finish on its own — it is unregistered, so nothing footman does on the way out can truncate the write the flag protects.Ctrl-Cat a terminal reaches it anyway, because the kernel signals the whole foreground group directly; a supervisor that must bound an atomic child does it with its own escalation.- An
@task(interactive=True)task owns the real terminal, so its subprocess stays attached to it and isn't group-isolated, so it keeps its controlling tty and its ownCtrl-C. - An in-process tool call (a toolroom handle on its in-process path) has no subprocess to signal, so it always finishes on its own.
Override a task's options per use: .opts()¶
keep_going, atomic, and the rest are set on the @task decorator, once. When
one use wants a different policy, .opts() overrides it there, without
touching the registered task:
from footman import Forward
@task(pre=[fmt.opts(atomic=True), lint]) # protect fmt's writes here, not everywhere
def check(fix: Forward[bool] = False): ...
.opts() returns the same task with the options overridden for that use only,
whether a pre=/post= target or a body call, and reads everywhere a bare
task does: same name, same signature, same call. It takes the policy options
keep_going, atomic, interactive, progress, confirm, infinite,
shared, cwd, rel, lanes, serial, and exclusive.
It takes policy, not parameters. A task's own arguments go in the call; the
options ride beside it, as in deploy.opts(atomic=True)("prod"), the same split
toolroom handles draw with their .opts(). Passing a task parameter to .opts() is a
taught error. A runnable group has .opts() too, riding its default action:
pre=[lint.opts(keep_going=True)] scopes keep-going to that prerequisite's
subtree (see per-subtree scoping above).
Deduplication keys on (task, options). Same policy: one node, exactly as
a shared bare prerequisite runs once. Different policy: a different run, so
both appear in the graph. An empty .opts() is just the bare task, and
options must be hashable values.
When something else asks the run to stop¶
A supervisor stops a program with a signal, and the senders are everyday:
timeout, docker stop, kill, a cancelled CI job, systemd, Kubernetes.
footman takes one as a stop the way it takes Ctrl-C: pending tasks are
cancelled, the subprocess trees still in flight are reaped (they are
group-isolated, so the signal never reached them on its own), stderr says
terminated, and a --json run still gets its one document. The exit code is
143, or 129 for SIGHUP; on Windows SIGBREAK — what a cancelled job
sends — means the same as SIGTERM and exits 143 too.
The stop is immediate: the grace belongs to whoever asked for it, so nothing
waits for work in flight. Reaping sends SIGTERM to each child's group and
insists with SIGKILL two seconds later, comfortably inside docker stop's
ten seconds and Kubernetes' thirty. SIGKILL itself — and taskkill /F on
Windows — cannot be caught by any program in any language; a run ended that way
leaves no receipt, and nothing footman could do would change that.
Interactive input¶
One parallelism consequence belongs here: a run that contains an
@task(interactive=True) task goes fully sequential, because that task owns the
real terminal, so it can't share it with parallel siblings, and the live status
line steps aside so its repaints can't scribble over a prompt. The three ways to
ask the person at the keyboard (ask() for a value, @task(confirm=…) for a
gate, and interactive=True for a mid-task wizard, all CI-safe by construction)
have their own page: Asking for input.
Dependencies with pre / post¶
Declare prerequisites and follow-ups on the task; footman schedules them (a prerequisite mounted in twice runs once) and skips a task whose prerequisite failed:
@task(pre=[fmt, lint, typecheck, test]) # all four run before check
def check(): ...
@task(post=[notify]) # notify runs after deploy succeeds
def deploy(): ...
check's four prerequisites have no edges between them, so footman runs all
four at once and only starts check when the last finishes:
graph LR
fmt --> check
lint --> check
typecheck --> check
test --> check
This is the declared graph: static, so --dry-run and completion show it
without running anything, and deduped by identity. A cycle in it is a taught
error naming the loop. A prerequisite is named by reference, so it runs with its
defaults: a task used as a prerequisite needs every parameter defaulted (a
required one errors with missing required positional(s)). To run a prerequisite
with specific arguments, name it in the chain: fm build --release deploy runs
build --release once, and deploy's pre=[build] waits on that same run.
Forward a value to what a task dispatches¶
Running defaulted is a floor, not a ceiling. Mark a parameter forward and
its value threads to every task this one dispatches (its pre/post
prerequisites and a runnable group's members) that declares
a parameter of the same name:
from typing import Annotated
from footman import task
from footman.params import forward
@task(pre=[fmt, lint, test])
def check(fix: Annotated[bool, forward] = False):
"fm check --fix reaches fmt & lint; test (no `fix`) runs defaulted."
Forward[bool] is the shorthand (Forward[T] ≡ Annotated[T, forward], like
Many[T]). The rules:
- Partial reach. Only tasks that declare the parameter receive it; the rest
run on their own defaults, so
check --fixfixes what's fixable and lints the rest. - It chains. A callee that re-declares
forwardpasses the value on, so it reaches a group's members through its default. - Overrides a default, never rescues a required one. A prerequisite stays runnable on its own; forwarding only changes a value that already has a default.
- Different values are different work, not a conflict. Two tasks forwarding different values to one shared prerequisite each get their own run of it — the same identity rule as every other request: resolve to the same arguments and you share, resolve to different arguments and you are asking for different work. Never a silent last-wins, and never a refusal.
Forwarding threads values, not graph structure, so --dry-run and completion
are unchanged. The explicit hand-forwarding of
inherited()
stays for the override case: calling a task you shadow and changing what it
gets.
Fan out from inside a task¶
parallel() runs tasks and steps concurrently, waits, and fails if any
fail. It honours the same -s and -j as the scheduler (one worker under
-s), so concurrency stays controlled in one place:
from footman import task, parallel, step
def clean(): ...
@task
def check():
parallel(lint, typecheck, test, step(clean)())
Three things can go in:
| you pass | it runs as | reported as |
|---|---|---|
lint — a task |
a full request | its own row, shares, hooks fire |
convert(images) — a built step item |
the step, pumped in a child | its own receipt, reviewable |
clean — a zero-argument step maker |
built here, then the same | same |
Nothing anonymous runs. Footman only schedules, records, and safely
cancels work it owns, and a bare callable is a stranger, with no name for
the report, no place in the plan and no way to stop it cleanly, so a lambda,
a functools.partial, or a plain function is a taught error naming the
one-word fix: step(fn)(…) lifts it, and the lift buys a receipt. A task
with arguments belongs in the block form below, where owned calls carry
them naturally. Counting is not affected either way: one piece of work is
one unit whichever way you wrote it.
The block form, when you want the values¶
Passing arguments through built step items works for two or three; past that, writing the calls plainly reads better, and the block form is the only one that hands the return values back:
@task
def release():
with parallel() as p:
build("web")
build("api")
web, api = p.results
publish(web, api)
Inside the block a task call is queued, not run, so it has no value
there, and using one is a taught error rather than a silent None. Everything
runs when the block ends, under exactly the rules a call has anywhere else:
own result row, sharing, hooks, -s/-j. p.results is in the order you
wrote them; p itself is still the list of exit codes parallel() returns.
Footman only owns its own __call__, so a call to something that is not
a task runs where it stands rather than joining the fan-out. A lifted step
joins through p(item), and its value lands in results in written
order:
Building an item runs nothing, so an item built inside the block and
never handed to it would be work that silently doesn't happen, the
classic forgotten p(...). The block refuses instead: it raises before
running anything, naming the dead items, so the mistake costs one taught
error rather than a missing step nobody notices.
The one other difference from a plain call: a queued failure surfaces when the block ends, not at the line that queued it.
Unlike pre/post, a parallel() fan-out is in-body, so footman can't see
it without running the task. That is the trade: declared prerequisites are
static and show up in --dry-run; an in-body fan-out is dynamic, since its
shape can depend on a run()'s output, but opaque to the planner, which
stops at the task body. Reach for declared prerequisites when you want the
plan to see the work, and parallel() when the fan-out has to be
computed at run time.
Passing data between tasks
Result data flows within a task, where run() hands back a Result (the
exit code, which the value is, plus .stdout/.stderr) and a called
function its return, and out of a parallel() fan-out through its block form
(p.results), or through a shared closure the calls write to (they run
in-process, so a captured list just works). parallel(*calls) itself
returns exit codes, not values, and the declared graph carries no data
between tasks: pre/post are ordering, not a pipe.
Steps of your own¶
run() makes a step out of a command, and step() makes one out of your
own code: lifted functions, recorded blocks, generator checkpoints, each
earning a real receipt. The full contract lives on
The execution model.
Runnable groups¶
A group is a namespace: fm lint.markdown runs a task under lint, but bare
fm lint is an error. Give the group a default action with @group.default
and the bare form runs, while the members stay addressable:
from footman import group, run, task
from footman.params import Forward
from toolroom import ruff, markdownlint, cspell
lint = group("lint")
@lint.task
def python(fix: bool = False): ruff("check", "src", fix=fix)
@lint.task
def markdown(fix: bool = False): markdownlint("**/*.md", fix=fix)
@lint.task
def spelling(): cspell("lint", "**/*") # no --fix
@lint.default
def lint_all(fix: Forward[bool] = False):
"Lint everything; --fix reaches the members that support it."
fm lintfans out every member task;fm lint --fixfixes what's fixable and lints the rest (theforwardmarker carries--fixto the members that take it; see above).fm lint.markdown/fm lint.markdown --fixruns one member.- The default's signature is the group's whole CLI surface, positionals
included:
fm lint src/handssrc/to the default the way any task takes an argument. There is no ambiguity to guard against, since a nested task always keeps its own dotted address, so a bare word after the group is the default's value. When a value happens to equal (or nearly equal) a child's name, the run carries a one-line stderr note pointing at the dotted spelling (note: ran lint's default with 'markdown'; for the subtask: fm lint.markdown), and a path-shaped value (fm lint ./markdown) keeps quiet. - The default registers as the child task named
default, a fixed, well-known name, so the decorator you wrote is the address you type:@lint.default↔fm lint.default. Barefm lintstays the idiomatic spelling, the wayGET /serves/index.html, and like the index document, the default has a well-known name, not whatever the author called the function (which stays private; renaming it is free). The name is the mechanism:@group.defaultis sugar, and any task nameddefault(@task(name="default"), or one arriving throughinclude()) is its group's default through the same validations. So naming a taskdefaultmeans something, exactly liketasks.pyitself does. A group nameddefaultis a load-time error (a group-typed default would make barefm lintresolve to another bare group, which could point at another still, a regress with no floor). - An empty body fans out the group's own tasks; a non-empty body is the
escape hatch where you write the fan-out yourself. Empty means a body of
only a docstring,
pass, or...— the three stub spellings all read the same way, so the idiom you reach for first is the one that works. - On an empty-body default, mark a parameter
Forwardif you want it to reach the members. The default has no body, so a plain parameter binds to it and goes nowhere:fix: boolaccepts--fixand then nothing happens with it.fix: Forward[bool]threads the value to every member that declaresfix. (A parameter the default does use in a custom body needs noForward; the marker is only for values that must travel onward.) - The default takes the same policy options as
@task:@lint.default(pre=[...], keep_going=True, confirm="…", atomic=True)and the rest, with noname(the group already names it).interactive=Trueneeds a real body: an empty-body default fans out in parallel, so there is no single body to own the terminal, and asking for one is a load-time error.
The group tab-completes (fm lint <Tab> offers --fix and the surface names)
and fm --help lint renders it as a command in its own right. And it composes: a
check gate reaches its members through the group with one forwarded flag —
@task(pre=[format, lint, test]) def check(fix: Forward[bool]), and
fm check --fix threads all the way down.
A runnable group is also callable from a task body, the way a task is:
@task
def check(fix: bool = False):
lint(fix=fix) # runs lint's default — fans out, or runs its body
if fix:
run("./stamp-version.sh")
lint(fix=fix) runs the default's action synchronously and in order — its body
as written, or, for an empty-body default, the group's own tasks, each handed
the arguments it declares. Like every body call it forwards arguments explicitly
and runs to completion before the next statement; reach for pre=, a chain, or
parallel() when you want prerequisites or concurrency. The declarative
pre=[lint] form above is usually cleaner — a body call is for when you need
real control flow.
How often a call runs, what an omitted parameter binds to, and when work is
shared — the identity rules behind everything above — are the province of
The execution model, together with step() and tasks
made mid-run.
Progress & the live status line¶
Both parallel engines — the scheduler and a parallel() inside a task body —
feed one live status line, so a chain and an in-body fan-out present
identically: a real progress bar once footman has learned the run's timing, a
bouncing pulse until then. A task can also report its own progress
(track() / progress()) and the bar fills from that instead of an estimate.
The whole story — the status line, the timing history, and the off switches —
is on Progress & timing.
JSON for CI and agents¶
Pass --json and stdout becomes exactly one JSON document: per-task results
(with captured output, structured run() steps, and the task's own
returned data), or an error envelope when footman refuses the line. The
whole contract lives on JSON output; the CI recipes on
CI & automation.