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

536 statements  

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

1"""Render the invoking project's task tree as markdown — `fm 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 collections.abc import Callable 

18from pathlib import Path 

19from typing import Annotated, Any, Literal, NamedTuple 

20 

21from footman import ( 

22 _config, 

23 _describe, 

24 _discover, 

25 _manifest, 

26 _paths, 

27 _shellcomp, 

28 context, 

29 markdown, 

30 registry, 

31) 

32from footman.params import between, default, doc 

33from footman.registry import Group, requires, requires_dep 

34 

35 

36def invoking_prog() -> str: 

37 """The name the CLI was invoked as, so a branded runner documents itself.""" 

38 return context.current().prog 

39 

40 

41_invoking_cli = default(invoking_prog) 

42"""The default for every `prog`/`cmd` parameter here. Resolved when the task 

43runs — it is a property of the invocation, which is exactly what an import-time 

44default cannot see, and what `--help` now prints instead of prose.""" 

45 

46 

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

48 

49 

50def _project_tree(include_self: bool) -> dict[str, Any]: 

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

52 

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

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

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

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

57 """ 

58 cwd = Path.cwd() 

59 ceiling = _paths.find_repo_root(cwd) 

60 cfg = _config.load_config( 

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

62 ) 

63 name = cfg.get("tasks") 

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

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

66 base = registry.Group("root") 

67 reg = _discover.load_tree(files, base=base) 

68 if not include_self: 

69 _prune_first_party(reg) 

70 # The one surface that bakes: a page renders every parameter's choices and 

71 # has no reader to resolve them later, so the completers run here. 

72 tree: dict[str, Any] = _manifest.build_manifest(reg, bake_completers=True)["tree"] 

73 if _config.sort_listing(cfg): # the pages follow the same one setting 

74 tree = _describe.sort_tree(tree) 

75 return tree 

76 

77 

78def _prune_first_party(node: registry.Group) -> None: 

79 """Don't document the documenter (opted back in with --all): drop every 

80 *task* mounted from footman's own plugins, wherever the author mounted 

81 it — the per-fn provenance stamp is the identity, never a mount name. 

82 Task-level on purpose: a mounted group may carry the author's own tasks 

83 grafted onto it (`docs = plugin("footman.docs")` plus `@docs.task`), 

84 and those are theirs to document. A group emptied by the prune was the 

85 bare mount point and goes too.""" 

86 for name, sub in list(node.groups.items()): 

87 _prune_first_party(sub) 

88 if not sub.tasks and not sub.groups and sub.default_task is None: 

89 del node.groups[name] 

90 for name, fn in list(node.tasks.items()): 

91 if str(getattr(fn, registry._MOUNTED, "")).startswith("footman."): 

92 del node.tasks[name] 

93 

94 

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

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

97 

98 

99@tasks.task 

100def page( 

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

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

103 flavor: Annotated[ 

104 Literal["plain", "material"], 

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

106 ] = "plain", 

107 out: Annotated[ 

108 Path | None, doc("file to write the page into; omitted = stdout") 

109 ] = None, 

110 prog: Annotated[str, _invoking_cli, doc("command name in usage and examples")] = "", 

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

112) -> list[str] | None: 

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

114 

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

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

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

118 drops into zensical/mkdocs via a snippet include. 

119 """ 

120 tree = _project_tree(all) 

121 try: 

122 text = markdown.render_page( 

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

124 ) 

125 except ValueError as exc: 

126 # An unknown --target: the resolver's message already teaches (the 

127 # menu of what it does know), so it only needs delivering as the 

128 # deliberate refusal it is, not as a raw ValueError with a traceback. 

129 context.fail(f"--target: {exc}") 

130 if out is None: 

131 print(text, end="") 

132 return None 

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

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

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

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

137 print(f"wrote {out}") 

138 return [str(out)] 

139 

140 

141@tasks.task 

142def site( 

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

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

145 flavor: Annotated[ 

146 Literal["plain", "material"], 

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

148 ] = "material", 

149 prog: Annotated[str, _invoking_cli, doc("command name in usage and examples")] = "", 

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

151) -> list[str]: 

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

153 

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

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

156 """ 

157 tree = _project_tree(all) 

158 try: 

159 files = markdown.render_site( 

160 tree, path=_path_of(target), flavor=flavor, prog=prog 

161 ) 

162 except ValueError as exc: 

163 # Same delivery as `page` above: teach, don't traceback. 

164 context.fail(f"--target: {exc}") 

165 written: list[str] = [] 

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

167 dest = out / rel 

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

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

170 written.append(str(dest)) 

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

172 return written 

173 

174 

175_CLEAR = "\x1b[K" 

176 

177 

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

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

180 

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

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

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

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

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

186 through untouched. 

187 """ 

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

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

190 return "\n".join(lines) 

191 

192 

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

194@requires_dep("rich") 

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

196def shots( 

197 *argv: str, 

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

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

200 cmd: Annotated[str, _invoking_cli, doc("executable to run")] = "", 

201 # After `cmd`, deliberately: a default reads only what is to its left, and 

202 # this one is the command line it is about to screenshot. 

203 title: Annotated[ 

204 str, 

205 default(lambda p: " ".join([p["cmd"], *p["argv"]])), 

206 doc("window title"), 

207 ] = "", 

208) -> list[str]: 

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

210 

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

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

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

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

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

216 

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

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

219 error message is a perfectly good screenshot. 

220 """ 

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

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

223 import fcntl 

224 import pty 

225 import struct 

226 import termios 

227 

228 prog = cmd 

229 exe = shutil.which(prog) 

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

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

232 

233 env = os.environ.copy() 

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

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

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

237 master, slave = pty.openpty() 

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

239 proc = subprocess.Popen( 

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

241 ) 

242 os.close(slave) 

243 chunks: list[bytes] = [] 

244 while True: 

245 try: 

246 data = os.read(master, 65536) 

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

248 break 

249 if not data: 

250 break 

251 chunks.append(data) 

252 os.close(master) 

253 proc.wait() 

254 

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

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

257 from rich.console import Console 

258 from rich.text import Text 

259 

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

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

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

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

264 out.write_text(console.export_svg(title=title), encoding="utf-8") 

265 print(f"wrote {out}") 

266 return [str(out)] 

267 

268 

269# --- animated casts ----------------------------------------------------------- 

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

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

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

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

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

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

276# plays it. 

277 

278_KEY_TOKENS = { 

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

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

281 "<SPACE>": b" ", 

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

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

284 # PSReadLine's menu swallows Ctrl-C while it is open — the line survives 

285 # and the next keystrokes land *inside* it (`fm build -- fm deploy.` ran 

286 # together in one recording). Escape dismisses the menu first, so the 

287 # cancel that follows reaches the line. 

288 "<ESC>": b"\x1b", 

289 # Arrows, so a cast can *walk* a completion menu rather than only open 

290 # one. PowerShell's MenuComplete grid, nushell's menu and fish's pager 

291 # are all navigable by default, and a still frame of a menu says much 

292 # less than a selection moving through it. The CSI sequences a terminal 

293 # sends, which is what the pty is being handed. 

294 "<UP>": b"\x1b[A", 

295 "<DOWN>": b"\x1b[B", 

296 "<RIGHT>": b"\x1b[C", 

297 "<LEFT>": b"\x1b[D", 

298} 

299 

300 

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

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

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

304_SETTLE = -1.0 

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

306# The same idea between events: after a key, the capture waits for the shell 

307# to stop redrawing and takes the frame there. Shorter than _SETTLE_GAP — 

308# this runs after every single key, and a shell answering a keystroke is far 

309# quicker than one rendering a prompt from cold. 

310_SNAP_GAP = 0.18 

311# How long an ordinary key gets to draw nothing at all before its frame is 

312# taken anyway: a character either echoes promptly or never will. A Tab is 

313# given _SETTLE_MAX instead — it is the key that sends a shell away to 

314# compute, and the first Tab of a pwsh session pays for .NET's JIT on top, 

315# which on a loaded runner outlasted any bound worth applying to a keystroke. 

316# Once output starts, _SNAP_GAP decides for both. 

317_SNAP_IDLE = 1.5 

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

319 

320 

321# How a key is captioned in the recording's corner. A reader watching a 

322# completion menu appear needs to know whether a human pressed Tab or simply 

323# typed — the terminal itself shows neither. Arrows get their glyph; the rest 

324# get the name printed on the key. 

325_KEY_CAPTIONS = { 

326 "<TAB>": "Tab", 

327 "<ENTER>": "Enter", 

328 "<SPACE>": "Space", 

329 "<BACKSPACE>": "Backspace", 

330 "<ESC>": "Esc", 

331 "<CTRL-C>": "Ctrl-C", 

332 "<UP>": "↑", 

333 "<DOWN>": "↓", 

334 "<LEFT>": "←", 

335 "<RIGHT>": "→", 

336} 

337 

338 

339class Send(NamedTuple): 

340 """One write into the pty, and what the recording should make of it. 

341 

342 *snap* marks the end of a script step: the pty loop waits there for the 

343 shell to finish responding and the recording takes one frame. Everything 

344 between snaps — the characters of a typed word — goes in without being 

345 filmed, so a recording is a sequence of *events* rather than a sampling 

346 of a redraw. 

347 """ 

348 

349 delay: float 

350 data: bytes 

351 caption: str 

352 snap: bool 

353 

354 

355def keystrokes(script: tuple[str, ...]) -> list[Send]: 

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

357 

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

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

360 `<BACKSPACE>`, `<CTRL-C>`, the arrows `<UP>`/`<DOWN>`/`<LEFT>`/`<RIGHT>` 

361 (for walking a shell's completion menu), `<WAIT>` (pause 0.8 s), 

362 `<WAIT:ms>`, or `<SETTLE>` (wait until output stops changing — 

363 timing-independent, for a prompt whose render time you can't predict). 

364 

365 The caption is what the recording shows in its corner for that step: 

366 a key's name, the character typed, or empty for a pause — a wait carries 

367 no caption of its own, so the key that opened a menu stays legible for 

368 as long as the menu is on screen. 

369 """ 

370 sends: list[Send] = [] 

371 for part in script: 

372 if part in _KEY_TOKENS: 

373 sends.append( 

374 Send(0.3, _KEY_TOKENS[part], _KEY_CAPTIONS.get(part, part), True) 

375 ) 

376 elif part == "<SETTLE>": 

377 sends.append(Send(_SETTLE, b"", "", False)) 

378 elif part == "<WAIT>": 

379 sends.append(Send(0.8, b"", "", False)) 

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

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

382 else: 

383 # Typed text is one event, not one per letter: the frame that 

384 # matters is the finished word, and a frame per character is 

385 # what made a recording read as a flicker. The characters still 

386 # go in one at a time, so the shell sees ordinary typing. 

387 chars = list(part) 

388 sends.extend( 

389 Send(0.02, ch.encode("utf-8"), part, i == len(chars) - 1) 

390 for i, ch in enumerate(chars) 

391 ) 

392 return sends 

393 

394 

395# Modern interactive stacks interrogate their terminal before painting a 

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

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

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

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

400# reported cursor is the truth. 

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

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

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

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

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

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

407] 

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

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

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

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

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

413# CSI sequences with a *private* marker — `ESC [ > … m`, `ESC [ ? … m`. They 

414# are modes, not styling: `ESC[>4;2m` is modifyOtherKeys, which fish and 

415# nushell turn on at startup. pyte ignores the marker and reads the 

416# parameters as SGR, so that one turns on underline (4) and `ESC[>1m` turns 

417# on bold — and every cell drawn afterwards inherits them. That is what 

418# underlined a recording's prompt in shells that emit no underline at all, 

419# and why the whole listing looked ruled. Dropped before the emulator sees 

420# them; pyte handles the non-`m` private sequences (`ESC[?2004h`) correctly. 

421_PRIVATE_SGR = re.compile(r"\x1b\[[<=>?][\d;:]*m") 

422 

423 

424class _Descs: 

425 """Strip DCS replies from a stream that arrives in pieces. 

426 

427 The pattern has to see a whole sequence, and a pty hands back whatever 

428 happened to be readable — so a reply split across two reads was stripped 

429 from neither half, and its debris went to the terminal emulator as text. 

430 pyte then parsed the fragments: a capability answer landing across a read 

431 boundary set attributes nothing had asked for, and every cell drawn 

432 afterwards inherited them. That is what underlined a recording's prompt 

433 in shells that never emit an underline at all. 

434 

435 So the tail of a possibly-incomplete sequence is held back and prepended 

436 to the next read, exactly as the query answerer already does. 

437 """ 

438 

439 __slots__ = ("_tail",) 

440 

441 def __init__(self) -> None: 

442 self._tail = "" 

443 

444 def feed(self, text: str) -> str: 

445 buf = _PRIVATE_SGR.sub("", _DCS.sub("", self._tail + text)) 

446 start = buf.rfind("\x1bP") 

447 if start == -1: 447 ↛ 452line 447 didn't jump to line 452 because the condition on line 447 was always true

448 self._tail = "" 

449 return buf 

450 # A sequence that never terminates must not grow without bound: past 

451 # a sane length it is not a reply, it is a stuck stream. 

452 self._tail = buf[start:] if len(buf) - start < 4096 else "" 

453 return buf[:start] 

454 

455 

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

457 

458 

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

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

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

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

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

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

465 

466 

467def _answer_queries( 

468 buf: bytes, 

469 new_from: int, 

470 cursor_at: Callable[[], tuple[int, int]], 

471 reply: Callable[[bytes], object], 

472 *, 

473 cursor: bool = True, 

474) -> None: 

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

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

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

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

479 if m.end() > new_from: 

480 row, col = cursor_at() 

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

482 for m in _OSC_COLOUR.finditer(buf): 

483 if m.end() > new_from: 

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

485 for pattern, answer in _TERM_QUERIES: 

486 for m in pattern.finditer(buf): 

487 if m.end() > new_from: 

488 reply(answer) 

489 

490 

491def _pty_session( 

492 argv: list[str], 

493 *, 

494 width: int, 

495 height: int, 

496 sends: list[Send], 

497 settle: float, 

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

499 keep_echo: bool = False, 

500 cwd: Path | None = None, 

501 answer_cursor: bool = True, 

502 key_log: list[tuple[float, str]] | None = None, 

503 steady: float = 1.0, 

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

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

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

507 if sys.platform == "win32": # pragma: no cover — the @requires gates hold 

508 raise RuntimeError("terminal recording needs a POSIX pseudo-terminal") 

509 import fcntl 

510 import pty 

511 import select 

512 import struct 

513 import termios 

514 import time as _time 

515 

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

517 

518 env = os.environ.copy() 

519 env.pop("NO_COLOR", None) 

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

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

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

523 env.update(env_extra or {}) 

524 master, slave = pty.openpty() 

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

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

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

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

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

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

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

532 if not keep_echo: 

533 attrs = termios.tcgetattr(slave) 

534 attrs[3] &= ~termios.ECHO 

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

536 

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

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

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

540 # (zsh and bash merely grumble). 

541 os.setsid() 

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

543 

544 proc = subprocess.Popen( 

545 argv, 

546 stdin=slave, 

547 stdout=slave, 

548 stderr=slave, 

549 env=env, 

550 cwd=cwd, 

551 preexec_fn=_own_the_tty, 

552 ) 

553 os.close(slave) 

554 start = _time.monotonic() 

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

556 queue = list(sends) 

557 tracker = pyte.Screen(width, height) 

558 tracker_stream = pyte.Stream(tracker) 

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

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

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

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

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

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

565 typing_started = False 

566 next_at: float | None = None 

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

568 deadline = None if queue else start + settle 

569 # Set between writing a snap step and the shell going quiet after it: 

570 # while it holds a caption, no further keys go in and the frame for that 

571 # event has not been marked yet. 

572 awaiting: str | None = None 

573 awaiting_since = start 

574 # `steady` widens the quiet-detection windows without touching the hard 

575 # caps: on a loaded machine "output went quiet" is often the shell being 

576 # CPU-starved mid-redraw, and a key sent into that gap types into a menu 

577 # that has not opened yet — the interaction diverges and the beats never 

578 # render at all. Slower judgement, same script. 

579 snap_gap = _SNAP_GAP * steady 

580 settle_gap = _SETTLE_GAP * steady 

581 awaiting_bound = _SNAP_IDLE * steady 

582 awaiting_screen: tuple[str, ...] = () 

583 try: 

584 while True: 

585 now = _time.monotonic() 

586 # Silence straight after a key does not mean the shell has 

587 # finished — it usually means it has not started. A Tab sends the 

588 # shell off to run `--complete` in a subprocess, and the quiet 

589 # while it does that is longer than the quiet after it finishes 

590 # painting. So a frame is taken once the *screen has changed* and 

591 # then gone quiet; a key that genuinely draws nothing falls 

592 # through on the idle bound instead. 

593 # 

594 # Screen, not bytes. Counting any output as the answer let a 

595 # keystroke's own echo — or a terminal query pwsh fires the 

596 # instant Tab is pressed — end the wait before the completion it 

597 # was waiting for had been computed, so the recording showed a 

598 # prompt with nothing completed on it. 

599 answered = tuple(tracker.display) != awaiting_screen 

600 if awaiting is not None and ( 

601 (answered and now - last_output >= snap_gap) 

602 or (not answered and now - awaiting_since >= awaiting_bound) 

603 or now - awaiting_since >= _SETTLE_MAX 

604 ): 

605 if key_log is not None: 

606 key_log.append((now - start, awaiting)) 

607 awaiting = None 

608 if queue: 

609 nd = queue[0].delay 

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

611 else: 

612 next_at = now 

613 deadline = now + settle 

614 if awaiting is None and queue and next_at is not None: 

615 if queue[0].delay < 0: 

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

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

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

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

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

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

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

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

624 ready = ( 

625 now - last_output >= settle_gap and tracker.cursor.x > 0 

626 ) or now - next_at >= _SETTLE_MAX 

627 else: 

628 # Before the *first* key, a prompt has to be on the line — 

629 # not merely a lull. A boot's last act is often silent: 

630 # pwsh's rc ends by running `--setup-completion`, a Python 

631 # subprocess that prints nothing to the terminal, so 

632 # "output has stopped" arrives while completion is still 

633 # being registered. Typing into that gap gives a session 

634 # whose keys echo and whose Tab is bound to nothing — 

635 # which is what a loaded runner recorded while a warm 

636 # laptop never did. Capped, so a prompt that genuinely 

637 # ends at column 0 cannot hang the recording. 

638 ready = now >= next_at and ( 

639 typing_started 

640 or tracker.cursor.x > 0 

641 or now - start >= _SETTLE_MAX 

642 ) 

643 if ready: 

644 typing_started = True 

645 step = queue.pop(0) 

646 if step.data: 

647 os.write(master, step.data) 

648 if step.snap: 

649 # One event, one frame. Nothing more is sent until 

650 # the shell has finished answering this key, and the 

651 # frame is taken at that settled state — so a fast 

652 # machine and a loaded runner record the same frames 

653 # and only the wall clock differs, which playback 

654 # throws away anyway. 

655 awaiting = step.caption 

656 awaiting_since = now 

657 awaiting_screen = tuple(tracker.display) 

658 # How long this key gets to draw *nothing at all* 

659 # before its frame is taken anyway. A Tab is the one 

660 # key that reliably sends the shell away to compute — 

661 # a subprocess, and on a cold pwsh the JIT as well — 

662 # so it is given the full settle. An ordinary 

663 # character either echoes at once or never will, and 

664 # waiting on it only makes every recording slower. 

665 awaiting_bound = ( 

666 _SETTLE_MAX if step.data == b"\t" else _SNAP_IDLE * steady 

667 ) 

668 continue 

669 if queue: 

670 nd = queue[0].delay 

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

672 else: 

673 next_at = now 

674 deadline = now + settle 

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

676 if readable: 

677 try: 

678 data = os.read(master, 65536) 

679 except OSError: # EIO: the child hung up 

680 break 

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

682 break 

683 last_output = _time.monotonic() 

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

685 # Per chunk, deliberately: this screen exists to answer the 

686 # shell's cursor-position queries *live*, and the stateful 

687 # stripper holds back a possibly-incomplete tail — which 

688 # leaves the tracker a read behind and answers PSReadLine 

689 # with a stale cursor, after which it stops completing at 

690 # all. Debris from a split DCS costs this screen nothing; it 

691 # is never rendered. The frames get the careful stripping. 

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

693 buf = pending + data 

694 _answer_queries( 

695 buf, 

696 len(pending), 

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

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

699 cursor=answer_cursor, 

700 ) 

701 pending = buf[-64:] 

702 if not typing_started and queue: 

703 nd = queue[0].delay 

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

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

706 deadline = last_output + settle 

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

708 break 

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

710 break 

711 finally: 

712 import contextlib as _contextlib 

713 

714 with _contextlib.suppress(ProcessLookupError): 

715 proc.kill() 

716 os.close(master) 

717 proc.wait() 

718 return chunks 

719 

720 

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

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

723 bits: list[str] = [] 

724 if char.bold: 

725 bits.append("bold") 

726 if char.italics: 

727 bits.append("italic") 

728 if char.underscore: 

729 bits.append("underline") 

730 

731 def named(attr: str) -> str: 

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

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

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

735 

736 fg = named(char.fg) if char.fg and char.fg != "default" else _SVG_FG 

737 bg = named(char.bg) if char.bg and char.bg != "default" else _SVG_BG 

738 if char.reverse: 

739 # Swapped here, with concrete colours, rather than left to rich's 

740 # `reverse`. On the SVG export that painted the background but not 

741 # the text, so a selected menu entry — which is how zsh's 

742 # `menu select` marks its choice — came out as a solid block with 

743 # its own text invisible inside it. 

744 fg, bg = bg, fg 

745 if bg != _SVG_BG and _contrast(fg, bg) < 4.5: 

746 # Text too close in tone to its own background is not text. Shells 

747 # paint a highlight by setting one half of the pair and trusting the 

748 # terminal's default for the other — a menu selection with an 

749 # explicit light background and a default (light) foreground came out 

750 # as a solid block with its text hidden inside it. Exact equality was 

751 # far too narrow a test, and so was "nearly equal": PSReadLine's 

752 # selected row came out at 2.34:1, which is not the same colour by any 

753 # measure and is still unreadable at terminal sizes — it reads as a 

754 # blank white bar until you select the text. The bar is WCAG AA for 

755 # body text; below it, take whichever of the palette's two ends the 

756 # background is furthest from. 

757 # 

758 # Only where a background was actually painted. Dim text on the 

759 # ordinary background is *meant* to be low contrast — that is what 

760 # fish's grey autosuggestion is — and rescuing it would render it as 

761 # ordinary typed characters, which is the bug the bright-colour test 

762 # guards against. 

763 fg = _SVG_BG if _contrast(_SVG_BG, bg) > _contrast(_SVG_FG, bg) else _SVG_FG 

764 if fg != _SVG_FG: 

765 bits.append(fg) 

766 if bg != _SVG_BG: 

767 bits.append(f"on {bg}") 

768 return " ".join(bits) 

769 

770 

771def _event_screens( 

772 chunks: list[tuple[float, bytes]], 

773 key_log: list[tuple[float, str]], 

774 *, 

775 width: int, 

776 height: int, 

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

778 """One screen per event: the settled state after each key, captioned. 

779 

780 The alternative — a frame per output chunk — records how a shell happened 

781 to redraw rather than what it did. Menus that a shell paints and clears 

782 within one chunk vanish; a shell that paints in bursts loses keystrokes 

783 into a single frame; and the same script gives a different recording on a 

784 loaded machine. Playing back one frame per keypress at a fixed pace 

785 removes all of that: the wall clock only decided *when* the shell went 

786 quiet, never what is on screen. 

787 """ 

788 import pyte 

789 

790 screen = pyte.Screen(width, height) 

791 stream = pyte.Stream(screen) 

792 descs = _Descs() 

793 frames: list[tuple[str, list[list[tuple[str, str]]]]] = [] 

794 fed = 0 

795 for at, caption in key_log: 

796 while fed < len(chunks) and chunks[fed][0] <= at: 

797 stream.feed(descs.feed(chunks[fed][1].decode("utf-8", "replace"))) 

798 fed += 1 

799 frames.append( 

800 ( 

801 caption, 

802 [ 

803 [ 

804 (screen.buffer[y][x].data, _cell_style(screen.buffer[y][x])) 

805 for x in range(width) 

806 ] 

807 for y in range(height) 

808 ], 

809 ) 

810 ) 

811 return frames 

812 

813 

814def _screens( 

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

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

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

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

819 import pyte 

820 

821 screen = pyte.Screen(width, height) 

822 stream = pyte.Stream(screen) 

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

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

825 descs = _Descs() 

826 for t, data in chunks: 

827 stream.feed(descs.feed(data.decode("utf-8", "replace"))) 

828 snap = [ 

829 [ 

830 (cell.data, _cell_style(cell)) 

831 for x in range(width) 

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

833 ] 

834 for y in range(height) 

835 ] 

836 if snap != last: 

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

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

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

840 frames.append((t, snap)) 

841 last = snap 

842 return frames 

843 

844 

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

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

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

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

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

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

851# rich's own SVG export palette, named here so a reversed cell can be swapped 

852# to concrete colours: "default" means these two, and a swap has to say which 

853# colour it landed on rather than leave it to be resolved later. 

854_SVG_FG = "#c5c8c6" 

855_SVG_BG = "#292929" 

856_LUMA: dict[str, float] = {} 

857 

858 

859def _luminance(colour: str) -> float: 

860 """Relative luminance, 0 (black) to 1 (white). 

861 

862 Resolved through **rich's own export palette**, never a table of our own. 

863 That theme is not the one the names suggest: its ANSI `black` is `#4b4e55` 

864 and its `white` is `#c5c8c6`. Weighing a private approximation instead 

865 said a selected menu row was black-on-white — near-perfect contrast — when 

866 what rich painted was dark grey on light grey at 2.34:1, which reads as a 

867 blank bar until you select the text. 

868 """ 

869 if colour not in _LUMA: 

870 from rich.color import Color 

871 from rich.terminal_theme import SVG_EXPORT_THEME 

872 

873 try: 

874 triplet = Color.parse(colour).get_truecolor(SVG_EXPORT_THEME) 

875 rgb = (triplet.red, triplet.green, triplet.blue) 

876 except Exception: # an unparseable name should not fail a recording 

877 rgb = (128, 128, 128) 

878 r, g, b = (c / 255 for c in rgb) 

879 _LUMA[colour] = 0.2126 * r + 0.7152 * g + 0.0722 * b 

880 return _LUMA[colour] 

881 

882 

883def _contrast(one: str, two: str) -> float: 

884 """WCAG-style contrast ratio, 1 (identical) to 21 (black on white).""" 

885 a, b = sorted((_luminance(one), _luminance(two)), reverse=True) 

886 return (a + 0.05) / (b + 0.05) 

887 

888 

889# rich centres the window title at y=27; the caption sits on that line at 

890# the other end of the chrome, right-aligned a little in from the edge. 

891_SVG_OPEN = re.compile(r"<svg[^>]*viewBox=\"0 0 ([\d.]+) [\d.]+\"[^>]*>") 

892_CAPTION_INSET = 30.0 

893 

894 

895def _with_caption(svg: str, caption: str, uid: str) -> str: 

896 """Draw *caption* in the recording's top-right corner. 

897 

898 A terminal shows what a keystroke *did*, never that one happened — so a 

899 completion menu appearing out of a still line reads as magic rather than 

900 as Tab. The caption is added here, per frame, rather than by the shell: 

901 nothing in the session has to know it is being filmed. 

902 """ 

903 if not caption: 903 ↛ 904line 903 didn't jump to line 904 because the condition on line 903 was never true

904 return svg 

905 match = _SVG_OPEN.search(svg) 

906 if match is None: # unknown geometry: a caption in the wrong place is worse 906 ↛ 907line 906 didn't jump to line 907 because the condition on line 906 was never true

907 return svg 

908 x = float(match.group(1)) - _CAPTION_INSET 

909 text = ( 

910 f'<text class="{uid}-title" fill="#c5c8c6" text-anchor="end" ' 

911 f'x="{x:.1f}" y="27">{_xml_escape(caption)}</text>' 

912 ) 

913 return svg.replace("</svg>", f"{text}</svg>", 1) 

914 

915 

916def _xml_escape(text: str) -> str: 

917 return ( 

918 text.replace("&", "&amp;") 

919 .replace("<", "&lt;") 

920 .replace(">", "&gt;") 

921 .replace('"', "&quot;") 

922 ) 

923 

924 

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

926 

927 

928def compose_animation( 

929 svgs: list[str], times: list[float], *, hold: float = 1.6, prefix: str = "cf" 

930) -> str: 

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

932 

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

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

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

936 topmost visible frame is the whole picture. 

937 

938 *prefix* names this recording's classes and keyframes, and **must be 

939 unique per recording on a page**. An inlined SVG's `<style>` is not 

940 scoped to it: put five recordings on one page under the same names and 

941 the last one's rules win for all of them — every cast animating to 

942 another's timings, and every cell painted in another's palette. That is 

943 why five recordings each correct on their own were wrong together. 

944 """ 

945 total = times[-1] + hold 

946 head = svgs[0] 

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

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

949 # `visibility` alongside `opacity`, because a frame at opacity 0 is still 

950 # *there*: it hit-tests, so devtools' Inspect lands on the last frame 

951 # wherever you click, and its text joins any selection — so copying a 

952 # line out of a recording returns text from frames nobody can see. Both 

953 # sent two separate diagnoses down the wrong path. `visibility` is 

954 # animatable and steps discretely, which is exactly what is wanted here. 

955 css: list[str] = [".cast-frame{opacity:0;visibility:hidden}"] 

956 body: list[str] = [] 

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

958 a = 100.0 * t / total 

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

960 on = "opacity:1;visibility:visible" 

961 gone = "opacity:0;visibility:hidden" 

962 window = f"{a:.3f}%{{{on}}}" if a > 0 else f"0%{{{on}}}" 

963 off = f"{b:.3f}%{{{gone}}}" if b < 100.0 else "" 

964 pre = f"0%{{{gone}}}" if a > 0 else "" 

965 css.append(f"@keyframes {prefix}{i}{{{pre}{window}{off}}}") 

966 css.append( 

967 f".{prefix}{i}{{animation:{prefix}{i} {total:.3f}s step-end infinite}}" 

968 ) 

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

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

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

972 # The frame boundaries, in milliseconds, stated rather than left to be 

973 # re-derived. The docs' player steps a reader through a recording one 

974 # keypress at a time, and reading these back out of the keyframes meant 

975 # parsing CSS whose shape varies — the final frame has no closing 

976 # `opacity:0` because it holds until the loop — which silently cost that 

977 # last frame. Whoever writes the timeline should publish it. 

978 stamps = ",".join(f"{t * 1000:.0f}" for t in times) 

979 shell_open = shell_open.replace( 

980 "<svg", 

981 f'<svg data-cast-frames="{stamps}" data-cast-total="{total * 1000:.0f}"', 

982 1, 

983 ) 

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

985 

986 

987_CAST_BOOT: dict[str, str] = { 

988 "zsh": "zsh", 

989 "bash": "bash", 

990 "fish": "fish", 

991 "pwsh": "pwsh", 

992 "nushell": "nu", 

993} 

994 

995 

996def _boot_shell( 

997 shell: str, prog: str, scratch: Path 

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

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

1000 

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

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

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

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

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

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

1007 """ 

1008 env = { 

1009 "HOME": str(scratch), 

1010 "XDG_CONFIG_HOME": str(scratch), 

1011 _paths.env_var("CACHE_DIR"): str(_paths.footman_cache_dir()), 

1012 } 

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

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

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

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

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

1018 if shell == "zsh": 

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

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

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

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

1023 # Menu selection, so Tab *walks* the candidates instead of only 

1024 # listing them \u2014 the same story pwsh's MenuComplete binding 

1025 # records. It is one documented line rather than a tuned setup, 

1026 # and the completion page says so: a recording must not promise 

1027 # a terminal the reader cannot have. 

1028 "zstyle ':completion:*' menu select\n" 

1029 # LIST_AMBIGUOUS suppresses the listing whenever a Tab managed to 

1030 # extend the common prefix — so `-`<Tab> inserted `--` and showed 

1031 # nothing, and the next character was typed without the reader 

1032 # ever seeing that an option existed. Off, the candidates appear 

1033 # with the prefix, which is the point of pressing Tab. 

1034 "unsetopt LIST_AMBIGUOUS\n" 

1035 # ...but without underlining half the screen while it is up. 

1036 # zle_highlight's defaults underline the region zle considers 

1037 # "special" during menu selection, which in a recording came out 

1038 # as a rule under every row — the selected entry's standout is 

1039 # the highlight worth keeping. 

1040 "zle_highlight=(region:standout special:standout suffix:none " 

1041 "isearch:none paste:none)\n" 

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

1043 encoding="utf-8", 

1044 ) 

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

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

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

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

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

1050 # hermetic shell: scratch rc only. 

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

1052 if shell == "bash": 

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

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

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

1056 env["BASH_SILENCE_DEPRECATION_WARNING"] = "1" 

1057 rc = scratch / "bashrc" 

1058 rc.write_text( 

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

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

1061 # Vanilla bash rings the bell on the first Tab and only lists on 

1062 # the second, so a recording had to press twice for every menu \u2014 

1063 # which in an event-per-keypress capture spends two frames 

1064 # saying what the other shells say in one. `show-all-if-ambiguous` 

1065 # is readline's own one-line setting for exactly this, the same 

1066 # class of documented tweak as zsh's `menu select` and pwsh's 

1067 # MenuComplete binding. 

1068 "bind 'set show-all-if-ambiguous on'\n" 

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

1070 encoding="utf-8", 

1071 ) 

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

1073 if shell == "fish": 

1074 boot = ( 

1075 "set -g fish_greeting ''; " 

1076 # Autosuggestions draw on whatever is installed beside footman: 

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

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

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

1080 # completion, not the host's PATH. 

1081 "set -g fish_autosuggestion_enabled 0; " 

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

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

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

1085 "set_color normal; end" 

1086 ) 

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

1088 if shell == "pwsh": 

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

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

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

1092 boot = ( 

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

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

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

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

1097 ) 

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

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

1100 hook = scratch / "hook.nu" 

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

1102 env_nu = scratch / "env.nu" 

1103 env_nu.write_text( 

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

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

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

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

1108 encoding="utf-8", 

1109 ) 

1110 config_nu = scratch / "config.nu" 

1111 config_nu.write_text( 

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

1113 encoding="utf-8", 

1114 ) 

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

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

1117 

1118 

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

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

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

1122def cast( 

1123 *keys: str, 

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

1125 shell: Annotated[ 

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

1127 doc("interactive shell to drive"), 

1128 ] = "zsh", 

1129 title: Annotated[ 

1130 str, 

1131 # `shell` is declared to the left, so it is resolved by the time this 

1132 # runs — which is the whole reason a default may read its siblings. 

1133 default(lambda p: f"{p['shell']} · completion"), 

1134 doc("window title"), 

1135 ] = "", 

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

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

1138 prog: Annotated[str, _invoking_cli, doc("CLI whose completion is installed")] = "", 

1139 cwd: Annotated[ 

1140 Path | None, doc("directory the shell starts in; empty takes here") 

1141 ] = None, 

1142 pace: Annotated[ 

1143 float, between(0.2, 5.0), doc("seconds each keypress stays on screen") 

1144 ] = 1.2, 

1145 steady: Annotated[ 

1146 float, 

1147 between(1.0, 5.0), 

1148 doc("scale the quiet-detection windows; raise on a loaded machine"), 

1149 ] = 1.0, 

1150) -> list[str]: 

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

1152 

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

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

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

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

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

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

1159 regenerated on every docs build so it cannot drift. 

1160 """ 

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

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

1163 import tempfile 

1164 

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

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

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

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

1169 

1170 with tempfile.TemporaryDirectory() as scratch: 

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

1172 # A cold shell on a loaded CI runner — pwsh especially, with its 

1173 # .NET startup — can take longer than one settle window to draw 

1174 # anything at all, and an empty capture is indistinguishable from 

1175 # a dead session. Retry once with a much longer settle before 

1176 # declaring failure; the happy path pays nothing extra. 

1177 frames = [] 

1178 key_log: list[tuple[float, str]] = [] 

1179 for settle in (1.5, 5.0): 1179 ↛ 1200line 1179 didn't jump to line 1200

1180 key_log.clear() # a retry re-plays the script; keep only its keys 

1181 chunks = _pty_session( 

1182 argv, 

1183 width=width, 

1184 height=height, 

1185 sends=keystrokes(keys), 

1186 settle=settle, 

1187 key_log=key_log, 

1188 env_extra=env_extra, 

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

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

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

1192 keep_echo=shell == "bash", 

1193 cwd=cwd, 

1194 steady=steady, 

1195 answer_cursor=shell in _NEEDS_CURSOR_REPLY, 

1196 ) 

1197 frames = _event_screens(chunks, key_log, width=width, height=height) 

1198 if frames: 1198 ↛ 1179line 1198 didn't jump to line 1179 because the condition on line 1198 was always true

1199 break 

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

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

1202 # No thinning: every frame is an event the script asked for, so dropping 

1203 # one drops a keypress from the story rather than a redundant redraw. 

1204 

1205 from rich.console import Console 

1206 from rich.text import Text 

1207 

1208 # Every class and keyframe this file defines is namespaced by the file's 

1209 # own name. rich's ids are unique within one export, not between two — 

1210 # and an inlined SVG's <style> is not scoped to that SVG, so five 

1211 # recordings on one page all defined `.cf0`, `.cf7-r3`, `@keyframes cf0` 

1212 # and the last one in the document won for every one of them: casts 

1213 # animating to another recording's timings, cells painted in another's 

1214 # palette. Each was correct alone and wrong together. 

1215 uid = re.sub(r"[^A-Za-z0-9_-]", "-", out.stem) or "cast" 

1216 

1217 svgs: list[str] = [] 

1218 for i, (caption, grid) in enumerate(frames): 

1219 console = Console( 

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

1221 ) 

1222 for row in grid: 

1223 text = Text() 

1224 for ch, style in row: 

1225 text.append(ch, style or None) 

1226 console.print(text) 

1227 svgs.append( 

1228 _with_caption( 

1229 console.export_svg(title=title, unique_id=f"{uid}-cf{i}"), 

1230 caption, 

1231 f"{uid}-cf{i}", 

1232 ) 

1233 ) 

1234 # A fixed beat per event, not the timing of the capture. What the machine 

1235 # was doing while it recorded is not part of the story, and pacing on it 

1236 # made a shell's own quirks — fish clearing its pager, pwsh painting in 

1237 # bursts — decide how long a reader got to look at each step. 

1238 times = [i * pace for i in range(len(frames))] 

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

1240 # The last frame gets a beat exactly like every other one. The default 

1241 # hold is longer, which made sense when frames ran at the capture's own 

1242 # timing and the end needed marking — under a fixed pace it is just the 

1243 # recording sitting still before it loops. 

1244 out.write_text( 

1245 compose_animation(svgs, times, hold=pace, prefix=f"{uid}-cf"), 

1246 encoding="utf-8", 

1247 ) 

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

1249 return [str(out)] 

1250 

1251 

1252@tasks.task 

1253def errors( 

1254 out: Annotated[ 

1255 Path | None, doc("file to write the page into; omitted = stdout") 

1256 ] = None, 

1257) -> list[str] | None: 

1258 """Render every runtime error and note as a markdown reference page. 

1259 

1260 The entries are read from the source itself rather than written by 

1261 hand: every taught error and every teach-once note the runner can 

1262 print, with the runtime-filled parts shown as \u27e8like this\u27e9. A page 

1263 that regenerates this on each docs build can never drift from what the 

1264 runner actually says. Without --out the page is the task's stdout. 

1265 """ 

1266 import ast 

1267 

1268 import footman 

1269 

1270 def template(node: ast.expr) -> str | None: 

1271 if isinstance(node, ast.Constant) and isinstance(node.value, str): 

1272 return node.value 

1273 if isinstance(node, ast.JoinedStr): 

1274 parts: list[str] = [] 

1275 for value in node.values: 

1276 if isinstance(value, ast.Constant): 

1277 parts.append(str(value.value)) 

1278 elif isinstance(value, ast.FormattedValue): 1278 ↛ 1275line 1278 didn't jump to line 1275 because the condition on line 1278 was always true

1279 parts.append("\u27e8" + ast.unparse(value.value) + "\u27e9") 

1280 return "".join(parts) 

1281 return None 

1282 

1283 src = Path(footman.__file__).parent 

1284 entries: list[tuple[str, str, str]] = [] # (module, kind, message) 

1285 titles: dict[str, str] = {} # module stem -> its docstring's first line 

1286 for py in sorted(src.glob("*.py")): 

1287 tree = ast.parse(py.read_text(encoding="utf-8")) 

1288 summary = (ast.get_docstring(tree) or "").partition("\n")[0].strip() 

1289 titles[py.stem] = summary.rstrip(".") 

1290 for node in ast.walk(tree): 

1291 if isinstance(node, ast.Raise) and isinstance(node.exc, ast.Call): 

1292 call = node.exc 

1293 func = call.func 

1294 kind = getattr(func, "id", None) or getattr(func, "attr", None) 

1295 if not call.args or kind is None: 1295 ↛ 1296line 1295 didn't jump to line 1296 because the condition on line 1295 was never true

1296 continue 

1297 message = template(call.args[0]) 

1298 # Length filters the trivia (AttributeError(name), re-raises); 

1299 # every taught error is a sentence or three. 

1300 if message and len(message) >= 40: 

1301 entries.append((py.stem, kind, message)) 

1302 elif isinstance(node, ast.Call): 

1303 func_name = getattr(node.func, "id", None) 

1304 if func_name == "_note" and len(node.args) >= 2: 

1305 message = template(node.args[1]) 

1306 if message: 1306 ↛ 1290line 1306 didn't jump to line 1290 because the condition on line 1306 was always true

1307 entries.append((py.stem, "note", message)) 

1308 

1309 lines = [ 

1310 "# Errors & notes", 

1311 "", 

1312 "Everything footman can say when it refuses, warns, or teaches —", 

1313 "extracted from the source on every docs build, so this page cannot", 

1314 "drift from the runner. Angle-bracketed parts (\u27e8like this\u27e9) are", 

1315 "filled in at runtime; **note** entries print once per task on", 

1316 "stderr and never stop the run.", 

1317 "", 

1318 ] 

1319 for module in sorted({m for m, _, _ in entries}): 

1320 # The heading a reader can use: the module's own one-line summary, 

1321 # not its private filename. The stem is the fallback, never the face. 

1322 lines += [f"## {titles.get(module) or f'`{module}`'}", ""] 

1323 module_entries = sorted((k, msg) for m, k, msg in entries if m == module) 

1324 for kind, message in module_entries: 

1325 lines += [f"**{kind}**", "", "``` text", message.rstrip(), "```", ""] 

1326 text = "\n".join(lines) 

1327 if out is None: 1327 ↛ 1328line 1327 didn't jump to line 1328 because the condition on line 1327 was never true

1328 print(text, end="") 

1329 return None 

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

1331 out.write_text(text + "\n", encoding="utf-8") 

1332 print(f"wrote {out}") 

1333 return [str(out)] 

1334 

1335 

1336@tasks.task 

1337def config( 

1338 out: Annotated[ 

1339 Path | None, doc("file to write the table into; omitted = stdout") 

1340 ] = None, 

1341 prog: Annotated[str, _invoking_cli, doc("command name in the table")] = "", 

1342) -> list[str] | None: 

1343 """Render this runner's config keys as a markdown table. 

1344 

1345 The rows come from the runner's own list of recognised keys, so a 

1346 reference page that regenerates this on each docs build can neither 

1347 describe a key the runner lacks nor miss one it has — and it names the 

1348 table *this* runner reads (`[tool.acme]` under a branded CLI), because 

1349 a runner opens its own table and no other. Without --out the table is 

1350 the task's stdout; with --out it is written to the file. 

1351 """ 

1352 text = markdown.config_table(prog=prog) 

1353 if out is None: 1353 ↛ 1354line 1353 didn't jump to line 1354 because the condition on line 1353 was never true

1354 print(text, end="") 

1355 return None 

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

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

1358 print(f"wrote {out}") 

1359 return [str(out)] 

1360 

1361 

1362@tasks.task 

1363def notes( 

1364 out: Annotated[ 

1365 Path | None, doc("file to write the table into; omitted = stdout") 

1366 ] = None, 

1367 prog: Annotated[str, _invoking_cli, doc("command name in the table")] = "", 

1368) -> list[str] | None: 

1369 """Render the note kinds as a markdown table. 

1370 

1371 The rows come from the runner's own kind registry — what the level 

1372 system resolves against and the config validator refuses unknown kinds 

1373 by — so a reference page that regenerates this on each docs build can 

1374 neither list a kind footman lacks nor miss one it has. Without --out 

1375 the table is the task's stdout; with --out it is written to the file. 

1376 """ 

1377 text = markdown.notes_table(prog=prog) 

1378 if out is None: 1378 ↛ 1379line 1378 didn't jump to line 1379 because the condition on line 1378 was never true

1379 print(text, end="") 

1380 return None 

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

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

1383 print(f"wrote {out}") 

1384 return [str(out)] 

1385 

1386 

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

1388def globals_( 

1389 out: Annotated[ 

1390 Path | None, doc("file to write the table into; omitted = stdout") 

1391 ] = None, 

1392 prog: Annotated[str, _invoking_cli, doc("command name in the table")] = "", 

1393) -> list[str] | None: 

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

1395 

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

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

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

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

1400 """ 

1401 text = markdown.globals_table(prog=prog) 

1402 if out is None: 

1403 print(text, end="") 

1404 return None 

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

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

1407 print(f"wrote {out}") 

1408 return [str(out)]