Coverage for src/footman/params.py: 95%
64 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +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, TypeVar
17_T = TypeVar("_T")
20class suggest:
21 """Attach a dynamic completer to a parameter, via `Annotated`:
23 ```python
24 def build(project: Annotated[str, suggest(list_projects)]): ...
25 ```
27 `list_projects() -> list[str]` returns the candidate values. footman runs
28 it on the execution path — refreshing a cache the completion hot path serves
29 — and, when *strict* (the default), validates the supplied value against a
30 fresh call. A bare callable in `Annotated` is treated as `suggest(fn)`.
31 """
33 __slots__ = ("fn", "strict")
35 def __init__(self, fn: Callable[[], Any], *, strict: bool = True) -> None:
36 self.fn = fn
37 self.strict = strict
40# `Many[T]` is exactly `list[T]`: a parameter that is *always* a list — one or
41# more values, variadic when positional. It reads more intentfully than a bare
42# `list[T]` at a call site, but carries no runtime marker of its own.
43Many = list
46class _NoSplitMarker:
47 """Marker for `nosplit`."""
49 __slots__ = ()
51 def __repr__(self) -> str:
52 return "nosplit"
55nosplit = _NoSplitMarker()
56"""Opt a list/dict parameter OUT of comma-splitting, via `Annotated`:
58```python
59def build(names: Annotated[list[str], nosplit] = ()): ...
60```
62By default a collection parameter splits a single token on commas
63(`--tag a,b,c` -> `["a", "b", "c"]`) *in addition to* the repeatable form
64(`--tag a --tag b`). Mark it `nosplit` when a value may itself contain a comma:
65then only the repeated flag adds items and `--name "a,b"` stays the literal
66`"a,b"`."""
69NoSplit = Annotated[_T, nosplit]
70"""Shorthand for `Annotated[T, nosplit]`: `NoSplit[list[str]]` opts a collection
71out of comma-splitting (see `nosplit`)."""
74class _ForwardMarker:
75 """Marker for `forward`."""
77 __slots__ = ()
79 def __repr__(self) -> str:
80 return "forward"
83forward = _ForwardMarker()
84"""Forward this parameter to the tasks this task dispatches, via `Annotated`:
86```python
87@task(pre=[build, lint])
88def check(fix: Annotated[bool, forward] = False): ...
89```
91A `forward`-marked parameter's value is passed to every task this one
92dispatches — its `pre`/`post` prerequisites, and a runnable group's fan-out —
93that declares a parameter of the same name; tasks that don't declare it run on
94their own defaults. The forwarded value overrides the callee's default, and it
95chains through callees that re-declare the marker. Forwarding supplies a
96*value*, never runnability: a prerequisite must still be independently runnable
97(every parameter defaulted)."""
100Forward = Annotated[_T, forward]
101"""Shorthand for `Annotated[T, forward]`, like `Many[T]` is for a list:
103```python
104@task(pre=[build, lint])
105def check(fix: Forward[bool] = False): ...
106```
108`Forward[bool]` expands to `Annotated[bool, forward]` — the same marker, less
109noise on a signature full of forwarded parameters. See `forward`."""
112class _PathRequirement:
113 """Marker for `exists` / `isfile` / `isdir`."""
115 __slots__ = ("_name", "kind")
117 def __init__(self, kind: str, name: str) -> None:
118 self.kind = kind
119 self._name = name
121 def __repr__(self) -> str:
122 return self._name
125exists = _PathRequirement("exists", "exists")
126"""Require a `Path` parameter to name something that exists on disk:
128```python
129def rm(target: Annotated[Path, exists]): ...
130```
132Validated eagerly (at parse time) with a taught error. See also `isfile`
133and `isdir`."""
135isfile = _PathRequirement("file", "isfile")
136"""Require a `Path` parameter to name an existing *file* (see `exists`)."""
138isdir = _PathRequirement("dir", "isdir")
139"""Require a `Path` parameter to name an existing *directory* (see `exists`)."""
142Exists = Annotated[Path, exists]
143"""Shorthand for `Annotated[Path, exists]` — `target: Exists` requires the path
144to exist. Type-fixed to `Path`; use `Annotated` directly for a `list[Path]`."""
146IsFile = Annotated[Path, isfile]
147"""Shorthand for `Annotated[Path, isfile]`: require an existing *file*."""
149IsDir = Annotated[Path, isdir]
150"""Shorthand for `Annotated[Path, isdir]`: require an existing *directory*."""
153class between:
154 """Inclusive numeric bounds for an `int`/`float` parameter:
156 ```python
157 def test(jobs: Annotated[int, between(1, 32)] = 4): ...
158 ```
160 Validated eagerly with a taught error (`--jobs must be between 1 and 32`).
161 Either bound may be `None` for open-ended ranges. A bare `range` in
162 `Annotated` also works for ints, with Python's half-open semantics
163 (`range(0, 8)` accepts 0 through 7).
164 """
166 __slots__ = ("hi", "lo")
168 def __init__(self, lo: float | None, hi: float | None) -> None:
169 self.lo = lo
170 self.hi = hi
173class env:
174 """Fall back to an environment variable when the option isn't given:
176 ```python
177 def deploy(target: Annotated[str, env("DEPLOY_ENV")] = "staging"): ...
178 ```
180 Precedence is CLI > `$DEPLOY_ENV` > default. The env value flows through
181 the same coercion and validation as a command-line token. Only valid on a
182 parameter with a default — an env fallback *makes* it optional, so it
183 needs somewhere to fall.
184 """
186 __slots__ = ("var",)
188 def __init__(self, var: str) -> None:
189 self.var = var
192class check:
193 """A custom validator, run after coercion; raise `ValueError` to reject:
195 ```python
196 def tag(version: Annotated[str, check(semver)]): ...
197 ```
199 The callable receives the coerced value (each element, for collections).
200 Its `ValueError` message is shown to the user, so write it for them.
202 Declare a second parameter to also receive the **siblings** — the parameters
203 to this one's left at their *effective* values (a provided value, else the
204 parameter's own default), coerced and read-only (empty for the first
205 parameter) — so a check can validate against another input:
207 ```python
208 def newer(v, params):
209 current = current_version(params["name"]) # the package named earlier
210 if Version(v) <= current:
211 raise ValueError(f"must be newer than {current}")
213 def release(name: str, version: Annotated[str, check(newer)]): ...
214 ```
215 """
217 __slots__ = ("fn",)
219 def __init__(self, fn: Callable[..., Any]) -> None:
220 self.fn = fn
223class doc:
224 """Help text for one parameter, via `Annotated`:
226 ```python
227 def lint(fix: Annotated[bool, doc("apply fixes in place")] = False): ...
228 ```
230 One line, written for the person at the prompt. It shows in
231 `fm --help <task>`, as the option's description in shells that render
232 one (zsh, fish, nushell, PowerShell tooltips), and in the
233 `fm --json --list` catalog.
234 """
236 __slots__ = ("text",)
238 def __init__(self, text: str) -> None:
239 self.text = text
242class ask:
243 """Prompt for a parameter's value when it isn't supplied, via `Annotated`:
245 ```python
246 def release(version: Annotated[str, ask()]): ...
247 ```
249 A *required* (defaultless) parameter marked `ask()` is prompted for when it
250 is not given on the command line and no `env` fills it — the answer runs
251 through the same coercion and validation as a CLI token, re-asking on a bad
252 value. Precedence stays CLI > env > default > prompt, so `ask()` only
253 prompts a parameter with **no default** (a default is the answer). Off a
254 terminal, under `--no-input`, or in `--json` it errors naming the flag
255 rather than hanging. `secret=True` hides the input (getpass); `prompt="…"`
256 overrides the question text.
257 """
259 __slots__ = ("prompt", "secret")
261 def __init__(self, *, secret: bool = False, prompt: str | None = None) -> None:
262 self.secret = secret
263 self.prompt = prompt