Coverage for src/footman/tasks/docs.py: 94%
352 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Render the invoking project's task tree as markdown — `fm footman docs …`.
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 pathlib import Path
18from typing import Annotated, Any, Literal
20from footman import _paths, _shellcomp, config, context, discover, markdown, registry
21from footman import manifest as _manifest
22from footman.params import between, doc
23from footman.registry import Group, requires, requires_dep
25tasks = Group("docs", help="Generate markdown docs for this project's tasks")
28def _project_tree(include_self: bool) -> dict:
29 """The invoking project's manifest tree, rebuilt the way `fm` builds it.
31 Plugin tasks run from the invocation directory (the composing contract),
32 so `Path.cwd()` is the right anchor for the cascade walk. Re-importing
33 the tasks files inside a running task is the same same-process repeat
34 `Runner` performs — `discover` isolates each file per import.
35 """
36 cwd = Path.cwd()
37 ceiling = _paths.find_repo_root(cwd)
38 cfg = config.load_config(
39 cwd, ceiling, None, on_warning=lambda m: print(m, file=sys.stderr)
40 )
41 name = cfg.get("tasks")
42 filename = name if isinstance(name, str) else _paths.DEFAULT_TASKS_FILE
43 files = _paths.task_files(cwd, ceiling, filename)
44 base = registry.Group("root")
45 plugins = cfg.get("plugins")
46 if isinstance(plugins, list) and plugins: 46 ↛ 50line 46 didn't jump to line 50 because the condition on line 46 was always true
47 from footman import compose
49 compose.mount_plugins(base, plugins)
50 reg = discover.load_tree(files, base=base)
51 tree = _manifest.build_manifest(reg)["tree"]
52 if not include_self:
53 # Don't document the documenter: the mounted `footman` group is
54 # opted back in with --all.
55 tree["groups"].pop("footman", None)
56 return tree
59def _path_of(target: str) -> tuple[str, ...]:
60 return tuple(target.replace(".", " ").split())
63@tasks.task
64def page(
65 target: Annotated[str, doc("dotted task/group to scope to; empty = all")] = "",
66 heading: Annotated[int, between(1, 6), doc("top heading level")] = 1,
67 flavor: Annotated[
68 Literal["plain", "material"],
69 doc("plain CommonMark, or material/zensical extras"),
70 ] = "plain",
71 out: Path | None = None,
72 prog: Annotated[
73 str, doc("command name in usage and examples (default: the invoking CLI)")
74 ] = "",
75 all: Annotated[bool, doc("include footman's own mounted tasks")] = False,
76):
77 """Render the task tree (or one group/task) as one markdown page.
79 Without --out the page is the task's stdout, ready to redirect or pipe
80 (into pandoc, say); with --out it is written to the file. The heading
81 level makes the page nest under a host site's own structure, so it
82 drops into zensical/mkdocs via a snippet include.
83 """
84 tree = _project_tree(all)
85 prog = prog or context.current().prog # a branded CLI documents itself
86 text = markdown.render_page(
87 tree, path=_path_of(target), heading=heading, flavor=flavor, prog=prog
88 )
89 if out is None:
90 print(text, end="")
91 return None
92 out.parent.mkdir(parents=True, exist_ok=True)
93 out.write_text(text, encoding="utf-8")
94 # Inside a task, stderr merges into task output by contract — a plain
95 # print is the honest note here; `returned` carries the machine copy.
96 print(f"wrote {out}")
97 return [str(out)]
100@tasks.task
101def site(
102 out: Annotated[Path, doc("directory to write the pages into")],
103 target: Annotated[str, doc("dotted group to scope to; empty = all")] = "",
104 flavor: Annotated[
105 Literal["plain", "material"],
106 doc("material fits zensical/mkdocs; plain is portable"),
107 ] = "material",
108 prog: Annotated[
109 str, doc("command name in usage and examples (default: the invoking CLI)")
110 ] = "",
111 all: Annotated[bool, doc("include footman's own mounted tasks")] = False,
112):
113 """Render the task tree as linked pages: index.md per group, one file per task.
115 Made for docs sites — point <out> into your docs tree and add the pages
116 to the nav. Regenerate on each docs build so they can't drift.
117 """
118 tree = _project_tree(all)
119 prog = prog or context.current().prog # a branded CLI documents itself
120 files = markdown.render_site(tree, path=_path_of(target), flavor=flavor, prog=prog)
121 written: list[str] = []
122 for rel, content in files.items():
123 dest = out / rel
124 dest.parent.mkdir(parents=True, exist_ok=True)
125 dest.write_text(content, encoding="utf-8")
126 written.append(str(dest))
127 print(f"wrote {len(written)} pages under {out}")
128 return written
131_CLEAR = "\x1b[K"
134def reduce_frames(raw: str) -> str:
135 """Collapse a pty capture to the final frame of every line.
137 footman's live output repaints a line in place — `\\r` then a full
138 rewrite (step lines, the status bar) — and clears with `ESC[K`; a pty
139 records every intermediate frame. Keeping only the text after the last
140 `\\r` of each physical line, and dropping the clear sequences, leaves
141 what a human saw once the run settled. Colour (SGR) sequences pass
142 through untouched.
143 """
144 text = raw.replace("\r\n", "\n") # the pty's ONLCR translation, undone
145 lines = [seg.rsplit("\r", 1)[-1].replace(_CLEAR, "") for seg in text.split("\n")]
146 return "\n".join(lines)
149@tasks.task(name="shots")
150@requires_dep("rich")
151@requires(lambda: sys.platform != "win32", reason="needs a POSIX pseudo-terminal")
152def shots(
153 *argv: str,
154 out: Annotated[Path, doc("the SVG file to write")],
155 title: Annotated[str, doc("window title (default: the command line)")] = "",
156 width: Annotated[int, between(40, 200), doc("terminal columns")] = 72,
157 cmd: Annotated[str, doc("executable to run (default: the invoking CLI)")] = "",
158):
159 """Run the CLI on a pseudo-terminal and save a framed SVG screenshot.
161 Runs `<cmd> <argv…>` on a real pty — colours, receipts, taught errors,
162 exactly as a terminal shows them — collapses live rewrites to their
163 final frame, and renders the capture with rich as an SVG in a
164 macOS-style window. Regenerate on every docs build and a screenshot
165 can never drift from the CLI: it *is* the CLI.
167 The command really executes, so don't screenshot tasks whose side
168 effects you don't want. A failing command still renders — a taught
169 error message is a perfectly good screenshot.
170 """
171 if sys.platform == "win32": # the @requires gate already refused; belt 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 raise RuntimeError("docs shots needs a POSIX pseudo-terminal")
173 import fcntl
174 import pty
175 import struct
176 import termios
178 prog = cmd or context.current().prog
179 exe = shutil.which(prog)
180 if exe is None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 raise RuntimeError(f"{prog!r} is not on PATH")
183 env = os.environ.copy()
184 env.pop("NO_COLOR", None) # the pty asks for colour; let it answer
185 env["TERM"] = "xterm-256color"
186 env["COLUMNS"] = str(width)
187 master, slave = pty.openpty()
188 fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, width, 0, 0))
189 proc = subprocess.Popen(
190 [exe, *argv], stdin=slave, stdout=slave, stderr=slave, env=env
191 )
192 os.close(slave)
193 chunks: list[bytes] = []
194 while True:
195 try:
196 data = os.read(master, 65536)
197 except OSError: # EIO: the child hung up (how Linux spells EOF)
198 break
199 if not data:
200 break
201 chunks.append(data)
202 os.close(master)
203 proc.wait()
205 # The blessed lazy import: rich is docs tooling, never a dependency —
206 # @requires_dep("rich") lists this task as unavailable when it's absent.
207 from rich.console import Console
208 from rich.text import Text
210 capture = reduce_frames(b"".join(chunks).decode("utf-8", "replace"))
211 console = Console(record=True, width=width, file=io.StringIO(), force_terminal=True)
212 console.print(Text.from_ansi(capture.rstrip("\n")))
213 out.parent.mkdir(parents=True, exist_ok=True)
214 line = " ".join([prog, *argv])
215 out.write_text(console.export_svg(title=title or line), encoding="utf-8")
216 print(f"wrote {out}")
217 return [str(out)]
220# --- animated casts -----------------------------------------------------------
221# An interactive session (TAB completion!) can't be a static screenshot or a
222# line-based reduction: shells paint menus with real cursor movement. A cast
223# drives a live shell on the pty with scripted keystrokes, replays the byte
224# stream through a terminal emulator (pyte) into screen states, renders each
225# state with rich, and stacks the frames in one self-contained SVG animated
226# by CSS keyframes with the capture's own timing. No JavaScript; an <img>
227# plays it.
229_KEY_TOKENS = {
230 "<TAB>": b"\t",
231 "<ENTER>": b"\r",
232 "<SPACE>": b" ",
233 "<BACKSPACE>": b"\x7f",
234 "<CTRL-C>": b"\x03",
235}
238# A negative "delay" marks a <SETTLE> step: instead of waiting a fixed time, the
239# pty loop holds the next key until output has gone quiet (see _pty_session) — so
240# a prompt whose render time you can't predict is waited on, not guessed at.
241_SETTLE = -1.0
242_SETTLE_GAP = 0.5 # seconds of silence that count as "settled"
243_SETTLE_MAX = 10.0 # hard cap: fire anyway, so a never-quiet stream can't hang
246def keystrokes(script: tuple[str, ...]) -> list[tuple[float, bytes]]:
247 """Compile a cast script into (delay-before-send, bytes) steps.
249 Each script argument is either literal text — typed one character at a
250 time at a human-ish cadence — or a token: `<TAB>`, `<ENTER>`, `<SPACE>`,
251 `<BACKSPACE>`, `<CTRL-C>`, `<WAIT>` (pause 0.8 s), `<WAIT:ms>`, or
252 `<SETTLE>` (wait until output stops changing — timing-independent, for a
253 prompt whose render time you can't predict).
254 """
255 sends: list[tuple[float, bytes]] = []
256 for part in script:
257 if part in _KEY_TOKENS:
258 sends.append((0.3, _KEY_TOKENS[part]))
259 elif part == "<SETTLE>":
260 sends.append((_SETTLE, b""))
261 elif part == "<WAIT>":
262 sends.append((0.8, b""))
263 elif part.startswith("<WAIT:") and part.endswith(">"):
264 sends.append((int(part[6:-1]) / 1000.0, b""))
265 else:
266 sends.extend((0.07, ch.encode("utf-8")) for ch in part)
267 return sends
270# Modern interactive stacks interrogate their terminal before painting a
271# prompt — fish asks for capabilities (XTGETTCAP), PSReadLine and reedline
272# need cursor-position answers (DSR), kitty-keyboard and colour queries
273# round it out — and block or bail when nothing replies. The session
274# answers like a plain xterm; DSR answers come from a live emulator so the
275# reported cursor is the truth.
276_TERM_QUERIES: list[tuple[re.Pattern[bytes], bytes]] = [
277 (re.compile(rb"\x1b\[0?c"), b"\x1b[?62;22c"), # primary DA
278 (re.compile(rb"\x1b\[>0?c"), b"\x1b[>1;95;0c"), # secondary DA
279 (re.compile(rb"\x1b\[\?u"), b"\x1b[?0u"), # kitty keyboard
280 (re.compile(rb"\x1b\[\?2026\$p"), b"\x1b[?2026;0$y"), # sync output
281 (re.compile(rb"\x1bP\+q[0-9a-fA-F;]*(?:\x1b\\|\x07)"), b"\x1bP0+r\x1b\\"),
282]
283_DSR = re.compile(rb"\x1b\[6n")
284# Device Control Strings — terminal protocol, never screen content. pyte
285# doesn't consume them, so their payload (fish's XTGETTCAP capability
286# names, hex-encoded) would render as stray text for one frame.
287_DCS = re.compile(r"\x1bP.*?(?:\x1b\\|\x07)", re.S)
288_OSC_COLOUR = re.compile(rb"\x1b\](1[012]);\?(?:\x07|\x1b\\)")
291# Which line editors need a cursor-position answer to paint at all.
292# PSReadLine and reedline hang without one — with DSR unanswered, pwsh
293# and nushell produce no output whatever. fish is the opposite: it asks
294# mid-session and then *types the answer into the command line*, so an
295# `fm che` becomes `fm ch77e`. bash and zsh don't care either way.
296_NEEDS_CURSOR_REPLY = frozenset({"pwsh", "nushell"})
299def _answer_queries(
300 buf: bytes, new_from: int, cursor_at, reply, *, cursor: bool = True
301) -> None:
302 """Answer terminal queries in *buf* that end at or past *new_from* —
303 earlier bytes were answered on a previous read (the buffer overlaps so
304 a sequence split across reads still matches exactly once)."""
305 for m in _DSR.finditer(buf) if cursor else ():
306 if m.end() > new_from:
307 row, col = cursor_at()
308 reply(f"\x1b[{row};{col}R".encode())
309 for m in _OSC_COLOUR.finditer(buf):
310 if m.end() > new_from:
311 reply(b"\x1b]" + m.group(1) + b";rgb:2828/2c2c/3434\x07")
312 for pattern, answer in _TERM_QUERIES:
313 for m in pattern.finditer(buf):
314 if m.end() > new_from:
315 reply(answer)
318def _pty_session(
319 argv: list[str],
320 *,
321 width: int,
322 height: int,
323 sends: list[tuple[float, bytes]],
324 settle: float,
325 env_extra: dict[str, str] | None = None,
326 keep_echo: bool = False,
327 cwd: Path | None = None,
328 answer_cursor: bool = True,
329) -> list[tuple[float, bytes]]:
330 """Run *argv* on a pty, play the keystroke script, and record
331 (elapsed-seconds, bytes) chunks until output has settled."""
332 import fcntl
333 import pty
334 import select
335 import struct
336 import termios
337 import time as _time
339 import pyte # tracks the live cursor for honest DSR answers
341 env = os.environ.copy()
342 env.pop("NO_COLOR", None)
343 env["TERM"] = "xterm-256color"
344 env["COLUMNS"] = str(width)
345 env["LINES"] = str(height)
346 env.update(env_extra or {})
347 master, slave = pty.openpty()
348 fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", height, width, 0, 0))
349 # Query replies are written into the pty during boot, before the shell
350 # enters raw mode — with kernel ECHO on, the tty prints them back (the
351 # digits of a cursor-position reply flashing on screen for a frame).
352 # Shells that interrogate the terminal render their own input anyway,
353 # so echo goes off — except where the caller says otherwise: readline
354 # honours the tty's echo flag and falls silent without it.
355 if not keep_echo:
356 attrs = termios.tcgetattr(slave)
357 attrs[3] &= ~termios.ECHO
358 termios.tcsetattr(slave, termios.TCSANOW, attrs)
360 def _own_the_tty() -> None: # child, pre-exec: the pty.fork() idiom
361 # A new session *and* the slave as controlling terminal — fish,
362 # nushell, and PSReadLine all refuse interactive mode without one
363 # (zsh and bash merely grumble).
364 os.setsid()
365 fcntl.ioctl(0, termios.TIOCSCTTY, 0)
367 proc = subprocess.Popen(
368 argv,
369 stdin=slave,
370 stdout=slave,
371 stderr=slave,
372 env=env,
373 cwd=cwd,
374 preexec_fn=_own_the_tty,
375 )
376 os.close(slave)
377 start = _time.monotonic()
378 chunks: list[tuple[float, bytes]] = []
379 queue = list(sends)
380 tracker = pyte.Screen(width, height)
381 tracker_stream = pyte.Stream(tracker)
382 pending = b"" # tail kept so a query split across reads still matches
383 # Typing begins only after the boot has *settled*: keys written before
384 # the line editor exists are half-echoed raw and eaten (a TAB pressed
385 # then never completes anything), and a slow rc — compinit, the hook's
386 # own `--setup-completion` subprocess — paints in bursts. Every boot
387 # chunk pushes the start out another half second of silence.
388 typing_started = False
389 next_at: float | None = None
390 last_output = start # for <SETTLE>: when the pty last emitted anything
391 deadline = None if queue else start + settle
392 try:
393 while True:
394 now = _time.monotonic()
395 if queue and next_at is not None:
396 if queue[0][0] < 0:
397 # A <SETTLE> step (negative delay) waits for a prompt to
398 # finish rendering: output quiet for _SETTLE_GAP *and* the
399 # cursor sitting past column 0 — a prompt on the line, not
400 # the col-0 lull of a program still starting up (Python
401 # boot, task discovery), which is silent but not ready.
402 # Capped at _SETTLE_MAX so a prompt that never lands — or
403 # one that ends at column 0 — can't hang the cast; next_at
404 # holds when the step became due, so the cap counts there.
405 ready = (
406 now - last_output >= _SETTLE_GAP and tracker.cursor.x > 0
407 ) or now - next_at >= _SETTLE_MAX
408 else:
409 ready = now >= next_at
410 if ready:
411 typing_started = True
412 _, data = queue.pop(0)
413 if data:
414 os.write(master, data)
415 if queue:
416 nd = queue[0][0]
417 next_at = now if nd < 0 else now + nd
418 else:
419 next_at = now
420 deadline = now + settle
421 readable, _, _ = select.select([master], [], [], 0.03)
422 if readable:
423 try:
424 data = os.read(master, 65536)
425 except OSError: # EIO: the child hung up
426 break
427 if not data: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 break
429 last_output = _time.monotonic()
430 chunks.append((last_output - start, data))
431 tracker_stream.feed(_DCS.sub("", data.decode("utf-8", "replace")))
432 buf = pending + data
433 _answer_queries(
434 buf,
435 len(pending),
436 lambda: (tracker.cursor.y + 1, tracker.cursor.x + 1),
437 lambda answer: os.write(master, answer),
438 cursor=answer_cursor,
439 )
440 pending = buf[-64:]
441 if not typing_started and queue:
442 nd = queue[0][0]
443 next_at = last_output if nd < 0 else last_output + 0.5 + nd
444 if deadline is not None: # let late repaints settle too 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true
445 deadline = last_output + settle
446 if deadline is not None and _time.monotonic() >= deadline:
447 break
448 if proc.poll() is not None and not readable: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 break
450 finally:
451 import contextlib as _contextlib
453 with _contextlib.suppress(ProcessLookupError):
454 proc.kill()
455 os.close(master)
456 proc.wait()
457 return chunks
460def _cell_style(char: Any) -> str:
461 """A pyte cell's attributes as a rich style string ('' = default)."""
462 bits: list[str] = []
463 if char.bold:
464 bits.append("bold")
465 if char.italics:
466 bits.append("italic")
467 if char.underscore:
468 bits.append("underline")
469 if char.reverse:
470 bits.append("reverse")
471 for attr, prefix in ((char.fg, ""), (char.bg, "on ")):
472 if attr and attr != "default":
473 # pyte names ansi colours ("red") and spells the rest as bare
474 # 6-digit hex ("87d7ff") — rich wants a `#` on the hex form.
475 longhand = (
476 f"#{attr}" if _HEX6.fullmatch(attr) else _BRIGHT.sub(r"bright_\1", attr)
477 )
478 bits.append(f"{prefix}{longhand}")
479 return " ".join(bits)
482def _screens(
483 chunks: list[tuple[float, bytes]], *, width: int, height: int
484) -> list[tuple[float, list[list[tuple[str, str]]]]]:
485 """Replay timed chunks through a terminal emulator; return the deduped
486 (time, screen) states, each screen a grid of (char, style) cells."""
487 import pyte
489 screen = pyte.Screen(width, height)
490 stream = pyte.Stream(screen)
491 frames: list[tuple[float, list[list[tuple[str, str]]]]] = []
492 last: list[list[tuple[str, str]]] | None = None
493 for t, data in chunks:
494 stream.feed(_DCS.sub("", data.decode("utf-8", "replace")))
495 snap = [
496 [
497 (cell.data, _cell_style(cell))
498 for x in range(width)
499 for cell in (screen.buffer[y][x],)
500 ]
501 for y in range(height)
502 ]
503 if snap != last:
504 # Skip the blank screens before the shell first paints: a
505 # recording should open on the prompt, not on an empty frame.
506 if frames or any(cell[0].strip() for row in snap for cell in row):
507 frames.append((t, snap))
508 last = snap
509 return frames
512_HEX6 = re.compile(r"[0-9a-fA-F]{6}")
513# pyte spells the bright ANSI colours "brightblack"; rich spells them
514# "bright_black" and silently ignores anything it cannot parse — which
515# renders dim text in the normal foreground. That is how fish's grey
516# autosuggestion came to look like characters typed into the prompt.
517_BRIGHT = re.compile(r"^bright([a-z]+)$")
518_SVG_SHELL = re.compile(r"^\s*<svg[^>]*>|</svg>\s*$")
521def compose_animation(svgs: list[str], times: list[float], *, hold: float = 1.6) -> str:
522 """Stack per-frame SVGs into one, cycled by CSS keyframes.
524 Each frame plays over its captured window; the last holds for *hold*
525 seconds before the loop restarts. `step-end` opacity keeps the switch
526 discrete, and every frame carries its own opaque background, so the
527 topmost visible frame is the whole picture.
528 """
529 total = times[-1] + hold
530 head = svgs[0]
531 match = re.search(r"<svg[^>]*>", head)
532 shell_open = match.group(0) if match else "<svg>"
533 css: list[str] = [".cast-frame{opacity:0}"]
534 body: list[str] = []
535 for i, (svg, t) in enumerate(zip(svgs, times, strict=True)):
536 a = 100.0 * t / total
537 b = 100.0 * (times[i + 1] / total) if i + 1 < len(times) else 100.0
538 window = f"{a:.3f}%{{opacity:1}}" if a > 0 else "0%{opacity:1}"
539 off = f"{b:.3f}%{{opacity:0}}" if b < 100.0 else ""
540 pre = "0%{opacity:0}" if a > 0 else ""
541 css.append(f"@keyframes cf{i}{{{pre}{window}{off}}}")
542 css.append(f".cf{i}{{animation:cf{i} {total:.3f}s step-end infinite}}")
543 inner = _SVG_SHELL.sub("", svg.strip())
544 body.append(f'<g class="cast-frame cf{i}">{inner}</g>')
545 style = f"<style>{''.join(css)}</style>"
546 return f"{shell_open}{style}{''.join(body)}</svg>"
549_CAST_BOOT: dict[str, str] = {
550 "zsh": "zsh",
551 "bash": "bash",
552 "fish": "fish",
553 "pwsh": "pwsh",
554 "nushell": "nu",
555}
558def _boot_shell(
559 shell: str, prog: str, scratch: Path
560) -> tuple[list[str], dict[str, str]]:
561 """(argv, extra env) for an interactive *shell* with completion loaded.
563 Each shell boots from a scratch config dir — the user's own dotfiles
564 never run — with a minimal green prompt and footman's hook installed
565 via the same `--setup-completion` path users eval. The scratch HOME
566 would also hide the completion cache (TAB answers from cache alone),
567 so the invoker's real cache dir is passed through FOOTMAN_CACHE_DIR —
568 the override doing exactly the job it was built for.
569 """
570 env = {
571 "HOME": str(scratch),
572 "XDG_CONFIG_HOME": str(scratch),
573 "FOOTMAN_CACHE_DIR": str(_paths.footman_cache_dir()),
574 }
575 # A system rc (/etc/zsh/*, /etc/profile) may rebuild PATH and lose the
576 # venv that owns *prog* \u2014 then the rc's `eval "$(prog \u2026)"` silently
577 # produces nothing and the hook never loads. Pin the interpreter's own
578 # bin dir first, the same lesson the functional shell tests carry.
579 bin_dir = str(Path(sys.executable).parent)
580 if shell == "zsh":
581 (scratch / ".zshrc").write_text(
582 f"path=({bin_dir!r} $path)\n"
583 "PROMPT='%F{green}\u276f%f '\n"
584 "autoload -Uz compinit && compinit -u\n"
585 f'eval "$({prog} --setup-completion zsh)"\n',
586 encoding="utf-8",
587 )
588 env["ZDOTDIR"] = str(scratch)
589 # --no-globalrcs: some machine images (GitHub runners among them)
590 # ship an /etc/zsh rc that runs a bare compinit, which stops the
591 # boot at an interactive compaudit question — and the first typed
592 # key of the script gets eaten answering it. A recording wants a
593 # hermetic shell: scratch rc only.
594 return ["zsh", "--no-globalrcs", "-i"], env
595 if shell == "bash":
596 # macOS prints a "default shell is now zsh" advert into interactive
597 # bash; Apple's own switch silences it — the recording is about
598 # bash, not about Apple's feelings toward it.
599 env["BASH_SILENCE_DEPRECATION_WARNING"] = "1"
600 rc = scratch / "bashrc"
601 rc.write_text(
602 f'PATH="{bin_dir}:$PATH"\n'
603 "PS1='\\[\\e[32m\\]\u276f\\[\\e[0m\\] '\n"
604 f'eval "$({prog} --setup-completion bash)"\n',
605 encoding="utf-8",
606 )
607 return ["bash", "--rcfile", str(rc), "-i"], env
608 if shell == "fish":
609 boot = (
610 "set -g fish_greeting ''; "
611 # Autosuggestions draw on whatever is installed beside footman:
612 # the build machine offered `factor` on macOS and `f77` (the
613 # Fortran compiler) on Linux, so the same script recorded
614 # differently per box. A recording should show footman's
615 # completion, not the host's PATH.
616 "set -g fish_autosuggestion_enabled 0; "
617 f"fish_add_path --prepend {bin_dir!r}; "
618 f"{prog} --setup-completion fish | source; "
619 "function fish_prompt; set_color green; echo -n '\u276f '; "
620 "set_color normal; end"
621 )
622 return ["fish", "-i", "-C", boot], env
623 if shell == "pwsh":
624 # MenuComplete: the grid-with-tooltip menu \u2014 the completion story
625 # worth recording. The hook loads exactly as the docs teach.
626 boot = "$env.PATH = $null; " # placeholder never reached; see below
627 boot = (
628 f"$env:PATH = '{bin_dir}' + [IO.Path]::PathSeparator + $env:PATH; "
629 'function prompt { "\u276f " }; '
630 "Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete; "
631 f"{prog} --setup-completion pwsh | Out-String | Invoke-Expression"
632 )
633 return ["pwsh", "-NoLogo", "-NoProfile", "-NoExit", "-Command", boot], env
634 if shell == "nushell": 634 ↛ 651line 634 didn't jump to line 651 because the condition on line 634 was always true
635 hook = scratch / "hook.nu"
636 hook.write_text(_shellcomp.script_for("nushell", prog), encoding="utf-8")
637 env_nu = scratch / "env.nu"
638 env_nu.write_text(
639 f"$env.PATH = ($env.PATH | prepend {str(bin_dir)!r})\n"
640 '$env.PROMPT_COMMAND = {|| "" }\n'
641 '$env.PROMPT_COMMAND_RIGHT = {|| "" }\n'
642 '$env.PROMPT_INDICATOR = {|| $"(ansi green)\u276f (ansi reset)" }\n',
643 encoding="utf-8",
644 )
645 config_nu = scratch / "config.nu"
646 config_nu.write_text(
647 f"$env.config.show_banner = false\nsource {hook}\n",
648 encoding="utf-8",
649 )
650 return ["nu", "--env-config", str(env_nu), "--config", str(config_nu)], env
651 raise RuntimeError(f"cast drives zsh, bash, fish, pwsh, or nushell (got {shell!r})")
654@tasks.task(name="cast")
655@requires_dep("rich", "pyte")
656@requires(lambda: sys.platform != "win32", reason="needs a POSIX pseudo-terminal")
657def cast(
658 *keys: str,
659 out: Annotated[Path, doc("the animated SVG file to write")],
660 shell: Annotated[
661 Literal["zsh", "bash", "fish", "pwsh", "nushell"],
662 doc("interactive shell to drive"),
663 ] = "zsh",
664 title: Annotated[str, doc("window title (default: '<shell> · completion')")] = "",
665 width: Annotated[int, between(40, 200), doc("terminal columns")] = 72,
666 height: Annotated[int, between(4, 50), doc("terminal rows")] = 14,
667 prog: Annotated[
668 str, doc("CLI whose completion is installed (default: the invoking CLI)")
669 ] = "",
670 cwd: Annotated[
671 Path | None, doc("directory the shell starts in (default: here)")
672 ] = None,
673 max_frames: Annotated[int, between(2, 120), doc("frame budget")] = 60,
674):
675 """Record an animated SVG of a real interactive shell session.
677 Boots the shell from a scratch config with footman completion loaded
678 (via `--setup-completion`; nushell sources the generated hook), types
679 the script — everything after `--`, where `<TAB>`, `<ENTER>`,
680 `<WAIT>` and friends are keys — and replays
681 the capture through a terminal emulator into an animated, dependency-
682 free SVG with the session's real timing. TAB completion, in motion,
683 regenerated on every docs build so it cannot drift.
684 """
685 if sys.platform == "win32": # the @requires gate already refused; belt 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true
686 raise RuntimeError("docs cast needs a POSIX pseudo-terminal")
687 import tempfile
689 prog = prog or context.current().prog
690 if shutil.which(prog) is None: 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true
691 raise RuntimeError(f"{prog!r} is not on PATH")
692 if shutil.which(_CAST_BOOT.get(shell, shell)) is None: 692 ↛ 693line 692 didn't jump to line 693 because the condition on line 692 was never true
693 raise RuntimeError(f"{shell!r} is not on PATH")
695 with tempfile.TemporaryDirectory() as scratch:
696 argv, env_extra = _boot_shell(shell, prog, Path(scratch))
697 chunks = _pty_session(
698 argv,
699 width=width,
700 height=height,
701 sends=keystrokes(keys),
702 settle=1.5,
703 env_extra=env_extra,
704 # bash: readline honours the tty's echo flag and types
705 # invisibly without it — and bash sends no queries, so
706 # nothing can flash. Every other shell self-renders.
707 keep_echo=shell == "bash",
708 cwd=cwd,
709 answer_cursor=shell in _NEEDS_CURSOR_REPLY,
710 )
711 frames = _screens(chunks, width=width, height=height)
712 if not frames: 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true
713 raise RuntimeError(f"the {shell} session produced no output")
714 if len(frames) > max_frames: # keep first/last, thin the middle evenly 714 ↛ 715line 714 didn't jump to line 715 because the condition on line 714 was never true
715 keep = {0, len(frames) - 1}
716 keep.update(
717 round(i * (len(frames) - 1) / (max_frames - 1)) for i in range(max_frames)
718 )
719 frames = [f for i, f in enumerate(frames) if i in keep]
721 from rich.console import Console
722 from rich.text import Text
724 label = title or f"{shell} · completion"
725 svgs: list[str] = []
726 for i, (_, grid) in enumerate(frames):
727 console = Console(
728 record=True, width=width, file=io.StringIO(), force_terminal=True
729 )
730 for row in grid:
731 text = Text()
732 for ch, style in row:
733 text.append(ch, style or None)
734 console.print(text)
735 svgs.append(console.export_svg(title=label, unique_id=f"cf{i}"))
736 start = frames[0][0]
737 times = [t - start for t, _ in frames]
738 out.parent.mkdir(parents=True, exist_ok=True)
739 out.write_text(compose_animation(svgs, times), encoding="utf-8")
740 print(f"wrote {out} ({len(frames)} frames)")
741 return [str(out)]
744@tasks.task(name="globals")
745def globals_(
746 out: Path | None = None,
747 prog: Annotated[
748 str, doc("command name in the table (default: the invoking CLI)")
749 ] = "",
750):
751 """Render the runner's global options as a markdown table.
753 The rows come straight from the CLI grammar — the same table `--help`
754 prints — so a reference page that regenerates this on each docs build
755 can never drift from the runner. Without --out the table is the task's
756 stdout; with --out it is written to the file.
757 """
758 prog = prog or context.current().prog # a branded CLI documents itself
759 text = markdown.globals_table(prog=prog)
760 if out is None:
761 print(text, end="")
762 return None
763 out.parent.mkdir(parents=True, exist_ok=True)
764 out.write_text(text, encoding="utf-8")
765 print(f"wrote {out}")
766 return [str(out)]