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) |
tags: set[str] = frozenset() |
the same, handed back as a set |
size: Size (a NamedTuple) |
one option filling every field (--size=800,600) |
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 with a default 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 option could never
be reached. Footman refuses the file rather than shadow the parameter
quietly, and a refusal at load time takes the whole tasks file with it:
every task in it, fm --list and fm --help included, exits 64 with the
reserved-name message until the parameter is renamed (show_help,
explain).
The reservation is exactly as wide as the problem. A help with no
default is a required positional, and *help a variadic; neither ever
spells --help, so both stay legal. 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.
Naming an option without a value¶
Every option may be named on its own:
fm build --target # the default, and asked for on purpose
fm build # the default, with no opinion expressed
fm build --target=prod # a value
A bare --target binds exactly what absence would have bound, since the same
env()-then-default ladder runs, so on its own it changes nothing. What it
adds is that somebody named it, which given() reports:
from footman import task, given
@task
def build(*, profile: Path = Path("build-profile.json")):
"""Build, and write a trace when asked for one."""
if given("profile"):
trace_to(profile)
fm build writes no trace. fm build --profile writes the declared default.
fm build --profile=other.json writes there. Three outcomes from one declared
default. A value alone could never separate the first two, because both hand
the body the same path.
Supplied means the caller: an option on the line (bare or attached), a
keyword on a body call, a piped stdin payload, an answered ask() prompt.
Not supplied means footman worked it out: an env() fallback, which is
ambient and answers for nobody in particular, or the declared default. A body
call says the same thing a command line does (build(profile=…) reads as
given, build() does not) and a called task never inherits its caller's
answer.
A parameter with no default has no absence to mean, so naming it bare is still
the taught =-attachment error.
A default computed when the task runs¶
A Python default is evaluated once, when the module is imported: 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:
It sits one rung above the declared default (CLI value > env() >
default(fn) > the declared default) and, like env(), needs a declared
default to sit on, because a plain Python call of the task with no run around
it has to keep working. --help prints what it computes, since the manifest is
built on the execution path: what help shows is what this run would use.
Declare one positional argument and it receives the sibling parameters
resolved so far, the same courtesy check(fn) gets, because a default is
often a function of the inputs beside it:
@task
def cast(
*keys: str,
shell: str = "zsh",
title: Annotated[str, default(lambda p: f"{p['shell']} · completion")] = "",
): ...
Read-only, and only what is to its left in the signature. The view holds effective values, what each parameter will actually be from whichever rung of the ladder supplied it, and only a left parameter has one yet. That is also why a cycle cannot be written down: the signature fixes a total order and binding walks it, so nothing can depend on something unresolved.
Reaching rightwards is a taught error rather than a silent surprise, through
p["later"] and p.get("later") alike:
'title' may only read parameters declared before it, and 'cmd' comes after
— so it has no value yet. Move 'cmd' above 'title' in the signature.
--help shows no default for a sibling-reading one, since there is no
invocation to read, but it does say there is one, as default computed. A computed default
that can be resolved is marked too, because a bare number reads as an
arbitrary constant when it is really this machine's:
-j, --jobs=N max parallel tasks; default: 13 (computed)
--color=WHEN when to colour: always|never|auto; default: auto (computed)
What if I don't like annotating types?¶
Then don't. Every rule above still holds, because the one that sorts a parameter into a position or a flag reads the default, not the annotation. This is a working CLI:
from footman import task
@task
def ship(target, port=8000, ratio=1.5, name="web", verbose=False):
"Ship it."
print(target, port + 1, ratio, name, verbose)
fm ship prod --port=9000 --verbose gives you a required positional, three
options, and a --verbose/--no-verbose flag pair, completed, listed, and
documented like any other task. The four basic types read from their
defaults: port arrives as an int, ratio as a float, name as a
str, and a bool default becomes a flag. Passing --port=abc is refused
before anything runs, with the same taught message an annotated int gets.
The limit is what a bare default can't say. These stay strings:
A None default names no type, and a container's default says nothing about
what belongs in it, so footman infers nothing and hands the value over as
typed on the command line. That is deliberate: the rule is to infer exactly
where Python's own inference is definite, which is also exactly where a type
checker reading your file agrees. Anything less certain would be a guess, and
a wrong guess is worse than a string.
So annotate when you want what a default cannot express:
| you want | annotate |
|---|---|
| a fixed set of choices, completed and validated | Literal["dev", "prod"] |
| a bound | Annotated[int, between(1, 32)] |
| a path that must exist | Exists / IsFile |
| a list, and what goes in it | list[Path], Many[int] |
| several fields filled from one option | a NamedTuple |
| a value from the environment | Annotated[str, env("DEPLOY_ENV")] |
| a prompt when it is missing | Annotated[str, ask(prompt="Target?")] |
Everything else on this page is that list expanded. None of it is required to get a good CLI out of a plain function.
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"]
set[T], frozenset[T] and tuple[T, ...] accept values exactly the same
way. They differ only in the container your function receives, which is the
point of naming one:
A bare list, set, frozenset or tuple means a collection of str.
Comma-splitting and nosplit¶
Values accumulate from commas and from repetition into one stream, so
--tag=a,b,c and --tag=a --tag=b --tag=c are the same three values. 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 footman import NoSplit
@task
def notify(lines: NoSplit[list[str]] = []): ...
# fm notify --lines="Smith, John" --lines="Doe, Jane" -> two names, commas kept
NoSplit[list[str]] is shorthand for Annotated[list[str], nosplit]: the
same marker, less to type. Every bare marker has a subscript form like this;
Terse aliases below has the full set and the
rule for which markers can have one.
One stream is the whole rule. A collection takes all of it; a shape with a declared arity takes it in groups of that size, which is the next section.
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[...]]:
Fixed-arity values¶
A shape that declares how many fields it has takes that many values from one
option. Prefer a NamedTuple: it names its fields, and the names do real
work:
from typing import NamedTuple
from footman import task
class Size(NamedTuple):
width: int
height: int
@task
def render(size: Size = Size(1920, 1080)): ... # fm render --size=800,600
--size=800,600 fills width and height. So does --size=800 --size=600,
because there is only ever one stream of values and the declared arity groups
it. That is also what makes a container of shapes work:
class Spot(NamedTuple):
x: float
y: float
@task
def route(points: list[Spot] = ()): ...
# fm route --points=1,2 --points=3,4 -> [Spot(1.0, 2.0), Spot(3.0, 4.0)]
# fm route --points=1,2,3,4 -> the same two points
Nothing is guessed. --points=1,2,3 cannot be a whole number of points, so it
is refused rather than rounded:
$ fm route --points=1,2,3
fm: route: --points takes values in groups of 2 (x,y) — got 3, which leaves 1 over
A plain tuple[int, int] behaves identically: same grouping, same
chunking, same refusals. Only the messages are poorer, because a plain tuple
has no field names to report with:
$ fm render --size=800,tall
fm: render: --size: height expects an integer (got 'tall') # a NamedTuple
fm: render: --size: value 2 expects an integer (got 'tall') # tuple[int, int]
That is the whole argument for preferring the named form.
A dataclass, or any class with an annotated __init__, works the same way,
with the constructor's parameters as the fields:
from dataclasses import dataclass
@dataclass
class Window:
title: str
width: int = 800
@task
def open_(window: Window = Window("footman")): ... # fm open --window=Docs,1024
Arity ranges¶
When a shape has optional fields, the count settles it: Window above takes
one value or two, and --window=Docs leaves width at its default. --help
says so: 1 to 2 values.
Inside a container that flexibility goes away: every field must be given.
One group can let the count settle it, but two cannot:
--windows=Docs,1024,Notes could be one window and a second one, or two
windows of one field each, and guessing is what this design refuses to do. So
a container groups by the full arity and says so:
$ fm many --windows=Docs,1024,Notes
fm: many: --windows takes values in groups of 2 (title,width) — got 3, which leaves 1 over
Give every field a value, or bind the list from stdin, where JSON's own brackets say where each one ends.
What is refused, and where to go instead¶
A shape only takes a command-line spelling when one token can fill each of its fields. Two things put a shape out of reach, both with somewhere to go:
- A field that is itself a shape or a collection.
Line(start: Point, end: Point)has no comma spelling, since nothing in--line=1,2,3,4says which pair is the start. Pipe it as JSON instead: a nested document binds in full. - An untyped or
*argsconstructor. Footman groups a shape it can type; a constructor that describes none of its parameters is not one, and keeps the single-tokenT(value)form below.
Both are pipe-able today, and fm --describe prints the exact JSON a task
expects; see JSON.
The same annotation, whichever channel¶
A parameter's type means the same thing wherever the value comes from. The
command line and a JSON document on stdin are two spellings of one contract:
Size is a Size whether it arrived as --size=800,600 or as
{"width": 800, "height": 600}, and a list[Spot] is a list of Spot
either way.
Where they differ is only in what a spelling can express. A command line has commas; JSON has brackets, so a shape holding another shape has no command-line form and is pipe-only, and a shape whose fields are all scalars has both. Nothing is silently downgraded in either direction: a shape footman cannot honour on a channel says so.
Pipelines covers the boundary itself.
Custom types¶
Any type footman can construct from a single token works, and it is called
with that token. 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): ...
This is the one-field case of the rule above: a type with a single constructor parameter takes the whole token, and a type with two or more annotated ones is filled from a group instead.
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
def semver(value: str) -> None: ... # your validator: raise ValueError to refuse
@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, since 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].Hidden[T]≡Annotated[T, hidden].
Arg-taking markers (suggest, between, env, check, doc, ask) keep the
full Annotated[...] form, because their value can't ride in a type subscript.
The forward marker is an orchestration tool that threads a value onward to
the tasks this one dispatches, and is covered in
Chaining & parallelism.
Markers compose by listing them: Annotated[bool, ask(prompt="Fix?"), forward] both
prompts for the value and forwards it: one prompt at the top, the answer
flowing down.
Keeping a parameter out of the listings¶
hidden takes a parameter out of what a human reads, and out of nothing
else:
from typing import Annotated
from footman import task
from footman.params import hidden
@task
def publish(target: str, legacy: Annotated[str, hidden] = ""):
"""Ship it."""
fm --help publish shows <target> and stops there: no --legacy, and the
synthesized example does not use it. Everything else about it is unchanged:
it still binds, it still completes when you type --l<TAB>, and
fm --describe carries it marked "hidden": true rather than dropping it.
fm --all --help publish lists it.
That is the same rule hidden=True follows on a task, one level down. Hiding
and completing are different questions. A long machine-facing flag is the one
you most want spelled for you, and a machine reading the contract is exactly
who is meant to still find it.
For a deprecated flag you keep working but stop advertising, a debug switch, or a flag a wrapper script passes.
Or just write a docstring¶
Footman reads the parameter docs you already write, in 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 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: a value you Tab
to answer a build-critical question must be current, so nothing keeps a copy to
serve you instead. 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. Pair a completer with ask() and the same
values become a numbered menu when the flag is missing, asked up front with
the run's other questions.
A completer runs only where its values are wanted: the parameter whose value
is being completed or validated, and the options --help is printing. A line
that never mentions the parameter never calls it, so a completer that shells
out costs nothing on an unrelated task. --help shows the values it found and
marks them (dynamic), the way a computed default is marked (computed):
what you are reading is this moment's answer, not a fixed set.
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.