Coverage for src/footman/params.py: 92%
134 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
1"""Public parameter markers, used inside `Annotated` annotations.
3Dynamic completion (`suggest`), one-or-more (`Many`), comma-split opt-out
4(`nosplit`), path requirements (`exists`/`isfile`/`isdir`), numeric bounds
5(`between`, or a bare `range`), environment fallbacks (`env`), custom
6validators (`check`), and per-parameter help (`doc`). Each carries no runtime
7weight beyond a small marker object; `footman._coerce.peel` reads them all in
8one place.
9"""
11from __future__ import annotations
13from collections.abc import Callable
14from pathlib import Path
15from typing import Annotated, Any, Final, TypeVar
17_T = TypeVar("_T")
20class Secret(str):
21 """A string that redacts everywhere it is *shown*.
23 Answers to `ask(secret=True)` and `prompt(secret=True)` arrive as
24 `Secret`, and a parameter annotated `Secret` coerces into one whatever
25 its value came from — a flag, an `env()` fallback, a default. It is a
26 real `str` for the body (comparisons, formatting — the caller's
27 business), but its repr — what tracebacks, logs, and debuggers print —
28 is `Secret('***')`, and structured surfaces serialise it as `***`: the
29 `--json` envelope, a `Stdout[…]` document, baked manifest defaults.
31 Redaction covers the places footman *shows* a value, not the bytes a
32 task deliberately writes. `print(token)` and `f"TOKEN={token}"` emit the
33 real thing, because every string operation on a `Secret` yields a plain
34 `str` — which is what makes a task that must print a secret (an
35 `export …` line for `eval`) work without a switch to disarm.
37 `reveal()` is that unwrap said out loud, for where it isn't implicit:
39 ```python
40 @task
41 def creds(token: Secret) -> Stdout[dict]:
42 return {"token": token.reveal()} # meant to leave; not redacted
43 ```
45 Because it is a method, every intentional exposure is greppable — an
46 audit list a run-wide "don't redact" flag could never give you.
47 """
49 __slots__ = ()
51 def __repr__(self) -> str:
52 return "Secret('***')"
54 def reveal(self) -> str:
55 """The real value as a plain `str`, deliberately un-redacted.
57 Reach for it where a `Secret` would otherwise survive into a
58 structured surface that redacts — a dict, a dataclass field, a
59 `Stdout[…]` document — and you mean the value to be there.
60 """
61 return str.__str__(self)
64class suggest:
65 """Attach a dynamic completer to a parameter, via `Annotated`:
67 ```python
68 def build(project: Annotated[str, suggest(list_projects)]): ...
69 ```
71 `list_projects() -> list[str]` returns the candidate values. footman runs
72 it on the execution path — refreshing a cache the completion hot path serves
73 — and, when *strict* (the default), validates the supplied value against a
74 fresh call. The wrapper is required: a bare callable in `Annotated` is
75 refused, so a marker that happens to be callable can never be mistaken
76 for a completer.
77 """
79 __slots__ = ("fn", "strict")
81 fn: Callable[[], Any]
82 strict: bool
84 def __init__(self, fn: Callable[[], Any], *, strict: bool = True) -> None:
85 self.fn = fn
86 self.strict = strict
89def _takes_an_argument(fn: Callable[..., Any]) -> bool:
90 """Whether *fn* accepts a positional argument — the sibling view."""
91 import inspect
93 try:
94 params = inspect.signature(fn).parameters.values()
95 except (TypeError, ValueError):
96 return False # a builtin with no signature: treat as no-argument
97 return any(
98 p.kind
99 in (
100 inspect.Parameter.VAR_POSITIONAL,
101 inspect.Parameter.POSITIONAL_ONLY,
102 inspect.Parameter.POSITIONAL_OR_KEYWORD,
103 )
104 for p in params
105 )
108class default:
109 """Compute a parameter's default when the task runs, via `Annotated`:
111 ```python
112 def install(shell: Annotated[str, default(detect_shell)] = ""): ...
113 ```
115 A Python default is evaluated once, at import — fine for a constant, wrong
116 for anything that depends on the machine, the environment or the clock.
117 `default(fn)` calls `fn()` at bind time instead, so `--help` and the run
118 agree and both are current.
120 It sits in the ladder just above the Python default — **CLI value > env >
121 `default(fn)` > the declared default** — and, like `env()`, it needs a
122 declared default to sit on: a plain Python call of the task, outside any
123 run, has to keep working.
125 Declare one positional argument and it receives the **sibling parameters**
126 resolved so far — the same courtesy `check(fn)` gets, and for the same
127 reason: a default is often a function of the inputs beside it.
129 ```python
130 def cast(*, shell: str = "zsh",
131 title: Annotated[str, default(lambda p: f"{p['shell']} · x")] = ""): ...
132 ```
134 Read-only, and only what is to its *left* in the signature. The view holds
135 *effective* values — what each parameter will actually be — and only a left
136 one has that yet, which is also why a cycle cannot be written down: the
137 signature fixes a total order and binding walks it. Reaching rightwards is
138 a taught error, through `p["x"]` and `p.get("x")` alike. `--help` cannot
139 show such a default — there is no invocation to read — so it shows none
140 rather than one it would have to invent.
142 The value is used as it comes back, not coerced: `fn()` returns a real
143 object, and coercion exists because the command line only has strings. It
144 still runs the annotation's validators, so a `default(fn)` that would be
145 refused as a typed value is refused here too rather than smuggled in.
146 """
148 __slots__ = ("fn", "reads_siblings")
150 fn: Callable[..., Any]
151 reads_siblings: bool
152 """Whether *fn* takes the sibling view. Decided once, here, because both the
153 binder and the manifest need the answer and the manifest cannot import the
154 binder — asking the marker beats asking the same question twice in two
155 modules. Inspected rather than probed by call, so a real arity error raised
156 inside *fn* is never mistaken for the no-argument form."""
158 def __init__(self, fn: Callable[..., Any]) -> None:
159 self.fn = fn
160 self.reads_siblings = _takes_an_argument(fn)
163# `Many[T]` is exactly `list[T]`: a parameter that is *always* a list — one or
164# more values, variadic when positional. It reads more intentfully than a bare
165# `list[T]` at a call site, but carries no runtime marker of its own.
166Many = list
169class NoSplitMarker:
170 """Marker for `nosplit`."""
172 __slots__ = ()
174 def __repr__(self) -> str:
175 return "nosplit"
178nosplit: Final[NoSplitMarker] = NoSplitMarker()
179"""Opt a list/dict parameter OUT of comma-splitting, via `Annotated`:
181```python
182def build(names: Annotated[list[str], nosplit] = ()): ...
183```
185By default a collection parameter splits a single token on commas
186(`--tag a,b,c` -> `["a", "b", "c"]`) *in addition to* the repeatable form
187(`--tag a --tag b`). Mark it `nosplit` when a value may itself contain a comma:
188then only the repeated flag adds items and `--name "a,b"` stays the literal
189`"a,b"`. `NoSplit[list[str]]` is the terser spelling of the same thing."""
192NoSplit = Annotated[_T, nosplit]
193"""Shorthand for `Annotated[T, nosplit]`: `NoSplit[list[str]]` opts a collection
194out of comma-splitting (see `nosplit`)."""
197class HiddenMarker:
198 """Marker for `hidden`."""
200 __slots__ = ()
202 def __repr__(self) -> str:
203 return "hidden"
206hidden: Final[HiddenMarker] = HiddenMarker()
207"""Keep a parameter out of the listings a human reads, via `Annotated`:
209```python
210def deploy(target: str, legacy: Annotated[str, hidden] = ""): ...
211```
213It means on a parameter what `hidden=True` means on a task. `--help` does not
214list it and the synthesised example does not use it, but it still binds, still
215completes, and `--all` reveals it. The manifest marks it rather than dropping
216it, so an agent reading `--describe` still learns it exists — hiding and
217completing are different questions, and a long machine-facing flag is the one
218you most want spelled for you.
220For a deprecated flag kept working but unadvertised, a debug switch, or a flag
221a wrapper script passes. `Hidden[str]` is the terser spelling."""
224Hidden = Annotated[_T, hidden]
225"""Shorthand for `Annotated[T, hidden]`: `Hidden[str]` keeps a parameter out of
226the listings while it still binds and completes (see `hidden`)."""
229class ForwardMarker:
230 """Marker for `forward`."""
232 __slots__ = ()
234 def __repr__(self) -> str:
235 return "forward"
238forward: Final[ForwardMarker] = ForwardMarker()
239"""Forward this parameter to the tasks this task dispatches, via `Annotated`:
241```python
242@task(pre=[build, lint])
243def check(fix: Annotated[bool, forward] = False): ...
244```
246A `forward`-marked parameter's value is passed to every task this one
247dispatches — its `pre`/`post` prerequisites, and a runnable group's fan-out —
248that declares a parameter of the same name; tasks that don't declare it run on
249their own defaults. The forwarded value overrides the callee's default, chains
250through callees that re-declare the marker, and may satisfy a defaultless
251parameter — the same courtesy `ask()` and `stdin` extend, so a receiver never
252needs a fake default just to be reachable. Presence travels beside the value,
253so `given()` reads the same sentence in the dispatcher and in what it
254dispatched."""
257class ArgMarker:
258 """Marker for `Arg`."""
260 __slots__ = ()
262 def __repr__(self) -> str:
263 return "optional-arg"
266_arg: Final[ArgMarker] = ArgMarker()
268Arg = Annotated[_T, _arg]
269"""An *optional trailing positional*, via `Annotated`-alias:
271```python
272@task
273def files(pattern: Arg[str] = "*"): ...
274```
276`fm files src` fills the positional; a bare `fm files` runs on the default.
277The grammar is deterministic and greedy: when a bare word follows, it *is*
278the value — never re-interpreted as the next task, no name-peeking — and
279capped at one token. To run the task argument-less ahead of another, say so
280with the explicit boundary: `fm files + build`. An `Arg` needs a default
281(absence must mean something), takes at most one token (use a list
282parameter for many), and must trail every required positional.
284`Arg` is the one exception to the rule that sorts every other parameter:
285*the default decides* — no default makes a required positional, a default
286makes an option you pass by name. `Arg` says "keep me a positional even
287though I have a default", which is why it needs the greedy-but-capped
288grammar above. The rules it is bending are laid out in the
289[typing guide](https://footman.willem.net/typing/)."""
292Forward = Annotated[_T, forward]
293"""Shorthand for `Annotated[T, forward]`, like `Many[T]` is for a list:
295```python
296@task(pre=[build, lint])
297def check(fix: Forward[bool] = False): ...
298```
300`Forward[bool]` expands to `Annotated[bool, forward]` — the same marker, less
301noise on a signature full of forwarded parameters. See `forward`."""
304class PathRequirement:
305 """Marker for `exists` / `isfile` / `isdir`."""
307 __slots__ = ("_name", "kind")
309 kind: str
310 _name: str
312 def __init__(self, kind: str, name: str) -> None:
313 self.kind = kind
314 self._name = name
316 def __repr__(self) -> str:
317 return self._name
320exists: Final[PathRequirement] = PathRequirement("exists", "exists")
321"""Require a `Path` parameter to name something that exists on disk:
323```python
324def rm(target: Annotated[Path, exists]): ...
325```
327Validated eagerly (at parse time) with a taught error. See also `isfile`
328and `isdir`."""
330isfile: Final[PathRequirement] = PathRequirement("file", "isfile")
331"""Require a `Path` parameter to name an existing *file* (see `exists`)."""
333isdir: Final[PathRequirement] = PathRequirement("dir", "isdir")
334"""Require a `Path` parameter to name an existing *directory* (see `exists`)."""
337class matching:
338 """Narrow a `Path` parameter's <kbd>Tab</kbd> completion to a glob:
340 ```python
341 def load(env_file: Annotated[Path, matching(".env*")] = Path(".env")): ...
342 ```
344 footman cannot complete paths itself — it answers from a cached manifest
345 and never touches the filesystem — so a path value is handed to the
346 shell's own file completion. This is what it hands *along*: the pattern
347 the shell filters by, so `--env-file` offers `.env` and `.env.local`
348 rather than every file in the directory.
350 Directories are always offered whatever the pattern says, or a match one
351 level down would be unreachable. The glob matches the file's *name*
352 (`*.json`, not `**/*.json`) — the vocabulary all five shells share.
354 **Completion only.** It narrows what <kbd>Tab</kbd> shows; it does not
355 reject a value typed anyway — that is `check()`'s job, or
356 `exists`/`isfile`'s. A completion filter that quietly became validation
357 would refuse the perfectly good path someone pasted.
358 """
360 __slots__ = ("pattern",)
362 pattern: str
364 def __init__(self, pattern: str) -> None:
365 if not pattern: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 raise ValueError("matching() needs a glob, e.g. matching('*.json')")
367 self.pattern = pattern
369 def __repr__(self) -> str:
370 return f"matching({self.pattern!r})"
373Exists = Annotated[Path, exists]
374"""Shorthand for `Annotated[Path, exists]` — `target: Exists` requires the path
375to exist. Type-fixed to `Path`; use `Annotated` directly for a `list[Path]`."""
377IsFile = Annotated[Path, isfile]
378"""Shorthand for `Annotated[Path, isfile]`: require an existing *file*."""
380IsDir = Annotated[Path, isdir]
381"""Shorthand for `Annotated[Path, isdir]`: require an existing *directory*."""
384class between:
385 """Inclusive numeric bounds for an `int`/`float` parameter:
387 ```python
388 def test(jobs: Annotated[int, between(1, 32)] = 4): ...
389 ```
391 Validated eagerly with a taught error (`--jobs must be between 1 and 32`).
392 Either bound may be `None` for open-ended ranges. A bare `range` in
393 `Annotated` also works for ints, with Python's half-open semantics
394 (`range(0, 8)` accepts 0 through 7).
395 """
397 __slots__ = ("hi", "lo")
399 lo: float | None
400 hi: float | None
402 def __init__(self, lo: float | None, hi: float | None) -> None:
403 self.lo = lo
404 self.hi = hi
407class env:
408 """Fall back to an environment variable when the option isn't given:
410 ```python
411 def deploy(target: Annotated[str, env("DEPLOY_ENV")] = "staging"): ...
412 ```
414 Precedence is CLI > `$DEPLOY_ENV` > default. The env value flows through
415 the same coercion and validation as a command-line token. Only valid on a
416 parameter with a default — an env fallback *makes* it optional, so it
417 needs somewhere to fall. A body call that omits the parameter reads the
418 same ladder; a value passed explicitly wins over env, however the task
419 was asked for.
420 """
422 __slots__ = ("var",)
424 var: str
426 def __init__(self, var: str) -> None:
427 self.var = var
430class stdin:
431 """Bind this parameter from the process's standard input, via `Annotated`:
433 ```python
434 def review(diff: Annotated[str, stdin] = ""): ...
435 # git diff | fm review AND fm review < changes.patch
436 ```
438 The annotation decides how the bytes are interpreted: `str` reads the
439 stream as UTF-8 text (universal newlines), `bytes` reads it raw. Two
440 call forms refine that:
442 - `stdin("prompt")` binds one top-level field of a JSON document on
443 stdin — the value flows through the same coercion and validation as
444 a CLI token.
445 - `stdin(lines=True)` binds a list parameter from lines of text — each
446 line is coerced like one CLI token, so `list[int]` and `list[Path]`
447 work as they would as repeated flags.
449 Precedence is CLI > stdin > env > default > prompt, so an explicit
450 option always wins and one signature serves both `fm process file.txt`
451 and `cat file.txt | fm process`. The stream is read once, fully, at the
452 boundary — never inside a task body, so the parallel-stdin guard is
453 never in play — and the same payload serves every parameter that asks.
454 A terminal on stdin means "not provided": the parameter falls back to
455 its default, and a required parameter refuses with a taught message
456 rather than blocking on a read.
457 """
459 __slots__ = ("field", "lines")
461 field: str | None
462 lines: bool
464 def __init__(self, field: str | None = None, *, lines: bool = False) -> None:
465 if field is not None and lines:
466 raise ValueError(
467 "stdin(field) and stdin(lines=True) are exclusive — a field "
468 "binds one JSON value, lines bind a list of tokens"
469 )
470 self.field = field
471 self.lines = lines
474Stdin = Annotated[_T, stdin]
475"""Shorthand for `Annotated[T, stdin]`, in the `Arg`/`Forward` mould:
477```python
478def review(diff: Stdin[str] = ""): ...
479```
481The bare-marker form only — the call forms (`stdin("field")`,
482`stdin(lines=True)`) are spelled inside `Annotated`, like `env("VAR")`."""
485class StdoutMarker:
486 """Marker for `stdout`."""
488 __slots__ = ()
490 def __repr__(self) -> str:
491 return "stdout"
494stdout: Final[StdoutMarker] = StdoutMarker()
495"""Declare that a task's return value is the document on stdout, via the
496*return* annotation:
498```python
499@task
500def status() -> Stdout[dict]: ...
501# fm status | jq .branch
502```
504The task is then a Unix filter by declaration — no flag at any call site.
505The return type decides the bytes, mirroring `stdin`: `Stdout[str]` emits
506the string verbatim (plus a trailing newline), `Stdout[bytes]` writes raw
507bytes, anything structured is JSON — pretty-printed on a terminal, one
508compact line into a pipe, dataclasses and `Secret` redaction handled by
509the same encoder `--json` uses — so a `Secret` inside a structured document
510serialises as `***`, and a task that *means* to emit one says
511`token.reveal()`. A `Stdout[str]` built by formatting is unaffected: string
512operations on a `Secret` yield a plain `str`, which is how the
513print-a-credential filter (`eval "$(fm env-export)"`) works without a
514switch to disarm redaction everywhere else.
516The rules that keep it honest: an explicit `--json` wins (the document
517rides inside `results[].returned`); only the addressed task emits (a
518declaring task reached as a `pre=`/`post=` dependency or a fan-out member
519is suppressed, not refused); two declaring tasks in one chain is a
520plan-time refusal; `None` means empty stdout, exit 0; a failed task emits
521nothing; everything that is not the document — prints, `run()` output —
522replays on stderr. A bare `-> int` stays the exit-code channel; declaring
523`Stdout[int]` makes the int the document instead. A body call is
524unaffected: `status()` from another task just returns the value."""
527Stdout = Annotated[_T, stdout]
528"""Shorthand for `Annotated[T, stdout]` on a *return* annotation:
530```python
531def wordcount(text: Stdin[str] = "") -> Stdout[int]: ...
532```
534`Stdout[dict | None]` is the house spelling for a filter that sometimes
535has nothing to say (marker outermost, like `NoSplit[list[X] | None]`);
536`Stdout[dict] | None` means the same thing. See `stdout`."""
539class check:
540 """A custom validator, run after coercion; raise `ValueError` to reject:
542 ```python
543 def tag(version: Annotated[str, check(semver)]): ...
544 ```
546 The callable receives the coerced value (each element, for collections).
547 Its `ValueError` message is shown to the user, so write it for them.
549 Declare a second parameter to also receive the **siblings** — the parameters
550 to this one's left at their *effective* values (a provided value, else the
551 parameter's own default), coerced and read-only (empty for the first
552 parameter) — so a check can validate against another input:
554 ```python
555 def newer(v, params):
556 current = current_version(params["name"]) # the package named earlier
557 if Version(v) <= current:
558 raise ValueError(f"must be newer than {current}")
560 def release(name: str, version: Annotated[str, check(newer)]): ...
561 ```
562 """
564 __slots__ = ("fn",)
566 fn: Callable[..., Any]
568 def __init__(self, fn: Callable[..., Any]) -> None:
569 self.fn = fn
572class doc:
573 """Help text for one parameter, via `Annotated`:
575 ```python
576 def lint(fix: Annotated[bool, doc("apply fixes in place")] = False): ...
577 ```
579 One line, written for the person at the prompt. It shows in
580 `fm --help <task>`, as the option's description in shells that render
581 one (zsh, fish, nushell, PowerShell tooltips), and in the
582 `fm --json --list` catalog.
583 """
585 __slots__ = ("text",)
587 text: str
589 def __init__(self, text: str) -> None:
590 self.text = text
593class ask:
594 """Prompt for a parameter's value when it isn't supplied, via `Annotated`:
596 ```python
597 def release(version: Annotated[str, ask()]): ...
598 ```
600 A parameter marked `ask()` is prompted for when the command line does not
601 give it and no `env` fills it — the answer runs through the same coercion
602 and validation as a CLI token, re-asking on a bad value. Precedence is
603 **CLI > env > prompt (offering the default) > the default**.
605 A declared default becomes the *offer*: the prompt shows it, Enter accepts
606 it, and where nobody can be asked — off a terminal, under `--no-input`, in
607 `--json` — it is simply used. So `ask()` is safe on any parameter: a person
608 gets asked, an unattended run gets the default. Without a default there is
609 no other answer, so those cases error naming the flag rather than hanging.
611 Naming the option bare (`--version`) skips the question: the caller has
612 already said "the declared one", and asking again would be footman not
613 listening.
615 `secret=True` hides the input (getpass) and never shows the default, though
616 Enter still accepts it; `prompt="…"` overrides the question text.
617 """
619 __slots__ = ("prompt", "secret")
621 prompt: str | None
622 secret: bool
624 def __init__(self, *, secret: bool = False, prompt: str | None = None) -> None:
625 self.secret = secret
626 self.prompt = prompt