Coverage for src/footman/_manifest.py: 95%

584 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""Manifest generation and caching — the "cold" path. 

2 

3The manifest is a JSON description of the command tree (groups, tasks, and the 

4CLI shape of every parameter). The execution path imports the user's tasks 

5module anyway, so introspecting the tree and rewriting the cache is effectively 

6free. The completion hot path (`footman._complete`) only ever *reads* the 

7cached JSON — it never imports this module or the user's code. 

8 

9Parameter mapping (function signature -> CLI shape): 

10 

11| Signature | CLI shape | 

12| ------------------------ | ----------------------------------------- | 

13| `fix: bool = False` | flag `--fix` / `--no-fix` | 

14| `mode: str = "loose"` | option `--mode=VALUE`, bare `--mode` ok | 

15| `env: Literal[...]` | completable, eagerly-validated choices | 

16| `count: int = 100` | typed option, validated at parse time | 

17| `paths: list[Path] = ()` | repeatable option (`--paths a --paths b`) | 

18| `template: Path` | required positional (exact arity) | 

19| `*cmd: str` | variadic trailing passthrough | 

20""" 

21 

22from __future__ import annotations 

23 

24import contextlib 

25import dataclasses 

26import datetime 

27import decimal 

28import enum 

29import hashlib 

30import inspect 

31import json 

32import os 

33import sys 

34import typing 

35import uuid 

36from pathlib import Path, PurePath 

37from typing import Any 

38 

39from footman import _binder, _coerce, _describe, _discover, _paths, registry 

40from footman.context import context_param_name 

41from footman.params import suggest 

42from footman.registry import Group 

43 

44SCHEMA_VERSION = 11 

45 

46_warned: set[str] = set() 

47 

48 

49def _warn(message: str) -> None: 

50 """An advisory on stderr, once per message — never a Python UserWarning. 

51 

52 `warnings.warn` dressed these as `…/_manifest.py:416: UserWarning:` with 

53 footman's own source line quoted underneath — internals nobody asked 

54 about, above every `--help` and `--list`, pointing at the framework 

55 instead of the user's file the message already names. The message is 

56 the whole story; it is delivered as one line, once, like every other 

57 advisory footman prints. 

58 """ 

59 if message in _warned: 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true

60 return 

61 _warned.add(message) 

62 print(message, file=sys.stderr) 

63 

64 

65class ManifestError(Exception): 

66 """A tasks file describes a command surface footman cannot honour. 

67 

68 Raised at manifest-build time (the execution path) with a taught message; 

69 the app layer reports it and exits 2. 

70 """ 

71 

72 

73class CompleterError(ManifestError): 

74 """A strict dynamic completer failed while refreshing its choices. 

75 

76 Raised so a broken completer surfaces as a taught error instead of 

77 silently baking an empty choice list — which would disable the very 

78 validation `strict=True` promises. 

79 """ 

80 

81 

82class SpecError(ManifestError): 

83 """A parameter's markers are inconsistent (e.g. `env()` with no default).""" 

84 

85 

86_SIGNATURE = "_footman_signature" 

87_CALL_SIGNATURE = "_footman_call_signature" 

88 

89 

90def _memo(fn: Any, attr: str) -> tuple[Any, inspect.Signature | None]: 

91 """The body to stamp a signature memo on, and the memo already there. 

92 

93 Keyed on the **body**, through `task_body`, so a handle, an `.opts()` 

94 reference and the bare function share one answer — they share one 

95 signature by construction (`__wrapped__` runs the whole way down). A 

96 callable that refuses attributes (a builtin) simply never memoises. 

97 """ 

98 body = registry.task_body(fn) 

99 return body, getattr(body, attr, None) 

100 

101 

102def resolved_signature(fn: Any) -> inspect.Signature: 

103 """Signature of *fn* with string annotations evaluated to real types. 

104 

105 `from __future__ import annotations` (and any PEP 563 usage) turns a 

106 tasks file's annotations into strings; `eval_str` turns them back into the 

107 types the grammar reasons about. `eval_str` is all-or-nothing — one name 

108 that cannot resolve raises for the whole signature — so on failure each 

109 annotation is evaluated on its own: one broken parameter degrades to 

110 pass-through text, and the rest keep their types, choices and completion. 

111 

112 Memoised on the function, the `_footman_*` house pattern: the answer is a 

113 pure function of a function object whose annotations are fixed by the 

114 time anything asks (discovery imports a module whole before a task in it 

115 can run), and the ask is *hot* — every execution path asks about a task 

116 around eight times, at ~68 µs a call on a string-annotated file, and the 

117 manifest asks once more per task. The memo dies with the function it is 

118 stamped on, and discovery's per-file isolation mints fresh functions on 

119 every load, so it cannot outlive what it describes. 

120 """ 

121 body, cached = _memo(fn, _SIGNATURE) 

122 if cached is not None: 

123 return cached 

124 try: 

125 sig = inspect.signature(fn, eval_str=True) 

126 except (NameError, TypeError, AttributeError): 

127 sig = _partially_resolved(fn) 

128 with contextlib.suppress(AttributeError, TypeError): 

129 setattr(body, _SIGNATURE, sig) 

130 return sig 

131 

132 

133def _partially_resolved(fn: Any) -> inspect.Signature: 

134 """The raw signature with every annotation that *can* resolve, resolved. 

135 

136 Evaluates each string annotation against the function's module globals — 

137 the same environment `eval_str` uses — and leaves only the failing ones 

138 as strings, warning once per broken parameter with the underlying error. 

139 (`warnings` dedups an identical message per process, so the repeated 

140 `resolved_signature` calls of one invocation say this once.) 

141 """ 

142 sig = inspect.signature(fn) 

143 module_globals = getattr(fn, "__globals__", {}) 

144 where = f"{getattr(fn, '__module__', '?')}.{getattr(fn, '__qualname__', fn)}" 

145 

146 def resolve(name: str, annotation: Any) -> Any: 

147 if not isinstance(annotation, str): 

148 return annotation 

149 try: 

150 # What `eval_str` does per annotation, minus the all-or-nothing. 

151 return eval(annotation, module_globals) 

152 except Exception as exc: # any failure means "not a usable type" 

153 _warn( 

154 f"footman: {where} <{name}>: annotation {annotation!r} did " 

155 f"not resolve ({exc}); values pass through as text" 

156 ) 

157 return annotation 

158 

159 params = [ 

160 p.replace(annotation=resolve(p.name, p.annotation)) 

161 for p in sig.parameters.values() 

162 ] 

163 return sig.replace( 

164 parameters=params, 

165 return_annotation=resolve("return", sig.return_annotation), 

166 ) 

167 

168 

169def call_signature(fn: Any) -> inspect.Signature: 

170 """The signature a Python caller binds against: the declared one minus the 

171 context parameter. A body call never passes `ctx` — `run_bound` injects it 

172 at the task boundary — so binding a call's arguments against the declared 

173 signature would land the first positional value in the `ctx` slot. 

174 

175 Memoised beside `resolved_signature`, for the same reason and with the 

176 same lifetime: `given()` and the futures memo ask per call, not per run. 

177 """ 

178 body, cached = _memo(fn, _CALL_SIGNATURE) 

179 if cached is not None: 

180 return cached 

181 sig = resolved_signature(fn) 

182 name = context_param_name(sig) 

183 if name is not None: 

184 sig = sig.replace( 

185 parameters=[p for p in sig.parameters.values() if p.name != name] 

186 ) 

187 with contextlib.suppress(AttributeError, TypeError): 

188 setattr(body, _CALL_SIGNATURE, sig) 

189 return sig 

190 

191 

192def _unique_globals(root: Group) -> list[Any]: 

193 """The tree's plugin globals, deduped by identity, contribution order. 

194 

195 The same singleton mounted through two routes is one option; a name clash 

196 between two different singletons was refused at discovery, before the 

197 manifest could bake either.""" 

198 seen: list[Any] = [] 

199 for opt in root.contributions.get("globals", ()): 

200 if not any(o is opt for o in seen): 200 ↛ 199line 200 didn't jump to line 199 because the condition on line 200 was always true

201 seen.append(opt) 

202 return seen 

203 

204 

205def _global_spec( 

206 opt: Any, memo: dict[int, list[str]], *, bake: bool = False 

207) -> dict[str, Any]: 

208 """One manifest entry for a plugin's global option — described by the 

209 same machinery as a task parameter, so choices, path-typed file 

210 completion and `suggest()` come along by construction.""" 

211 # The option builds its own synthetic parameter — one construction, 

212 # shared with the binder's absent-option ladder. (A bool default is 

213 # already normalised at declaration, by GlobalOption itself.) 

214 spec = _finish(param_spec(opt._as_parameter()), memo, bake=bake) 

215 spec["name"] = opt.name # the cli spelling is the identity 

216 if opt.help: 

217 spec["help"] = opt.help 

218 spec["owner"] = opt.owner 

219 return spec 

220 

221 

222def param_spec(param: inspect.Parameter) -> dict[str, Any]: 

223 """Map one function parameter to its CLI shape (one manifest entry). 

224 

225 Dynamic-completer params get a transient `_completer` key that 

226 `_finish` replaces with the completer's (cached) choices. 

227 """ 

228 spec: dict[str, Any] = {"name": registry.cli_name(param.name)} 

229 ann = param.annotation 

230 empty = inspect.Parameter.empty 

231 

232 if param.kind is inspect.Parameter.VAR_KEYWORD: 

233 raise SpecError( 

234 f"**{param.name} is not supported — declare named parameters, or " 

235 f"accept KEY=VALUE pairs with a dict[str, str] parameter" 

236 ) 

237 

238 if param.kind is inspect.Parameter.VAR_POSITIONAL: 

239 spec["kind"] = "variadic" 

240 if ann is not empty: 

241 peeled = _coerce.peel(ann) # unwrap Annotated so markers reach the spec 

242 tags = _coerce.element_tags(peeled.element) 

243 if tags and tags != ["str"]: 

244 spec["types"] = tags 

245 _marker_keys(spec, peeled, param, has_default=False) 

246 return spec 

247 

248 has_default = param.default is not empty 

249 if has_default: 

250 # Bake the default into the manifest when it survives the JSON 

251 # coercion mirror (Path → str, Enum → value, …) — an additive key for 

252 # help, the catalog, and the markdown exporter. An exotic default is 

253 # simply omitted, never an error. 

254 ok_default, encoded = _describe.jsonable(param.default) 

255 if ok_default: 

256 spec["default"] = encoded 

257 # A keyword-only parameter (after `*` or `*args`) is an option by 

258 # Python's own declaration — defaultless, it is a *required* option, 

259 # the same shape defaultless dicts and flags already take. 

260 kw_only = param.kind is inspect.Parameter.KEYWORD_ONLY 

261 

262 if ann is empty: 

263 if isinstance(param.default, bool): 

264 spec["kind"] = "flag" 

265 elif has_default or kw_only: 

266 spec["kind"] = "option" 

267 if not has_default: 

268 spec["required"] = True 

269 else: 

270 spec["kind"] = "positional" 

271 # A basic default types the parameter as surely as an annotation 

272 # would — `port=8000` is an int to Python and to every checker, so 

273 # it is an int here. Recording the tags is what buys the eager 

274 # refusal, the catalog's `types`, and the coercion at bind time; 

275 # `str` is omitted like any other str-tagged spec, since it is the 

276 # shape a bare command-line value already has. 

277 inferred = _coerce.inferred_type(param.default) 

278 if inferred is not None and (tags := _coerce.element_tags(inferred)) != ["str"]: 

279 spec["types"] = tags 

280 return spec 

281 

282 peeled = _coerce.peel(ann) 

283 if peeled.mapping: 

284 # A dict is always an option (--name KEY=VALUE); when it has no default 

285 # it is a *required* option — footman has no positional-mapping syntax. 

286 spec["kind"] = "option" 

287 spec["mapping"] = True 

288 if not has_default: 

289 spec["required"] = True 

290 _marker_keys(spec, peeled, param, has_default) 

291 if peeled.nosplit: 

292 spec["nosplit"] = True 

293 if (ktags := _coerce.element_tags(peeled.key)) and ktags != ["str"]: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 spec["key_types"] = ktags 

295 vchoices = _coerce.all_choices(peeled.element) 

296 vtags = _coerce.element_tags(peeled.element) 

297 if vchoices is not None: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 spec["value_choices"] = vchoices 

299 if vtags and vtags != ["str"] and _coerce.eagerly_checkable(peeled.element): 

300 spec["value_types"] = vtags 

301 return spec 

302 

303 element = peeled.element 

304 reads_document = ( 

305 peeled.stdin is not None 

306 and peeled.stdin.field is None 

307 and not peeled.stdin.lines 

308 and element is not bytes 

309 ) 

310 record = _coerce.fields_of(element) if reads_document else None 

311 if record is not None: 

312 # What a machine needs to build the JSON this parameter expects: the 

313 # shape's name, its fields, each field's coercion tags and whether it 

314 # must be given. A name alone — which is all this used to carry — told 

315 # a reader the pipe wanted a `Config` and nothing whatever about what 

316 # a `Config` is. 

317 spec["shape"] = _shape_spec(element) 

318 if record is not None and not peeled.multiple and _coerce.group_of(element) is None: 

319 # No command-line spelling, so no token spelling: a shape with a 

320 # nested record or a collection in it cannot say where a slot ends 

321 # inside a comma-separated group, and a TypedDict has named keys 

322 # rather than positions. The splitter and completion key on the known 

323 # kinds, so `"stdin"` is invisible to them by construction; help still 

324 # lists it, with the shape it binds. 

325 # 

326 # A shape that *does* group keeps its `--opt=a,b` spelling and reads 

327 # the pipe too — the command line wins when both are given. 

328 spec["kind"] = "stdin" 

329 _marker_keys(spec, peeled, param, has_default) 

330 return spec 

331 

332 if _coerce.is_flag(element) and not peeled.multiple: 

333 # Only a *scalar* bool is a --flag; `list[bool]` stays a repeatable 

334 # option whose tokens parse as booleans (true/false/1/0/yes/no/on/off). 

335 spec["kind"] = "flag" 

336 # ask() prompts if absent; stdin fills at the boundary — either one 

337 # makes a defaultless flag satisfiable without the command line. 

338 if not has_default and peeled.ask is None and peeled.stdin is None: 

339 spec["required"] = True # else state it explicitly: --x or --no-x 

340 _marker_keys(spec, peeled, param, has_default) 

341 return spec 

342 

343 if peeled.optional: 

344 # Arg[T]: an optional trailing positional — greedy for one token when 

345 # present, running on the default when absent (`+` says "absent, next 

346 # task"). Deterministic by construction: no name-peeking, cap one. 

347 if not has_default: 

348 raise SpecError( 

349 f"<{param.name}>: Arg[…] needs a default — an optional " 

350 f"positional's absence must mean something" 

351 ) 

352 if peeled.multiple: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 raise SpecError( 

354 f"<{param.name}>: Arg[…] takes at most one token; a " 

355 f"many-valued positional is already optionalable as " 

356 f"`Many[…]` with a default" 

357 ) 

358 spec["kind"] = "positional" 

359 spec["optional"] = True 

360 elif (peeled.ask is not None or peeled.stdin is not None) and not has_default: 

361 # ask() and stdin both make a defaultless parameter a CLI-optional 

362 # option: absence is filled at the boundary (a prompt, the piped 

363 # payload) or refused with a taught message there, so the splitter 

364 # must let it be missing rather than enforce it as a required 

365 # positional. 

366 spec["kind"] = "option" 

367 elif has_default or kw_only: 

368 spec["kind"] = "option" 

369 if not has_default: 

370 spec["required"] = True 

371 else: 

372 spec["kind"] = "positional" 

373 _marker_keys(spec, peeled, param, has_default) 

374 # A fixed-arity shape accumulates values exactly as a collection does — 

375 # commas and repetition both feed one stream — and the declared arity 

376 # groups that stream. So it is `multiple` to the splitter, plus a 

377 # `group` the splitter and `--help` read for arity and per-position 

378 # types. The class itself is not in the manifest (it is not JSON); the 

379 # executor reads it back off the annotation when it builds. 

380 group = _coerce.group_of(peeled.element) 

381 if group is not None: 

382 spec["multiple"] = True 

383 spec["group"] = { 

384 "names": list(group.names) if group.names else None, 

385 "types": [_coerce.element_tags(t) for t in group.types], 

386 "min": group.required, 

387 "max": group.total, 

388 "many": peeled.multiple, 

389 "label": group.label(), 

390 } 

391 if peeled.nosplit: 391 ↛ 392line 391 didn't jump to line 392 because the condition on line 391 was never true

392 spec["nosplit"] = True 

393 return spec 

394 if peeled.multiple: 

395 spec["multiple"] = True 

396 if peeled.nosplit: 

397 spec["nosplit"] = True 

398 if peeled.completer is not None: 

399 # No `choices` key at all unless someone bakes one: *absent* means 

400 # "nobody has asked yet", which is what lets validation and help 

401 # resolve live, while a baked `[]` keeps its old meaning — the 

402 # completer ran and genuinely offered nothing. 

403 spec["dynamic"] = {"strict": peeled.completer.strict} 

404 spec["_completer"] = peeled.completer 

405 return spec 

406 

407 try: 

408 choices = _coerce.all_choices(element) 

409 accepts = _coerce.enum_accepts(element) if choices is not None else [] 

410 except _coerce.EnumContractError as exc: 

411 # Declaration-time refusal, loudly, through the taught door: the 

412 # enum spells an ambiguous or unprojectable surface, and refusing 

413 # here — where the contract is first read — is what keeps every 

414 # later door free of precedence rules. 

415 raise ManifestError(f"parameter {param.name!r}: {exc}") from exc 

416 tags = _coerce.element_tags(element) 

417 if choices is not None: 

418 spec["choices"] = choices 

419 # The accepted synonym set beyond the choices — an enum's aliases 

420 # and value faces. Baked, so every frontend's eager gate accepts 

421 # the same inputs by contract: authored and enumerated, never 

422 # leniency. Completion and help read `choices` alone; a synonym is 

423 # accepted, not taught. 

424 if accepts: 

425 spec["accepts"] = accepts 

426 # Emit `types` only when the element is eagerly checkable — a union with a 

427 # custom member (`UUID | int`) can't be accept/rejected up front, so leave 

428 # it to binding rather than eagerly rejecting valid values. 

429 if tags and tags != ["str"] and _coerce.eagerly_checkable(element): 

430 spec["types"] = tags 

431 elif choices is None and not tags and not isinstance(element, type): 

432 if isinstance(element, str) and "stdin" in element: 

433 # `eval_str` failed for the whole signature, so the marker never 

434 # even peeled — but the annotation names it, and a silently 

435 # text-degraded stdin parameter would be a mystery at bind time. 

436 raise SpecError( 

437 f"<{param.name}>: annotation {element!r} did not resolve — " 

438 f"a stdin payload type and everything it names must be " 

439 f"module-level, where `eval_str` can see them" 

440 ) 

441 # The annotation resolves to nothing footman can coerce (a value, an 

442 # exotic generic): values will pass through as plain text. Silent 

443 # degrade is a debugging tax — say so. A *string* landing here was 

444 # already reported by `_partially_resolved`, which knows the task and 

445 # the underlying error; repeating it per spec build would turn one 

446 # broken name back into a warning block. 

447 if not isinstance(element, str): 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 _warn( 

449 f"footman: parameter {param.name!r}: annotation {element!r} " 

450 f"is not a usable type; values are passed through as text" 

451 ) 

452 return spec 

453 

454 

455def _shape_spec(target: Any, seen: tuple[Any, ...] = ()) -> dict[str, Any]: 

456 """The JSON a document parameter expects, as data a machine can act on: 

457 the shape's name and, for each field, its name, its type, whether it must 

458 be given, and — when the field is itself a record — that field's shape in 

459 turn. 

460 

461 A field with no `types`, `choices` or `shape` is one footman does not 

462 coerce: whatever JSON holds arrives as it is. That is a description, not 

463 an omission. 

464 

465 A shape already on the way down is emitted by name alone. A 

466 self-referential record has no finite expansion, and every shape appears 

467 in full somewhere above, so the name resolves. 

468 """ 

469 spec: dict[str, Any] = {"name": getattr(target, "__name__", str(target))} 

470 if target in seen: 

471 return spec # recursive: named above, expanded there 

472 fields = _coerce.fields_of(target) 

473 if fields is None: 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true

474 return spec 

475 spec["fields"] = [_field_spec(f, (*seen, target)) for f in fields] 

476 return spec 

477 

478 

479def _field_spec(field: _coerce.Field, seen: tuple[Any, ...]) -> dict[str, Any]: 

480 out: dict[str, Any] = {"name": field.name} 

481 inner = field.type 

482 if (collection := _coerce.collection_of(inner)) is not None: 

483 inner = (typing.get_args(inner) or (str,))[0] 

484 out["many"] = collection.__name__ 

485 if _coerce.fields_of(inner) is not None: 

486 out["shape"] = _shape_spec(inner, seen) 

487 else: 

488 if (choices := _coerce.all_choices(inner)) is not None: 

489 out["choices"] = choices 

490 if tags := _coerce.element_tags(inner): 

491 out["types"] = tags 

492 if field.required: 

493 out["required"] = True 

494 return out 

495 

496 

497def _marker_keys( 

498 spec: dict[str, Any], 

499 peeled: _coerce.Peeled, 

500 param: inspect.Parameter, 

501 has_default: bool, 

502) -> None: 

503 """Additive manifest keys for the `Annotated` markers (path/bounds/env/doc). 

504 

505 `check(fn)` deliberately never lands in the manifest — functions don't 

506 serialize (the same reason `_finish` strips `_completer`); it runs at 

507 binding time instead. 

508 """ 

509 if peeled.hidden: 

510 # Marked, never missing — the same rule a hidden *task* follows. A 

511 # machine reading the manifest is exactly who is meant to find it; 

512 # only the listing a human reads leaves it out. 

513 spec["hidden"] = True 

514 if peeled.ask is not None and peeled.ask.secret: 

515 # A secret parameter never publishes values: no baked choices, no 

516 # completer run — the flag completes, its value stays yours. 

517 spec["secret"] = True 

518 if peeled.doc is not None: 

519 spec["doc"] = peeled.doc 

520 if peeled.path_req is not None: 

521 spec["path"] = peeled.path_req 

522 if peeled.glob is not None: 

523 # Completion only: the pattern the shell's own file completion filters 

524 # by. Never a validation rule — see `matching`. 

525 spec["glob"] = peeled.glob 

526 if peeled.bounds is not None: 

527 lo, hi = peeled.bounds 

528 if lo is not None: 528 ↛ 530line 528 didn't jump to line 530 because the condition on line 528 was always true

529 spec["min"] = lo 

530 if hi is not None: 

531 spec["max"] = hi 

532 if peeled.default_fn is not None: 

533 if not has_default: 

534 raise SpecError( 

535 f"<{param.name}>: default(…) needs a declared default to sit " 

536 f"on — a plain Python call of the task, with no run around it, " 

537 f"has nothing else to fall back to" 

538 ) 

539 # Called here, on the execution path, so `--help` prints what this run 

540 # would actually use rather than a value baked whenever a cache was 

541 # last built. A raising computer is left to raise at bind time instead 

542 # of taking the whole tree's help down with it. 

543 spec["computed"] = True # help says so: a bare number reads arbitrary 

544 if peeled.default_fn.reads_siblings: 

545 # It reads values only an invocation has, so there is nothing to 

546 # show — and the *declared* default must go too: it exists so a 

547 # plain Python call still binds, and printing that sentinel would 

548 # advertise the very thing this marker replaces. 

549 spec.pop("default", None) 

550 else: 

551 ok_computed, computed = False, None 

552 with contextlib.suppress(Exception): # help degrades; the run teaches 

553 ok_computed, computed = _describe.jsonable(peeled.default_fn.fn()) 

554 if ok_computed: 554 ↛ 556line 554 didn't jump to line 556 because the condition on line 554 was always true

555 spec["default"] = computed 

556 if peeled.env is not None: 

557 if spec.get("mapping"): 

558 raise SpecError( 

559 f"<{param.name}>: env() is not supported on dict parameters" 

560 ) 

561 if not has_default: 

562 raise SpecError( 

563 f"<{param.name}>: env({peeled.env!r}) needs a default — an " 

564 f"env fallback makes the parameter optional, so it needs " 

565 f"somewhere to fall" 

566 ) 

567 spec["env"] = peeled.env 

568 if peeled.stdin is not None: 

569 marker = peeled.stdin 

570 if spec.get("mapping") and (marker.field is not None or marker.lines): 

571 raise SpecError( 

572 f"<{param.name}>: a dict parameter reads stdin whole (a JSON " 

573 f"object) — stdin(field)/stdin(lines=True) do not apply" 

574 ) 

575 if marker.lines and not peeled.multiple: 

576 raise SpecError( 

577 f"<{param.name}>: stdin(lines=True) needs a list parameter — " 

578 f"each line binds as one element" 

579 ) 

580 if marker.field is not None and peeled.multiple: 

581 raise SpecError( 

582 f"<{param.name}>: stdin({marker.field!r}) binds a single " 

583 f"value — a list parameter reads lines (stdin(lines=True)) " 

584 f"or a JSON array (bare stdin)" 

585 ) 

586 if isinstance(peeled.element, str): 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true

587 raise SpecError( 

588 f"<{param.name}>: annotation {peeled.element!r} did not " 

589 f"resolve — a stdin payload type and everything it names " 

590 f"must be module-level, where `eval_str` can see them" 

591 ) 

592 if marker.field is not None: 

593 spec["stdin"] = f"field:{marker.field}" 

594 elif marker.lines: 

595 spec["stdin"] = "lines" 

596 elif peeled.element is bytes: 

597 spec["stdin"] = "bytes" 

598 elif ( 

599 spec.get("mapping") 

600 or peeled.multiple 

601 or spec.get("kind") == "stdin" 

602 or _binder.is_document_target(peeled.element) 

603 ): 

604 spec["stdin"] = "json" 

605 else: 

606 spec["stdin"] = "text" 

607 

608 

609class _Undescribable(Exception): 

610 """Internal: a return annotation outside the describable set. Never a user 

611 error — "describable" ⊆ "returnable", so the caller degrades to no spec.""" 

612 

613 

614def returned_spec(ann: Any) -> dict[str, Any] | None: 

615 """The output spec a return annotation declares, or None for no claims. 

616 

617 The mirror of `param_spec` for the *output* side: the annotation is the 

618 declaration, and the describable set is exactly what 

619 `_describe.json_default` serialises — dataclasses (nested), TypedDict, 

620 NamedTuple, `list`/`tuple[T, ...]`/`set`/`dict[str, …]`, the scalar 

621 bridge types, `Literal`/`Enum` as choices, `T | None` as nullable. The 

622 spec is footman's own compact shape (one vocabulary with the param 

623 specs); `_describe.returns_json_schema` renders it as JSON Schema at the 

624 describe door. 

625 

626 An annotation outside the set — a broken string, a wider union, an 

627 exotic generic — yields `None`, never an error: the task still runs and 

628 its value still serialises (or refuses) at runtime exactly as before, it 

629 just makes no claims. Bare `int` is the exit-code channel, not data, so 

630 it too declares nothing; `Stdout[T]` describes `T` — the document and 

631 the returned value are one declaration. 

632 """ 

633 if ann is inspect.Parameter.empty or ann is None or ann is type(None): 

634 return None 

635 # `emitted` fully normalises (Annotated *and* Optional stripped) — the 

636 # right view for the bare-int test, where `int | None` is still the 

637 # exit-code channel. The walk below gets the *original* annotation: 

638 # it strips Annotated itself, so `Report | None` keeps its nullability. 

639 _, flat = _coerce.emitted(ann) 

640 if flat is int or flat is Any or flat is object: 

641 return None # exit-code channel / no claims — a schema would lie 

642 try: 

643 return _returned_of(ann, ()) 

644 except _Undescribable: 

645 return None 

646 

647 

648_RETURN_SCALARS: list[tuple[type, str]] = [ 

649 # Order matters where the types nest: datetime before date (a datetime 

650 # *is* a date), bool before int (checked via `is`, so moot, but kept 

651 # explicit in `_returned_of` below). 

652 (datetime.datetime, "datetime"), 

653 (datetime.date, "date"), 

654 (datetime.time, "time"), 

655 (uuid.UUID, "uuid"), 

656 (decimal.Decimal, "decimal"), 

657 (PurePath, "path"), 

658] 

659 

660 

661def _returned_of(ann: Any, seen: tuple[Any, ...]) -> dict[str, Any]: 

662 """One node of the output spec — recursive, raising on the undescribable.""" 

663 while typing.get_origin(ann) is typing.Annotated: 

664 # Markers (`Stdout`, a doc) never change the shape; unions survive. 

665 ann = typing.get_args(ann)[0] 

666 if ann is Any or ann is object: 666 ↛ 667line 666 didn't jump to line 667 because the condition on line 666 was never true

667 return {"kind": "any"} # a real "no claims here" inside a container 

668 if ann is type(None): 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true

669 return {"kind": "none"} 

670 if _coerce._is_union(ann): 

671 # T | None is nullable; a wider union has no json_default story to 

672 # mirror, so it makes no claims at all rather than partial ones. 

673 members = _coerce.union_members(ann) 

674 if len(members) != 1: 

675 raise _Undescribable 

676 spec = _returned_of(members[0], seen) 

677 spec["nullable"] = True 

678 return spec 

679 if typing.get_origin(ann) is typing.Literal: 

680 values: list[Any] = [] 

681 nullable = False 

682 for value in typing.get_args(ann): 

683 if value is None: 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true

684 nullable = True 

685 continue 

686 ok, encoded = _describe.jsonable(value) 

687 if not ok: 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 raise _Undescribable 

689 values.append(encoded) 

690 if not values: 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true

691 raise _Undescribable 

692 literal: dict[str, Any] = {"kind": "enum", "values": values} 

693 if nullable: 693 ↛ 694line 693 didn't jump to line 694 because the condition on line 693 was never true

694 literal["nullable"] = True 

695 return literal 

696 if isinstance(ann, type) and issubclass(ann, enum.Enum): 

697 if not len(ann): 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

698 raise _Undescribable # an empty Enum has no members to claim 

699 # The returns side moves in lockstep with choices: `values` speaks 

700 # tokens (what documents carry, what the JSON Schema projection 

701 # lists natively), and `members` carries the pairs first-class — 

702 # token beside declared value, aliases as input synonyms — so a 

703 # foreign frontend binds the same faces by contract. 

704 pairs: list[dict[str, Any]] = [] 

705 for face in _coerce.enum_faces(ann): 

706 ok, encoded = _describe.jsonable(face.member.value) 

707 if not ok: 707 ↛ 708line 707 didn't jump to line 708 because the condition on line 707 was never true

708 raise _Undescribable 

709 entry: dict[str, Any] = {"token": face.token, "value": encoded} 

710 if face.aliases: 

711 entry["aliases"] = list(face.aliases) 

712 pairs.append(entry) 

713 return { 

714 "kind": "enum", 

715 "name": ann.__name__, 

716 "values": [m["token"] for m in pairs], 

717 "members": pairs, 

718 } 

719 if ann is bool: 

720 return {"kind": "bool"} 

721 if ann is int: 

722 return {"kind": "int"} 

723 if ann is float: 

724 return {"kind": "float"} 

725 if ann is str: 

726 return {"kind": "str"} 

727 if isinstance(ann, type): 

728 for base, kind in _RETURN_SCALARS: 

729 if issubclass(ann, base): 

730 return {"kind": kind} 

731 origin = typing.get_origin(ann) 

732 if ann in (list, set, frozenset) or origin in (list, set, frozenset): 

733 args = typing.get_args(ann) 

734 element = args[0] if args else Any 

735 return {"kind": "list", "items": _returned_of(element, seen)} 

736 if origin is tuple: 

737 args = typing.get_args(ann) 

738 if len(args) == 2 and args[1] is Ellipsis: 

739 return {"kind": "list", "items": _returned_of(args[0], seen)} 

740 raise _Undescribable # a heterogeneous tuple has no named positions 

741 if ann is dict or origin is dict: 

742 args = typing.get_args(ann) 

743 if not args: 

744 return {"kind": "object"} # no field claims 

745 key, value = args 

746 if key is not str: 

747 raise _Undescribable # JSON object keys are strings 

748 if value is Any or value is object: 

749 return {"kind": "object"} 

750 return {"kind": "map", "values": _returned_of(value, seen)} 

751 if typing.is_typeddict(ann): 

752 return _returned_fields(ann, seen, kind="object", typeddict=True) 

753 if dataclasses.is_dataclass(ann) and isinstance(ann, type): 

754 # Every dataclass field serialises (`asdict` has no optionality). 

755 return _returned_fields(ann, seen, kind="object") 

756 if isinstance(ann, type) and issubclass(ann, tuple) and hasattr(ann, "_fields"): 

757 # A NamedTuple serialises as a JSON *array* (it is a tuple); "row" 

758 # keeps that honest — named positions, not an object. 

759 return _returned_fields(ann, seen, kind="row") 

760 raise _Undescribable 

761 

762 

763def _returned_fields( 

764 cls: Any, 

765 seen: tuple[Any, ...], 

766 *, 

767 kind: str, 

768 typeddict: bool = False, 

769) -> dict[str, Any]: 

770 """An `object`/`row` spec for a dataclass, TypedDict, or NamedTuple. 

771 

772 Fields ride as a name-keyed mapping in declaration order (for a "row" 

773 the order *is* the positions). A recursive shape has no finite spec to 

774 bake, so re-entering a class under description bails the whole 

775 annotation out to "no claims".""" 

776 if any(cls is s for s in seen): 

777 raise _Undescribable 

778 # Read the class-level facts before any `is_dataclass` narrowing below 

779 # can convince a type-checker `cls` no longer has them. 

780 class_name = str(getattr(cls, "__name__", cls)) 

781 total = bool(getattr(cls, "__total__", True)) 

782 try: 

783 # `include_extras` keeps `Required`/`NotRequired` visible: under 

784 # `from __future__ import annotations` a TypedDict's own 

785 # `__required_keys__` cannot see through the string forms, but the 

786 # resolved wrappers can't lie. 

787 hints = typing.get_type_hints(cls, include_extras=True) 

788 except Exception as exc: 

789 raise _Undescribable from exc # a name in a field didn't resolve 

790 if dataclasses.is_dataclass(cls): 

791 names = [f.name for f in dataclasses.fields(cls)] 

792 elif kind == "row": 

793 names = list(cls._fields) 

794 else: 

795 names = list(hints) 

796 fields: dict[str, Any] = {} 

797 for name in names: 

798 hint = hints[name] 

799 required = True 

800 if typeddict: 

801 origin = typing.get_origin(hint) 

802 if origin is typing.Required: 802 ↛ 803line 802 didn't jump to line 803 because the condition on line 802 was never true

803 hint = typing.get_args(hint)[0] 

804 elif origin is typing.NotRequired: 

805 hint, required = typing.get_args(hint)[0], False 

806 else: 

807 required = total 

808 spec = _returned_of(hint, (*seen, cls)) 

809 if not required: 

810 spec["required"] = False 

811 fields[name] = spec 

812 if not fields: 812 ↛ 813line 812 didn't jump to line 813 because the condition on line 812 was never true

813 raise _Undescribable # a fieldless shape claims nothing worth baking 

814 return {"kind": kind, "name": class_name, "fields": fields} 

815 

816 

817def _run_completer(completer: suggest, memo: dict[int, list[str]]) -> list[str]: 

818 """Call a completer at most once per build (deduped by function identity). 

819 

820 A raising *strict* completer aborts the build with `CompleterError` — its 

821 whole point is validation, so failing silent would validate nothing. A 

822 best-effort completer (`strict=False`) degrades to no candidates. 

823 """ 

824 key = id(completer.fn) 

825 if key not in memo: 

826 try: 

827 memo[key] = [str(v) for v in completer.fn()] 

828 except Exception as exc: 

829 if completer.strict: 

830 name = getattr(completer.fn, "__qualname__", repr(completer.fn)) 

831 raise CompleterError( 

832 f"dynamic choices from {name}() failed: " 

833 f"{type(exc).__name__}: {exc} — fix the completer, or pass " 

834 f"suggest(fn, strict=False) if this data is best-effort" 

835 ) from exc 

836 memo[key] = [] 

837 return memo[key] 

838 

839 

840def _finish( 

841 spec: dict[str, Any], memo: dict[int, list[str]], *, bake: bool = False 

842) -> dict[str, Any]: 

843 """Drop the transient completer, baking its choices only when asked. 

844 

845 *bake* is for a surface that renders every parameter it can find and has 

846 nowhere to resolve later — the docs exporter. Everywhere else the 

847 completer stays unrun: the command line resolves the one parameter it is 

848 validating, help resolves the ones it prints, and a <kbd>Tab</kbd> 

849 recomputes fresh anyway. Running them all on every invocation is what 

850 made an unrelated `fm build` shell out to a git-branch completer. 

851 """ 

852 completer = spec.pop("_completer", None) 

853 if spec.get("secret"): 

854 spec.pop("choices", None) # never bake a secret's values 

855 completer = None 

856 if completer is not None and bake: 

857 spec["choices"] = _run_completer(completer, memo) 

858 return spec 

859 

860 

861def _cli_params(fn: Any) -> list[inspect.Parameter]: 

862 """The parameters that form a task's CLI (the injected ctx is not one).""" 

863 sig = resolved_signature(fn) 

864 ctx_name = context_param_name(sig) 

865 return [p for p in sig.parameters.values() if p.name != ctx_name] 

866 

867 

868def _source_of(fn: Any) -> str: 

869 """Where *fn* is written, for the shadow chain `--help` prints. 

870 

871 The one answer, shared with the refusals that name a declaration and the 

872 failure line that places an exception: three spellings of 

873 `co_filename:co_firstlineno` is how they drift apart. 

874 """ 

875 return _describe.source_of(fn) 

876 

877 

878def _task_node( 

879 fn: Any, 

880 memo: dict[int, list[str]], 

881 *, 

882 bake: bool = False, 

883 user_dir: str | None = None, 

884) -> dict[str, Any]: 

885 sig = resolved_signature(fn) 

886 infinite = registry.is_infinite(fn) 

887 interactive = registry.is_interactive(fn) 

888 confirm = registry.task_confirm(fn) 

889 lane = registry.task_lane(fn) 

890 ctx_name = context_param_name(sig) # the injected ctx param is not a CLI arg 

891 # Imported here, not at module scope: the module compiles eleven regexes 

892 # on the way in (~0.24 ms of its 0.65 ms) and only manifest *building* 

893 # reads a docstring. 

894 # 

895 # Worth being honest about what that buys today: `sync_manifest` builds 

896 # unconditionally, so every run reaches this line anyway and only 

897 # `--version` is spared. It pays properly once building is skipped when 

898 # nothing changed — see notes/20260816-startup-perf.md, which measures 

899 # that rebuild at ~70 us per task on every run. 

900 from footman import docstrings 

901 

902 parsed = docstrings.parse(inspect.getdoc(fn)) 

903 params = [ 

904 _finish(param_spec(p), memo, bake=bake) 

905 for p in sig.parameters.values() 

906 if p.name != ctx_name 

907 ] 

908 # An optional positional must trail everything positional: a required 

909 # argument or a rest-consumer after it would make "which token is 

910 # whose" ambiguous — exactly what the grammar refuses to be. 

911 seen_optional: str | None = None 

912 for spec in params: 

913 if seen_optional is not None and ( 

914 spec["kind"] == "variadic" 

915 or (spec["kind"] == "positional" and not spec.get("optional")) 

916 or (spec["kind"] == "positional" and spec.get("multiple")) 

917 ): 

918 raise SpecError( 

919 f"<{seen_optional}>: an Arg[…] optional positional must come " 

920 f"last — <{spec['name']}> follows it, so which token belongs " 

921 f"to whom would be a guess. Reorder, or make " 

922 f"<{spec['name']}> an option." 

923 ) 

924 if spec["kind"] == "positional" and spec.get("optional"): 

925 seen_optional = spec["name"] 

926 for spec in params: 

927 if spec["name"] == "help" and spec["kind"] in ("flag", "option"): 

928 raise SpecError( 

929 "<help>: 'help' is a reserved parameter name — it maps to " 

930 "--help, which footman intercepts anywhere on the line to show " 

931 "help and never run a task, so the option could never be " 

932 "reached. Rename it (e.g. show_help). It is the only reserved " 

933 "name: every other global must come before the first task, so a " 

934 "task parameter may reuse it (fm deploy --json binds --json to " 

935 "deploy)." 

936 ) 

937 known: set[str] = set() 

938 for spec in params: 

939 python_name = str(spec["name"]).replace("-", "_") 

940 known.add(python_name) 

941 # The docstring fills in; an explicit doc() marker already won. 

942 if "doc" not in spec and (text := parsed.params.get(python_name)): 

943 spec["doc"] = text 

944 if ctx_name: 

945 known.add(ctx_name) # documenting the injected ctx param is fine 

946 if unknown := sorted(set(parsed.params) - known): 

947 _warn( 

948 f"footman: {getattr(fn, '__name__', fn)!s}: docstring documents " 

949 f"unknown parameter(s): {', '.join(unknown)}" 

950 ) 

951 node: dict[str, Any] = {"help": parsed.summary, "params": params} 

952 # Where this task comes from, as the listings group it: a built-in 

953 # carries no defining directory (the cascade never tagged it), and the 

954 # personal rung's directory is the user tasks file's own. Everything 

955 # else is the project's cascade — including a plugin the project chose 

956 # to mount, which is the project's decision and reads as its own task. 

957 # Marked only when global: the project's tasks are the common case and 

958 # a per-row field for them would be bytes on the completion hot path. 

959 defined_in = _discover.defining_dir(fn) 

960 if defined_in is None or (user_dir is not None and defined_in == user_dir): 

961 node["global"] = True 

962 if (provider := registry.mounted_from(fn)) is not None: 

963 # Additive: which provider this task came from — the entry-point 

964 # identity for `plugin()`, the module name for `include()`. Omitted 

965 # for a task the tasks file defines itself, which is the honest 

966 # answer rather than a hole: the project owns it. 

967 # 

968 # A provider, not a file. A source path would answer only for a 

969 # Python function (`__code__`), and would make a reader infer 

970 # ownership by matching path prefixes; this names the owner 

971 # outright, and keeps naming it if a task is one day implemented 

972 # over something that is not Python. It also keeps absolute home 

973 # paths out of `--json --list` output people paste and commit. 

974 node["mounted_from"] = provider 

975 used = registry.task_uses(fn) 

976 if used: 

977 # The globals this task declares it reads — help, the catalog and 

978 # agents see the dependency without running anything. 

979 node["uses"] = [opt.name for opt in used] 

980 if (previous := _discover.shadowed(fn)) is not None: 

981 # Additive, and only for the rare overridden task: the options of 

982 # the task this one shadows, so `--help` can show the call 

983 # `inherited()` will make. 

984 node["shadows"] = { 

985 "params": [ 

986 _finish(param_spec(p), memo, bake=bake) for p in _cli_params(previous) 

987 ], 

988 "where": _source_of(previous), 

989 } 

990 declares, _inner = _coerce.emitted(sig.return_annotation) 

991 if declares: 

992 if interactive: 

993 raise SpecError( 

994 f"{getattr(fn, '__name__', fn)!s}: Stdout[…] and " 

995 f"interactive=True cannot both hold — an interactive task " 

996 f"owns the real terminal, uncaptured, and a declaring task's " 

997 f"stdout belongs to its return value. Drop one." 

998 ) 

999 node["emits"] = True # additive: this task's stdout is its return value 

1000 if (returned := returned_spec(sig.return_annotation)) is not None: 

1001 # Additive: the output contract the return annotation declares — 

1002 # static, so it bakes beside the param specs and every surface 

1003 # (envelope, --describe, help, docs) reads the same shape. 

1004 node["returned"] = returned 

1005 if parsed.returns: 

1006 node["returned_doc"] = parsed.returns # additive: what the value means 

1007 if infinite: 

1008 node["infinite"] = True # additive: listings and help say how it ends 

1009 if interactive: 

1010 node["interactive"] = True # additive: this task owns the terminal 

1011 if lane is not None: 

1012 node["lane"] = lane # additive: "serial" or "exclusive" scheduling 

1013 if confirm: 

1014 node["confirm"] = confirm # additive: the yes/no gate before it runs 

1015 # Additive, and deliberately visible: a caller that can see a task already 

1016 # retries will not wrap it in a retry of its own, and every layer that 

1017 # cannot see it is one more that can stack — curl's `--retry` multiplying 

1018 # with `@task(retries=)` is the case the design note names. Baking the 

1019 # field in while the schema is being touched is free; adding it later is a 

1020 # version bump (notes/20260807-timeout-and-retry.md). 

1021 if retries := registry.task_retries(fn): 

1022 node["retries"] = retries 

1023 if timeout := registry.task_timeout(fn): 

1024 node["timeout"] = timeout 

1025 if parsed.long: 

1026 node["long"] = parsed.long 

1027 # Additive availability annotation (`@requires`): the name stays listed and 

1028 # completable either way — execution re-checks the predicate live. 

1029 if (reason := registry.availability(fn)) is not None: 

1030 node["disabled"] = reason 

1031 return node 

1032 

1033 

1034def _scoped( 

1035 node: dict[str, Any], fn: Any, inherited: str | None, project: bool 

1036) -> dict[str, Any]: 

1037 """Stamp `unexposed` on a task that does not belong here. 

1038 

1039 Resolved at build time, where the mode is known, so every reader 

1040 downstream — listing, help, completion, the address resolver — asks one 

1041 flat question instead of each re-deriving "am I in a project". The three 

1042 `expose` answers collapse to that one flag against the mode, and the 

1043 flag alone is enough to phrase the refusal: a task kept out *of* a 

1044 project can only be `global_only`, one kept out *outside* one can only 

1045 be `project_only`. 

1046 """ 

1047 # Unsealed means unclaimed: a person's own tasks file and a project's 

1048 # own cascade ride everywhere. `project_only` is imposed by `seal_expose` 

1049 # on the rung that promises it, never assumed here. 

1050 where = registry.declared_expose(fn) or inherited or "always" 

1051 if where == "always": 

1052 return node 

1053 if (where == "project_only") is not project: 

1054 node["unexposed"] = True 

1055 return node 

1056 

1057 

1058def _hide(node: dict[str, Any], own: bool | None, inherited: bool) -> dict[str, Any]: 

1059 """Stamp the resolved `hidden` onto a task node: its own answer if it gave 

1060 one, otherwise the group's. Additive — absent means listed.""" 

1061 if inherited if own is None else own: 

1062 node["hidden"] = True 

1063 return node 

1064 

1065 

1066def _node( 

1067 g: Group, 

1068 memo: dict[int, list[str]], 

1069 hidden: bool = False, 

1070 *, 

1071 bake: bool = False, 

1072 expose: str | None = None, 

1073 project: bool = True, 

1074 user_dir: str | None = None, 

1075) -> dict[str, Any]: 

1076 # `hidden` is resolved here, where the tree structure is: a node that never 

1077 # declared one inherits its group's answer, so hiding a subtree is said once 

1078 # at the root and `hidden=False` on a child is a real way back. The group's 

1079 # own answer may come from its default action — hiding what a bare 

1080 # `fm <group>` runs hides the group it speaks for. 

1081 own = g.hidden 

1082 if own is None and g.default_task is not None: 

1083 own = registry.declared_hidden(g.default_task) 

1084 mine = hidden if own is None else own 

1085 # The same inheritance for scope, one line down: a group's answer covers 

1086 # its subtree, a child overrides it, and silence asks whoever encloses it. 

1087 here = g.expose if g.expose is not None else expose 

1088 node: dict[str, Any] = { 

1089 "help": g.help, 

1090 "tasks": { 

1091 name: _scoped( 

1092 _hide( 

1093 _task_node(fn, memo, bake=bake, user_dir=user_dir), 

1094 registry.declared_hidden(fn), 

1095 mine, 

1096 ), 

1097 fn, 

1098 here, 

1099 project, 

1100 ) 

1101 for name, fn in g.tasks.items() 

1102 }, 

1103 "groups": { 

1104 name: _node( 

1105 sub, 

1106 memo, 

1107 mine, 

1108 bake=bake, 

1109 expose=here, 

1110 project=project, 

1111 user_dir=user_dir, 

1112 ) 

1113 for name, sub in g.groups.items() 

1114 }, 

1115 } 

1116 if mine: 

1117 node["hidden"] = True # additive: listings skip it, the address still runs 

1118 if g.mounted_from is not None: 

1119 # The same field, answering a *stronger* question here: this whole 

1120 # group is one provider's, which is true only when the mount landed 

1121 # on a free name and grafted the stamped group whole. Compose into a 

1122 # group that already exists and the destination group survives 

1123 # unstamped — shared, so no single owner — while every task under it 

1124 # still answers exactly. Read ownership off the task rows; this is 

1125 # for "is this entire subtree theirs". 

1126 node["mounted_from"] = g.mounted_from 

1127 # A runnable group (one with `@group.default`) carries the default's option 

1128 # surface — the same `{help, params}` shape a task node has — so the splitter 

1129 # parses a bare `fm <group> [flags]` against it and completion/help render it. 

1130 # The fan-out flag rides beside it (not inside: `_task_node` memoises task 

1131 # specs, and fan-out is a property of this default, not of the function) 

1132 # so listings can *say* what an undocumented default does. 

1133 if g.default_task is not None: 

1134 node["default"] = _scoped( 

1135 _hide( 

1136 _task_node(g.default_task, memo, bake=bake, user_dir=user_dir), 

1137 registry.declared_hidden(g.default_task), 

1138 mine, 

1139 ), 

1140 g.default_task, 

1141 here, 

1142 project, 

1143 ) 

1144 node["default_fanout"] = registry.fans_out(g.default_task) 

1145 # The bare group name IS this action's other spelling, so the group 

1146 # answers whatever its default answers — stamped here, once, rather 

1147 # than taught to each of the listing, completion and dispatch paths. 

1148 # `hidden` already resolves this way a few lines up; this is the same 

1149 # rule, and without it `fm lint` ran while `fm lint.default` refused. 

1150 if node["default"].get("unexposed"): 

1151 node["unexposed"] = True 

1152 return node 

1153 

1154 

1155def tree_hash(tree: dict[str, Any]) -> str: 

1156 """Stable hash of the tree's structure (names, params, help).""" 

1157 blob = json.dumps(tree, sort_keys=True, ensure_ascii=False) 

1158 return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16] 

1159 

1160 

1161def build_manifest( 

1162 root: Group, 

1163 *, 

1164 completion_max_age: int | None = None, 

1165 bake_completers: bool = False, 

1166 project: bool = True, 

1167 user_dir: str | None = None, 

1168) -> dict[str, Any]: 

1169 """Introspect *root* into a serialisable manifest dict. 

1170 

1171 *project* says whether this manifest describes a directory that has one. 

1172 It is the only place the question is asked: tasks that need a project are 

1173 stamped `unexposed` here, so listing, help, completion and the address 

1174 resolver each read one flat flag rather than re-deriving the mode. 

1175 

1176 *completion_max_age* (seconds, or `None` to disable) is baked in so the 

1177 stdlib-only completion hot path can decide whether to trigger a background 

1178 refresh without reading config. 

1179 

1180 Dynamic completers do **not** run here. They used to — every invocation 

1181 executed every `suggest()` function in the tree, so a git-branch completer 

1182 on one task shelled out on every `fm anything` — and nothing needed it: 

1183 a <kbd>Tab</kbd> recomputes fresh, the command line resolves the one 

1184 parameter whose value it validates, and help resolves the ones it prints. 

1185 *bake_completers* is for the surface with nowhere to resolve later: the 

1186 docs exporter, which renders every parameter it can find into a page. 

1187 """ 

1188 memo: dict[int, list[str]] = {} 

1189 tree = _node(root, memo, bake=bake_completers, project=project, user_dir=user_dir) 

1190 tree["globals"] = [ 

1191 _global_spec(opt, memo, bake=bake_completers) for opt in _unique_globals(root) 

1192 ] 

1193 tree["global_help"] = _core_global_help() 

1194 # Which side this tree was built for. Every `unexposed` stamp under it 

1195 # was resolved against this one answer, so a reader that has to *phrase* 

1196 # the refusal can say which way the task was withheld without the stamp 

1197 # having to carry a reason of its own. 

1198 tree["project"] = project 

1199 return { 

1200 "schema": SCHEMA_VERSION, 

1201 "hash": tree_hash(tree), 

1202 "completion_max_age": completion_max_age, 

1203 "tree": tree, 

1204 } 

1205 

1206 

1207def _core_global_help() -> dict[str, str]: 

1208 """`--flag -> one line`, for footman's own globals. 

1209 

1210 The completion hot path knows the core flags by name (its own frozenset 

1211 mirror — it may not import `_split`), but a name with no words beside it 

1212 is the difference between a Tab that teaches and a Tab that lists. Rather 

1213 than mirror thirty-five help strings into `_complete` as well — prose is 

1214 exactly what rots — the words ride in the manifest the hot path already 

1215 reads and parses. `CORE_OPTIONS` stays the one place they are written. 

1216 

1217 Aliases carry their long form's line: `-j` and `--jobs` are one option, 

1218 and a reader scanning a column wants to know what it does, not that it 

1219 has two spellings. 

1220 

1221 `{prog}` is substituted here, the way `--help` substitutes it when it 

1222 renders the same table — a manifest belongs to one brand (its path is 

1223 brand-scoped and it already bakes `tasks_file`), so the brand's own 

1224 command name is the honest thing to store. Left raw, a Tab would offer 

1225 "help for {prog}" in braces. 

1226 

1227 The keys are the *offerable spellings*, exactly as the splitter reads 

1228 them — what plugin options always got from `_opt_rows`, the core table 

1229 now gets too. An option's value goes attached, so `--opt=` is a key of 

1230 its own; its bare mention stands for its default, so only a defaulted 

1231 option gets a bare key (bare `--where` is a taught refusal, and a menu 

1232 must not offer what the grammar refuses). The bare key's line carries 

1233 the resolved default — a manifest belongs to this machine, so `--jobs` 

1234 naming its actual width is the honest thing to store. 

1235 """ 

1236 from footman import _app, _describe, _split 

1237 

1238 rows: dict[str, str] = {} 

1239 for name, alias, kind, _hint, default, help_text in _split.GLOBALS: 

1240 if not help_text: 1240 ↛ 1241line 1240 didn't jump to line 1241 because the condition on line 1240 was never true

1241 continue 

1242 line = help_text.replace("{prog}", _app._brand.prog) 

1243 for flag in (name, alias): 

1244 if not flag: 

1245 continue 

1246 if kind != "option": 

1247 rows[flag] = line 

1248 continue 

1249 rows[flag + "="] = line 

1250 if default is not None: 

1251 rows[flag] = line + _describe.global_default_suffix(name) 

1252 return rows 

1253 

1254 

1255_REPLACE_ATTEMPTS = 25 

1256_REPLACE_PAUSE = 0.04 # ≈1s of retries in all 

1257 

1258 

1259def write_manifest(manifest: dict[str, Any], path: Path) -> None: 

1260 """Write *manifest* to *path* atomically (never leave a half file). 

1261 

1262 Windows refuses to replace a destination another process holds open, and 

1263 a reader holding it open is the *design* here: a completion poll reads 

1264 this file every few milliseconds while a detached refresh rewrites it. 

1265 Losing that race silently means the rebuild never lands — the caller is 

1266 a background child that swallows its errors — so the file stays stale 

1267 until some later write happens to find a quiet moment. 

1268 

1269 Hence a bounded retry: about a second of attempts, then give up without 

1270 leaving the temp file behind. POSIX never takes this path (rename over 

1271 an open file is fine), and no caller waits longer than it already did. 

1272 """ 

1273 import time 

1274 

1275 path.parent.mkdir(parents=True, exist_ok=True) 

1276 tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") 

1277 tmp.write_text(json.dumps(manifest, indent=1, ensure_ascii=False), "utf-8") 

1278 for attempt in range(_REPLACE_ATTEMPTS): 1278 ↛ exitline 1278 didn't return from function 'write_manifest' because the loop on line 1278 didn't complete

1279 try: 

1280 os.replace(tmp, path) 

1281 return 

1282 except PermissionError: 

1283 if attempt == _REPLACE_ATTEMPTS - 1: 

1284 # Out of patience: clean up rather than litter the cache 

1285 # directory with `<name>.<pid>.tmp` files nobody reads. 

1286 tmp.unlink(missing_ok=True) 

1287 raise 

1288 time.sleep(_REPLACE_PAUSE) 

1289 

1290 

1291def load_manifest(path: Path) -> dict[str, Any] | None: 

1292 """Read a cached manifest, or `None` if missing/unreadable/corrupt.""" 

1293 try: 

1294 data = json.loads(path.read_text("utf-8")) 

1295 except (OSError, ValueError): 

1296 return None 

1297 return data if isinstance(data, dict) else None 

1298 

1299 

1300def sync_manifest( 

1301 root: Group, 

1302 key_dir: Path, 

1303 *, 

1304 completion_max_age: int | None = None, 

1305 tasks_file: str | None = None, 

1306 path: Path | None = None, 

1307 bake_cwd: bool = True, 

1308 builtin: tuple[str, ...] = (), 

1309 project: bool = True, 

1310 user_dir: str | None = None, 

1311) -> dict[str, Any]: 

1312 """Build the fresh manifest and rewrite the cache only on a hash change. 

1313 

1314 Called on the execution path, which has already paid to import the tree. 

1315 The cache is keyed by *key_dir* (the cwd), since the effective task set is 

1316 the cascade from the repo root down. The hash guard avoids needless disk 

1317 writes (and mtime churn) when nothing about the command surface changed — a 

1318 changed *completion_max_age* also forces a rewrite so a config edit takes 

1319 effect. 

1320 

1321 The write itself is best-effort. Everything the caller needs is the 

1322 returned tree, which is already in hand; the file is only there to make 

1323 the next TAB fast. A cache nobody can write to — a read-only HOME in a 

1324 locked-down image, a stray `chmod` — must cost a cold completion, not 

1325 every command, so an `OSError` here is swallowed the way `_progress` 

1326 swallows the timing history's. 

1327 """ 

1328 fresh = build_manifest( 

1329 root, 

1330 completion_max_age=completion_max_age, 

1331 project=project, 

1332 user_dir=user_dir, 

1333 ) 

1334 if bake_cwd: 

1335 # The directory this manifest describes, baked in (additive) so the 

1336 # cache collector can tell a deleted project's leftovers from a 

1337 # living one's without guessing from hashes. The global manifest 

1338 # bakes none — it describes no directory, so the collector's idle 

1339 # sweep owns it rather than the deleted-project rule. 

1340 fresh["cwd"] = str(key_dir) 

1341 if builtin: 

1342 # What the refresh child rebuilds the global tree from: the entry- 

1343 # point names, which travel where live objects cannot. 

1344 fresh["builtin"] = sorted(builtin) 

1345 if tasks_file: 

1346 # Additive, like `cwd`: the background refresh reads it back, so a 

1347 # branded CLI's custom filename survives a refresh it can't attend. 

1348 fresh["tasks_file"] = tasks_file 

1349 # What the user-level rungs looked like when this was built. The 

1350 # resolver compares it to spot a config or user-tasks edit the age 

1351 # clock would otherwise hide for a whole `max_age` — and it joins the 

1352 # write guard below, or a rebuild triggered by a stamp change would 

1353 # leave the old stamp in place and every later TAB would spawn again. 

1354 fresh["user_stamp"] = _paths.user_stamp() 

1355 # `path` lets a caller key the cache file separately from the baked 

1356 # `key_dir` — a `-f` run caches by (cwd, file) yet still bakes the cwd, so 

1357 # the collector prunes it with the project like any other. 

1358 path = path or _paths.manifest_path(key_dir) 

1359 cached = load_manifest(path) 

1360 if ( 

1361 cached is None 

1362 # An upgrade bumps the schema with the tree unchanged, so the hash 

1363 # alone says "nothing to do" — and the old-schema file then lives 

1364 # forever, every TAB refusing it, spawning a rebuild that "succeeds" 

1365 # without writing, and paying the full cold bound. The schema is 

1366 # part of what the file IS, not part of what it describes. 

1367 or cached.get("schema") != SCHEMA_VERSION 

1368 or cached.get("hash") != fresh["hash"] 

1369 or cached.get("completion_max_age") != completion_max_age 

1370 or cached.get("cwd") != fresh.get("cwd") 

1371 or cached.get("tasks_file") != fresh.get("tasks_file") 

1372 or cached.get("user_stamp") != fresh.get("user_stamp") 

1373 ): 

1374 # Guarded here rather than inside `write_manifest`, whose contract is 

1375 # "the write landed, or you hear about it" — that is what its retry 

1376 # loop is for. Only a caller can say the file was optional. 

1377 with contextlib.suppress(OSError): 

1378 write_manifest(fresh, path) 

1379 return fresh