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/__init__.py +6 -0
- showrun/cast.py +86 -0
- showrun/check.py +95 -0
- showrun/cli.py +236 -0
- showrun/export.py +50 -0
- showrun/keys.py +149 -0
- showrun/mcp_server.py +205 -0
- showrun/model.py +203 -0
- showrun/notes.py +96 -0
- showrun/player.py +467 -0
- showrun/record.py +163 -0
- showrun/render.py +51 -0
- showrun/shell.py +238 -0
- showrun/starter.yaml +65 -0
- showrun_cli-0.1.0.dist-info/METADATA +247 -0
- showrun_cli-0.1.0.dist-info/RECORD +19 -0
- showrun_cli-0.1.0.dist-info/WHEEL +4 -0
- showrun_cli-0.1.0.dist-info/entry_points.txt +2 -0
- showrun_cli-0.1.0.dist-info/licenses/LICENSE +9 -0
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))
|