Coverage for src/footman/manifest.py: 97%

211 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +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` | 

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 hashlib 

25import inspect 

26import json 

27import os 

28import warnings 

29from pathlib import Path 

30from typing import Any 

31 

32from footman import _describe, _paths, coerce, discover, docstrings, registry 

33from footman.context import context_param_name 

34from footman.params import suggest 

35from footman.registry import Group 

36 

37SCHEMA_VERSION = 1 

38 

39 

40class ManifestError(Exception): 

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

42 

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

44 the app layer reports it and exits 2. 

45 """ 

46 

47 

48class CompleterError(ManifestError): 

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

50 

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

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

53 validation `strict=True` promises. 

54 """ 

55 

56 

57class SpecError(ManifestError): 

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

59 

60 

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

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

63 

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

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

66 types the grammar reasons about. Falls back to the raw signature if a name 

67 cannot be resolved (e.g. a type defined in a local scope). 

68 """ 

69 try: 

70 return inspect.signature(fn, eval_str=True) 

71 except (NameError, TypeError, AttributeError): 

72 return inspect.signature(fn) 

73 

74 

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

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

77 

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

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

80 """ 

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

82 ann = param.annotation 

83 empty = inspect.Parameter.empty 

84 

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

86 raise SpecError( 

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

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

89 ) 

90 

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

92 spec["kind"] = "variadic" 

93 if ann is not empty: 

94 peeled = coerce.peel(ann) # unwrap Annotated so markers reach the spec 

95 tags = coerce.element_tags(peeled.element) 

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

97 spec["types"] = tags 

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

99 return spec 

100 

101 has_default = param.default is not empty 

102 if has_default: 

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

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

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

106 # simply omitted, never an error. 

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

108 if ok_default: 108 ↛ 113line 108 didn't jump to line 113 because the condition on line 108 was always true

109 spec["default"] = encoded 

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

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

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

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

114 

115 if ann is empty: 

116 if isinstance(param.default, bool): 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true

117 spec["kind"] = "flag" 

118 elif has_default or kw_only: 

119 spec["kind"] = "option" 

120 if not has_default: 

121 spec["required"] = True 

122 else: 

123 spec["kind"] = "argument" 

124 return spec 

125 

126 peeled = coerce.peel(ann) 

127 if peeled.mapping: 

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

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

130 spec["kind"] = "option" 

131 spec["mapping"] = True 

132 if not has_default: 

133 spec["required"] = True 

134 _marker_keys(spec, peeled, param, has_default) 

135 if peeled.nosplit: 

136 spec["nosplit"] = True 

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

138 spec["key_types"] = ktags 

139 vchoices = coerce.all_choices(peeled.element) 

140 vtags = coerce.element_tags(peeled.element) 

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

142 spec["value_choices"] = vchoices 

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

144 spec["value_types"] = vtags 

145 return spec 

146 

147 element = peeled.element 

148 if coerce.is_flag(element) and not peeled.multiple: 

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

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

151 spec["kind"] = "flag" 

152 if not has_default and peeled.ask is None: # ask() prompts if absent 

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

154 _marker_keys(spec, peeled, param, has_default) 

155 return spec 

156 

157 if peeled.ask is not None and not has_default: 

158 # ask() makes a defaultless parameter a CLI-optional option: absence is 

159 # filled by prompting (executor.bind), so the splitter must let it be 

160 # missing rather than enforce it as a required positional. 

161 spec["kind"] = "option" 

162 elif has_default or kw_only: 

163 spec["kind"] = "option" 

164 if not has_default: 

165 spec["required"] = True 

166 else: 

167 spec["kind"] = "argument" 

168 _marker_keys(spec, peeled, param, has_default) 

169 if peeled.multiple: 

170 spec["multiple"] = True 

171 if peeled.nosplit: 

172 spec["nosplit"] = True 

173 if peeled.completer is not None: 

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

175 spec["choices"] = [] 

176 spec["_completer"] = peeled.completer 

177 return spec 

178 

179 choices = coerce.all_choices(element) 

180 tags = coerce.element_tags(element) 

181 if choices is not None: 

182 spec["choices"] = choices 

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

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

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

186 if tags and tags != ["str"] and coerce.eagerly_checkable(element): 

187 spec["types"] = tags 

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

189 # The annotation resolves to nothing footman can coerce (a string 

190 # that never resolved, a value, an exotic generic): values will pass 

191 # through as plain text. Silent degrade is a debugging tax — say so. 

192 warnings.warn( 

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

194 f"not a usable type; values are passed through as text", 

195 stacklevel=2, 

196 ) 

197 return spec 

198 

199 

200def _marker_keys( 

201 spec: dict[str, Any], 

202 peeled: coerce.Peeled, 

203 param: inspect.Parameter, 

204 has_default: bool, 

205) -> None: 

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

207 

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

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

210 binding time instead. 

211 """ 

212 if peeled.doc is not None: 

213 spec["doc"] = peeled.doc 

214 if peeled.path_req is not None: 

215 spec["path"] = peeled.path_req 

216 if peeled.bounds is not None: 

217 lo, hi = peeled.bounds 

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

219 spec["min"] = lo 

220 if hi is not None: 

221 spec["max"] = hi 

222 if peeled.env is not None: 

223 if spec.get("mapping"): 

224 raise SpecError( 

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

226 ) 

227 if not has_default: 

228 raise SpecError( 

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

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

231 f"somewhere to fall" 

232 ) 

233 spec["env"] = peeled.env 

234 

235 

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

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

238 

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

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

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

242 """ 

243 key = id(completer.fn) 

244 if key not in memo: 

245 try: 

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

247 except Exception as exc: 

248 if completer.strict: 

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

250 raise CompleterError( 

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

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

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

254 ) from exc 

255 memo[key] = [] 

256 return memo[key] 

257 

258 

259def _finish(spec: dict[str, Any], memo: dict[int, list[str]]) -> dict[str, Any]: 

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

261 if completer is not None: 

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

263 return spec 

264 

265 

266def _cli_params(fn: Any): 

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

268 sig = resolved_signature(fn) 

269 ctx_name = context_param_name(sig) 

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

271 

272 

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

274 code = getattr(fn, "__code__", None) 

275 if code is None: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 return "" 

277 return f"{code.co_filename}:{code.co_firstlineno}" 

278 

279 

280def _task_node(fn: Any, memo: dict[int, list[str]]) -> dict[str, Any]: 

281 sig = resolved_signature(fn) 

282 infinite = registry.is_infinite(fn) 

283 interactive = registry.is_interactive(fn) 

284 confirm = registry.task_confirm(fn) 

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

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

287 params = [ 

288 _finish(param_spec(p), memo) 

289 for p in sig.parameters.values() 

290 if p.name != ctx_name 

291 ] 

292 for spec in params: 

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

294 raise SpecError( 

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

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

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

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

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

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

301 "deploy)." 

302 ) 

303 known: set[str] = set() 

304 for spec in params: 

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

306 known.add(python_name) 

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

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

309 spec["doc"] = text 

310 if ctx_name: 

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

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

313 warnings.warn( 

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

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

316 stacklevel=2, 

317 ) 

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

319 if (previous := discover.shadowed(fn)) is not None: 

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

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

322 # `inherited()` will make. 

323 node["shadows"] = { 

324 "params": [_finish(param_spec(p), memo) for p in _cli_params(previous)], 

325 "where": _source_of(previous), 

326 } 

327 if infinite: 

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

329 if interactive: 

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

331 if confirm: 

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

333 if parsed.long: 

334 node["long"] = parsed.long 

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

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

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

338 node["disabled"] = reason 

339 return node 

340 

341 

342def _node(g: Group, memo: dict[int, list[str]]) -> dict[str, Any]: 

343 node: dict[str, Any] = { 

344 "help": g.help, 

345 "tasks": {name: _task_node(fn, memo) for name, fn in g.tasks.items()}, 

346 "groups": {name: _node(sub, memo) for name, sub in g.groups.items()}, 

347 } 

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

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

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

351 if g.default_task is not None: 

352 node["default"] = _task_node(g.default_task, memo) 

353 return node 

354 

355 

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

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

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

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

360 

361 

362def build_manifest( 

363 root: Group, *, completion_max_age: int | None = None 

364) -> dict[str, Any]: 

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

366 

367 Dynamic completers run here (once each, deduped) — this is the execution 

368 path, so paying to refresh their cached choices is free. *completion_max_age* 

369 (seconds, or `None` to disable) is baked in so the stdlib-only completion hot 

370 path can decide whether to trigger a background refresh without reading config. 

371 """ 

372 tree = _node(root, {}) 

373 return { 

374 "schema": SCHEMA_VERSION, 

375 "hash": tree_hash(tree), 

376 "completion_max_age": completion_max_age, 

377 "tree": tree, 

378 } 

379 

380 

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

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

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

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

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

386 os.replace(tmp, path) 

387 

388 

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

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

391 try: 

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

393 except (OSError, ValueError): 

394 return None 

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

396 

397 

398def sync_manifest( 

399 root: Group, 

400 key_dir: Path, 

401 *, 

402 completion_max_age: int | None = None, 

403 tasks_file: str | None = None, 

404 path: Path | None = None, 

405) -> dict[str, Any]: 

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

407 

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

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

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

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

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

413 effect. 

414 """ 

415 fresh = build_manifest(root, completion_max_age=completion_max_age) 

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

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

418 # one's without guessing from hashes. 

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

420 if tasks_file: 

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

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

423 fresh["tasks_file"] = tasks_file 

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

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

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

427 path = path or _paths.manifest_path(key_dir) 

428 cached = load_manifest(path) 

429 if ( 

430 cached is None 

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

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

433 or cached.get("cwd") != fresh["cwd"] 

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

435 ): 

436 write_manifest(fresh, path) 

437 return fresh