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

1"""Public parameter markers, used inside `Annotated` annotations. 

2 

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""" 

10 

11from __future__ import annotations 

12 

13from collections.abc import Callable 

14from pathlib import Path 

15from typing import Annotated, Any, Final, TypeVar 

16 

17_T = TypeVar("_T") 

18 

19 

20class Secret(str): 

21 """A string that redacts everywhere it is *shown*. 

22 

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. 

30 

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. 

36 

37 `reveal()` is that unwrap said out loud, for where it isn't implicit: 

38 

39 ```python 

40 @task 

41 def creds(token: Secret) -> Stdout[dict]: 

42 return {"token": token.reveal()} # meant to leave; not redacted 

43 ``` 

44 

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 """ 

48 

49 __slots__ = () 

50 

51 def __repr__(self) -> str: 

52 return "Secret('***')" 

53 

54 def reveal(self) -> str: 

55 """The real value as a plain `str`, deliberately un-redacted. 

56 

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) 

62 

63 

64class suggest: 

65 """Attach a dynamic completer to a parameter, via `Annotated`: 

66 

67 ```python 

68 def build(project: Annotated[str, suggest(list_projects)]): ... 

69 ``` 

70 

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 """ 

78 

79 __slots__ = ("fn", "strict") 

80 

81 fn: Callable[[], Any] 

82 strict: bool 

83 

84 def __init__(self, fn: Callable[[], Any], *, strict: bool = True) -> None: 

85 self.fn = fn 

86 self.strict = strict 

87 

88 

89def _takes_an_argument(fn: Callable[..., Any]) -> bool: 

90 """Whether *fn* accepts a positional argument — the sibling view.""" 

91 import inspect 

92 

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 ) 

106 

107 

108class default: 

109 """Compute a parameter's default when the task runs, via `Annotated`: 

110 

111 ```python 

112 def install(shell: Annotated[str, default(detect_shell)] = ""): ... 

113 ``` 

114 

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. 

119 

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. 

124 

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. 

128 

129 ```python 

130 def cast(*, shell: str = "zsh", 

131 title: Annotated[str, default(lambda p: f"{p['shell']} · x")] = ""): ... 

132 ``` 

133 

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. 

141 

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 """ 

147 

148 __slots__ = ("fn", "reads_siblings") 

149 

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.""" 

157 

158 def __init__(self, fn: Callable[..., Any]) -> None: 

159 self.fn = fn 

160 self.reads_siblings = _takes_an_argument(fn) 

161 

162 

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 

167 

168 

169class NoSplitMarker: 

170 """Marker for `nosplit`.""" 

171 

172 __slots__ = () 

173 

174 def __repr__(self) -> str: 

175 return "nosplit" 

176 

177 

178nosplit: Final[NoSplitMarker] = NoSplitMarker() 

179"""Opt a list/dict parameter OUT of comma-splitting, via `Annotated`: 

180 

181```python 

182def build(names: Annotated[list[str], nosplit] = ()): ... 

183``` 

184 

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.""" 

190 

191 

192NoSplit = Annotated[_T, nosplit] 

193"""Shorthand for `Annotated[T, nosplit]`: `NoSplit[list[str]]` opts a collection 

194out of comma-splitting (see `nosplit`).""" 

195 

196 

197class HiddenMarker: 

198 """Marker for `hidden`.""" 

199 

200 __slots__ = () 

201 

202 def __repr__(self) -> str: 

203 return "hidden" 

204 

205 

206hidden: Final[HiddenMarker] = HiddenMarker() 

207"""Keep a parameter out of the listings a human reads, via `Annotated`: 

208 

209```python 

210def deploy(target: str, legacy: Annotated[str, hidden] = ""): ... 

211``` 

212 

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. 

219 

220For a deprecated flag kept working but unadvertised, a debug switch, or a flag 

221a wrapper script passes. `Hidden[str]` is the terser spelling.""" 

222 

223 

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`).""" 

227 

228 

229class ForwardMarker: 

230 """Marker for `forward`.""" 

231 

232 __slots__ = () 

233 

234 def __repr__(self) -> str: 

235 return "forward" 

236 

237 

238forward: Final[ForwardMarker] = ForwardMarker() 

239"""Forward this parameter to the tasks this task dispatches, via `Annotated`: 

240 

241```python 

242@task(pre=[build, lint]) 

243def check(fix: Annotated[bool, forward] = False): ... 

244``` 

245 

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.""" 

255 

256 

257class ArgMarker: 

258 """Marker for `Arg`.""" 

259 

260 __slots__ = () 

261 

262 def __repr__(self) -> str: 

263 return "optional-arg" 

264 

265 

266_arg: Final[ArgMarker] = ArgMarker() 

267 

268Arg = Annotated[_T, _arg] 

269"""An *optional trailing positional*, via `Annotated`-alias: 

270 

271```python 

272@task 

273def files(pattern: Arg[str] = "*"): ... 

274``` 

275 

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. 

283 

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/).""" 

290 

291 

292Forward = Annotated[_T, forward] 

293"""Shorthand for `Annotated[T, forward]`, like `Many[T]` is for a list: 

294 

295```python 

296@task(pre=[build, lint]) 

297def check(fix: Forward[bool] = False): ... 

298``` 

299 

300`Forward[bool]` expands to `Annotated[bool, forward]` — the same marker, less 

301noise on a signature full of forwarded parameters. See `forward`.""" 

302 

303 

304class PathRequirement: 

305 """Marker for `exists` / `isfile` / `isdir`.""" 

306 

307 __slots__ = ("_name", "kind") 

308 

309 kind: str 

310 _name: str 

311 

312 def __init__(self, kind: str, name: str) -> None: 

313 self.kind = kind 

314 self._name = name 

315 

316 def __repr__(self) -> str: 

317 return self._name 

318 

319 

320exists: Final[PathRequirement] = PathRequirement("exists", "exists") 

321"""Require a `Path` parameter to name something that exists on disk: 

322 

323```python 

324def rm(target: Annotated[Path, exists]): ... 

325``` 

326 

327Validated eagerly (at parse time) with a taught error. See also `isfile` 

328and `isdir`.""" 

329 

330isfile: Final[PathRequirement] = PathRequirement("file", "isfile") 

331"""Require a `Path` parameter to name an existing *file* (see `exists`).""" 

332 

333isdir: Final[PathRequirement] = PathRequirement("dir", "isdir") 

334"""Require a `Path` parameter to name an existing *directory* (see `exists`).""" 

335 

336 

337class matching: 

338 """Narrow a `Path` parameter's <kbd>Tab</kbd> completion to a glob: 

339 

340 ```python 

341 def load(env_file: Annotated[Path, matching(".env*")] = Path(".env")): ... 

342 ``` 

343 

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. 

349 

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. 

353 

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 """ 

359 

360 __slots__ = ("pattern",) 

361 

362 pattern: str 

363 

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 

368 

369 def __repr__(self) -> str: 

370 return f"matching({self.pattern!r})" 

371 

372 

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]`.""" 

376 

377IsFile = Annotated[Path, isfile] 

378"""Shorthand for `Annotated[Path, isfile]`: require an existing *file*.""" 

379 

380IsDir = Annotated[Path, isdir] 

381"""Shorthand for `Annotated[Path, isdir]`: require an existing *directory*.""" 

382 

383 

384class between: 

385 """Inclusive numeric bounds for an `int`/`float` parameter: 

386 

387 ```python 

388 def test(jobs: Annotated[int, between(1, 32)] = 4): ... 

389 ``` 

390 

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 """ 

396 

397 __slots__ = ("hi", "lo") 

398 

399 lo: float | None 

400 hi: float | None 

401 

402 def __init__(self, lo: float | None, hi: float | None) -> None: 

403 self.lo = lo 

404 self.hi = hi 

405 

406 

407class env: 

408 """Fall back to an environment variable when the option isn't given: 

409 

410 ```python 

411 def deploy(target: Annotated[str, env("DEPLOY_ENV")] = "staging"): ... 

412 ``` 

413 

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 """ 

421 

422 __slots__ = ("var",) 

423 

424 var: str 

425 

426 def __init__(self, var: str) -> None: 

427 self.var = var 

428 

429 

430class stdin: 

431 """Bind this parameter from the process's standard input, via `Annotated`: 

432 

433 ```python 

434 def review(diff: Annotated[str, stdin] = ""): ... 

435 # git diff | fm review AND fm review < changes.patch 

436 ``` 

437 

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: 

441 

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. 

448 

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 """ 

458 

459 __slots__ = ("field", "lines") 

460 

461 field: str | None 

462 lines: bool 

463 

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 

472 

473 

474Stdin = Annotated[_T, stdin] 

475"""Shorthand for `Annotated[T, stdin]`, in the `Arg`/`Forward` mould: 

476 

477```python 

478def review(diff: Stdin[str] = ""): ... 

479``` 

480 

481The bare-marker form only — the call forms (`stdin("field")`, 

482`stdin(lines=True)`) are spelled inside `Annotated`, like `env("VAR")`.""" 

483 

484 

485class StdoutMarker: 

486 """Marker for `stdout`.""" 

487 

488 __slots__ = () 

489 

490 def __repr__(self) -> str: 

491 return "stdout" 

492 

493 

494stdout: Final[StdoutMarker] = StdoutMarker() 

495"""Declare that a task's return value is the document on stdout, via the 

496*return* annotation: 

497 

498```python 

499@task 

500def status() -> Stdout[dict]: ... 

501# fm status | jq .branch 

502``` 

503 

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. 

515 

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.""" 

525 

526 

527Stdout = Annotated[_T, stdout] 

528"""Shorthand for `Annotated[T, stdout]` on a *return* annotation: 

529 

530```python 

531def wordcount(text: Stdin[str] = "") -> Stdout[int]: ... 

532``` 

533 

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`.""" 

537 

538 

539class check: 

540 """A custom validator, run after coercion; raise `ValueError` to reject: 

541 

542 ```python 

543 def tag(version: Annotated[str, check(semver)]): ... 

544 ``` 

545 

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. 

548 

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: 

553 

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}") 

559 

560 def release(name: str, version: Annotated[str, check(newer)]): ... 

561 ``` 

562 """ 

563 

564 __slots__ = ("fn",) 

565 

566 fn: Callable[..., Any] 

567 

568 def __init__(self, fn: Callable[..., Any]) -> None: 

569 self.fn = fn 

570 

571 

572class doc: 

573 """Help text for one parameter, via `Annotated`: 

574 

575 ```python 

576 def lint(fix: Annotated[bool, doc("apply fixes in place")] = False): ... 

577 ``` 

578 

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 """ 

584 

585 __slots__ = ("text",) 

586 

587 text: str 

588 

589 def __init__(self, text: str) -> None: 

590 self.text = text 

591 

592 

593class ask: 

594 """Prompt for a parameter's value when it isn't supplied, via `Annotated`: 

595 

596 ```python 

597 def release(version: Annotated[str, ask()]): ... 

598 ``` 

599 

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**. 

604 

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. 

610 

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. 

614 

615 `secret=True` hides the input (getpass) and never shows the default, though 

616 Enter still accepts it; `prompt="…"` overrides the question text. 

617 """ 

618 

619 __slots__ = ("prompt", "secret") 

620 

621 prompt: str | None 

622 secret: bool 

623 

624 def __init__(self, *, secret: bool = False, prompt: str | None = None) -> None: 

625 self.secret = secret 

626 self.prompt = prompt