Skip to content

API reference

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

Defining tasks

footman.registry.task module-attribute

task = root.task

footman.registry.group module-attribute

group = root.group

footman.registry.Group

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

__call__

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

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

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

default

default(fn: Callable[_P, _R_co]) -> TaskFn[_P, _R_co]
default(
    fn: None = None,
    *,
    pre: Sequence[Task] = (),
    post: Sequence[Task] = (),
    progress: bool = True,
    infinite: bool = False,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
) -> 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,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
) -> 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 @taskpre/post, progress, infinite, confirm, interactive, keep_going, atomic — with no name (the group already names it).

The function's signature is the group's option surface, so it takes flags/options only: a positional parameter is rejected at load time, because a bare word after a group names a child, not a value. Model a positional action as a task, or take free arguments via -- passthrough.

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.

finalize

finalize(fn: Finalizer) -> Finalizer

Register a hook that edits the discovered command tree in place.

Every @finalize function runs once, after the whole tasks.py cascade is assembled but before dispatch, handed a Tasks view of the merged tree. Its edits are part of the plan, never a runtime surprise: an added pre runs and shows in --dry-run, a disabled task drops from listings and completion. It is footman's collection_modifyitems.

Finalizers 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 itself uses. Read and edit each task through the TaskView surface, never the private _footman_* attributes.

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

group

group(name: str, help: str = '') -> Group

Create and register a nested command group, returning it.

opts

opts(**overrides: Any) -> _Opted

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).

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,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
) -> 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,
    confirm: str = "",
    interactive: bool = False,
    keep_going: bool | None = None,
    atomic: bool = False,
) -> Task | Callable[[Task], Task]

Register a function as a task.

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

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

Availability gating lives in the @requires decorators — stack @requires, @requires_dep, @requires_tool, or @requires_env above @task to list a task as unavailable (with a reason) where it can't run, rather than hide it. To hide a task entirely, use plain Python: if sys.platform == "darwin": @task ...

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.

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.

Availability gates

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

footman.registry.requires

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

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

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

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

footman.registry.requires_dep

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

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.registry.requires_tool

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

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

footman.registry.requires_env

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

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

Running commands

footman.context.run

run(
    cmd: str | list[str] | Callable[..., Any],
    *args: Any,
    nofail: bool = False,
    silent: bool = False,
    capture: bool = True,
    title: str | None = None,
    env: dict[str, str] | None = None,
    cwd: 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).

_show is an internal channel from the tools.* 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.context.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.

code property

code: int

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

command instance-attribute

command: str

The command line that ran, normalised for reading — options in separated form, values shell-quoted. What recording() asserts against, and what the terminal shows.

duration instance-attribute

duration: float

Wall-clock seconds the step took.

ok property

ok: bool

Whether the command succeeded (exit code 0).

output property

output: str

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

raw instance-attribute

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.

stderr instance-attribute

stderr: str

Captured standard error; empty when the step streamed instead.

stdout instance-attribute

stdout: str

Captured standard output; empty when the step streamed instead.

footman.context.parallel

parallel(
    *calls: Callable[[], Any], keep_going: bool = False
) -> list[int]

Run task calls / thunks concurrently; wait; fail if any fail.

Each call 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)) or thunks for arguments (parallel(lambda: build("web"), lambda: build("api"))).

footman.context.passthrough

passthrough() -> list[str]

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

footman.context.inherited

inherited() -> Any

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

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

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

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

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

footman.context.progress

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

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

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

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

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

footman.context.track

track(iterable: Any, total: int | None = None) -> Any

Iterate iterable, reporting progress as it goes.

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

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

Asking the person running it

footman.context.prompt

prompt(
    message: str = "",
    *,
    default: str | None = None,
    secret: bool = False,
) -> str

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 when it would otherwise block, it returns default if given, else raises — an unattended run fails loudly. For a value a script must supply, take it as a task parameter (a CLI flag) instead.

footman.context.confirm

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

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

footman.context.select

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 it returns default, or raises if none was given.

Fetching

footman._fetch.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…")
    tools.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._fetch.FetchError

Bases: Exception

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

footman.context.Context dataclass

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

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.

cwd class-attribute instance-attribute

cwd: Path | None = None

Where run() executes: the folder that defined the task; None means the process cwd (plain calls outside a footman run).

dry_run class-attribute instance-attribute

dry_run: bool = False

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

env class-attribute instance-attribute

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

Extra environment variables overlaid on every run() subprocess.

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.

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.

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.

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.

passthrough class-attribute instance-attribute

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

Everything after -- on the command line, verbatim.

prog class-attribute instance-attribute

prog: str = 'fm'

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

quiet class-attribute instance-attribute

quiet: bool = False

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

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.

shell_default class-attribute instance-attribute

shell_default: str = ''

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

sink class-attribute instance-attribute

sink: TextIO | None = None

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

steps class-attribute instance-attribute

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

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

task class-attribute instance-attribute

task: str = ''

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

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.

verbose class-attribute instance-attribute

verbose: bool = False

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

footman.context.RunFailed

Bases: Exception

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

footman.context.fail

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

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

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

footman.context.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.

Composing tasks

footman.compose.include

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

Graft another module's tasks into the current tree (or into a group).

include("shared_tasks")                          # everything, at root
include("shared_tasks", only=["lint", "fmt"])    # cherry-pick by CLI name
include("mkdocs_helpers.tasks", into=docs)       # namespace: fm docs …
include(plugin("mkdocs"), only=["build"])        # from an entry point

source is a dotted module name, an imported module, or a Group. The provider imports under a registry capture, so its decorators can't leak into your tree. Collisions are loud (RegistrationError) unless override=True; unknown only=/exclude= names are errors too (typo protection). Included tasks run from your file's directory — a shared lint task lints this project. Returns the group it grafted into.

footman.compose.plugin

plugin(name: str) -> Group

The Group a package advertises under the footman.tasks entry point.

# the plugin package's pyproject.toml
[project.entry-points."footman.tasks"]
mkdocs = "footman_mkdocs:tasks"

Raises RegistrationError naming the installed entry points when name isn't one of them — a configured-but-missing plugin should read as the typo or missing install it is.

footman.registry.capture

capture() -> Iterator[Group]

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

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

Editing the discovered tree

@finalize runs a hook over the fully-merged cascade at discovery — see Composing tasks. The hook is handed a Tasks view; iterating or indexing it yields a TaskView that reads and edits one task.

footman.registry.finalize module-attribute

finalize = root.finalize

footman.registry.Tasks

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

get

get(name: str) -> TaskView | None

The task named name (command-line spelling), or None.

footman.registry.TaskView

A finalizer'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.

atomic property

atomic: bool

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

confirm property

confirm: str

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

defining_dir property

defining_dir: str | None

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

disabled property

disabled: str | None

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

fn instance-attribute

fn = fn

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

group instance-attribute

group = group

The group this task lives in, or None for a top-level task — its .name is the group's command-line spelling (e.g. docs). Use it to disambiguate two tasks that share a leaf name across groups.

infinite property

infinite: bool

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

interactive property

interactive: bool

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

keep_going property

keep_going: bool | None

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

name instance-attribute

name = name

The task's command-line name, e.g. deploy-web.

post property

post: tuple[Task, ...]

The tasks that run after this one.

pre property

pre: tuple[Task, ...]

The prerequisites that run before this task.

shadow_chain property

shadow_chain: tuple[Task, ...]

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

shadowed property

shadowed: Task | None

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

source_file property

source_file: str | None

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

timed property

timed: bool

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

add_post

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

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

add_pre

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

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

disable

disable(reason: str) -> None

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

set_opts

set_opts(**overrides: Any) -> None

Set orchestration options on the task permanently, for every use — the finalize-time counterpart to a per-use .opts(). Takes the same options (keep_going, atomic, interactive, progress, confirm, infinite) 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 finalizer 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.App

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

run

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

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

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

footman.app.Brand dataclass

The names a CLI shows the user.

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).

Typed-parameter helpers

footman.params.Many module-attribute

Many = list

footman.params.nosplit module-attribute

nosplit = _NoSplitMarker()

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

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

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

footman.params.suggest

Attach a dynamic completer to a parameter, via Annotated:

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

list_projects() -> list[str] returns the candidate values. footman runs it on the execution path — refreshing a cache the completion hot path serves — and, when strict (the default), validates the supplied value against a fresh call. A bare callable in Annotated is treated as suggest(fn).

footman.params.exists module-attribute

exists = _PathRequirement('exists', 'exists')

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

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

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

footman.params.isfile module-attribute

isfile = _PathRequirement('file', 'isfile')

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

footman.params.isdir module-attribute

isdir = _PathRequirement('dir', 'isdir')

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

footman.params.between

Inclusive numeric bounds for an int/float parameter:

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

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

footman.params.env

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

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

Precedence is CLI > $DEPLOY_ENV > default. The env value flows through the same coercion and validation as a command-line token. Only valid on a parameter with a default — an env fallback makes it optional, so it needs somewhere to fall.

footman.params.check

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

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

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

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

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

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

footman.params.doc

Help text for one parameter, via Annotated:

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

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

footman.params.ask

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

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

A required (defaultless) parameter marked ask() is prompted for when it is not given on the command line 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 stays CLI > env > default > prompt, so ask() only prompts a parameter with no default (a default is the answer). Off a terminal, under --no-input, or in --json it errors naming the flag rather than hanging. secret=True hides the input (getpass); prompt="…" overrides the question text.

Docstrings

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

footman.docstrings.parse

parse(text: str | None) -> Docstring

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

footman.docstrings.Docstring dataclass

The parsed pieces of one docstring.

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

long class-attribute instance-attribute

long: str = ''

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

params class-attribute instance-attribute

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

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

summary class-attribute instance-attribute

summary: str = ''

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

Markdown export

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

footman.markdown.render_page

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

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

footman.markdown.render_site

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

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

Testing

footman.context.use_context

use_context(
    ctx: Context | None = None,
) -> Iterator[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 tools.* inside the block read this context instead of a fresh default. footman.testing.recording builds on it.

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

footman.testing.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,
) -> 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. 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.testing.recording

recording(**overrides: Any) -> Iterator[list[Result]]

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

Yields the live step list; each run()/tools.* call inside the block appends a Result instead of executing. In-process callables passed to run() are skipped too — that is the point, but worth knowing. Keyword overrides go to the underlying Context (e.g. env={...}).