JSON output¶
--json makes one promise: stdout is exactly one JSON document, whatever
happened. A run, a refusal, a listing, a dry-run, a --version: if
--json is on the line, the answer is a single envelope you can hand
straight to jq, a CI dashboard, or an agent. Everything a task (or
anything it spawned) writes is captured into the payload, so stdout never
mixes prose with data.
This page is the whole contract. Every other page that mentions --json
links here.
The items envelope¶
A run prints one flat list of records, in the order the work was
created, tasks and their steps alike, every item carrying its
request address (address): the path of requests that led to it, with an ordinal once a
label repeats (check/git, check/git#2). An address prefix names a
subtree, so the tree is always recoverable; it just never makes a reader
recurse to ask a flat question. Flat is affordable because addresses are
deterministic: parentage lives in the name, so the tree derives from the
list instead of nesting inside it. A row has "task"; a step has
"command", and that is the kind test:
$ fm --json check
{
"schema": 1,
"total_ms": 5412.7,
"items": [
{
"task": "lint",
"address": "lint",
"ok": true,
"code": 0,
"duration_ms": 812.4,
"output": "...",
"error": null,
"returned": {"files": 42}
},
{
"command": "ruff check src tests",
"address": "lint/ruff-check",
"code": 0,
"duration_ms": 790.1,
"stdout": "...",
"stderr": "",
"audit": [["body", "ruff check src tests", 0]],
"failed_at": null
}
]
}
Top-level, total_ms is wall-clock for the whole run: the human summary's
took line, as a number.
A task row carries task (the dotted name), its request address
(address), ok, code, duration_ms, output (what the body itself
printed; footman's own receipt lines stay out, since every step already
has a row of its own below), error (null, or the exception as a
string), and, when the task returns a value, returned. An exception
footman did not expect — anything but a command exiting non-zero, a
timeout, or a deliberate fail() — adds traceback, the stack with
footman's own frames taken out, so a log carries the place a bug happened
without anyone re-running with -v. Its steps follow it in the list, one item per
run(), tool, or step() call: command, the request
address, code, duration_ms, split stdout/stderr, the audit (the
verdict's provenance: one [moment, actor, code] entry per actor that
touched it), and failed_at, the moment a failure came from (null on
success, since a red tool reviewed green is green). The same name can
appear twice, as distinct work with distinct request addresses, so looking
a task up by name returns a list.
state is the one word for what happened (ok, failed, cancelled,
shared, skipped) and it is an open set: tolerate values you don't
know. The remaining fields appear when they have something to say:
blocked_by— on askippedrow, what prevented it; the row seats directly after its cause, so the envelope accounts for every node the plan had, not only the ones that ran. Asharedrow carries no blame: nothing blocked it, it was answered, and it seats at the moment it was.queued_ms— how long a node sat ready after its last prerequisite finished, waiting for a worker. Launch latency, never part ofduration_ms.lane_waits— when a lane claim actually waited:[{"lane": "cspell-cache", "waited_ms": 812.4}, …], labelled with the claim's own name (a named lane,serial,exclusive,console). Which lane serialised what, and for how long, answerable from the report without re-running with eyes on the terminal; a claim granted on arrival records nothing.thread/thread_id— on a row that executed, the worker's stable name and OS thread id: the correlation keys a profiler's timeline uses. While a task runs, its worker wears the task's name (fm:build, badged[serial]/[exclusive]under a lane hold), so a sampling profiler reads as tasks rather than pool threads.after— on a row with prerequisites, the addresses it waited for: the plan's edges.notes— what the run had to say about how this task treated the process (Notes & levels):[{"kind": "popen-inject:git", "level": "warning", "site": "tasks.py:50", "text": "…"}, …]. Every fired note rides here whatever its level —traceincluded, since the machine channel ignores the terminal's print gating — which is what lets CI assert on notes directly, beyond the exit code anerrorclassification already moves.sections— a task's own recorded timing (see Profiling a run):[{"name": "resolve", "at_ms": 12.5, "duration_ms": 830.2}, …], each placed relative to the task's start.streamnames a parallel timeline when the record used one, and a negativeat_msis legal there, since a retroactive window may predate the task. A step's entry carries the same placement asat_ms, so a reader can rebuild the timeline the profile plugin draws.
returned: a task's own data¶
Return a value from a task and it lands in the task's entry. No decorator,
no context API: the return statement is the whole feature.
from pathlib import Path
from footman import task
@task
def coverage() -> dict:
"Measure coverage."
...
return {"percent": 94.2, "failed": [], "report": Path("htmlcov/index.html")}
$ fm --json coverage | jq '.items[0].returned'
{"percent": 94.2, "failed": [], "report": "htmlcov/index.html"}
The rules, all of them:
None(the usual case) omits the key entirely.- An
intreturn keeps its long-standing meaning, the task's exit code, never data. Return{"count": 42}when you mean data, or declareStdout[int](below) when the number is the document. Bools are data. - The types footman coerces in serialise on the way out:
Path→ string,Enum→ its value,datetime/date/time→ ISO format,UUID→ string,Decimal→ string (precision kept), dataclasses → dicts, sets → sorted lists. Dicts, lists, strings, numbers, bools pass through as themselves. - Anything else is refused loudly but locally: the entry gets a
returned_errornote naming the type, stderr gets a warning, and the run's exit code stays the task's own, so a payload problem never turns a green build red, and never hides in silence either.
In tests, the same value is Runner.invoke(...).results[n].returned; see
Testing your tasks. Without --json, return values are
ignored, unless the task claims stdout, below.
The declared shape: returned_schema¶
The return annotation is the output contract, the same way a typed signature is the input contract. No decorator, no schema language:
from dataclasses import dataclass
from footman import task
@dataclass
class Affected:
tasks: list[str]
reason: str
since: str
@task
def affected() -> Affected:
"""The tasks a change reaches.
Returns:
Which tasks the change reaches, and why.
"""
...
A declaring task's entry carries returned_schema beside returned, so
data and how to read it arrive in one call, in footman's own compact shape (the
same vocabulary the catalog speaks; --describe, below, renders standard
JSON Schema):
$ fm --json affected | jq '.items[0].returned_schema'
{
"kind": "object",
"name": "Affected",
"fields": {
"tasks": {"kind": "list", "items": {"kind": "str"}},
"reason": {"kind": "str"},
"since": {"kind": "str"}
}
}
The describable set is exactly the serialisable set above, recursively:
dataclasses (nested), TypedDict (NotRequired fields marked),
NamedTuple (described as a row — it serialises as an array),
list/tuple[T, ...]/set/dict[str, T], Path, Enum and Literal
as choices, datetime/date/time, UUID, Decimal, and T | None as
nullable. dict[str, Any] describes as an object with no field claims.
An annotation outside the set (a wider union, an exotic generic, a
broken name) declares nothing: the task runs and serialises exactly as it
always did. "Describable" is a subset of "returnable",
never a new gate. Bare int stays the exit-code channel, so it declares
nothing either. Stdout[T] describes T, one declaration for two doors —
with the one exception the exit-code channel forces: a bare int return
means "exit code", so Stdout[int]'s number is the document and the
annotation declares nothing about it.
Declaring buys drift protection on both sides of a repo boundary:
- Producer side. Every reported value with a declared shape is walked
against it at the boundary. A break (a renamed key, a wrong type, an
undeclared extra) warns on stderr in every mode and rides the entry as
a
returned_mismatchnote naming the first broken path ("returned.tasks[1]: expected text, got int"). Likereturned_error, it is loud but local: the value still serialises and the exit code never moves. The rename goes red in your own gate, before any consumer integrates. - Consumer side. Check the
--describeoutput into the consuming repo and diff it in CI, so a contract change becomes a visible diff at integration time, replacing the hand-written key-pinning test you never write again.
The docstring's Returns: section (Google, NumPy, or Sphinx style) rides
beside the schema as returned_doc in the manifest: --help shows it on
a returns: line, and task docs pages render the fields.
The contract without a run: --describe¶
fm --describe prints the whole tree's input+output API as one JSON
document, without running anything: every task's parameters (the same
specs the catalog bakes) and its declared return shape rendered as JSON
Schema (2020-12 vocabulary), with the Returns: prose beside it.
fm --describe=docs.build answers for one task, and a group address
answers for its whole subtree, by the same prefix-names-a-subtree rule
addresses speak everywhere, so fm --describe=docs is every task under
docs., nested groups included. A runnable group's default rides in that
list under its real group.default address, which also answers alone.
There is no wildcard syntax and none is needed: the group address is
the pattern, and jq filters the rest. Plain JSON on stdout either way,
like --where: the output already is the machine format.
$ fm --describe=affected
{
"schema": 1,
"tasks": [
{
"task": "affected",
"help": "The tasks a change reaches.",
"params": [],
"returns": {
"schema": {
"title": "Affected",
"type": "object",
"properties": {
"tasks": {"type": "array", "items": {"type": "string"}},
"reason": {"type": "string"},
"since": {"type": "string"}
},
"additionalProperties": false,
"required": ["tasks", "reason", "since"]
},
"doc": "Which tasks the change reaches, and why."
}
}
]
}
The document is built for pinning: tasks sort by address (invariant to
declaration order), hidden tasks are included and marked ("hidden":
true, since a machine is exactly who calls them), availability is left out
(it varies per machine), and a dynamic completer's values are left out
of the params (runtime data, not contract). The rendered
schema is itself contract, not presentation: snapshots pin it, so a
rendering change is envelope-grade and belongs in the changelog.
The document on stdout: Stdout[T]¶
A task can declare that its return value is the document on stdout, in the signature, where the rest of its contract lives:
from footman import Stdout, task
@task
def status() -> Stdout[dict]:
"Where the repo stands."
return {"branch": "main", "dirty": False}
No flag at any call site: fm status is a filter the way sort and jq
are filters. The return type decides the bytes, mirroring stdin:
Stdout[str] emits the string verbatim plus a trailing newline,
Stdout[bytes] writes raw bytes, and anything structured is JSON,
pretty-printed at a terminal, one compact line into a pipe, encoded by the
same rules as returned above (dataclasses to dicts, Secret redacted).
The rules, all of them:
- An explicit
--jsonwins. The envelope keeps stdout and the document rides insideitems[].returned, where a return value already lives. - Only the addressed task emits. A declaring task reached as a
pre=/post=dependency or a group fan-out member is suppressed, not refused, so composing a filter into a bigger task stays legal. - Two declaring tasks in one chain is a refusal, at plan time: "whose document?" has no answer worth guessing.
Nonereturned means empty stdout, exit 0. Nothing to say, said nothing.Stdout[dict | None]is the house spelling for that signature.- Declaring
Stdout[int]makes the number the document. The bare-> intexit-code channel applies only to undeclared returns, so a counting filter is possible. - A failed task emits nothing; the exit code talks.
- A text or JSON document is UTF-8, whatever the console's encoding —
the payload is footman's to define, so an accent or a CJK glyph reaches
the pipe intact.
Stdout[bytes]is the raw channel and is untouched. - Everything that is not the document goes to stderr: a declaring
task's prints and
run()lines replay there, beside the summary, sofm status > out.jsoncaptures exactly the document. - A body call is unaffected:
status()from another task returns the value; stdout is a boundary concern.
Stdout[T] and interactive=True cannot both hold (an interactive task
owns the real terminal, uncaptured). That is a taught error at declaration
time, not a surprise in a pipeline.
Refusals¶
A line footman refuses (a typo'd task, a misplaced flag, a broken tasks
file, a bad --config, Ctrl-C, a supervisor's stop signal) emits an error
envelope, with the same taught message on stderr for humans:
$ fm --json chekc
{
"schema": 1,
"error": {
"code": 64,
"message": "no task named 'chekc' — did you mean 'check'? (know: docs, lint, test, check)"
},
"items": []
}
So a wrapper needs exactly one parser: error is null or absent when
things ran; present when footman refused.
The catalog: fm --json --list¶
The machine twin of --list/--tree (bare fm --json does the same): the
full task tree, every task and group with its parameters: kinds, types,
choices, bounds, env fallbacks, required-ness, and the one-line help:
$ fm --json --list
{
"schema": 1,
"tree": {
"help": "",
"tasks": {
"lint": {
"help": "Lint with ruff.",
"params": [{"name": "fix", "kind": "flag"}]
}
},
"groups": {
"docs": {"help": "Documentation", "tasks": {"serve": "..."}, "groups": {}}
}
}
}
Each parameter always has name and kind (flag | option | positional
| variadic | stdin, the last being a whole-document parameter with no
token spelling; see Pipelines), plus whichever apply:
required, default, computed (the default resolves live), choices,
accepts (input synonyms beyond the choices — an enum's aliases and value
faces), types, multiple, mapping, key_types/value_types/
value_choices (a dict parameter's two sides), nosplit, path, glob,
min/max, env, secret, hidden, stdin (how the value binds the
pipe), shape, group, dynamic, and doc, the author's
per-parameter help, whether from a
doc("…") marker or a parsed docstring. A task node carries
help (the docstring's first line) and, when the docstring has a body,
long.
This is one command's answer to "what can I run here?", the discovery
call for agents and tooling.
The shape a pipe expects¶
A parameter that reads a JSON document carries shape: the document's
structure as data, so a caller can build the JSON without knowing the
Python behind it.
{
"name": "cfg",
"kind": "option",
"stdin": "json",
"shape": {
"name": "Config",
"fields": [
{"name": "name", "types": ["str"], "required": true},
{"name": "port", "types": ["int"]},
{"name": "at", "shape": {"name": "Point", "fields": [
{"name": "x", "types": ["float"], "required": true},
{"name": "y", "types": ["float"], "required": true}
]}},
{"name": "tags", "many": "list", "types": ["str"]}
]
}
}
A field carries types when footman coerces it, choices when it is an
enum or a Literal, many when it holds a collection (the name of the
container), shape when it is itself a record, and required when it has
no default. A field with none of those is one footman does not coerce:
whatever JSON holds arrives as it is. A recursive shape, a field whose
type is the record it sits in, is emitted as a bare {"name": "…"},
since it appears in full higher up.
Every record is described the same way, whether it is a dataclass, a
NamedTuple, a TypedDict, or a class with an annotated __init__.
A shape whose fields are all scalars also has a command-line spelling, and
then the parameter is an ordinary option carrying a group — its
positional view: names, per-position types, min/max arity, and the
label help prints. Give it either way; the command line wins. A shape
holding a record or a collection has no command-line spelling, because no
token can say where the inner one ends, so its kind is stdin and the pipe
is the only channel.
The rehearsal: fm --json --dry-run¶
A dry-run answers in the same items envelope a real run does, because a dry-run is a run: bodies execute, and footman's own recorded work is faked into plan-line records (empty audit, zero duration). A typo'd chain still refuses with the error envelope and exit 64 before anything runs:
$ fm --json --dry-run release 1.2.0
{
"schema": 1,
"total_ms": 3.7,
"items": [
{"task": "release", "address": "release", "ok": true, "code": 0, ...},
{"command": "git tag v1.2.0", "address": "release/git-tag",
"code": 0, "duration_ms": 0.0, "audit": [], "failed_at": null, ...},
{"command": "git push origin v1.2.0", "address": "release/git-push",
"code": 0, "duration_ms": 0.0, "audit": [], "failed_at": null, ...}
]
}
--version¶
The two exceptions¶
--helpalways renders human text; its machine twin isfm --json --list. (A--helprefusal, a typo'd name, still emits the error envelope.)--where=TASKprints a barefile:line, already a machine format.
Exit codes¶
The process exit code tells the same story as the envelope:
| code | meaning |
|---|---|
| 0 | everything ran and succeeded |
| 1 | a task raised an exception |
| N | a task returned N / its run() command exited N — first failure wins |
| 64 | footman refused before or while binding: parse, tasks-file, config, availability |
| 129 | stopped by SIGHUP |
| 130 | interrupted (Ctrl-C) |
| 143 | stopped by a signal: SIGTERM, or SIGBREAK on Windows |
Exit 64 before anything runs is a feature in CI: a typo'd workflow fails in milliseconds with a taught message, not after twenty minutes of setup.
Recipes¶
A shape-check in CI, guarding .error too, because an empty items list
on a refusal would pass all(.ok) vacuously:
Mount one task's data out of a pipeline:
Pin a producer's contract from a consuming repo. The snapshot is the version pin, and a rename over there becomes a visible diff over here:
fm --describe > tests/producer-contract.json # once, checked in
fm --describe | diff tests/producer-contract.json - # in CI
Stability¶
The envelope is versioned: schema is 1, bumped only if a field ever has
to change meaning. Post-1.0, changes are additive only: parse what you
know, ignore what you don't, and pin schema == 1 if you're strict.
--dry-run's human output carries no such promise; its items
envelope does.