Troubleshooting¶
footman treats error messages as product surface: every one names the culprit, states the expectation, and proposes the fix. This page is the catalogue — each example below is real output, not paraphrase. If you ever hit a raw Python traceback instead of one of these, that's a footman bug; please report it.
Reading an error¶
$ fm deploy produ
fm: deploy: <target> must be one of dev|staging|prod (got 'produ') — did you mean 'prod'?
The shape is always prog: task: what — hint. The prefix is the brand
(fm, or your own CLI's name), so in a chain of tools you know who's
talking.
Parse errors — exit 2, nothing has run¶
The splitter validates the whole command line against the manifest before executing anything, so a typo never half-runs a chain.
| You'll see | It means | The fix |
|---|---|---|
expected a task name, got 'linnt' (know: deploy, lint, up) |
no task by that name here | the know: list is your menu; fm --list for help text |
lint: unknown option --fx (task options come right after their task; globals go before the first task) |
the option isn't lint's | fm --help lint shows its real options |
deploy: missing required argument(s): <target> |
a positional with no default wasn't given | required params have no default — pass a value |
deploy: <target> must be one of dev\|staging\|prod (got 'produ') — did you mean 'prod'? |
eager choice validation (Literal, Enum, strict suggest) |
take the hint |
deploy: <target> must be one of dev\|staging\|prod — 'check' looks like the next task; did you forget <target>? |
a chain word landed where a required value belongs | give the value, then the next task |
test: --jobs expects an integer (got 'many') |
eager type validation from the annotation | typed params parse before anything runs |
test: --jobs must be between 1 and 32 (got '99') |
a between(...)/range bound |
bounds are inclusive; the message quotes them |
render: <template> must be an existing file (got 'missing.toml') |
an exists/isfile/isdir marker |
the path is checked before the task runs |
deploy: --env expects KEY=VALUE (got 'DEBUG') |
a dict[K, V] param needs pairs |
--env DEBUG=1, comma-split or repeated |
lint: --fix is a flag and takes no value |
--fix=yes on a bool param |
flags are bare: --fix, or --no-fix |
--where expects a value |
a value-bearing global given bare | --where TASK |
unknown global option --bogus (global options go before the first task) |
not one of fm's globals | fm --help lists them all |
One asymmetry worth knowing: constraints on env-supplied values
(env("VAR") fallbacks) are enforced at binding time rather than parse
time — the parser never sees your environment — so those surface as a
failed task result with the same wording, plus the source:
--jobs (from $JOBS) must be between 1 and 32 (got 99).
Tasks-file errors — your tasks.py needs attention¶
| You'll see | It means | The fix |
|---|---|---|
/repo/tasks.py: the root already has a task named 'build' |
two tasks claimed one name | rename one, or @task(name=...) |
failed to import /repo/tasks.py: SyntaxError: ... |
the file doesn't parse | the named file is the culprit, cascade or not |
failed to import /repo/tasks.py: ImportError: ... |
an import inside the tasks file failed | footman shows the type and message, never a traceback |
<target>: env('DEPLOY_ENV') needs a default — an env fallback makes the parameter optional, so it needs somewhere to fall |
env() on a required param |
give it a default |
<opts>: env() is not supported on dict parameters |
env() on a dict[K, V] |
read the variable inside the task instead |
dynamic choices from projects() failed: FileNotFoundError: ... — fix the completer, or pass suggest(fn, strict=False) if this data is best-effort |
a strict completer raised | strict promises validation, so it fails loudly rather than validating nothing |
include('shared_tasks'): the module was already imported outside include(), so its tasks were never captured — ... |
a bare import beat your include() |
include() first, or expose an explicit Group |
include(): 'shared_tasks' has no task or group named 'lnt' (has: fmt, lint) |
a typo in only=/exclude= |
the message lists what the provider has |
plugin 'mkdocs': no 'footman.tasks' entry point found (installed: none) |
a configured plugin isn't installed | install it, or drop it from [tool.footman] plugins |
plugin 'mkdocs': failed to import (ModuleNotFoundError: ...) |
the plugin is installed but its own import failed (a missing optional dep) | install what the plugin needs, or drop it — footman names the cause, never a traceback |
A parameter whose annotation footman can't use (an unresolved name, a
value) emits a UserWarning — values pass through as plain text until you
fix the annotation.
Run-time errors — a task went wrong¶
| You'll see | It means |
|---|---|
test: RunFailed: `pytest -q` exited with code 1 (plus the replayed output) |
a run() command failed; its captured output is shown only now |
release: ValueError: 'nope' is not MAJOR.MINOR.PATCH |
the task (or a check(fn) validator) raised; type and message, no traceback |
build: exited with code 3 |
the task returned a non-zero int |
up: Unavailable: requires docker on PATH |
a @requires-gated task was asked to run; the reason is live, not cached |
dependency cycle: b -> a -> b (check the pre/post declarations of these tasks) |
your pre/post graph loops |
interrupted (exit 130) |
Ctrl-C — pending tasks were cancelled |
In a chain, a failed task's dependents are skipped; -k/--keep-going runs
every independent branch anyway. Output from parallel tasks never
interleaves — each task's buffer is flushed as one block to stdout, while
the ok/FAIL summary itself is stderr commentary.
Config errors¶
A malformed discovered config (a pyproject.toml or footman.toml in
the cascade) warns and is skipped — one broken file between the repo root
and your cwd must not brick every invocation:
fm: ignoring malformed config: /repo/footman.toml: Expected '=' after a key in a key/value pair (at line 1, column 5)
A file you named explicitly with --config is a hard error (exit 2) when
it's malformed, unreadable, or missing — you asked for that file on purpose, so
a typo like --config prod.tmol is reported (--config: prod.tmol: no such
file), never silently ignored.
Timing estimates¶
The progress bar's estimates come from *.times.json files beside the
completion manifests (~/.cache/footman/, or wherever
$FOOTMAN_CACHE_DIR points). The cache also tends itself: at most once
a day, a detached collector removes pairs whose directory no longer
exists and pairs idle for 90 days — everything in the cache rebuilds on
the next run, so collection can never lose anything that matters.
Delete files by hand to reset a stale history, or
turn the whole apparatus off — --no-progress for a run,
progress = false in [tool.footman] for good.
Exit codes¶
The two worth remembering here: 2 always means footman refused before or
while binding — a parse, tasks-file, config, or availability problem, and
nothing ran — and any other non-zero code is the first failing task's own.
The full table is part of the machine contract:
JSON output § exit codes. --json consumers get the
same story per task in the envelope — ok, code, error.