Coverage for src/footman/tasks/docs.py: 94%

352 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +0000

1"""Render the invoking project's task tree as markdown — `fm footman docs …`. 

2 

3`page` prints (or writes) one document; `site` writes linked pages with an 

4`index.md` per group. Both rebuild the project's tree exactly the way `fm` 

5itself does — the cascade, the config, the mounted plugins — so the output 

6can't drift from what `fm --list` shows. 

7""" 

8 

9from __future__ import annotations 

10 

11import io 

12import os 

13import re 

14import shutil 

15import subprocess 

16import sys 

17from pathlib import Path 

18from typing import Annotated, Any, Literal 

19 

20from footman import _paths, _shellcomp, config, context, discover, markdown, registry 

21from footman import manifest as _manifest 

22from footman.params import between, doc 

23from footman.registry import Group, requires, requires_dep 

24 

25tasks = Group("docs", help="Generate markdown docs for this project's tasks") 

26 

27 

28def _project_tree(include_self: bool) -> dict: 

29 """The invoking project's manifest tree, rebuilt the way `fm` builds it. 

30 

31 Plugin tasks run from the invocation directory (the composing contract), 

32 so `Path.cwd()` is the right anchor for the cascade walk. Re-importing 

33 the tasks files inside a running task is the same same-process repeat 

34 `Runner` performs — `discover` isolates each file per import. 

35 """ 

36 cwd = Path.cwd() 

37 ceiling = _paths.find_repo_root(cwd) 

38 cfg = config.load_config( 

39 cwd, ceiling, None, on_warning=lambda m: print(m, file=sys.stderr) 

40 ) 

41 name = cfg.get("tasks") 

42 filename = name if isinstance(name, str) else _paths.DEFAULT_TASKS_FILE 

43 files = _paths.task_files(cwd, ceiling, filename) 

44 base = registry.Group("root") 

45 plugins = cfg.get("plugins") 

46 if isinstance(plugins, list) and plugins: 46 ↛ 50line 46 didn't jump to line 50 because the condition on line 46 was always true

47 from footman import compose 

48 

49 compose.mount_plugins(base, plugins) 

50 reg = discover.load_tree(files, base=base) 

51 tree = _manifest.build_manifest(reg)["tree"] 

52 if not include_self: 

53 # Don't document the documenter: the mounted `footman` group is 

54 # opted back in with --all. 

55 tree["groups"].pop("footman", None) 

56 return tree 

57 

58 

59def _path_of(target: str) -> tuple[str, ...]: 

60 return tuple(target.replace(".", " ").split()) 

61 

62 

63@tasks.task 

64def page( 

65 target: Annotated[str, doc("dotted task/group to scope to; empty = all")] = "", 

66 heading: Annotated[int, between(1, 6), doc("top heading level")] = 1, 

67 flavor: Annotated[ 

68 Literal["plain", "material"], 

69 doc("plain CommonMark, or material/zensical extras"), 

70 ] = "plain", 

71 out: Path | None = None, 

72 prog: Annotated[ 

73 str, doc("command name in usage and examples (default: the invoking CLI)") 

74 ] = "", 

75 all: Annotated[bool, doc("include footman's own mounted tasks")] = False, 

76): 

77 """Render the task tree (or one group/task) as one markdown page. 

78 

79 Without --out the page is the task's stdout, ready to redirect or pipe 

80 (into pandoc, say); with --out it is written to the file. The heading 

81 level makes the page nest under a host site's own structure, so it 

82 drops into zensical/mkdocs via a snippet include. 

83 """ 

84 tree = _project_tree(all) 

85 prog = prog or context.current().prog # a branded CLI documents itself 

86 text = markdown.render_page( 

87 tree, path=_path_of(target), heading=heading, flavor=flavor, prog=prog 

88 ) 

89 if out is None: 

90 print(text, end="") 

91 return None 

92 out.parent.mkdir(parents=True, exist_ok=True) 

93 out.write_text(text, encoding="utf-8") 

94 # Inside a task, stderr merges into task output by contract — a plain 

95 # print is the honest note here; `returned` carries the machine copy. 

96 print(f"wrote {out}") 

97 return [str(out)] 

98 

99 

100@tasks.task 

101def site( 

102 out: Annotated[Path, doc("directory to write the pages into")], 

103 target: Annotated[str, doc("dotted group to scope to; empty = all")] = "", 

104 flavor: Annotated[ 

105 Literal["plain", "material"], 

106 doc("material fits zensical/mkdocs; plain is portable"), 

107 ] = "material", 

108 prog: Annotated[ 

109 str, doc("command name in usage and examples (default: the invoking CLI)") 

110 ] = "", 

111 all: Annotated[bool, doc("include footman's own mounted tasks")] = False, 

112): 

113 """Render the task tree as linked pages: index.md per group, one file per task. 

114 

115 Made for docs sites — point <out> into your docs tree and add the pages 

116 to the nav. Regenerate on each docs build so they can't drift. 

117 """ 

118 tree = _project_tree(all) 

119 prog = prog or context.current().prog # a branded CLI documents itself 

120 files = markdown.render_site(tree, path=_path_of(target), flavor=flavor, prog=prog) 

121 written: list[str] = [] 

122 for rel, content in files.items(): 

123 dest = out / rel 

124 dest.parent.mkdir(parents=True, exist_ok=True) 

125 dest.write_text(content, encoding="utf-8") 

126 written.append(str(dest)) 

127 print(f"wrote {len(written)} pages under {out}") 

128 return written 

129 

130 

131_CLEAR = "\x1b[K" 

132 

133 

134def reduce_frames(raw: str) -> str: 

135 """Collapse a pty capture to the final frame of every line. 

136 

137 footman's live output repaints a line in place — `\\r` then a full 

138 rewrite (step lines, the status bar) — and clears with `ESC[K`; a pty 

139 records every intermediate frame. Keeping only the text after the last 

140 `\\r` of each physical line, and dropping the clear sequences, leaves 

141 what a human saw once the run settled. Colour (SGR) sequences pass 

142 through untouched. 

143 """ 

144 text = raw.replace("\r\n", "\n") # the pty's ONLCR translation, undone 

145 lines = [seg.rsplit("\r", 1)[-1].replace(_CLEAR, "") for seg in text.split("\n")] 

146 return "\n".join(lines) 

147 

148 

149@tasks.task(name="shots") 

150@requires_dep("rich") 

151@requires(lambda: sys.platform != "win32", reason="needs a POSIX pseudo-terminal") 

152def shots( 

153 *argv: str, 

154 out: Annotated[Path, doc("the SVG file to write")], 

155 title: Annotated[str, doc("window title (default: the command line)")] = "", 

156 width: Annotated[int, between(40, 200), doc("terminal columns")] = 72, 

157 cmd: Annotated[str, doc("executable to run (default: the invoking CLI)")] = "", 

158): 

159 """Run the CLI on a pseudo-terminal and save a framed SVG screenshot. 

160 

161 Runs `<cmd> <argv…>` on a real pty — colours, receipts, taught errors, 

162 exactly as a terminal shows them — collapses live rewrites to their 

163 final frame, and renders the capture with rich as an SVG in a 

164 macOS-style window. Regenerate on every docs build and a screenshot 

165 can never drift from the CLI: it *is* the CLI. 

166 

167 The command really executes, so don't screenshot tasks whose side 

168 effects you don't want. A failing command still renders — a taught 

169 error message is a perfectly good screenshot. 

170 """ 

171 if sys.platform == "win32": # the @requires gate already refused; belt 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 raise RuntimeError("docs shots needs a POSIX pseudo-terminal") 

173 import fcntl 

174 import pty 

175 import struct 

176 import termios 

177 

178 prog = cmd or context.current().prog 

179 exe = shutil.which(prog) 

180 if exe is None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 raise RuntimeError(f"{prog!r} is not on PATH") 

182 

183 env = os.environ.copy() 

184 env.pop("NO_COLOR", None) # the pty asks for colour; let it answer 

185 env["TERM"] = "xterm-256color" 

186 env["COLUMNS"] = str(width) 

187 master, slave = pty.openpty() 

188 fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, width, 0, 0)) 

189 proc = subprocess.Popen( 

190 [exe, *argv], stdin=slave, stdout=slave, stderr=slave, env=env 

191 ) 

192 os.close(slave) 

193 chunks: list[bytes] = [] 

194 while True: 

195 try: 

196 data = os.read(master, 65536) 

197 except OSError: # EIO: the child hung up (how Linux spells EOF) 

198 break 

199 if not data: 

200 break 

201 chunks.append(data) 

202 os.close(master) 

203 proc.wait() 

204 

205 # The blessed lazy import: rich is docs tooling, never a dependency — 

206 # @requires_dep("rich") lists this task as unavailable when it's absent. 

207 from rich.console import Console 

208 from rich.text import Text 

209 

210 capture = reduce_frames(b"".join(chunks).decode("utf-8", "replace")) 

211 console = Console(record=True, width=width, file=io.StringIO(), force_terminal=True) 

212 console.print(Text.from_ansi(capture.rstrip("\n"))) 

213 out.parent.mkdir(parents=True, exist_ok=True) 

214 line = " ".join([prog, *argv]) 

215 out.write_text(console.export_svg(title=title or line), encoding="utf-8") 

216 print(f"wrote {out}") 

217 return [str(out)] 

218 

219 

220# --- animated casts ----------------------------------------------------------- 

221# An interactive session (TAB completion!) can't be a static screenshot or a 

222# line-based reduction: shells paint menus with real cursor movement. A cast 

223# drives a live shell on the pty with scripted keystrokes, replays the byte 

224# stream through a terminal emulator (pyte) into screen states, renders each 

225# state with rich, and stacks the frames in one self-contained SVG animated 

226# by CSS keyframes with the capture's own timing. No JavaScript; an <img> 

227# plays it. 

228 

229_KEY_TOKENS = { 

230 "<TAB>": b"\t", 

231 "<ENTER>": b"\r", 

232 "<SPACE>": b" ", 

233 "<BACKSPACE>": b"\x7f", 

234 "<CTRL-C>": b"\x03", 

235} 

236 

237 

238# A negative "delay" marks a <SETTLE> step: instead of waiting a fixed time, the 

239# pty loop holds the next key until output has gone quiet (see _pty_session) — so 

240# a prompt whose render time you can't predict is waited on, not guessed at. 

241_SETTLE = -1.0 

242_SETTLE_GAP = 0.5 # seconds of silence that count as "settled" 

243_SETTLE_MAX = 10.0 # hard cap: fire anyway, so a never-quiet stream can't hang 

244 

245 

246def keystrokes(script: tuple[str, ...]) -> list[tuple[float, bytes]]: 

247 """Compile a cast script into (delay-before-send, bytes) steps. 

248 

249 Each script argument is either literal text — typed one character at a 

250 time at a human-ish cadence — or a token: `<TAB>`, `<ENTER>`, `<SPACE>`, 

251 `<BACKSPACE>`, `<CTRL-C>`, `<WAIT>` (pause 0.8 s), `<WAIT:ms>`, or 

252 `<SETTLE>` (wait until output stops changing — timing-independent, for a 

253 prompt whose render time you can't predict). 

254 """ 

255 sends: list[tuple[float, bytes]] = [] 

256 for part in script: 

257 if part in _KEY_TOKENS: 

258 sends.append((0.3, _KEY_TOKENS[part])) 

259 elif part == "<SETTLE>": 

260 sends.append((_SETTLE, b"")) 

261 elif part == "<WAIT>": 

262 sends.append((0.8, b"")) 

263 elif part.startswith("<WAIT:") and part.endswith(">"): 

264 sends.append((int(part[6:-1]) / 1000.0, b"")) 

265 else: 

266 sends.extend((0.07, ch.encode("utf-8")) for ch in part) 

267 return sends 

268 

269 

270# Modern interactive stacks interrogate their terminal before painting a 

271# prompt — fish asks for capabilities (XTGETTCAP), PSReadLine and reedline 

272# need cursor-position answers (DSR), kitty-keyboard and colour queries 

273# round it out — and block or bail when nothing replies. The session 

274# answers like a plain xterm; DSR answers come from a live emulator so the 

275# reported cursor is the truth. 

276_TERM_QUERIES: list[tuple[re.Pattern[bytes], bytes]] = [ 

277 (re.compile(rb"\x1b\[0?c"), b"\x1b[?62;22c"), # primary DA 

278 (re.compile(rb"\x1b\[>0?c"), b"\x1b[>1;95;0c"), # secondary DA 

279 (re.compile(rb"\x1b\[\?u"), b"\x1b[?0u"), # kitty keyboard 

280 (re.compile(rb"\x1b\[\?2026\$p"), b"\x1b[?2026;0$y"), # sync output 

281 (re.compile(rb"\x1bP\+q[0-9a-fA-F;]*(?:\x1b\\|\x07)"), b"\x1bP0+r\x1b\\"), 

282] 

283_DSR = re.compile(rb"\x1b\[6n") 

284# Device Control Strings — terminal protocol, never screen content. pyte 

285# doesn't consume them, so their payload (fish's XTGETTCAP capability 

286# names, hex-encoded) would render as stray text for one frame. 

287_DCS = re.compile(r"\x1bP.*?(?:\x1b\\|\x07)", re.S) 

288_OSC_COLOUR = re.compile(rb"\x1b\](1[012]);\?(?:\x07|\x1b\\)") 

289 

290 

291# Which line editors need a cursor-position answer to paint at all. 

292# PSReadLine and reedline hang without one — with DSR unanswered, pwsh 

293# and nushell produce no output whatever. fish is the opposite: it asks 

294# mid-session and then *types the answer into the command line*, so an 

295# `fm che` becomes `fm ch77e`. bash and zsh don't care either way. 

296_NEEDS_CURSOR_REPLY = frozenset({"pwsh", "nushell"}) 

297 

298 

299def _answer_queries( 

300 buf: bytes, new_from: int, cursor_at, reply, *, cursor: bool = True 

301) -> None: 

302 """Answer terminal queries in *buf* that end at or past *new_from* — 

303 earlier bytes were answered on a previous read (the buffer overlaps so 

304 a sequence split across reads still matches exactly once).""" 

305 for m in _DSR.finditer(buf) if cursor else (): 

306 if m.end() > new_from: 

307 row, col = cursor_at() 

308 reply(f"\x1b[{row};{col}R".encode()) 

309 for m in _OSC_COLOUR.finditer(buf): 

310 if m.end() > new_from: 

311 reply(b"\x1b]" + m.group(1) + b";rgb:2828/2c2c/3434\x07") 

312 for pattern, answer in _TERM_QUERIES: 

313 for m in pattern.finditer(buf): 

314 if m.end() > new_from: 

315 reply(answer) 

316 

317 

318def _pty_session( 

319 argv: list[str], 

320 *, 

321 width: int, 

322 height: int, 

323 sends: list[tuple[float, bytes]], 

324 settle: float, 

325 env_extra: dict[str, str] | None = None, 

326 keep_echo: bool = False, 

327 cwd: Path | None = None, 

328 answer_cursor: bool = True, 

329) -> list[tuple[float, bytes]]: 

330 """Run *argv* on a pty, play the keystroke script, and record 

331 (elapsed-seconds, bytes) chunks until output has settled.""" 

332 import fcntl 

333 import pty 

334 import select 

335 import struct 

336 import termios 

337 import time as _time 

338 

339 import pyte # tracks the live cursor for honest DSR answers 

340 

341 env = os.environ.copy() 

342 env.pop("NO_COLOR", None) 

343 env["TERM"] = "xterm-256color" 

344 env["COLUMNS"] = str(width) 

345 env["LINES"] = str(height) 

346 env.update(env_extra or {}) 

347 master, slave = pty.openpty() 

348 fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", height, width, 0, 0)) 

349 # Query replies are written into the pty during boot, before the shell 

350 # enters raw mode — with kernel ECHO on, the tty prints them back (the 

351 # digits of a cursor-position reply flashing on screen for a frame). 

352 # Shells that interrogate the terminal render their own input anyway, 

353 # so echo goes off — except where the caller says otherwise: readline 

354 # honours the tty's echo flag and falls silent without it. 

355 if not keep_echo: 

356 attrs = termios.tcgetattr(slave) 

357 attrs[3] &= ~termios.ECHO 

358 termios.tcsetattr(slave, termios.TCSANOW, attrs) 

359 

360 def _own_the_tty() -> None: # child, pre-exec: the pty.fork() idiom 

361 # A new session *and* the slave as controlling terminal — fish, 

362 # nushell, and PSReadLine all refuse interactive mode without one 

363 # (zsh and bash merely grumble). 

364 os.setsid() 

365 fcntl.ioctl(0, termios.TIOCSCTTY, 0) 

366 

367 proc = subprocess.Popen( 

368 argv, 

369 stdin=slave, 

370 stdout=slave, 

371 stderr=slave, 

372 env=env, 

373 cwd=cwd, 

374 preexec_fn=_own_the_tty, 

375 ) 

376 os.close(slave) 

377 start = _time.monotonic() 

378 chunks: list[tuple[float, bytes]] = [] 

379 queue = list(sends) 

380 tracker = pyte.Screen(width, height) 

381 tracker_stream = pyte.Stream(tracker) 

382 pending = b"" # tail kept so a query split across reads still matches 

383 # Typing begins only after the boot has *settled*: keys written before 

384 # the line editor exists are half-echoed raw and eaten (a TAB pressed 

385 # then never completes anything), and a slow rc — compinit, the hook's 

386 # own `--setup-completion` subprocess — paints in bursts. Every boot 

387 # chunk pushes the start out another half second of silence. 

388 typing_started = False 

389 next_at: float | None = None 

390 last_output = start # for <SETTLE>: when the pty last emitted anything 

391 deadline = None if queue else start + settle 

392 try: 

393 while True: 

394 now = _time.monotonic() 

395 if queue and next_at is not None: 

396 if queue[0][0] < 0: 

397 # A <SETTLE> step (negative delay) waits for a prompt to 

398 # finish rendering: output quiet for _SETTLE_GAP *and* the 

399 # cursor sitting past column 0 — a prompt on the line, not 

400 # the col-0 lull of a program still starting up (Python 

401 # boot, task discovery), which is silent but not ready. 

402 # Capped at _SETTLE_MAX so a prompt that never lands — or 

403 # one that ends at column 0 — can't hang the cast; next_at 

404 # holds when the step became due, so the cap counts there. 

405 ready = ( 

406 now - last_output >= _SETTLE_GAP and tracker.cursor.x > 0 

407 ) or now - next_at >= _SETTLE_MAX 

408 else: 

409 ready = now >= next_at 

410 if ready: 

411 typing_started = True 

412 _, data = queue.pop(0) 

413 if data: 

414 os.write(master, data) 

415 if queue: 

416 nd = queue[0][0] 

417 next_at = now if nd < 0 else now + nd 

418 else: 

419 next_at = now 

420 deadline = now + settle 

421 readable, _, _ = select.select([master], [], [], 0.03) 

422 if readable: 

423 try: 

424 data = os.read(master, 65536) 

425 except OSError: # EIO: the child hung up 

426 break 

427 if not data: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true

428 break 

429 last_output = _time.monotonic() 

430 chunks.append((last_output - start, data)) 

431 tracker_stream.feed(_DCS.sub("", data.decode("utf-8", "replace"))) 

432 buf = pending + data 

433 _answer_queries( 

434 buf, 

435 len(pending), 

436 lambda: (tracker.cursor.y + 1, tracker.cursor.x + 1), 

437 lambda answer: os.write(master, answer), 

438 cursor=answer_cursor, 

439 ) 

440 pending = buf[-64:] 

441 if not typing_started and queue: 

442 nd = queue[0][0] 

443 next_at = last_output if nd < 0 else last_output + 0.5 + nd 

444 if deadline is not None: # let late repaints settle too 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 deadline = last_output + settle 

446 if deadline is not None and _time.monotonic() >= deadline: 

447 break 

448 if proc.poll() is not None and not readable: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 break 

450 finally: 

451 import contextlib as _contextlib 

452 

453 with _contextlib.suppress(ProcessLookupError): 

454 proc.kill() 

455 os.close(master) 

456 proc.wait() 

457 return chunks 

458 

459 

460def _cell_style(char: Any) -> str: 

461 """A pyte cell's attributes as a rich style string ('' = default).""" 

462 bits: list[str] = [] 

463 if char.bold: 

464 bits.append("bold") 

465 if char.italics: 

466 bits.append("italic") 

467 if char.underscore: 

468 bits.append("underline") 

469 if char.reverse: 

470 bits.append("reverse") 

471 for attr, prefix in ((char.fg, ""), (char.bg, "on ")): 

472 if attr and attr != "default": 

473 # pyte names ansi colours ("red") and spells the rest as bare 

474 # 6-digit hex ("87d7ff") — rich wants a `#` on the hex form. 

475 longhand = ( 

476 f"#{attr}" if _HEX6.fullmatch(attr) else _BRIGHT.sub(r"bright_\1", attr) 

477 ) 

478 bits.append(f"{prefix}{longhand}") 

479 return " ".join(bits) 

480 

481 

482def _screens( 

483 chunks: list[tuple[float, bytes]], *, width: int, height: int 

484) -> list[tuple[float, list[list[tuple[str, str]]]]]: 

485 """Replay timed chunks through a terminal emulator; return the deduped 

486 (time, screen) states, each screen a grid of (char, style) cells.""" 

487 import pyte 

488 

489 screen = pyte.Screen(width, height) 

490 stream = pyte.Stream(screen) 

491 frames: list[tuple[float, list[list[tuple[str, str]]]]] = [] 

492 last: list[list[tuple[str, str]]] | None = None 

493 for t, data in chunks: 

494 stream.feed(_DCS.sub("", data.decode("utf-8", "replace"))) 

495 snap = [ 

496 [ 

497 (cell.data, _cell_style(cell)) 

498 for x in range(width) 

499 for cell in (screen.buffer[y][x],) 

500 ] 

501 for y in range(height) 

502 ] 

503 if snap != last: 

504 # Skip the blank screens before the shell first paints: a 

505 # recording should open on the prompt, not on an empty frame. 

506 if frames or any(cell[0].strip() for row in snap for cell in row): 

507 frames.append((t, snap)) 

508 last = snap 

509 return frames 

510 

511 

512_HEX6 = re.compile(r"[0-9a-fA-F]{6}") 

513# pyte spells the bright ANSI colours "brightblack"; rich spells them 

514# "bright_black" and silently ignores anything it cannot parse — which 

515# renders dim text in the normal foreground. That is how fish's grey 

516# autosuggestion came to look like characters typed into the prompt. 

517_BRIGHT = re.compile(r"^bright([a-z]+)$") 

518_SVG_SHELL = re.compile(r"^\s*<svg[^>]*>|</svg>\s*$") 

519 

520 

521def compose_animation(svgs: list[str], times: list[float], *, hold: float = 1.6) -> str: 

522 """Stack per-frame SVGs into one, cycled by CSS keyframes. 

523 

524 Each frame plays over its captured window; the last holds for *hold* 

525 seconds before the loop restarts. `step-end` opacity keeps the switch 

526 discrete, and every frame carries its own opaque background, so the 

527 topmost visible frame is the whole picture. 

528 """ 

529 total = times[-1] + hold 

530 head = svgs[0] 

531 match = re.search(r"<svg[^>]*>", head) 

532 shell_open = match.group(0) if match else "<svg>" 

533 css: list[str] = [".cast-frame{opacity:0}"] 

534 body: list[str] = [] 

535 for i, (svg, t) in enumerate(zip(svgs, times, strict=True)): 

536 a = 100.0 * t / total 

537 b = 100.0 * (times[i + 1] / total) if i + 1 < len(times) else 100.0 

538 window = f"{a:.3f}%{{opacity:1}}" if a > 0 else "0%{opacity:1}" 

539 off = f"{b:.3f}%{{opacity:0}}" if b < 100.0 else "" 

540 pre = "0%{opacity:0}" if a > 0 else "" 

541 css.append(f"@keyframes cf{i}{{{pre}{window}{off}}}") 

542 css.append(f".cf{i}{{animation:cf{i} {total:.3f}s step-end infinite}}") 

543 inner = _SVG_SHELL.sub("", svg.strip()) 

544 body.append(f'<g class="cast-frame cf{i}">{inner}</g>') 

545 style = f"<style>{''.join(css)}</style>" 

546 return f"{shell_open}{style}{''.join(body)}</svg>" 

547 

548 

549_CAST_BOOT: dict[str, str] = { 

550 "zsh": "zsh", 

551 "bash": "bash", 

552 "fish": "fish", 

553 "pwsh": "pwsh", 

554 "nushell": "nu", 

555} 

556 

557 

558def _boot_shell( 

559 shell: str, prog: str, scratch: Path 

560) -> tuple[list[str], dict[str, str]]: 

561 """(argv, extra env) for an interactive *shell* with completion loaded. 

562 

563 Each shell boots from a scratch config dir — the user's own dotfiles 

564 never run — with a minimal green prompt and footman's hook installed 

565 via the same `--setup-completion` path users eval. The scratch HOME 

566 would also hide the completion cache (TAB answers from cache alone), 

567 so the invoker's real cache dir is passed through FOOTMAN_CACHE_DIR — 

568 the override doing exactly the job it was built for. 

569 """ 

570 env = { 

571 "HOME": str(scratch), 

572 "XDG_CONFIG_HOME": str(scratch), 

573 "FOOTMAN_CACHE_DIR": str(_paths.footman_cache_dir()), 

574 } 

575 # A system rc (/etc/zsh/*, /etc/profile) may rebuild PATH and lose the 

576 # venv that owns *prog* \u2014 then the rc's `eval "$(prog \u2026)"` silently 

577 # produces nothing and the hook never loads. Pin the interpreter's own 

578 # bin dir first, the same lesson the functional shell tests carry. 

579 bin_dir = str(Path(sys.executable).parent) 

580 if shell == "zsh": 

581 (scratch / ".zshrc").write_text( 

582 f"path=({bin_dir!r} $path)\n" 

583 "PROMPT='%F{green}\u276f%f '\n" 

584 "autoload -Uz compinit && compinit -u\n" 

585 f'eval "$({prog} --setup-completion zsh)"\n', 

586 encoding="utf-8", 

587 ) 

588 env["ZDOTDIR"] = str(scratch) 

589 # --no-globalrcs: some machine images (GitHub runners among them) 

590 # ship an /etc/zsh rc that runs a bare compinit, which stops the 

591 # boot at an interactive compaudit question — and the first typed 

592 # key of the script gets eaten answering it. A recording wants a 

593 # hermetic shell: scratch rc only. 

594 return ["zsh", "--no-globalrcs", "-i"], env 

595 if shell == "bash": 

596 # macOS prints a "default shell is now zsh" advert into interactive 

597 # bash; Apple's own switch silences it — the recording is about 

598 # bash, not about Apple's feelings toward it. 

599 env["BASH_SILENCE_DEPRECATION_WARNING"] = "1" 

600 rc = scratch / "bashrc" 

601 rc.write_text( 

602 f'PATH="{bin_dir}:$PATH"\n' 

603 "PS1='\\[\\e[32m\\]\u276f\\[\\e[0m\\] '\n" 

604 f'eval "$({prog} --setup-completion bash)"\n', 

605 encoding="utf-8", 

606 ) 

607 return ["bash", "--rcfile", str(rc), "-i"], env 

608 if shell == "fish": 

609 boot = ( 

610 "set -g fish_greeting ''; " 

611 # Autosuggestions draw on whatever is installed beside footman: 

612 # the build machine offered `factor` on macOS and `f77` (the 

613 # Fortran compiler) on Linux, so the same script recorded 

614 # differently per box. A recording should show footman's 

615 # completion, not the host's PATH. 

616 "set -g fish_autosuggestion_enabled 0; " 

617 f"fish_add_path --prepend {bin_dir!r}; " 

618 f"{prog} --setup-completion fish | source; " 

619 "function fish_prompt; set_color green; echo -n '\u276f '; " 

620 "set_color normal; end" 

621 ) 

622 return ["fish", "-i", "-C", boot], env 

623 if shell == "pwsh": 

624 # MenuComplete: the grid-with-tooltip menu \u2014 the completion story 

625 # worth recording. The hook loads exactly as the docs teach. 

626 boot = "$env.PATH = $null; " # placeholder never reached; see below 

627 boot = ( 

628 f"$env:PATH = '{bin_dir}' + [IO.Path]::PathSeparator + $env:PATH; " 

629 'function prompt { "\u276f " }; ' 

630 "Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete; " 

631 f"{prog} --setup-completion pwsh | Out-String | Invoke-Expression" 

632 ) 

633 return ["pwsh", "-NoLogo", "-NoProfile", "-NoExit", "-Command", boot], env 

634 if shell == "nushell": 634 ↛ 651line 634 didn't jump to line 651 because the condition on line 634 was always true

635 hook = scratch / "hook.nu" 

636 hook.write_text(_shellcomp.script_for("nushell", prog), encoding="utf-8") 

637 env_nu = scratch / "env.nu" 

638 env_nu.write_text( 

639 f"$env.PATH = ($env.PATH | prepend {str(bin_dir)!r})\n" 

640 '$env.PROMPT_COMMAND = {|| "" }\n' 

641 '$env.PROMPT_COMMAND_RIGHT = {|| "" }\n' 

642 '$env.PROMPT_INDICATOR = {|| $"(ansi green)\u276f (ansi reset)" }\n', 

643 encoding="utf-8", 

644 ) 

645 config_nu = scratch / "config.nu" 

646 config_nu.write_text( 

647 f"$env.config.show_banner = false\nsource {hook}\n", 

648 encoding="utf-8", 

649 ) 

650 return ["nu", "--env-config", str(env_nu), "--config", str(config_nu)], env 

651 raise RuntimeError(f"cast drives zsh, bash, fish, pwsh, or nushell (got {shell!r})") 

652 

653 

654@tasks.task(name="cast") 

655@requires_dep("rich", "pyte") 

656@requires(lambda: sys.platform != "win32", reason="needs a POSIX pseudo-terminal") 

657def cast( 

658 *keys: str, 

659 out: Annotated[Path, doc("the animated SVG file to write")], 

660 shell: Annotated[ 

661 Literal["zsh", "bash", "fish", "pwsh", "nushell"], 

662 doc("interactive shell to drive"), 

663 ] = "zsh", 

664 title: Annotated[str, doc("window title (default: '<shell> · completion')")] = "", 

665 width: Annotated[int, between(40, 200), doc("terminal columns")] = 72, 

666 height: Annotated[int, between(4, 50), doc("terminal rows")] = 14, 

667 prog: Annotated[ 

668 str, doc("CLI whose completion is installed (default: the invoking CLI)") 

669 ] = "", 

670 cwd: Annotated[ 

671 Path | None, doc("directory the shell starts in (default: here)") 

672 ] = None, 

673 max_frames: Annotated[int, between(2, 120), doc("frame budget")] = 60, 

674): 

675 """Record an animated SVG of a real interactive shell session. 

676 

677 Boots the shell from a scratch config with footman completion loaded 

678 (via `--setup-completion`; nushell sources the generated hook), types 

679 the script — everything after `--`, where `<TAB>`, `<ENTER>`, 

680 `<WAIT>` and friends are keys — and replays 

681 the capture through a terminal emulator into an animated, dependency- 

682 free SVG with the session's real timing. TAB completion, in motion, 

683 regenerated on every docs build so it cannot drift. 

684 """ 

685 if sys.platform == "win32": # the @requires gate already refused; belt 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true

686 raise RuntimeError("docs cast needs a POSIX pseudo-terminal") 

687 import tempfile 

688 

689 prog = prog or context.current().prog 

690 if shutil.which(prog) is None: 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true

691 raise RuntimeError(f"{prog!r} is not on PATH") 

692 if shutil.which(_CAST_BOOT.get(shell, shell)) is None: 692 ↛ 693line 692 didn't jump to line 693 because the condition on line 692 was never true

693 raise RuntimeError(f"{shell!r} is not on PATH") 

694 

695 with tempfile.TemporaryDirectory() as scratch: 

696 argv, env_extra = _boot_shell(shell, prog, Path(scratch)) 

697 chunks = _pty_session( 

698 argv, 

699 width=width, 

700 height=height, 

701 sends=keystrokes(keys), 

702 settle=1.5, 

703 env_extra=env_extra, 

704 # bash: readline honours the tty's echo flag and types 

705 # invisibly without it — and bash sends no queries, so 

706 # nothing can flash. Every other shell self-renders. 

707 keep_echo=shell == "bash", 

708 cwd=cwd, 

709 answer_cursor=shell in _NEEDS_CURSOR_REPLY, 

710 ) 

711 frames = _screens(chunks, width=width, height=height) 

712 if not frames: 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true

713 raise RuntimeError(f"the {shell} session produced no output") 

714 if len(frames) > max_frames: # keep first/last, thin the middle evenly 714 ↛ 715line 714 didn't jump to line 715 because the condition on line 714 was never true

715 keep = {0, len(frames) - 1} 

716 keep.update( 

717 round(i * (len(frames) - 1) / (max_frames - 1)) for i in range(max_frames) 

718 ) 

719 frames = [f for i, f in enumerate(frames) if i in keep] 

720 

721 from rich.console import Console 

722 from rich.text import Text 

723 

724 label = title or f"{shell} · completion" 

725 svgs: list[str] = [] 

726 for i, (_, grid) in enumerate(frames): 

727 console = Console( 

728 record=True, width=width, file=io.StringIO(), force_terminal=True 

729 ) 

730 for row in grid: 

731 text = Text() 

732 for ch, style in row: 

733 text.append(ch, style or None) 

734 console.print(text) 

735 svgs.append(console.export_svg(title=label, unique_id=f"cf{i}")) 

736 start = frames[0][0] 

737 times = [t - start for t, _ in frames] 

738 out.parent.mkdir(parents=True, exist_ok=True) 

739 out.write_text(compose_animation(svgs, times), encoding="utf-8") 

740 print(f"wrote {out} ({len(frames)} frames)") 

741 return [str(out)] 

742 

743 

744@tasks.task(name="globals") 

745def globals_( 

746 out: Path | None = None, 

747 prog: Annotated[ 

748 str, doc("command name in the table (default: the invoking CLI)") 

749 ] = "", 

750): 

751 """Render the runner's global options as a markdown table. 

752 

753 The rows come straight from the CLI grammar — the same table `--help` 

754 prints — so a reference page that regenerates this on each docs build 

755 can never drift from the runner. Without --out the table is the task's 

756 stdout; with --out it is written to the file. 

757 """ 

758 prog = prog or context.current().prog # a branded CLI documents itself 

759 text = markdown.globals_table(prog=prog) 

760 if out is None: 

761 print(text, end="") 

762 return None 

763 out.parent.mkdir(parents=True, exist_ok=True) 

764 out.write_text(text, encoding="utf-8") 

765 print(f"wrote {out}") 

766 return [str(out)]