Coverage for src/footman/_describe.py: 91%

525 statements  

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

1"""Phrase manifest nodes for humans: labels, usage lines, examples. 

2 

3The one home for turning a task's manifest entry into words, shared by the 

4help renderer (`_app`) and the markdown exporter (`markdown`) so the two can 

5never drift. Everything here is a pure function over manifest dicts — no 

6registry, no I/O. 

7""" 

8 

9from __future__ import annotations 

10 

11import dataclasses 

12import datetime 

13import decimal 

14import enum 

15import json 

16import re 

17import unicodedata 

18import uuid 

19from collections.abc import Callable, Iterator 

20from pathlib import Path, PurePath 

21from typing import Any 

22 

23TYPE_WORD = { 

24 "bool": "true/false", 

25 "int": "an integer", 

26 "float": "a number", 

27 "path": "a path", 

28 "str": "text", 

29} 

30 

31 

32# --- the palette -------------------------------------------------------------- 

33# One visual language for the whole CLI: bold for names and headers, dim for 

34# mechanics and secondary text, cyan for footman's own numbers and accents, 

35# green/red for verdicts. Every helper is a no-op when *on* is False, and 

36# every surface gates *on* by its own stream's tty-ness — piped output stays 

37# byte-clean. 

38 

39 

40def wants_color(stream: Any, mode: str = "auto") -> bool: 

41 """Whether to paint output for *stream* under a resolved colour *mode*. 

42 

43 `"always"`/`"never"` are the explicit tri-state answers (`--color=…`, config, 

44 `FORCE_COLOR`/`NO_COLOR`); `"auto"` (the default) falls back to the stream's 

45 own tty-ness, honouring `NO_COLOR` and a dumb terminal. 

46 """ 

47 if mode == "never": 

48 return False 

49 if mode == "always": 

50 return True 

51 import os as _os 

52 

53 return ( 

54 ansi_capable(stream) 

55 and "NO_COLOR" not in _os.environ 

56 and _os.environ.get("TERM") != "dumb" 

57 ) 

58 

59 

60def ansi_capable(stream: Any) -> bool: 

61 """tty-ness plus, on Windows, a console that interprets ANSI. 

62 

63 A Windows tty is not yet an ANSI surface: legacy conhost prints the 

64 escapes as `←[1m` noise. Ask the console for VT processing the way 

65 every modern CLI does — a no-op where it is already on (Windows 

66 Terminal), an upgrade where it is off but available (Windows 10's 

67 conhost), and a refusal exactly where the escapes would show as noise. 

68 """ 

69 try: 

70 tty = bool(stream.isatty()) 

71 except Exception: 

72 return False 

73 import os as _os 

74 

75 if not tty or _os.name != "nt": 

76 return tty 

77 try: 

78 fileno = stream.fileno() 

79 except Exception: 

80 # No OS handle to interrogate: a tty-claiming stream without one is 

81 # a wrapper (or a test fake) that answers for its own rendering, so 

82 # its claim stands. Every real console stream has a fileno, so the 

83 # probe below still covers the console that cannot paint. 

84 return True 

85 try: 

86 import ctypes 

87 import msvcrt 

88 

89 # getattr with a default, not attribute access: both exist only on 

90 # Windows, and the checkers read this file on every platform. 

91 get_handle = getattr(msvcrt, "get_osfhandle", None) 

92 windll = getattr(ctypes, "windll", None) 

93 if get_handle is None or windll is None: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 return False 

95 handle = get_handle(fileno) 

96 kernel32 = windll.kernel32 

97 mode = ctypes.c_uint32() 

98 if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true

99 return False 

100 vt = 0x0004 # ENABLE_VIRTUAL_TERMINAL_PROCESSING 

101 if mode.value & vt: 

102 return True 

103 return bool(kernel32.SetConsoleMode(handle, mode.value | vt)) 

104 except Exception: 

105 return False 

106 

107 

108def link(text: str, url: str | None, on: bool) -> str: 

109 """*text* as a terminal hyperlink (OSC 8), when *on* and a URL exists. 

110 

111 Zero visible width, so band layouts that track plain lengths stay 

112 untouched. Rides the same switch as colour: piped output never carries 

113 it, and a terminal without OSC 8 support ignores the sequence. 

114 """ 

115 if not on or not url: 

116 return text 

117 return f"\033]8;;{url}\033\\{text}\033]8;;\033\\" 

118 

119 

120_DOCS_PLACEHOLDERS = ("path", "slug") 

121 

122 

123def docs_url_error(template: object) -> str | None: 

124 """The refusal a broken `docs_url` template earns, or None for a sound 

125 one — a link scheme that silently pointed nowhere would be worse than 

126 no links at all.""" 

127 from footman import _paths 

128 

129 if not isinstance(template, str) or not template.strip(): 

130 return ( 

131 f"the [tool.{_paths.config_table()}] docs_url key is a URL " 

132 "template, e.g. " 

133 '"https://docs.example.dev/tasks/{path}/" — {path} is the ' 

134 "slash-joined task address, {slug} the dash-joined one" 

135 ) 

136 unknown = [ 

137 m for m in re.findall(r"\{([^{}]*)\}", template) if m not in _DOCS_PLACEHOLDERS 

138 ] 

139 if unknown: 

140 return ( 

141 f"docs_url: unknown placeholder {{{unknown[0]}}} — use {{path}} " 

142 f"(slash-joined address) or {{slug}} (dash-joined)" 

143 ) 

144 return None 

145 

146 

147def docs_url_for(template: str | None, address: str) -> str | None: 

148 """*address*'s documentation URL under *template*, or None without one. 

149 

150 `{path}` walks the generated per-task pages (`docs.site`: one file per 

151 task, directories per group); `{slug}` jumps to the one-page export's 

152 anchors (`docs.page`: `{ #docs-build }`). Plain replacement, not 

153 `format` — a URL's own braces are already refused by validation. 

154 """ 

155 if not template or not address: 

156 return None 

157 return template.replace("{path}", address.replace(".", "/")).replace( 

158 "{slug}", address.replace(".", "-") 

159 ) 

160 

161 

162def bold(text: str, on: bool) -> str: 

163 return f"\033[1m{text}\033[0m" if on else text 

164 

165 

166def dim(text: str, on: bool) -> str: 

167 return f"\033[2m{text}\033[0m" if on else text 

168 

169 

170def cyan(text: str, on: bool) -> str: 

171 return f"\033[36m{text}\033[0m" if on else text 

172 

173 

174def bold_cyan(text: str, on: bool) -> str: 

175 return f"\033[1;36m{text}\033[0m" if on else text 

176 

177 

178def red(text: str, on: bool) -> str: 

179 return f"\033[31m{text}\033[0m" if on else text 

180 

181 

182# --- columns ------------------------------------------------------------------ 

183# Every aligned surface asks the same question — how wide is this on screen? — 

184# and `len()` answers a different one, in three ways at once: an escape 

185# sequence is bytes the terminal eats rather than shows, a combining mark 

186# rides on the character before it, and an East-Asian wide character (or an 

187# emoji) takes two cells. One helper, so a column is a column everywhere. 

188 

189_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") 

190 

191 

192def _cell(ch: str) -> int: 

193 if unicodedata.category(ch) in ("Mn", "Me", "Cf"): 

194 return 0 # combining marks and format codes ride on their neighbour 

195 return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 

196 

197 

198def display_width(text: str) -> int: 

199 """How many terminal cells *text* occupies.""" 

200 if text.isascii() and "\033" not in text: 

201 # Plain ASCII is one cell per character and carries no escapes — two 

202 # C-speed checks so the common case (every column footman prints 

203 # about itself) costs what `len()` cost before. 

204 return len(text) 

205 return sum(_cell(ch) for ch in _ANSI.sub("", text)) 

206 

207 

208def pad_to(text: str, width: int) -> str: 

209 """*text* followed by spaces up to *width* cells — `str.ljust` counting 

210 what the terminal counts.""" 

211 return text + " " * max(width - display_width(text), 0) 

212 

213 

214def fit(text: str, width: int) -> str: 

215 """*text* cut to *width* cells, escape sequences kept whole. 

216 

217 Never cuts inside an escape (which would print its tail as literal 

218 gibberish) and never splits a wide character down the middle; whatever 

219 styling was still open at the cut is closed, so a truncated status line 

220 cannot leave the terminal wearing a colour nobody chose. 

221 """ 

222 if display_width(text) <= width: 

223 return text 

224 out: list[str] = [] 

225 used = 0 

226 styled = False 

227 

228 def take(run: str) -> bool: 

229 nonlocal used 

230 for ch in run: 

231 cells = _cell(ch) 

232 if used + cells > width: 

233 return True 

234 out.append(ch) 

235 used += cells 

236 return False 

237 

238 pos = 0 

239 for match in _ANSI.finditer(text): 

240 if take(text[pos : match.start()]): 

241 break 

242 out.append(match.group()) 

243 styled = match.group() != "\033[0m" 

244 pos = match.end() 

245 else: 

246 take(text[pos:]) 

247 return "".join(out) + ("\033[0m" if styled else "") 

248 

249 

250def value_hint(p: dict[str, Any]) -> str: 

251 """The value placeholder shown for an option/argument in help output.""" 

252 if p.get("mapping"): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 return "KEY=VALUE" 

254 if group := p.get("group"): 

255 # `--size=width,height` beats `--size=VALUE`: the shape named its own 

256 # slots, so the help can too. 

257 return str(group["label"]) 

258 choices = p.get("choices") 

259 if choices: 

260 return "{" + "|".join(choices) + "}" 

261 types = p.get("types") 

262 if types: 

263 return "|".join(t.upper() for t in types) 

264 return "VALUE" 

265 

266 

267def _repeats(p: dict[str, Any]) -> bool: 

268 """Whether the parameter takes a *stream* of values rather than one. 

269 

270 A grouped shape is `multiple` to the splitter because its values 

271 accumulate the same way, but `--size=width,height` takes one size — the 

272 `...` belongs only to a container of them. 

273 """ 

274 if group := p.get("group"): 

275 return bool(group["many"]) 

276 return bool(p.get("multiple")) 

277 

278 

279def usage_fragment(p: dict[str, Any]) -> str: 

280 kind = p["kind"] 

281 required = p.get("required") 

282 if kind == "stdin": 

283 return "" # a whole-document parameter has no token spelling 

284 if kind == "flag": 

285 name = f"no-{p['name']}" if p.get("default") is True else p["name"] 

286 return f"--{name}" if required else f"[--{name}]" 

287 if kind == "option": 

288 core = f"--{p['name']}={value_hint(p)}" 

289 if _repeats(p) or p.get("mapping"): 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true

290 core += " ..." 

291 return core if required else f"[{core}]" 

292 if kind == "variadic": 

293 return f"[<{p['name']}> ...]" 

294 suffix = "..." if p.get("multiple") else "" 

295 return f"<{p['name']}>{suffix}" 

296 

297 

298def param_label(p: dict[str, Any]) -> str: 

299 kind = p["kind"] 

300 if kind == "flag": 

301 return f"--no-{p['name']}" if p.get("default") is True else f"--{p['name']}" 

302 if kind == "option": 

303 return f"--{p['name']}={value_hint(p)}" 

304 suffix = "..." if kind == "variadic" or p.get("multiple") else "" 

305 return f"<{p['name']}>{suffix}" 

306 

307 

308def param_detail(p: dict[str, Any]) -> str: 

309 doc, mechanics = param_detail_parts(p) 

310 return "; ".join(bit for bit in (doc, mechanics) if bit) 

311 

312 

313def param_detail_parts(p: dict[str, Any]) -> tuple[str, str]: 

314 """(author's doc, the mechanical suffix) — split so help can dim the 

315 mechanics under the author's words.""" 

316 return p.get("doc", ""), _mechanics(p) 

317 

318 

319def default_text(p: dict[str, Any]) -> str: 

320 """The declared default, spelled the way the command line spells values, or 

321 `""` when there is nothing worth printing. 

322 

323 `None` prints as nothing rather than "None": absence is what it means, and 

324 "the default is None" tells a reader less than saying nothing does. An empty 

325 string is printed as `""`, because there it is the value. 

326 """ 

327 if "default" not in p or p.get("required"): 

328 return "" 

329 value = p["default"] 

330 if value is None: 

331 return "" 

332 if isinstance(value, bool): 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 return "true" if value else "false" 

334 if isinstance(value, (list, tuple)): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 return ",".join(str(v) for v in value) if value else "(none)" 

336 if isinstance(value, dict): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 return ",".join(f"{k}={v}" for k, v in value.items()) if value else "(none)" 

338 return str(value) if value != "" else '""' 

339 

340 

341def _mechanics(p: dict[str, Any]) -> str: 

342 bits: list[str] = [] 

343 if p["kind"] == "flag": 

344 # A flag defaulting true is only ever *turned off*, so the spelling 

345 # that does something leads, and the inert one is the parenthetical. 

346 if p.get("default") is True: 

347 bits.append(f"flag (--{p['name']} to enable)") 

348 else: 

349 bits.append(f"flag (--no-{p['name']} to disable)") 

350 choices = p.get("choices") 

351 if choices: 

352 # A dynamic parameter's values are what its completer answered just 

353 # now, not a fixed set — say so, the way a computed default does, so 

354 # nobody reads this list as the law of the task. A *static* option's 

355 # set is already in its own spelling (`--mode={dev|prod}`), so 

356 # repeating it here would print the list twice in one row; only a 

357 # positional, whose spelling is just its name, still needs the list. 

358 listed = "one of " + "|".join(choices) 

359 if p.get("dynamic"): 

360 bits.append(f"{listed} (dynamic)") 

361 elif p["kind"] == "positional": 

362 bits.append(listed) 

363 elif p.get("types"): 

364 bits.append(" or ".join(TYPE_WORD.get(str(t), str(t)) for t in p["types"])) 

365 if p.get("mapping"): 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 bits.append("KEY=VALUE pairs (repeat appends)") 

367 if group := p.get("group"): 367 ↛ 371line 367 didn't jump to line 371 because the condition on line 367 was never true

368 # A grouped shape is `multiple` to the splitter — commas and repetition 

369 # feed one stream — but saying "comma-split" would describe the wiring 

370 # rather than the spelling. What a reader needs is the arity. 

371 if group["many"]: 

372 bits.append(f"repeatable in groups of {group['max']}") 

373 elif group["min"] < group["max"]: 

374 bits.append(f"{group['min']} to {group['max']} values") 

375 elif p.get("multiple") or p.get("mapping"): 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true

376 bits.append("repeatable" if p.get("nosplit") else "repeatable/comma-split") 

377 if p["kind"] == "variadic": 

378 bits.append("extra arguments (also receives everything after --)") 

379 source = p.get("stdin") 

380 if source: 

381 if source.startswith("field:"): 

382 note = f"reads stdin (JSON field {source[6:]!r})" 

383 elif source == "lines": 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true

384 note = "reads stdin (one line per value)" 

385 elif source == "bytes": 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true

386 note = "reads stdin (raw bytes)" 

387 elif source == "json": 387 ↛ 392line 387 didn't jump to line 392 because the condition on line 387 was always true

388 shape = p.get("shape") 

389 named = f" → {shape['name']}" if shape else "" 

390 note = f"reads stdin (JSON document{named})" 

391 else: 

392 note = "reads stdin (text)" 

393 bits.append(note) 

394 # In ladder order, so the line reads the way resolution happens: the 

395 # environment answers first, the declared default last. Both were in the 

396 # manifest all along and neither was ever printed — a help page that knows 

397 # a parameter falls back to $DEPLOY_ENV and does not say so is holding out. 

398 if (var := p.get("env")) is not None: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 bits.append(f"from ${var}") 

400 # A flag's default is already said twice over by the label and its 

401 # parenthetical, so printing it again would be noise. 

402 if p["kind"] != "flag": 

403 shown, computed = default_text(p), p.get("computed") 

404 if shown and computed: 

405 # `default: 13` reads as an arbitrary constant. Saying it is 

406 # computed turns a number a reader might copy into one they know 

407 # is theirs — the machine's cores, today's date, this shell. 

408 bits.append(f"default: {shown} (computed)") 

409 elif shown: 

410 bits.append(f"default: {shown}") 

411 elif computed: 

412 # Computed from the other options, so there is no value to print 

413 # here: only an invocation knows it. Say that much rather than 

414 # nothing, or the parameter looks like it has no default at all. 

415 bits.append("default computed") 

416 if p.get("required"): 

417 bits.append("required") 

418 return "; ".join(bits) 

419 

420 

421def uses_line(task: dict[str, Any], tree: dict[str, Any]) -> str: 

422 """The globals a task declared it reads (`@task(uses=[...])`), with 

423 provenance — the dependency shown where the option will be typed.""" 

424 names = task.get("uses") 

425 if not names: 

426 return "" 

427 owners = {g["name"]: g.get("owner") for g in tree.get("globals", ())} 

428 bits = [ 

429 f"--{name}" + (f" (from {owner})" if (owner := owners.get(name)) else "") 

430 for name in names 

431 ] 

432 return "reads " + ", ".join(bits) 

433 

434 

435def sample_value(p: dict[str, Any]) -> str: 

436 """A realistic value for a param in a synthesised example: its first choice 

437 when it has one, else an `<name>` placeholder.""" 

438 choices = p.get("choices") 

439 return choices[0] if choices else f"<{p['name']}>" 

440 

441 

442# CLI lines (usage, examples) are token lists — (kind, text) — so every 

443# renderer paints the same structure: `prog` bold, `group` bold cyan (as in 

444# the tree), `task` bold, `req`/`value` cyan, `opt` dim, `flag` plain. 

445_CLI_PAINT: dict[str, Callable[[str, bool], str]] = { 

446 "prog": bold, 

447 "group": bold_cyan, 

448 "task": bold, 

449 "req": cyan, 

450 "value": cyan, 

451 "opt": dim, 

452 "flag": lambda text, on: text, 

453} 

454 

455 

456def paint_cli(parts: list[tuple[str, str]], on: bool) -> str: 

457 """The one way to print a command line, syntax-lit by token kind.""" 

458 return " ".join(_CLI_PAINT.get(kind, bold)(text, on) for kind, text in parts) 

459 

460 

461def invocation_parts(prog: str, path: list[str]) -> list[tuple[str, str]]: 

462 """`prog dotted.task` as tokens — the head of every usage and example. 

463 

464 The address is one dotted token (`fm docs.build`), never a token walk — 

465 what help shows must be what the splitter accepts.""" 

466 parts: list[tuple[str, str]] = [("prog", prog)] 

467 if path: 467 ↛ 469line 467 didn't jump to line 469 because the condition on line 467 was always true

468 parts.append(("task", ".".join(path))) 

469 return parts 

470 

471 

472def listed_params( 

473 task: dict[str, Any], *, show_hidden: bool = False 

474) -> list[dict[str, Any]]: 

475 """The parameters a listing shows — everything but the hidden ones. 

476 

477 The task-level rule, one level down: `hidden` takes a parameter out of 

478 what a human reads and out of nothing else. It still binds, it still 

479 completes, and the manifest still carries it marked, because hiding and 

480 completing are different questions. `--all` shows it. 

481 """ 

482 return [p for p in task["params"] if show_hidden or not p.get("hidden")] 

483 

484 

485def usage_parts( 

486 prog: str, path: list[str], task: dict[str, Any], *, show_hidden: bool = False 

487) -> list[tuple[str, str]]: 

488 parts = invocation_parts(prog, path) 

489 for p in listed_params(task, show_hidden=show_hidden): 

490 fragment = usage_fragment(p) 

491 if fragment: 491 ↛ 489line 491 didn't jump to line 489 because the condition on line 491 was always true

492 kind = "opt" if fragment.startswith("[") else "req" 

493 parts.append((kind, fragment)) 

494 return parts 

495 

496 

497def example_parts( 

498 path: list[str], task: dict[str, Any], prog: str 

499) -> list[tuple[str, str]]: 

500 """A realistic invocation synthesised straight from the signature — required 

501 positionals and options with sample values, plus one representative flag. 

502 

503 Derived, never written, so it can't drift from the task's actual parameters. 

504 Optional options are skipped as noise; the shape teaches the invocation. 

505 """ 

506 parts = invocation_parts(prog, path) 

507 flag_shown = False 

508 for p in listed_params(task): 

509 kind = p["kind"] 

510 if kind in ("positional", "variadic"): 

511 parts.append(("value", sample_value(p))) 

512 elif kind == "option" and p.get("required"): 

513 # One attached token, because that is the only spelling footman 

514 # accepts: `--out <file>` is exit 64 with "did you mean --out=…?". 

515 # An example is a line to copy, so it has to be a line that runs. 

516 parts.append(("value", f"--{p['name']}={sample_value(p)}")) 

517 elif kind == "flag" and (p.get("required") or not flag_shown): 

518 parts.append(("flag", f"--{p['name']}")) 

519 flag_shown = True 

520 return parts 

521 

522 

523def example(path: list[str], task: dict[str, Any], prog: str) -> str: 

524 """The example invocation as plain text (the markdown exporter's form).""" 

525 return " ".join(text for _, text in example_parts(path, task, prog)) 

526 

527 

528def task_line(task: dict[str, Any]) -> str: 

529 """A task's one-line description, plus how it ends when that's notable: 

530 availability if disabled, the Ctrl-C note if it runs until stopped.""" 

531 notes = [] 

532 if task.get("infinite"): 

533 notes.append("(runs until Ctrl-C)") 

534 if task.get("disabled"): 

535 notes.append(f"(unavailable: {task['disabled']})") 

536 line: str = task["help"] 

537 if not notes: 

538 return line 

539 return f"{line} {' '.join(notes)}".strip() 

540 

541 

542def default_line(node: dict[str, Any]) -> str: 

543 """The one-line description of a runnable group's default action. 

544 

545 The author's docstring when there is one; otherwise generated from what 

546 the default actually does — an empty body fans the group's tasks out, a 

547 custom body runs as written — so an undocumented default is still 

548 explained, never a blank cell. 

549 """ 

550 default = node["default"] 

551 line = task_line(default) 

552 if line: 

553 return line 

554 if node.get("default_fanout"): 

555 return "run every task in this group" 

556 return "run this group's default action" 

557 

558 

559def listed(node: dict[str, Any], *, show_hidden: bool = False) -> bool: 

560 """Whether a task or group node belongs in a human listing. 

561 

562 Two things take one out, and they are different in kind. `hidden` is a 

563 task nobody is meant to *read about*: it stays callable, still completes, 

564 shows up under `--json` marked, and `--all` reveals it — because a machine 

565 is exactly who calls it. `unexposed` is a task that does not belong 

566 *here at all*: nothing outside a project can run it, so listing it would 

567 be an offer footman cannot honour, and `--all` does not bring it back. 

568 """ 

569 if node.get("unexposed"): 

570 return False 

571 return show_hidden or not node.get("hidden") 

572 

573 

574def has_listed(node: dict[str, Any], *, show_hidden: bool = False) -> bool: 

575 """Whether anything under *node* is worth printing. 

576 

577 Deliberately *not* short-circuited on the group's own `hidden`: hiding a 

578 group hides everything that inherits from it, but a child that answered 

579 `hidden=False` is still listed, and it needs its heading to be placed. 

580 A group with nothing listed under it prints no heading at all, rather 

581 than one with nothing beneath it. 

582 """ 

583 if "default" in node and listed(node["default"], show_hidden=show_hidden): 

584 return True 

585 return any( 

586 listed(t, show_hidden=show_hidden) for t in node["tasks"].values() 

587 ) or any( 

588 has_listed(sub, show_hidden=show_hidden) for sub in node["groups"].values() 

589 ) 

590 

591 

592def ordered_tasks(node: dict[str, Any]) -> dict[str, Any]: 

593 """A group's tasks with its `default` first, then declaration order. 

594 

595 The default *is* the group — `fm db` runs it, and the group's own row is 

596 described by it — so a listing that showed it wherever it happened to be 

597 written put the group's headline act somewhere in the middle, or at the 

598 bottom. Where it sits in the file is the author's business; where it sits 

599 in a listing is footman's. 

600 """ 

601 tasks: dict[str, Any] = node["tasks"] 

602 if "default" not in tasks: 

603 return tasks 

604 return {"default": tasks["default"], **tasks} 

605 

606 

607def walk( 

608 node: dict[str, Any], 

609 prefix: str = "", 

610 depth: int = 0, 

611 *, 

612 show_hidden: bool = False, 

613 dedupe_defaults: bool = False, 

614 _covered: bool = False, 

615) -> Iterator[tuple[int, str, str, str, str]]: 

616 """The one traversal every human listing reads — `--list`, `--tree`, group 

617 help, and the did-you-mean index. 

618 

619 Yields `(depth, address, leaf, help, kind)` per row, parents before their 

620 children, hidden nodes and empty groups already gone: `--list` renders the 

621 address and ignores the depth, `--tree` renders the leaf and indents by it. 

622 Two views of one walk, so a rule about what is listed cannot be true of 

623 one and false of the other. *show_hidden* — `--all`, and the did-you-mean 

624 index, which owes an answer about every address a human can type — keeps 

625 the hidden rows in. 

626 

627 *dedupe_defaults* is the listings' rule: a runnable group's bare row IS 

628 its default action, so the `x.default` child row would be one action 

629 wearing two lines — skipped wherever the bare row was just emitted 

630 (*_covered* carries that fact into the recursion). The did-you-mean 

631 index leaves it off: `x.default` is an address a human can type, and a 

632 typo of it deserves the real spelling back. 

633 """ 

634 for name, task in ordered_tasks(node).items(): 

635 if _covered and name == "default": 

636 continue # the caller's bare-group row already says this 

637 if listed(task, show_hidden=show_hidden): 

638 yield depth, f"{prefix}{name}", name, task_line(task), "task" 

639 for name, sub in node["groups"].items(): 

640 if not has_listed(sub, show_hidden=show_hidden): 

641 continue 

642 # A runnable group is itself a *runnable address* — the bare 

643 # `fm <group>` spelling, described by its default action — so it earns 

644 # a row in the flat listing. A plain group is only a heading. 

645 runnable = ( 

646 listed(sub, show_hidden=show_hidden) 

647 and "default" in sub 

648 and listed(sub["default"], show_hidden=show_hidden) 

649 ) 

650 yield ( 

651 depth, 

652 f"{prefix}{name}", 

653 name, 

654 default_line(sub) if runnable else sub["help"], 

655 "runnable-group" if runnable else "group", 

656 ) 

657 yield from walk( 

658 sub, 

659 f"{prefix}{name}.", 

660 depth + 1, 

661 show_hidden=show_hidden, 

662 dedupe_defaults=dedupe_defaults, 

663 _covered=dedupe_defaults and runnable, 

664 ) 

665 

666 

667def iter_tasks( 

668 node: dict[str, Any], 

669 prefix: str = "", 

670 *, 

671 show_hidden: bool = False, 

672 dedupe_defaults: bool = False, 

673 covered: bool = False, 

674) -> Iterator[tuple[str, str]]: 

675 """`walk()` as the flat listing sees it: `(address, help)` for everything 

676 you can actually type, headings dropped. *covered* says the caller has 

677 already shown a row standing in for *node*'s own `default` (group help 

678 inserts the bare-group row itself).""" 

679 for _depth, address, _leaf, help_text, kind in walk( 

680 node, 

681 prefix, 

682 show_hidden=show_hidden, 

683 dedupe_defaults=dedupe_defaults, 

684 _covered=dedupe_defaults and covered, 

685 ): 

686 if kind != "group": 

687 yield address, help_text 

688 

689 

690def sort_tree(node: dict[str, Any]) -> dict[str, Any]: 

691 """A copy of *node* with tasks and groups each in name order, recursively. 

692 

693 The listing shape survives — tasks still come before groups at every 

694 level, and a group's `default` still leads it — so `[tool.footman] sort = 

695 true` changes only where names fall, never the two-band layout.""" 

696 copy = dict(node) 

697 by_name = {name: node["tasks"][name] for name in sorted(node["tasks"])} 

698 copy["tasks"] = ordered_tasks({"tasks": by_name}) 

699 copy["groups"] = { 

700 name: sort_tree(sub) for name, sub in sorted(node["groups"].items()) 

701 } 

702 return copy 

703 

704 

705def iter_group_paths(node: dict[str, Any], prefix: str = "") -> Iterator[str]: 

706 for name, sub in node["groups"].items(): 

707 yield f"{prefix}{name}" 

708 yield from iter_group_paths(sub, f"{prefix}{name}.") 

709 

710 

711def json_default(value: object) -> object: 

712 """JSON forms for the types footman coerces *in* — Path, Enum, datetime, 

713 UUID, Decimal, dataclasses, sets — so a task may return what it accepts. 

714 Anything else raises TypeError; the caller turns that into a 

715 `returned_error` note rather than a broken envelope.""" 

716 if isinstance(value, PurePath): 

717 return str(value) 

718 if isinstance(value, enum.Enum): 718 ↛ 723line 718 didn't jump to line 723 because the condition on line 718 was never true

719 # The one output face: the member's token. Int/str-subclassed enums 

720 # never reach this hook (they ride the encoder's fast paths), which 

721 # is why the `redact` pre-walk transforms them first — this branch 

722 # covers the plain-Enum stragglers that arrive through `default`. 

723 from footman import _coerce 

724 

725 return _coerce.token_of(value) 

726 if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): 

727 return value.isoformat() 

728 if isinstance(value, uuid.UUID): 

729 return str(value) 

730 if isinstance(value, decimal.Decimal): 

731 return str(value) # str, not float: Decimal exists to keep precision 

732 if dataclasses.is_dataclass(value) and not isinstance(value, type): 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true

733 return dataclasses.asdict(value) 

734 if isinstance(value, (set, frozenset)): 

735 return sorted(value, key=repr) # deterministic order for golden tests 

736 raise TypeError(f"{type(value).__name__} is not JSON-serialisable") 

737 

738 

739# --- the output contract ------------------------------------------------------ 

740# Three pure functions over the `returned` spec `_manifest.returned_spec` 

741# bakes: the JSON Schema renderer (the describe door's interop dialect), the 

742# producer-side value check, and the phrase help/docs print. The native shape 

743# never expresses anything JSON Schema cannot say — golden-pair tests hold the 

744# renderer to that. 

745 

746_RETURN_SCHEMA: dict[str, dict[str, Any]] = { 

747 "str": {"type": "string"}, 

748 "int": {"type": "integer"}, 

749 "float": {"type": "number"}, 

750 "bool": {"type": "boolean"}, 

751 "none": {"type": "null"}, 

752 "any": {}, 

753 "path": {"type": "string"}, 

754 "datetime": {"type": "string", "format": "date-time"}, 

755 "date": {"type": "string", "format": "date"}, 

756 "time": {"type": "string", "format": "time"}, 

757 "uuid": {"type": "string", "format": "uuid"}, 

758 "decimal": {"type": "string"}, 

759} 

760 

761 

762def returns_json_schema(spec: dict[str, Any]) -> dict[str, Any]: 

763 """Render a baked `returned` spec as JSON Schema (2020-12 vocabulary). 

764 

765 The describe door's dialect — and a *contract*, not presentation: 

766 consumer snapshots pin this output, so a change here is envelope-grade 

767 and belongs in the CHANGELOG, never a cosmetic tweak. 

768 """ 

769 kind = spec["kind"] 

770 if kind == "enum": 

771 schema: dict[str, Any] = {"enum": list(spec["values"])} 

772 elif kind == "list": 

773 schema = {"type": "array", "items": returns_json_schema(spec["items"])} 

774 elif kind == "map": 

775 schema = { 

776 "type": "object", 

777 "additionalProperties": returns_json_schema(spec["values"]), 

778 } 

779 elif kind == "object": 

780 fields: dict[str, Any] | None = spec.get("fields") 

781 if not fields: 

782 schema = {"type": "object"} # no field claims (dict[str, Any]) 

783 else: 

784 required = [n for n, f in fields.items() if f.get("required", True)] 

785 schema = { 

786 "type": "object", 

787 "properties": {n: returns_json_schema(f) for n, f in fields.items()}, 

788 # A declared shape is the whole claim: a dataclass cannot 

789 # carry extras, and an undeclared key is exactly the drift 

790 # the schema exists to catch. 

791 "additionalProperties": False, 

792 } 

793 if required: 793 ↛ 805line 793 didn't jump to line 805 because the condition on line 793 was always true

794 schema["required"] = required 

795 elif kind == "row": 

796 items = [returns_json_schema(f) for f in spec["fields"].values()] 

797 schema = { 

798 "type": "array", 

799 "prefixItems": items, 

800 "minItems": len(items), 

801 "maxItems": len(items), 

802 } 

803 else: 

804 schema = dict(_RETURN_SCHEMA[kind]) 

805 if "name" in spec and kind in ("object", "row", "enum"): 

806 schema = {"title": spec["name"], **schema} 

807 if spec.get("nullable"): 

808 return {"anyOf": [schema, {"type": "null"}]} 

809 return schema 

810 

811 

812def returned_mismatch( 

813 value: Any, spec: dict[str, Any], path: str = "returned" 

814) -> str | None: 

815 """The first place *value* breaks its declared spec, or None. 

816 

817 The producer-side drift check: validated against the *serialised* shape 

818 a consumer reads (a Path satisfies "path" because that is a JSON string 

819 either way), walked recursively, first mismatch wins. A mismatch is a 

820 loud-but-local note — never an exit-code change.""" 

821 if spec.get("nullable") and value is None: 

822 return None 

823 kind = spec["kind"] 

824 checks: dict[str, tuple[Any, str]] = { 

825 "str": (str, "text"), 

826 "bool": (bool, "true/false"), 

827 "none": (type(None), "null"), 

828 "path": ((PurePath, str), "a path"), 

829 "datetime": ((datetime.datetime, str), "a datetime"), 

830 "time": ((datetime.time, str), "a time"), 

831 "uuid": ((uuid.UUID, str), "a UUID"), 

832 "decimal": ((decimal.Decimal, str), "a decimal string"), 

833 } 

834 if kind == "any": 834 ↛ 835line 834 didn't jump to line 835 because the condition on line 834 was never true

835 return None 

836 if kind == "int": 

837 if isinstance(value, int) and not isinstance(value, bool): 

838 return None 

839 return f"{path}: expected an integer, got {_got(value)}" 

840 if kind == "float": 

841 if isinstance(value, (int, float)) and not isinstance(value, bool): 841 ↛ 843line 841 didn't jump to line 843 because the condition on line 841 was always true

842 return None 

843 return f"{path}: expected a number, got {_got(value)}" 

844 if kind == "date": 

845 # `datetime.datetime` is a `date`; declared date, returned datetime 

846 # still serialises to an ISO string. 

847 if isinstance(value, (datetime.date, str)): 847 ↛ 849line 847 didn't jump to line 849 because the condition on line 847 was always true

848 return None 

849 return f"{path}: expected a date, got {_got(value)}" 

850 if kind in checks: 

851 expected, word = checks[kind] 

852 if isinstance(value, expected): 

853 return None 

854 return f"{path}: expected {word}, got {_got(value)}" 

855 if kind == "enum": 

856 # The spec speaks tokens, so the member is compared in the same 

857 # face — validating `.value` against a token spec would refuse 

858 # every declared member (the wrong-face bug the lockstep exists to 

859 # prevent). An already-encoded value (a post-walk token string) 

860 # compares as itself. 

861 if isinstance(value, enum.Enum): 

862 from footman import _coerce 

863 

864 encoded: Any = _coerce.token_of(value) 

865 else: 

866 encoded = value 

867 if any(v == encoded for v in spec["values"]): 

868 return None 

869 return f"{path}: {encoded!r} is not one of the declared values" 

870 if kind == "list": 

871 if not isinstance(value, (list, tuple, set, frozenset)): 871 ↛ 872line 871 didn't jump to line 872 because the condition on line 871 was never true

872 return f"{path}: expected a list, got {_got(value)}" 

873 for i, element in enumerate(value): 

874 if note := returned_mismatch(element, spec["items"], f"{path}[{i}]"): 

875 return note 

876 return None 

877 if kind == "map": 

878 if not isinstance(value, dict): 878 ↛ 879line 878 didn't jump to line 879 because the condition on line 878 was never true

879 return f"{path}: expected a mapping, got {_got(value)}" 

880 for key, element in value.items(): 

881 if not isinstance(key, str): 

882 return f"{path}: key {key!r} is not a string" 

883 if note := returned_mismatch(element, spec["values"], f"{path}[{key!r}]"): 

884 return note 

885 return None 

886 if kind == "object": 

887 return _object_mismatch(value, spec, path) 

888 if kind == "row": 888 ↛ 901line 888 didn't jump to line 901 because the condition on line 888 was always true

889 fields: dict[str, Any] = spec["fields"] 

890 if not isinstance(value, (list, tuple)): 890 ↛ 891line 890 didn't jump to line 891 because the condition on line 890 was never true

891 return f"{path}: expected {spec['name']} (a row), got {_got(value)}" 

892 if len(value) != len(fields): 

893 return ( 

894 f"{path}: expected {len(fields)} values " 

895 f"({', '.join(fields)}), got {len(value)}" 

896 ) 

897 for element, (name, fspec) in zip(value, fields.items(), strict=True): 

898 if note := returned_mismatch(element, fspec, f"{path}.{name}"): 

899 return note 

900 return None 

901 return None # an unknown kind (a newer manifest) makes no claims here 

902 

903 

904def _object_mismatch(value: Any, spec: dict[str, Any], path: str) -> str | None: 

905 fields: dict[str, Any] | None = spec.get("fields") 

906 if dataclasses.is_dataclass(value) and not isinstance(value, type): 

907 # Read through `asdict`'s eyes without its deep conversion: the 

908 # instance's own field names and values, one shallow mapping. 

909 have = {f.name: getattr(value, f.name) for f in dataclasses.fields(value)} 

910 elif isinstance(value, dict): 910 ↛ 913line 910 didn't jump to line 913 because the condition on line 910 was always true

911 have = value 

912 else: 

913 shape = spec.get("name", "an object") 

914 return f"{path}: expected {shape}, got {_got(value)}" 

915 if not fields: 

916 return None # no field claims to check 

917 for name, fspec in fields.items(): 

918 if name not in have: 

919 if fspec.get("required", True): 

920 return f"{path}: missing key {name!r}" 

921 continue 

922 if note := returned_mismatch(have[name], fspec, f"{path}.{name}"): 

923 return note 

924 # The declared shape is the whole claim — an undeclared key IS the 

925 # silent-rename story, seen from the other side. 

926 if extra := sorted(set(have) - set(fields)): 

927 return f"{path}: undeclared key {extra[0]!r}" 

928 return None 

929 

930 

931def _got(value: Any) -> str: 

932 return "null" if value is None else type(value).__name__ 

933 

934 

935_RETURN_WORD: dict[str, tuple[str, str]] = { 

936 # (singular, plural) — the plural serves "a list of …". 

937 "str": ("text", "text"), 

938 "int": ("an integer", "integers"), 

939 "float": ("a number", "numbers"), 

940 "bool": ("true/false", "true/false"), 

941 "none": ("null", "nulls"), 

942 "any": ("anything", "anything"), 

943 "path": ("a path", "paths"), 

944 "datetime": ("a datetime", "datetimes"), 

945 "date": ("a date", "dates"), 

946 "time": ("a time", "times"), 

947 "uuid": ("a UUID", "UUIDs"), 

948 "decimal": ("a decimal", "decimals"), 

949} 

950 

951 

952def returns_phrase(spec: dict[str, Any], *, plural: bool = False) -> str: 

953 """The one-line phrase for a `returned` spec — the returns line in 

954 `--help` and the type cells on a docs page.""" 

955 kind = spec["kind"] 

956 if kind == "enum": 

957 core = "one of " + "|".join(str(v) for v in spec["values"]) 

958 elif kind == "object" and spec.get("fields"): 

959 name = spec.get("name", "an object") 

960 core = f"{name} {{{', '.join(spec['fields'])}}}" 

961 elif kind == "object": 961 ↛ 962line 961 didn't jump to line 962 because the condition on line 961 was never true

962 core = "an object" 

963 elif kind == "row": 

964 core = f"{spec['name']} ({', '.join(spec['fields'])})" 

965 elif kind == "map": 

966 core = "a mapping of " + returns_phrase(spec["values"], plural=True) 

967 elif kind == "list": 

968 core = ("lists of " if plural else "a list of ") + returns_phrase( 

969 spec["items"], plural=True 

970 ) 

971 else: 

972 singular, plural_word = _RETURN_WORD[kind] 

973 core = plural_word if plural else singular 

974 if spec.get("nullable"): 

975 core += " (or null)" 

976 return core 

977 

978 

979def redact(value: Any) -> Any: 

980 """The one redaction walk: any `params.Secret` inside *value* becomes 

981 `***` on its way to a surface that *shows* it — the `--json` envelope, 

982 baked manifest defaults, and the command line a step is printed under 

983 (`context._shown`). A str subclass rides json.dumps' fast path and never 

984 reaches the `default` hook, so this pre-walk is the only reliable 

985 interception. Records are walked on the way out, never on the way in: 

986 what `recording()` and a dependent read is the value that was passed.""" 

987 from footman.params import Secret 

988 

989 if isinstance(value, Secret): 

990 return "***" 

991 if isinstance(value, enum.Enum): 

992 # The enum wire face, in the same pre-walk and for the same reason 

993 # as Secret: `IntEnum` and str-valued enums subclass int/str, ride 

994 # `json.dumps`' fast paths, and never reach the `default` hook — 

995 # and dict *keys* never consult the hook for any type. The walk is 

996 # the only reliable interception, values and keys both; a member 

997 # always speaks its canonical token on a self-describing surface. 

998 from footman import _coerce 

999 

1000 return _coerce.token_of(value) 

1001 if isinstance(value, dict): 

1002 return {redact(k): redact(v) for k, v in value.items()} 

1003 if dataclasses.is_dataclass(value) and not isinstance(value, type): 

1004 # The shape a structured return is most likely to arrive in, and the 

1005 # one this walk used to step straight over: `json_default` turns a 

1006 # dataclass into a dict with `asdict`, which deep-copies — so a 

1007 # `Secret` field survived as the str subclass it is, rode `json.dumps`' 

1008 # fast path, and printed in the clear. A dict here is what the encoder 

1009 # would have produced anyway, now with the fields walked first. 

1010 return { 

1011 field.name: redact(getattr(value, field.name)) 

1012 for field in dataclasses.fields(value) 

1013 } 

1014 if isinstance(value, tuple): 

1015 return tuple(redact(v) for v in value) 

1016 if isinstance(value, list): 

1017 return [redact(v) for v in value] 

1018 if isinstance(value, (set, frozenset)): 

1019 return {redact(v) for v in value} 

1020 return value 

1021 

1022 

1023def jsonable(value: Any) -> tuple[bool, Any]: 

1024 """(True, encoded) when *value* survives the JSON coercion mirror — 

1025 used to bake parameter defaults into the manifest; (False, None) when it 

1026 doesn't, in which case the key is simply omitted.""" 

1027 try: 

1028 return True, json.loads(json.dumps(redact(value), default=json_default)) 

1029 except (TypeError, ValueError): 

1030 return False, None 

1031 

1032 

1033# --- where user code lives ---------------------------------------------------- 

1034# 

1035# One answer to "where is this?", for every message that needs it: the refusals 

1036# that name a declaration, the shadow chain in `--help`, and the failure line 

1037# for an exception nobody expected. Kept here, with the rest of the phrasing, 

1038# because three copies of `co_filename:co_firstlineno` is how they drift. 

1039 

1040_OURS = str(Path(__file__).resolve().parent) 

1041 

1042 

1043def source_of(fn: Any) -> str: 

1044 """`file:line` where *fn* is written, or `""` when it cannot be told. 

1045 

1046 `unwrap` first: a decorated body should point at what somebody wrote, not 

1047 at the wrapper standing in front of it. 

1048 """ 

1049 import inspect 

1050 

1051 code = getattr(inspect.unwrap(fn), "__code__", None) if fn is not None else None 

1052 return f"{code.co_filename}:{code.co_firstlineno}" if code is not None else "" 

1053 

1054 

1055def _is_ours(filename: str) -> bool: 

1056 return str(Path(filename).resolve()).startswith(_OURS) 

1057 

1058 

1059def user_frame(exc: BaseException) -> str: 

1060 """`file:line in name` for the innermost frame that is the caller's code. 

1061 

1062 Innermost *user* frame, not innermost frame: a body that calls `run()` with 

1063 a callable ends its traceback inside footman, and naming that would answer 

1064 a question nobody asked. Empty when every frame is footman's own. 

1065 """ 

1066 import traceback 

1067 

1068 for frame in reversed(traceback.extract_tb(exc.__traceback__)): 

1069 if not _is_ours(frame.filename): 

1070 return f"{frame.filename}:{frame.lineno} in {frame.name}" 

1071 return "" 

1072 

1073 

1074def stack_wanted(verbose: bool, is_terminal: bool) -> bool: 

1075 """Whether an unexpected exception should show its whole stack. 

1076 

1077 Under `-v`, or whenever the failure is headed somewhere nobody is 

1078 watching: a log, a redirect, CI, cron. There is no second run *there* to 

1079 add `-v` to — the artifact is what you get — and the progress estimate 

1080 already reads the same signal to take its one-shot path. 

1081 

1082 Pure, and given both answers rather than finding them, so the one rule can 

1083 live here (with the rest of the phrasing) while its callers sit on either 

1084 side of the import that would otherwise make this circular. 

1085 """ 

1086 return verbose or not is_terminal 

1087 

1088 

1089def user_traceback(exc: BaseException) -> str: 

1090 """The formatted traceback, with footman's own frames taken out. 

1091 

1092 Every one of them, not only the leading run. A step's body is reached 

1093 through the pump, so the framework sits in the *middle* of that stack, and 

1094 leaving it there puts `_pump` and `__call__` between two lines the reader 

1095 wrote. Nothing in those frames is ever theirs to fix, and the receipt above 

1096 already says the work was a step. 

1097 

1098 The cost is honest to name: two adjacent frames in the result were not 

1099 adjacent in the call. What is bought is that every line printed is one 

1100 somebody can act on. 

1101 """ 

1102 import traceback 

1103 

1104 kept = [ 

1105 frame 

1106 for frame in traceback.extract_tb(exc.__traceback__) 

1107 if not _is_ours(frame.filename) 

1108 ] 

1109 tail = "".join(traceback.format_exception_only(type(exc), exc)) 

1110 if not kept: # nothing but framework: the message is the whole story 

1111 return tail 

1112 body = "".join(traceback.StackSummary.from_list(kept).format()) 

1113 return f"Traceback (most recent call last):\n{body}{tail}" 

1114 

1115 

1116def global_default_suffix(name: str, *, code: bool = False, live: bool = True) -> str: 

1117 """The `; default: …` tail a global's help line carries — one composition 

1118 for `--help` and the docs table, so the two spellings cannot drift. 

1119 Empty when there is nothing to print: no default at all, or a bare 

1120 reading with no spelling of its own. *code* wraps the value in backticks 

1121 for a markdown cell. *live* resolves a computed default on this machine 

1122 — right at a terminal, wrong on a page that outlives it: the docs 

1123 exporter passes False and gets the symbolic phrase instead, so the 

1124 published reference stops baking in the build runner's core count.""" 

1125 from footman import _split 

1126 

1127 shown, computed = _split.global_default(name, resolved=live) 

1128 if not shown: 

1129 return "" 

1130 if computed and not live: 

1131 # The phrase is prose, never a copyable value: no backticks, and 

1132 # "(computed)" would be redundant under a description of how. 

1133 return f"; default: {shown}" 

1134 value = f"`{shown}`" if code else str(shown) 

1135 return f"; default: {value}{' (computed)' if computed else ''}"