Typed signatures¶
footman reads your function signature and turns it into a CLI — the same idea typer popularised, applied to a task runner. Types are validated eagerly, at parse time, with taught error messages.
The core mapping¶
| Signature | CLI shape |
|---|---|
fix: bool = False |
flag --fix / --no-fix |
mode: str = "loose" |
option --mode VALUE |
mode: Literal["a", "b"] |
completable, eagerly-validated choices |
count: int = 100 |
typed option, validated at parse time |
paths: list[Path] = () |
repeatable or comma-separated (--paths a,b) |
env: dict[str, int] |
--env KEY=VAL pairs (repeatable or comma-separated) |
template: Path |
required positional (consumed by exact count) |
*cmd: str |
variadic trailing passthrough |
The rule behind the table: the default decides. A parameter with no
default is a required positional — a bare word on the line fills it
(fm greet Ada). A parameter with a default is an option you pass by
name (--mode loose), or, for a bool, a --flag/--no-flag switch. That is
the whole distinction: give a parameter a default and it moves from the
command line's positions to its flags. The container types layer arity on
top — list[T]/Many[T] take one-or-many (a positional one needs at least
one), and *args sweeps up the variadic tail — but the default is still what
sorts each parameter into a position or a flag.
One reserved parameter name: help
A parameter named help is the one name the signature can't turn into a
working option. It would map to --help — but --help (and -h) is
intercepted anywhere before -- and turns the whole line into a help
request that never executes anything, so the flag is shown, never bound to
your parameter. Every other global name is free to reuse: the rest
(--json, --version, -s, -j, …) must come before the first task, so
fm deploy --json binds --json to deploy, not to footman — only
--help/-h win wherever they land on the line. It's the single reserved
name, and the collision is the harmless kind (help prints instead of the
task running); rename the parameter — show_help, explain — to get a real
flag.
Unions and one-or-many values¶
A parameter can accept a union of types; footman validates the value against the
union and coerces it by specificity — the most specific member that accepts the
value wins (int → float → Path → str, with str as the universal
fallback):
Many[T] is exactly list[T] — a parameter that accepts one or more values and
is always a list, even for a single value (it reads more intentfully than a
bare list[T] at a positional). Required when positional, so at least one value
must be given:
from footman import Many
@task
def build(targets: Many[str]): ... # fm build web -> ["web"]
# fm build web api -> ["web", "api"]
Comma-splitting and nosplit¶
Every collection parameter (list or dict) splits a single token on commas by
default, on top of the repeatable form — so --tag a,b,c and
--tag a --tag b --tag c both work. Only , is a separator (no alternatives),
and it is shell-portable, including PowerShell:
When a value may itself contain a comma, mark the parameter nosplit: then only
the repeated flag adds items, and a comma stays literal.
from typing import Annotated
from footman import nosplit
@task
def notify(lines: Annotated[list[str], nosplit]): ...
# fm notify --lines "Smith, John" --lines "Doe, Jane" -> two names, commas kept
Dictionaries¶
dict[K, V] maps KEY=VALUE pairs, and it composes with the rest of the type
system — dict[str, int | str], and even dict[str, list[...]]:
Custom types¶
Any type with a typed constructor works — footman calls it. datetime uses
fromisoformat; everything else is constructed as T(value):
from uuid import UUID
from decimal import Decimal
from datetime import datetime
@task
def record(id: UUID, amount: Decimal, when: datetime): ...
Validation markers¶
Eager, taught validation is the whole pitch, so constraints ride in
Annotated — the same idiom as suggest and nosplit:
from pathlib import Path
from typing import Annotated
from footman import task, between, check, doc, env, isfile
@task
def deploy(
config: Annotated[Path, isfile], # must exist, be a file
jobs: Annotated[int, between(1, 32)] = 4, # inclusive bounds
target: Annotated[str, env("DEPLOY_ENV")] = "staging", # CLI > $DEPLOY_ENV > default
version: Annotated[str, check(semver)] = "0.0.0", # your own validator
force: Annotated[bool, doc("skip the health check")] = False, # help text
): ...
$ fm deploy missing.toml
fm: deploy: <config> must be an existing file (got 'missing.toml')
$ fm deploy app.toml --jobs 99
fm: deploy: --jobs must be between 1 and 32 (got '99')
$ DEPLOY_ENV=prod fm deploy app.toml # target == "prod"
- Paths —
exists,isfile,isdirrequire the value to name something real on disk; validated at parse time like a bad choice would be. - Bounds —
between(lo, hi)is inclusive; either end may beNone. A barerange(0, 8)also works for ints, with Python's half-open semantics (0through7; the end is excluded, exactly as in aforloop). - Env fallbacks —
env("VAR")fills an absent option from the environment; the value flows through the same coercion, bounds, and checks a command-line token would (just at binding time — the parser never sees the environment). Only valid on a parameter with a default, because a fallback needs somewhere to fall. - Custom validators —
check(fn)runs after coercion, per element for collections; raiseValueErrorwith a message written for the user. - Help text —
doc("…")puts one line of your own words on a parameter. It leads the option's line infm --help <task>, becomes the option's description in shells that render one (zsh, fish, nushell, PowerShell tooltips), and rides along in thefm --json --listcatalog. The task's own help stays the docstring's first line;docis for the parameters.
Terse aliases, and forwarding¶
A bare marker — one that takes no arguments — has a Name[T] shorthand, the
way Many[T] reads better than list[T]:
NoSplit[list[str]]≡Annotated[list[str], nosplit].Exists,IsFile,IsDir≡Annotated[Path, exists/isfile/isdir]— bare, no subscript, since the type is alwaysPath:def rm(target: Exists).Forward[T]≡Annotated[T, forward].
Arg-taking markers (suggest, between, env, check, doc, ask) keep the
full Annotated[...] form — their value can't ride in a type subscript.
The forward marker threads a parameter's value to the tasks a task dispatches —
its pre/post prerequisites and a runnable group's surfaces. It's an
orchestration tool, covered in
Chaining & parallelism.
Markers compose by listing them: Annotated[bool, ask("Fix?"), forward] both
prompts for the value and forwards it — one prompt at the top, the answer
flowing down.
Or just write a docstring¶
footman reads the parameter docs you already write — Google, NumPy, and
Sphinx styles, auto-detected per docstring. Everything a doc("…") marker
feeds (help lines, completion descriptions, the catalog) fills from the
docstring instead, the body between the summary and the section renders in
fm --help <task> as the task's long help, and an explicit doc("…")
always wins over a docstring entry for the same parameter:
A docstring entry that names no real parameter earns a UserWarning — the
same loudness a broken annotation gets. The parser itself is public and
standalone (footman.docstrings.parse) if you want structured docstrings
for your own tooling.
One honest asymmetry to know about: path and bounds violations on the command line are caught eagerly (before anything runs); the same violations in an env-supplied value are caught at binding time, because that's when the environment is read.
Dynamic completion¶
suggest attaches a completer — a function that returns live values (git
branches, deploy targets, the shares below). footman runs it fresh each time
you complete that value, in a short-lived subprocess, rather than serving a copy
baked into the manifest: a value you Tab to answer a build-critical
question must be current, not a snapshot from your last run. The recompute is
bounded and isolated, so a slow or failing completer degrades to no candidates —
never the old values, never a hung keystroke. This holds for every completer,
whether or not the task owns the terminal (interactive=True); a real run
validates the value you pass against the same live call.
from typing import Annotated
from footman import task, suggest
def shares() -> list[str]:
return ["main", "scratch", "archive"]
@task
def mount(share: Annotated[str, suggest(shares)]): ...
Keep a completer's imports inside its body, the way footman keeps optional dependencies out of a task's import path. Loading your tasks file stays cheap — the completer's cost (a subprocess, a network round-trip) is paid only when it runs, not every time the file is imported:
def branches() -> list[str]:
import subprocess # here, not at module top
out = subprocess.run(
["git", "branch", "--format=%(refname:short)"],
capture_output=True, text=True,
)
return out.stdout.split()
The first example, recorded in PowerShell: the demo project's tasks.py is
extracted from this page at build time, so the code above and the session below
cannot disagree. Tab offers what shares() returned; Tab
again walks the menu.