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
« 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 …`.
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"""
9from __future__ import annotations
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
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
36def invoking_prog() -> str:
37 """The name the CLI was invoked as, so a branded runner documents itself."""
38 return context.current().prog
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."""
47tasks: Group = Group("docs", help="Generate markdown docs for this project's tasks")
50def _project_tree(include_self: bool) -> dict[str, Any]:
51 """The invoking project's manifest tree, rebuilt the way `fm` builds it.
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
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]
95def _path_of(target: str) -> tuple[str, ...]:
96 return tuple(target.replace(".", " ").split())
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.
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)]
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.
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
175_CLEAR = "\x1b[K"
178def reduce_frames(raw: str) -> str:
179 """Collapse a pty capture to the final frame of every line.
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)
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.
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.
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
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")
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()
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
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)]
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.
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}
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
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}
339class Send(NamedTuple):
340 """One write into the pty, and what the recording should make of it.
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 """
349 delay: float
350 data: bytes
351 caption: str
352 snap: bool
355def keystrokes(script: tuple[str, ...]) -> list[Send]:
356 """Compile a cast script into (delay-before-send, bytes, caption) steps.
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).
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
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")
424class _Descs:
425 """Strip DCS replies from a stream that arrives in pieces.
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.
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 """
439 __slots__ = ("_tail",)
441 def __init__(self) -> None:
442 self._tail = ""
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]
456_OSC_COLOUR = re.compile(rb"\x1b\](1[012]);\?(?:\x07|\x1b\\)")
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"})
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)
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
516 import pyte # tracks the live cursor for honest DSR answers
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)
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)
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
714 with _contextlib.suppress(ProcessLookupError):
715 proc.kill()
716 os.close(master)
717 proc.wait()
718 return chunks
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")
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)
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)
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.
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
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
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
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
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] = {}
859def _luminance(colour: str) -> float:
860 """Relative luminance, 0 (black) to 1 (white).
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
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]
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)
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
895def _with_caption(svg: str, caption: str, uid: str) -> str:
896 """Draw *caption* in the recording's top-right corner.
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)
916def _xml_escape(text: str) -> str:
917 return (
918 text.replace("&", "&")
919 .replace("<", "<")
920 .replace(">", ">")
921 .replace('"', """)
922 )
925_SVG_SHELL = re.compile(r"^\s*<svg[^>]*>|</svg>\s*$")
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.
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.
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>"
987_CAST_BOOT: dict[str, str] = {
988 "zsh": "zsh",
989 "bash": "bash",
990 "fish": "fish",
991 "pwsh": "pwsh",
992 "nushell": "nu",
993}
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.
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})")
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.
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
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")
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.
1205 from rich.console import Console
1206 from rich.text import Text
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"
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)]
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.
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
1268 import footman
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
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))
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)]
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.
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)]
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.
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)]
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.
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)]