showrun-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
showrun/player.py ADDED
@@ -0,0 +1,467 @@
1
+ """The interactive presenter: types commands, waits for keys, runs steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import re
7
+ import time
8
+ from typing import Callable, List, Optional
9
+
10
+ from rich.console import Console
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.syntax import Syntax
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from . import keys as K
18
+ from .model import Demo, Step
19
+ from .notes import NotesPublisher
20
+ from .shell import Result, Shell
21
+
22
+ HELP = """\
23
+ [bold]Enter[/] run / continue [bold]s[/] skip step [bold]b[/] back one step
24
+ [bold]r[/] retype command [bold]g[/] go to step N [bold]l[/] list steps
25
+ [bold]i[/] live shell (Ctrl-] returns) [bold]q[/] quit
26
+ [dim]While typing, any key finishes the command instantly.[/]"""
27
+
28
+ # Neighbouring keys used for believable typos.
29
+ _NEIGHBOURS = {
30
+ "a": "sq", "b": "vn", "c": "xv", "d": "sf", "e": "wr", "f": "dg", "g": "fh", "h": "gj",
31
+ "i": "uo", "j": "hk", "k": "jl", "l": "k;", "m": "n,", "n": "bm", "o": "ip", "p": "o[",
32
+ "q": "wa", "r": "et", "s": "ad", "t": "ry", "u": "yi", "v": "cb", "w": "qe", "x": "zc",
33
+ "y": "tu", "z": "xa",
34
+ }
35
+
36
+
37
+ class Quit(Exception):
38
+ pass
39
+
40
+
41
+ class Player:
42
+ def __init__(
43
+ self,
44
+ demo: Demo,
45
+ console: Optional[Console] = None,
46
+ keys: Optional[K.KeySource] = None,
47
+ shell_factory: Optional[Callable[[], Shell]] = None,
48
+ offline: bool = False,
49
+ fallback: bool = False,
50
+ start_at: int = 1,
51
+ notes_path: Optional[str] = None,
52
+ rng: Optional[random.Random] = None,
53
+ sleep: Callable[[float], None] = time.sleep,
54
+ on_step: Optional[Callable[[int, Step], None]] = None,
55
+ ):
56
+ self.demo = demo
57
+ self.s = demo.settings
58
+ self.console = console or Console(highlight=False)
59
+ self.keys = keys or K.TerminalKeys()
60
+ self.shell_factory = shell_factory or (
61
+ lambda: Shell(self.s.shell, cwd=self.s.cwd, env=self.s.env)
62
+ )
63
+ self.offline = offline
64
+ self.fallback = fallback # live first; on failure show the recorded output
65
+ self.index = max(0, start_at - 1)
66
+ self.notes = NotesPublisher(notes_path, demo)
67
+ self.rng = rng or random.Random()
68
+ self.sleep = sleep
69
+ self.on_step = on_step
70
+ self.shell: Optional[Shell] = None
71
+ self.results: List[Optional[Result]] = [None] * len(demo.steps)
72
+ self.warnings: List[str] = []
73
+
74
+ # ------------------------------------------------------------------ io
75
+ @property
76
+ def out(self):
77
+ return self.console.file
78
+
79
+ def write(self, text: str) -> None:
80
+ self.out.write(text)
81
+ self.out.flush()
82
+
83
+ # ----------------------------------------------------------- lifecycle
84
+ def play(self) -> int:
85
+ """Run the whole demo. Returns the number of steps that failed."""
86
+ failures = 0
87
+ with self.keys:
88
+ self.shell = self.shell_factory()
89
+ try:
90
+ self.shell.resize(self.console.width, self.console.height)
91
+ except Exception:
92
+ pass
93
+ try:
94
+ for cmd in self.demo.setup:
95
+ r = self.shell.run(cmd)
96
+ if r.exit_code != 0:
97
+ self._warn(f"setup command failed (exit {r.exit_code}): {cmd}")
98
+ failures = self._loop()
99
+ except (Quit, KeyboardInterrupt):
100
+ self.console.print("\n[dim]demo stopped.[/]")
101
+ finally:
102
+ try:
103
+ for cmd in self.demo.cleanup:
104
+ self.shell.run(cmd, timeout=30)
105
+ finally:
106
+ self.shell.close()
107
+ self.notes.finish()
108
+ return failures
109
+
110
+ def _loop(self) -> int:
111
+ steps = self.demo.steps
112
+ while self.index < len(steps):
113
+ i = self.index
114
+ step = steps[i]
115
+ self.notes.publish(i)
116
+ if self.on_step:
117
+ self.on_step(i, step)
118
+ outcome = self._step(step)
119
+ if outcome == "back":
120
+ self.index = self._prev_visible(i)
121
+ elif isinstance(outcome, int):
122
+ self.index = outcome
123
+ else:
124
+ self.index = i + 1
125
+ return sum(1 for i, r in enumerate(self.results)
126
+ if r is not None and not steps[i].exit_ok(r.exit_code))
127
+
128
+ def _prev_visible(self, i: int) -> int:
129
+ j = i - 1
130
+ while j > 0 and self.demo.steps[j].hidden:
131
+ j -= 1
132
+ return max(j, 0)
133
+
134
+ # --------------------------------------------------------------- steps
135
+ def _step(self, step: Step):
136
+ i = self.index
137
+ if step.hidden:
138
+ if step.run:
139
+ self._execute(step, i, silent=True)
140
+ if step.wait_for:
141
+ self._wait_for(step, silent=True)
142
+ return "next"
143
+
144
+ if step.clear:
145
+ self.console.clear()
146
+ if step.title:
147
+ self.console.rule(f"[bold]{step.title}")
148
+ if step.say:
149
+ self.console.print(Text("# " + step.say.strip().replace("\n", "\n# "), style="italic cyan"))
150
+
151
+ if not step.run:
152
+ if step.wait_for:
153
+ self._wait_for(step)
154
+ if step.pause or (step.say and step.wait):
155
+ return self._wait_controls(step, on_enter="next")
156
+ return "next"
157
+
158
+ if self.s.wait_before_typing and step.wait:
159
+ self._prompt()
160
+ r = self._wait_controls(step, on_enter="type", show_prompt=False)
161
+ if r != "type":
162
+ self.write("\n")
163
+ return r
164
+ self._type(step)
165
+ else:
166
+ self._prompt()
167
+ self._type(step)
168
+
169
+ if step.wait:
170
+ r = self._wait_controls(step, on_enter="run", show_prompt=False, typed=True)
171
+ if r != "run":
172
+ self.write("\n")
173
+ return r
174
+ self.write("\n")
175
+ self._execute(step, i)
176
+ if step.wait_for:
177
+ self._wait_for(step)
178
+ return "next"
179
+
180
+ # ------------------------------------------------------------ controls
181
+ def _wait_controls(self, step: Step, on_enter: str, show_prompt: bool = True, typed: bool = False):
182
+ while True:
183
+ key = self.keys.read()
184
+ if key in (K.ENTER, "right", " ", "n"):
185
+ return on_enter
186
+ if key == "q":
187
+ raise Quit()
188
+ if key == "s":
189
+ if typed:
190
+ self.write(self._dim(" ⏭ skipped"))
191
+ return "next"
192
+ if key in ("b", "left"):
193
+ return "back"
194
+ if key == "r" and step.run and typed:
195
+ self.write("\r\x1b[2K")
196
+ self._clear_typed(step)
197
+ self._prompt()
198
+ self._type(step)
199
+ continue
200
+ if key == "g":
201
+ target = self._read_number()
202
+ if target is not None:
203
+ return target
204
+ self._redraw(step, typed)
205
+ continue
206
+ if key == "l":
207
+ self.write("\n")
208
+ self._list_steps()
209
+ self._redraw(step, typed)
210
+ continue
211
+ if key == "i":
212
+ self.write("\n")
213
+ self._live_shell()
214
+ self._redraw(step, typed)
215
+ continue
216
+ if key in ("?", "h"):
217
+ self.write("\n")
218
+ self.console.print(Panel(HELP, title="controls", border_style="blue"))
219
+ self._redraw(step, typed)
220
+ continue
221
+
222
+ def _redraw(self, step: Step, typed: bool) -> None:
223
+ if typed and step.run:
224
+ self._prompt()
225
+ self._type(step, instant=True)
226
+ elif step.run and self.s.wait_before_typing:
227
+ self._prompt()
228
+
229
+ def _clear_typed(self, step: Step) -> None:
230
+ # Multi-line commands occupy several rows; move up and clear them.
231
+ extra = step.run.rstrip("\n").count("\n")
232
+ for _ in range(extra):
233
+ self.write("\x1b[1A\x1b[2K")
234
+ self.write("\r")
235
+
236
+ def _read_number(self) -> Optional[int]:
237
+ self.write("\n" + self._dim("go to step #: "))
238
+ digits = ""
239
+ while True:
240
+ k = self.keys.read()
241
+ if k == K.ENTER:
242
+ break
243
+ if k == K.ESC:
244
+ digits = ""
245
+ break
246
+ if k == K.BACKSPACE and digits:
247
+ digits = digits[:-1]
248
+ self.write("\b \b")
249
+ elif k and k.isdigit():
250
+ digits += k
251
+ self.write(k)
252
+ self.write("\n")
253
+ if digits and 1 <= int(digits) <= len(self.demo.steps):
254
+ return int(digits) - 1
255
+ return None
256
+
257
+ def _list_steps(self) -> None:
258
+ t = Table(box=None, show_header=False, padding=(0, 1))
259
+ for n, st in enumerate(self.demo.steps, 1):
260
+ mark = "▶" if n - 1 == self.index else " "
261
+ style = "dim" if st.hidden else ("bold" if n - 1 == self.index else "")
262
+ tag = " [dim](hidden)[/]" if st.hidden else ""
263
+ t.add_row(mark, str(n), Text(st.label(), style=style), tag)
264
+ self.console.print(t)
265
+
266
+ def _live_shell(self) -> None:
267
+ self.console.print(self._dim_markup("live shell — press Ctrl-] to return to the demo"))
268
+ with self.keys.suspend():
269
+ self.shell.interact()
270
+ self.write("\r\n")
271
+ self.console.print(self._dim_markup("back to the demo"))
272
+
273
+ # -------------------------------------------------------------- typing
274
+ def _prompt(self) -> None:
275
+ cwd = self.shell.cwd() if "{cwd}" in self.s.prompt else ""
276
+ markup = self.s.prompt.replace("{cwd}", cwd.replace("[", r"\["))
277
+ self.console.print(markup, end="")
278
+
279
+ def _styled(self, command: str) -> Text:
280
+ if not self.s.syntax_highlight:
281
+ return Text(command)
282
+ syn = Syntax(command, "bash", theme=self.s.theme, background_color="default")
283
+ text = syn.highlight(command)
284
+ text.rstrip()
285
+ return text
286
+
287
+ def _type(self, step: Step, instant: bool = False) -> None:
288
+ text = self._styled(step.run.rstrip("\n"))
289
+ speed = self.s.typing_speed if step.speed is None else step.speed
290
+ typo_rate = self.s.typo_rate if step.typos is None else (step.typos and max(self.s.typo_rate, 0.03))
291
+ if not step.type or speed == 0:
292
+ instant = True
293
+ plain = text.plain
294
+ n = 0
295
+ while n < len(plain):
296
+ if not instant and self.keys.poll() is not None:
297
+ instant = True # any key = fast-forward
298
+ if instant:
299
+ self._print_text(text[n:])
300
+ break
301
+ ch = plain[n]
302
+ if ch == "\n":
303
+ self.write("\n")
304
+ self.console.print("[dim]>[/] ", end="")
305
+ n += 1
306
+ continue
307
+ if typo_rate and ch.isalpha() and self.rng.random() < typo_rate:
308
+ wrong = self.rng.choice(_NEIGHBOURS.get(ch.lower(), "x"))
309
+ self.write(wrong)
310
+ self.sleep(speed * 4)
311
+ self.write("\b \b")
312
+ self.sleep(speed * 2)
313
+ self._print_text(text[n:n + 1])
314
+ jitter = speed * self.s.jitter
315
+ self.sleep(max(0.0, speed + self.rng.uniform(-jitter, jitter)) * (3 if ch == " " else 1))
316
+ n += 1
317
+
318
+ def _print_text(self, text: Text) -> None:
319
+ # Render continuation lines with a "> " marker.
320
+ lines = text.split("\n", allow_blank=True)
321
+ for k, line in enumerate(lines):
322
+ if k:
323
+ self.write("\n")
324
+ self.console.print("[dim]>[/] ", end="")
325
+ self.console.print(line, end="", soft_wrap=True, highlight=False)
326
+
327
+ # ----------------------------------------------------------- execution
328
+ def _canned(self, step: Step) -> Optional[str]:
329
+ if not step.execute:
330
+ return step.output or ""
331
+ if self.offline and step.recorded_output is not None:
332
+ return step.recorded_output
333
+ return None
334
+
335
+ def _execute(self, step: Step, i: int, silent: bool = False) -> None:
336
+ canned = self._canned(step)
337
+ if canned is not None:
338
+ if not silent and canned:
339
+ self._emit_output(step, canned.replace("\r\n", "\n").replace("\n", "\r\n"))
340
+ if not canned.endswith("\n"):
341
+ self.write("\r\n")
342
+ self.results[i] = Result(0, canned, 0.0)
343
+ return
344
+
345
+ if step.background and self.offline:
346
+ self.results[i] = Result(0, "", 0.0)
347
+ if not silent:
348
+ self.console.print(self._dim_markup("⟳ running in background (offline: not started)"))
349
+ return
350
+
351
+ if step.background:
352
+ pid, log = self.shell.start_background(step.run)
353
+ self.results[i] = Result(0, "", 0.0)
354
+ if not silent:
355
+ self.console.print(self._dim_markup(f"⟳ running in background (pid {pid}, log {log})"))
356
+ return
357
+
358
+ timeout = step.timeout or self.s.timeout
359
+ state = {"tail": "", "any": False}
360
+ highlighter = _LineHighlighter(step.highlight, self.s.highlight_style, self.console) if step.highlight else None
361
+
362
+ def on_output(chunk: str) -> None:
363
+ if silent:
364
+ return
365
+ state["any"] = True
366
+ state["tail"] = (state["tail"] + chunk)[-2:]
367
+ if highlighter:
368
+ highlighter.feed(chunk)
369
+ else:
370
+ self.write(chunk)
371
+
372
+ res = self.shell.run(step.run, on_output=on_output, timeout=timeout)
373
+ if highlighter:
374
+ highlighter.flush()
375
+ if not silent and state["any"] and not state["tail"].endswith("\n"):
376
+ self.write("\r\n")
377
+ self.results[i] = res
378
+
379
+ failed = res.timed_out or not step.exit_ok(res.exit_code) or (
380
+ step.expect_output and not re.search(step.expect_output, _strip_ansi(res.output), re.M))
381
+ if failed and self.fallback and step.recorded_output is not None:
382
+ if not silent:
383
+ self.console.print(self._dim_markup("↺ live run didn't go to plan, here's the recorded run:"))
384
+ self._emit_output(step, step.recorded_output.replace("\n", "\r\n"))
385
+ self.warnings.append(f"step {i + 1} fell back to recorded output")
386
+ self.results[i] = Result(0, step.recorded_output, res.duration)
387
+ return
388
+
389
+ if res.timed_out:
390
+ self._warn(f"step {i + 1} timed out after {timeout}s", silent)
391
+ elif not step.exit_ok(res.exit_code):
392
+ self._warn(f"step {i + 1} exited with {res.exit_code}", silent)
393
+ if step.expect_output and not re.search(step.expect_output, _strip_ansi(res.output), re.M):
394
+ self._warn(f"step {i + 1} output didn't match /{step.expect_output}/", silent)
395
+
396
+ def _emit_output(self, step: Step, text: str) -> None:
397
+ if step.highlight:
398
+ h = _LineHighlighter(step.highlight, self.s.highlight_style, self.console)
399
+ h.feed(text)
400
+ h.flush()
401
+ else:
402
+ self.write(text)
403
+
404
+ def _wait_for(self, step: Step, silent: bool = False) -> None:
405
+ if self.offline:
406
+ return
407
+ if silent:
408
+ ok = self.shell.wait_for(step.wait_for, step.wait_timeout)
409
+ else:
410
+ with self.console.status(f"[dim]waiting for: {step.wait_for}[/]", spinner="dots"):
411
+ ok = self.shell.wait_for(step.wait_for, step.wait_timeout)
412
+ if not ok:
413
+ self._warn(f"gave up waiting for `{step.wait_for}` after {step.wait_timeout}s", silent)
414
+
415
+ # ------------------------------------------------------------- helpers
416
+ def _warn(self, msg: str, silent: bool = False) -> None:
417
+ self.warnings.append(msg)
418
+ if not silent:
419
+ self.console.print(f"[red dim]✗ {msg}[/]")
420
+
421
+ @staticmethod
422
+ def _dim(text: str) -> str:
423
+ return f"\x1b[2m{text}\x1b[0m"
424
+
425
+ @staticmethod
426
+ def _dim_markup(text: str) -> str:
427
+ return f"[dim]{text.replace('[', chr(92) + '[')}[/]"
428
+
429
+
430
+ _ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\r")
431
+
432
+
433
+ def _strip_ansi(s: str) -> str:
434
+ return _ANSI.sub("", s)
435
+
436
+
437
+ class _LineHighlighter:
438
+ """Buffers output by line and highlights regex matches (keeps original ANSI colours)."""
439
+
440
+ def __init__(self, patterns: List[str], style: str, console: Console):
441
+ self.patterns = [re.compile(p) for p in patterns]
442
+ self.style = style
443
+ self.console = console
444
+ self.buf = ""
445
+
446
+ def feed(self, chunk: str) -> None:
447
+ self.buf += chunk
448
+ while "\n" in self.buf:
449
+ line, self.buf = self.buf.split("\n", 1)
450
+ self._line(line.rstrip("\r"), newline=True)
451
+
452
+ def flush(self) -> None:
453
+ if self.buf:
454
+ self._line(self.buf.rstrip("\r"), newline=False)
455
+ self.buf = ""
456
+
457
+ def _line(self, line: str, newline: bool) -> None:
458
+ text = Text.from_ansi(line)
459
+ hit = False
460
+ for p in self.patterns:
461
+ for m in p.finditer(text.plain):
462
+ text.stylize(self.style, m.start(), m.end())
463
+ hit = True
464
+ if hit:
465
+ marker = Text("▶ ", style="bold yellow")
466
+ text = marker + text
467
+ self.console.print(text, end="\r\n" if newline else "", soft_wrap=True, highlight=False)
showrun/record.py ADDED
@@ -0,0 +1,163 @@
1
+ """`showrun record`: do the demo once in a shell, get a replayable script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import yaml
10
+ from rich.console import Console
11
+
12
+ from .player import _strip_ansi
13
+ from .shell import Shell
14
+
15
+ RECORD_HELP = """\
16
+ [bold]Recording.[/] Type commands as normal; each one becomes a step.
17
+ [cyan]#say[/] text narration shown before the next command
18
+ [cyan]#title[/] text section heading before the next command
19
+ [cyan]#note[/] text presenter-only note for the next command
20
+ [cyan]#hide[/] cmd run cmd now, saved as a hidden step
21
+ [cyan]#bg[/] cmd start cmd in the background (servers)
22
+ [cyan]#wait[/] cmd add a wait_for check (e.g. curl -sf localhost:8000)
23
+ [cyan]#fake[/] last step: show its recorded output instead of running it
24
+ [cyan]#pause[/] add a pause step [cyan]#undo[/] drop the last step
25
+ [cyan]#list[/] show steps so far [cyan]#done[/] save and exit (or Ctrl-D)
26
+ [dim]Full-screen apps (vim, top) and commands that read from stdin aren't recordable.[/]"""
27
+
28
+
29
+ class _LiteralDumper(yaml.SafeDumper):
30
+ pass
31
+
32
+
33
+ def _str_rep(dumper, data):
34
+ style = "|" if "\n" in data else None
35
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
36
+
37
+
38
+ _LiteralDumper.add_representer(str, _str_rep)
39
+
40
+
41
+ def dump_yaml(doc: Dict[str, Any]) -> str:
42
+ return yaml.dump(doc, Dumper=_LiteralDumper, sort_keys=False, allow_unicode=True, width=1000)
43
+
44
+
45
+ class Recorder:
46
+ def __init__(self, path: str, title: Optional[str] = None, shell: str = "bash",
47
+ console: Optional[Console] = None, append: bool = False):
48
+ self.path = Path(path)
49
+ self.console = console or Console(highlight=False)
50
+ self.doc: Dict[str, Any] = {"title": title or self.path.stem.replace("-", " ").replace("_", " ").title(),
51
+ "settings": {"typing_speed": 0.04}, "steps": []}
52
+ if append and self.path.exists():
53
+ self.doc = yaml.safe_load(self.path.read_text()) or self.doc
54
+ self.doc.setdefault("steps", [])
55
+ self.shell_name = shell
56
+ self.pending: Dict[str, Any] = {}
57
+
58
+ @property
59
+ def steps(self) -> List[Dict[str, Any]]:
60
+ return self.doc["steps"]
61
+
62
+ def save(self) -> None:
63
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
64
+ tmp.write_text(dump_yaml(self.doc))
65
+ os.replace(tmp, self.path)
66
+
67
+ def _add(self, step: Dict[str, Any]) -> None:
68
+ step = {**self.pending, **step}
69
+ self.pending = {}
70
+ self.steps.append(step)
71
+ self.save()
72
+
73
+ # One line of input -> action. Returns False when recording should stop.
74
+ def handle(self, line: str, sh: Shell, echo=None) -> bool:
75
+ echo = echo or (lambda s: (self.console.file.write(s), self.console.file.flush()))
76
+ stripped = line.strip()
77
+ if not stripped:
78
+ return True
79
+ if stripped.startswith("#"):
80
+ word, _, rest = stripped[1:].partition(" ")
81
+ rest = rest.strip()
82
+ if word in ("done", "quit", "exit"):
83
+ return False
84
+ if word in ("say", "title", "note") and rest:
85
+ key = "notes" if word == "note" else word
86
+ self.pending[key] = (self.pending.get(key, "") + "\n" + rest).strip()
87
+ elif word == "hide" and rest:
88
+ r = sh.run(rest)
89
+ self._add({"run": rest, "hidden": True, **({"expect_exit": "any"} if r.exit_code else {})})
90
+ self.console.print(f"[dim]hidden step saved (exit {r.exit_code})[/]")
91
+ elif word == "bg" and rest:
92
+ pid, log = sh.start_background(rest)
93
+ self._add({"run": rest, "background": True})
94
+ self.console.print(f"[dim]⟳ background pid {pid}, log {log}[/]")
95
+ elif word == "wait" and rest:
96
+ ok = sh.wait_for(rest, 30)
97
+ self._add({"wait_for": rest, **({} if ok else {"wait_timeout": 60})})
98
+ self.console.print("[dim]ready[/]" if ok else "[yellow]never succeeded; saved anyway[/]")
99
+ elif word == "fake" and self.steps:
100
+ last = self.steps[-1]
101
+ if "recorded_output" in last:
102
+ last["execute"] = False
103
+ last["output"] = last.pop("recorded_output")
104
+ self.save()
105
+ self.console.print("[dim]last step will show its recorded output instead of running[/]")
106
+ elif word == "pause":
107
+ self._add({"pause": True})
108
+ elif word == "undo" and self.steps:
109
+ gone = self.steps.pop()
110
+ self.save()
111
+ self.console.print(f"[dim]removed: {gone.get('run') or gone}[/]")
112
+ elif word == "list":
113
+ for n, st in enumerate(self.steps, 1):
114
+ self.console.print(f"[dim]{n:>3}[/] {st.get('run') or st.get('say') or st}")
115
+ else:
116
+ self.console.print(RECORD_HELP)
117
+ return True
118
+
119
+ captured: List[str] = []
120
+
121
+ def out(chunk: str) -> None:
122
+ captured.append(chunk)
123
+ echo(chunk)
124
+
125
+ r = sh.run(line, on_output=out)
126
+ text = "".join(captured)
127
+ if text and not text.endswith("\n"):
128
+ echo("\r\n")
129
+ step: Dict[str, Any] = {"run": line.rstrip()}
130
+ clean = _strip_ansi(text).strip("\n")
131
+ if clean:
132
+ step["recorded_output"] = clean + "\n"
133
+ if r.exit_code != 0:
134
+ step["expect_exit"] = r.exit_code
135
+ self.console.print(f"[dim red]exit {r.exit_code} (saved as expected)[/]")
136
+ self._add(step)
137
+ return True
138
+
139
+ def run(self) -> None:
140
+ try:
141
+ import readline # noqa: F401 (line editing + history)
142
+ except ImportError:
143
+ pass
144
+ self.console.print(RECORD_HELP)
145
+ self.console.print(f"[dim]saving to {self.path}[/]\n")
146
+ self.save()
147
+ with Shell(self.shell_name) as sh:
148
+ while True:
149
+ cwd = sh.cwd()
150
+ prompt = f"\001\x1b[1;31m\002● rec\001\x1b[0m\002 \001\x1b[1;34m\002{cwd}\001\x1b[0m\002 $ "
151
+ try:
152
+ line = input(prompt)
153
+ except EOFError:
154
+ print()
155
+ break
156
+ except KeyboardInterrupt:
157
+ print()
158
+ continue
159
+ if not self.handle(line, sh):
160
+ break
161
+ self.save()
162
+ self.console.print(f"[green]saved {len(self.steps)} steps to {self.path}[/] "
163
+ f"[dim]next: showrun check {self.path} · showrun play {self.path}[/]")
showrun/render.py ADDED
@@ -0,0 +1,51 @@
1
+ """Render a demo to an asciinema .cast file without a terminal (for docs, CI and agents)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import random
7
+ from dataclasses import dataclass, field
8
+ from typing import List
9
+
10
+ from rich.console import Console
11
+
12
+ from .cast import CastTee
13
+ from .keys import AutoKeys
14
+ from .model import Demo
15
+ from .player import Player
16
+
17
+
18
+ class _Sink(io.StringIO):
19
+ """A stream that keeps nothing: the cast file is the only output."""
20
+
21
+ def write(self, data: str) -> int:
22
+ return len(data)
23
+
24
+ def isatty(self) -> bool:
25
+ return False
26
+
27
+
28
+ @dataclass
29
+ class RenderResult:
30
+ path: str
31
+ steps: int
32
+ failures: int
33
+ warnings: List[str] = field(default_factory=list)
34
+
35
+
36
+ def render_cast(demo: Demo, path: str, *, offline: bool = False, delay: float = 1.0,
37
+ width: int = 100, height: int = 30, seed: int = 7) -> RenderResult:
38
+ """Play *demo* unattended, pressing Enter after *delay* seconds, and record it to *path*.
39
+
40
+ Typing, typos and output timing are real, so the cast looks like a live presentation.
41
+ """
42
+ tee = CastTee(_Sink(), path, demo.title, size=(width, height), crlf=True)
43
+ console = Console(file=tee, force_terminal=True, color_system="truecolor", highlight=False,
44
+ width=width, height=height)
45
+ player = Player(demo, console=console, keys=AutoKeys(delay), offline=offline,
46
+ rng=random.Random(seed), on_step=lambda i, st: tee.marker(st.label()))
47
+ try:
48
+ failures = player.play()
49
+ finally:
50
+ tee.close()
51
+ return RenderResult(path, len(demo.steps), failures, list(player.warnings))