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/__init__.py
ADDED
showrun/cast.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""asciinema v2 .cast recording and playback, with no asciinema install required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, TextIO, Tuple
|
|
12
|
+
|
|
13
|
+
_LONE_LF = re.compile(r"(?<!\r)\n")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CastTee:
|
|
17
|
+
"""File-like wrapper: writes to the real stream and records an asciinema v2 cast."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, stream: TextIO, path: str, title: Optional[str] = None,
|
|
20
|
+
size: Optional[Tuple[int, int]] = None, crlf: bool = False):
|
|
21
|
+
self.stream = stream
|
|
22
|
+
self.crlf = crlf # no tty to turn "\n" into "\r\n", so do it here (headless renders)
|
|
23
|
+
cols, rows = size or shutil.get_terminal_size((100, 30))
|
|
24
|
+
self.out = open(path, "w", encoding="utf-8")
|
|
25
|
+
header = {"version": 2, "width": cols, "height": rows,
|
|
26
|
+
"timestamp": int(time.time()), "env": {"TERM": "xterm-256color"}}
|
|
27
|
+
if title:
|
|
28
|
+
header["title"] = title
|
|
29
|
+
self.out.write(json.dumps(header) + "\n")
|
|
30
|
+
self.start = time.monotonic()
|
|
31
|
+
|
|
32
|
+
def write(self, data: str) -> int:
|
|
33
|
+
if data:
|
|
34
|
+
rec = _LONE_LF.sub("\r\n", data) if self.crlf else data
|
|
35
|
+
self.out.write(json.dumps([round(time.monotonic() - self.start, 6), "o", rec]) + "\n")
|
|
36
|
+
return self.stream.write(data)
|
|
37
|
+
|
|
38
|
+
def marker(self, label: str) -> None:
|
|
39
|
+
self.out.write(json.dumps([round(time.monotonic() - self.start, 6), "m", label]) + "\n")
|
|
40
|
+
|
|
41
|
+
def flush(self) -> None:
|
|
42
|
+
self.stream.flush()
|
|
43
|
+
self.out.flush()
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
self.out.close()
|
|
47
|
+
|
|
48
|
+
# Rich inspects these.
|
|
49
|
+
def isatty(self) -> bool:
|
|
50
|
+
return self.stream.isatty()
|
|
51
|
+
|
|
52
|
+
def fileno(self) -> int:
|
|
53
|
+
return self.stream.fileno()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def encoding(self) -> str:
|
|
57
|
+
return getattr(self.stream, "encoding", "utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def replay(path: str, speed: float = 1.0, max_idle: Optional[float] = 1.0,
|
|
61
|
+
stream: TextIO = sys.stdout, sleep=time.sleep) -> float:
|
|
62
|
+
"""Play a .cast file to *stream*. Returns total (scaled) playback time."""
|
|
63
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
64
|
+
if not lines:
|
|
65
|
+
raise ValueError("empty cast file")
|
|
66
|
+
header = json.loads(lines[0])
|
|
67
|
+
if header.get("version") != 2:
|
|
68
|
+
raise ValueError("only asciinema v2 casts are supported")
|
|
69
|
+
last = 0.0
|
|
70
|
+
total = 0.0
|
|
71
|
+
for line in lines[1:]:
|
|
72
|
+
if not line.strip():
|
|
73
|
+
continue
|
|
74
|
+
t, kind, data = json.loads(line)
|
|
75
|
+
delay = t - last
|
|
76
|
+
last = t
|
|
77
|
+
if max_idle is not None:
|
|
78
|
+
delay = min(delay, max_idle)
|
|
79
|
+
delay /= speed
|
|
80
|
+
if delay > 0:
|
|
81
|
+
sleep(delay)
|
|
82
|
+
total += delay
|
|
83
|
+
if kind == "o":
|
|
84
|
+
stream.write(data)
|
|
85
|
+
stream.flush()
|
|
86
|
+
return total
|
showrun/check.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Rehearsal: run every step unattended and report what would break on stage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from .model import Demo
|
|
13
|
+
from .player import _strip_ansi
|
|
14
|
+
from .shell import Shell
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CheckResult:
|
|
19
|
+
n: int
|
|
20
|
+
label: str
|
|
21
|
+
status: str # ok | fail | skip
|
|
22
|
+
detail: str = ""
|
|
23
|
+
duration: float = 0.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rehearse(demo: Demo, shell: Optional[Shell] = None, stop_on_fail: bool = False) -> List[CheckResult]:
|
|
27
|
+
s = demo.settings
|
|
28
|
+
sh = shell or Shell(s.shell, cwd=s.cwd, env=s.env)
|
|
29
|
+
results: List[CheckResult] = []
|
|
30
|
+
try:
|
|
31
|
+
for cmd in demo.setup:
|
|
32
|
+
r = sh.run(cmd, timeout=s.timeout)
|
|
33
|
+
if r.exit_code != 0:
|
|
34
|
+
results.append(CheckResult(0, f"setup: {cmd}", "fail", f"exit {r.exit_code}"))
|
|
35
|
+
for n, step in enumerate(demo.steps, 1):
|
|
36
|
+
label = step.label()
|
|
37
|
+
if step.run and not step.execute:
|
|
38
|
+
results.append(CheckResult(n, label, "skip", "canned output"))
|
|
39
|
+
continue
|
|
40
|
+
if not step.run and not step.wait_for:
|
|
41
|
+
continue
|
|
42
|
+
problems = []
|
|
43
|
+
dur = 0.0
|
|
44
|
+
if step.run and step.background:
|
|
45
|
+
pid, log = sh.start_background(step.run)
|
|
46
|
+
elif step.run:
|
|
47
|
+
r = sh.run(step.run, timeout=step.timeout or s.timeout)
|
|
48
|
+
dur = r.duration
|
|
49
|
+
if r.timed_out:
|
|
50
|
+
problems.append(f"timed out after {step.timeout or s.timeout}s")
|
|
51
|
+
elif not step.exit_ok(r.exit_code):
|
|
52
|
+
problems.append(f"exit {r.exit_code} (expected {step.expect_exit})")
|
|
53
|
+
if step.expect_output and not re.search(step.expect_output, _strip_ansi(r.output), re.M):
|
|
54
|
+
problems.append(f"output missing /{step.expect_output}/")
|
|
55
|
+
if problems:
|
|
56
|
+
tail = _strip_ansi(r.output).strip().splitlines()[-3:]
|
|
57
|
+
if tail:
|
|
58
|
+
problems.append("… " + " | ".join(tail))
|
|
59
|
+
if step.wait_for and not sh.wait_for(step.wait_for, step.wait_timeout):
|
|
60
|
+
problems.append(f"wait_for failed: {step.wait_for}")
|
|
61
|
+
results.append(CheckResult(n, label, "fail" if problems else "ok", "; ".join(problems), dur))
|
|
62
|
+
if problems and stop_on_fail:
|
|
63
|
+
break
|
|
64
|
+
finally:
|
|
65
|
+
for cmd in demo.cleanup:
|
|
66
|
+
try:
|
|
67
|
+
sh.run(cmd, timeout=30)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
if shell is None:
|
|
71
|
+
sh.close()
|
|
72
|
+
return results
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def report(results: List[CheckResult], console: Console) -> bool:
|
|
76
|
+
t = Table(title="rehearsal", header_style="bold")
|
|
77
|
+
t.add_column("#", justify="right")
|
|
78
|
+
t.add_column("step")
|
|
79
|
+
t.add_column("result")
|
|
80
|
+
t.add_column("time", justify="right")
|
|
81
|
+
t.add_column("detail", overflow="fold")
|
|
82
|
+
icons = {"ok": "[green]✓ ok[/]", "fail": "[red]✗ fail[/]", "skip": "[dim]– skip[/]"}
|
|
83
|
+
for r in results:
|
|
84
|
+
t.add_row(str(r.n or "-"), r.label, icons[r.status],
|
|
85
|
+
f"{r.duration:.1f}s" if r.duration else "", r.detail)
|
|
86
|
+
console.print(t)
|
|
87
|
+
fails = sum(r.status == "fail" for r in results)
|
|
88
|
+
slow = sorted((r for r in results if r.duration > 5), key=lambda r: -r.duration)[:3]
|
|
89
|
+
if slow:
|
|
90
|
+
console.print("[yellow]slow steps (consider execute: false + output):[/] "
|
|
91
|
+
+ ", ".join(f"#{r.n} {r.duration:.0f}s" for r in slow))
|
|
92
|
+
total = sum(r.duration for r in results)
|
|
93
|
+
console.print(("[bold green]all good[/]" if not fails else f"[bold red]{fails} failing step(s)[/]")
|
|
94
|
+
+ f" [dim](command time {total:.1f}s)[/]")
|
|
95
|
+
return fails == 0
|
showrun/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .model import DemoError, load_demo
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True, rich_markup_mode="rich",
|
|
17
|
+
help="Scripted, keypress-driven terminal demos.")
|
|
18
|
+
err = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _vars(pairs: Optional[List[str]]) -> Dict[str, str]:
|
|
22
|
+
out = {}
|
|
23
|
+
for p in pairs or []:
|
|
24
|
+
if "=" not in p:
|
|
25
|
+
raise typer.BadParameter(f"expected KEY=VALUE, got {p!r}")
|
|
26
|
+
k, v = p.split("=", 1)
|
|
27
|
+
out[k.strip()] = v
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load(path: Path, var: Optional[List[str]]):
|
|
32
|
+
try:
|
|
33
|
+
return load_demo(path, _vars(var))
|
|
34
|
+
except DemoError as e:
|
|
35
|
+
err.print(f"[red]{e}[/]")
|
|
36
|
+
raise typer.Exit(2)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
VarOpt = typer.Option(None, "--var", "-v", help="Override a variable: KEY=VALUE (repeatable).")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def play(
|
|
44
|
+
demo_file: Path = typer.Argument(..., help="Demo YAML file."),
|
|
45
|
+
var: Optional[List[str]] = VarOpt,
|
|
46
|
+
offline: bool = typer.Option(False, "--offline", help="Replay recorded output instead of running commands."),
|
|
47
|
+
fallback: bool = typer.Option(False, "--fallback", help="Run live, but if a step fails show its recorded output."),
|
|
48
|
+
auto: Optional[float] = typer.Option(None, "--auto", help="Advance by itself every N seconds."),
|
|
49
|
+
start: int = typer.Option(1, "--start", "-s", help="Start at step N (hidden steps before it are skipped)."),
|
|
50
|
+
cast: Optional[Path] = typer.Option(None, "--cast", help="Also record the session to an asciinema .cast file."),
|
|
51
|
+
speed: float = typer.Option(1.0, "--speed", help="Typing speed multiplier (2 = twice as fast)."),
|
|
52
|
+
notes: bool = typer.Option(True, "--notes/--no-notes", help="Publish position for `showrun notes`."),
|
|
53
|
+
):
|
|
54
|
+
"""Present a demo. Enter advances; press ? during the demo for all keys."""
|
|
55
|
+
from .cast import CastTee
|
|
56
|
+
from .keys import AutoKeys, TerminalKeys
|
|
57
|
+
from .notes import default_notes_path
|
|
58
|
+
from .player import Player
|
|
59
|
+
|
|
60
|
+
demo = _load(demo_file, var)
|
|
61
|
+
if speed > 0:
|
|
62
|
+
demo.settings.typing_speed /= speed
|
|
63
|
+
if not sys.stdin.isatty():
|
|
64
|
+
err.print("[red]play needs an interactive terminal (try `showrun check` for unattended runs)[/]")
|
|
65
|
+
raise typer.Exit(2)
|
|
66
|
+
|
|
67
|
+
tee = CastTee(sys.stdout, str(cast), demo.title) if cast else None
|
|
68
|
+
console = Console(file=tee, highlight=False, force_terminal=True) if tee else Console(highlight=False)
|
|
69
|
+
keys = TerminalKeys()
|
|
70
|
+
if auto is not None:
|
|
71
|
+
keys = AutoKeys(auto, inner=keys)
|
|
72
|
+
on_step = (lambda i, st: tee.marker(st.label())) if tee else None
|
|
73
|
+
player = Player(demo, console=console, keys=keys, offline=offline, fallback=fallback, start_at=start,
|
|
74
|
+
notes_path=default_notes_path(str(demo_file)) if notes else None, on_step=on_step)
|
|
75
|
+
try:
|
|
76
|
+
failures = player.play()
|
|
77
|
+
finally:
|
|
78
|
+
if tee:
|
|
79
|
+
tee.close()
|
|
80
|
+
err.print(f"[dim]recorded {cast} — play it back with: showrun replay {cast}[/]")
|
|
81
|
+
raise typer.Exit(1 if failures else 0)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def check(
|
|
86
|
+
demo_file: Path = typer.Argument(..., help="Demo YAML file."),
|
|
87
|
+
var: Optional[List[str]] = VarOpt,
|
|
88
|
+
fail_fast: bool = typer.Option(False, "--fail-fast", "-x", help="Stop at the first failing step."),
|
|
89
|
+
):
|
|
90
|
+
"""Rehearse: run every step unattended and report failures (great in CI)."""
|
|
91
|
+
from .check import rehearse, report
|
|
92
|
+
|
|
93
|
+
demo = _load(demo_file, var)
|
|
94
|
+
ok = report(rehearse(demo, stop_on_fail=fail_fast), Console())
|
|
95
|
+
raise typer.Exit(0 if ok else 1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def record(
|
|
100
|
+
demo_file: Path = typer.Argument(..., help="YAML file to write."),
|
|
101
|
+
title: Optional[str] = typer.Option(None, "--title", "-t"),
|
|
102
|
+
shell: str = typer.Option("bash", "--shell"),
|
|
103
|
+
append: bool = typer.Option(False, "--append", "-a", help="Add steps to an existing file."),
|
|
104
|
+
):
|
|
105
|
+
"""Do the demo once in a shell; every command (and its output) becomes a step."""
|
|
106
|
+
from .record import Recorder
|
|
107
|
+
|
|
108
|
+
if demo_file.exists() and not append:
|
|
109
|
+
if not typer.confirm(f"{demo_file} exists. Overwrite?"):
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
Recorder(str(demo_file), title=title, shell=shell, append=append).run()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def replay(
|
|
116
|
+
cast_file: Path = typer.Argument(..., help="asciinema v2 .cast file."),
|
|
117
|
+
speed: float = typer.Option(1.0, "--speed", help="Playback speed multiplier."),
|
|
118
|
+
max_idle: float = typer.Option(1.0, "--max-idle", help="Cap pauses at this many seconds (0 = no cap)."),
|
|
119
|
+
):
|
|
120
|
+
"""Play back a recorded .cast session in the terminal."""
|
|
121
|
+
from .cast import replay as do_replay
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
do_replay(str(cast_file), speed=speed, max_idle=max_idle or None)
|
|
125
|
+
except (ValueError, FileNotFoundError) as e:
|
|
126
|
+
err.print(f"[red]{e}[/]")
|
|
127
|
+
raise typer.Exit(2)
|
|
128
|
+
except KeyboardInterrupt:
|
|
129
|
+
pass
|
|
130
|
+
sys.stdout.write("\x1b[0m\n")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def render(
|
|
135
|
+
demo_file: Path = typer.Argument(..., help="Demo YAML file."),
|
|
136
|
+
output: Path = typer.Option(..., "--output", "-o", help="The .cast file to write."),
|
|
137
|
+
var: Optional[List[str]] = VarOpt,
|
|
138
|
+
offline: bool = typer.Option(False, "--offline", help="Use recorded output instead of running commands."),
|
|
139
|
+
delay: float = typer.Option(1.0, "--delay", help="Seconds to wait before each Enter."),
|
|
140
|
+
width: int = typer.Option(100, "--width"),
|
|
141
|
+
height: int = typer.Option(30, "--height"),
|
|
142
|
+
):
|
|
143
|
+
"""Present a demo unattended and save it as an asciinema .cast (no terminal needed)."""
|
|
144
|
+
from .render import render_cast
|
|
145
|
+
|
|
146
|
+
demo = _load(demo_file, var)
|
|
147
|
+
r = render_cast(demo, str(output), offline=offline, delay=delay, width=width, height=height)
|
|
148
|
+
for w in r.warnings:
|
|
149
|
+
err.print(f"[yellow]{w}[/]")
|
|
150
|
+
err.print(f"[green]wrote {output}[/] — {r.steps} steps, {r.failures} failed. "
|
|
151
|
+
f"Play it with: showrun replay {output}")
|
|
152
|
+
raise typer.Exit(1 if r.failures else 0)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command()
|
|
156
|
+
def mcp():
|
|
157
|
+
"""Run an MCP server (stdio) so AI agents can write, check and render demos."""
|
|
158
|
+
try:
|
|
159
|
+
from .mcp_server import main
|
|
160
|
+
except ImportError:
|
|
161
|
+
err.print("[red]the MCP server needs the `mcp` extra:[/] pip install 'showrun-cli[mcp]' (Python 3.10+)")
|
|
162
|
+
raise typer.Exit(2)
|
|
163
|
+
main()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command()
|
|
167
|
+
def notes(demo_file: Path = typer.Argument(..., help="The demo being presented.")):
|
|
168
|
+
"""Presenter view: run in a second terminal to see the current step, notes and what's next."""
|
|
169
|
+
from .notes import default_notes_path, watch
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
watch(default_notes_path(str(demo_file)))
|
|
173
|
+
except KeyboardInterrupt:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def export(
|
|
179
|
+
demo_file: Path = typer.Argument(...),
|
|
180
|
+
var: Optional[List[str]] = VarOpt,
|
|
181
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md (runbook) or sh (plain script)."),
|
|
182
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o"),
|
|
183
|
+
include_hidden: bool = typer.Option(False, "--include-hidden"),
|
|
184
|
+
):
|
|
185
|
+
"""Export as a Markdown runbook or a plain shell script."""
|
|
186
|
+
from .export import to_markdown, to_script
|
|
187
|
+
|
|
188
|
+
demo = _load(demo_file, var)
|
|
189
|
+
if fmt == "md":
|
|
190
|
+
text = to_markdown(demo, include_hidden=include_hidden)
|
|
191
|
+
elif fmt == "sh":
|
|
192
|
+
text = to_script(demo)
|
|
193
|
+
else:
|
|
194
|
+
raise typer.BadParameter("format must be md or sh")
|
|
195
|
+
if output:
|
|
196
|
+
output.write_text(text)
|
|
197
|
+
err.print(f"[green]wrote {output}[/]")
|
|
198
|
+
else:
|
|
199
|
+
sys.stdout.write(text)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def validate(demo_file: Path = typer.Argument(...), var: Optional[List[str]] = VarOpt):
|
|
204
|
+
"""Check the file parses and list its steps."""
|
|
205
|
+
demo = _load(demo_file, var)
|
|
206
|
+
t = Table(title=demo.title or str(demo_file), header_style="bold")
|
|
207
|
+
for col in ("#", "kind", "step"):
|
|
208
|
+
t.add_column(col)
|
|
209
|
+
for n, st in enumerate(demo.steps, 1):
|
|
210
|
+
kind = ("hidden" if st.hidden else "bg" if st.background else "canned" if not st.execute
|
|
211
|
+
else "run" if st.run else "wait" if st.wait_for else "say" if st.say else "pause")
|
|
212
|
+
t.add_row(str(n), kind, st.label())
|
|
213
|
+
Console().print(t)
|
|
214
|
+
Console().print(f"[green]ok[/] — {len(demo.steps)} steps, {len(demo.setup)} setup, {len(demo.cleanup)} cleanup")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def init(path: Path = typer.Argument(Path("demo.yaml"))):
|
|
219
|
+
"""Write a starter demo file that shows off the features."""
|
|
220
|
+
from importlib.resources import files
|
|
221
|
+
|
|
222
|
+
if path.exists():
|
|
223
|
+
err.print(f"[red]{path} already exists[/]")
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
path.write_text(files("showrun").joinpath("starter.yaml").read_text())
|
|
226
|
+
err.print(f"[green]wrote {path}[/] — try: showrun check {path} && showrun play {path}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command()
|
|
230
|
+
def version():
|
|
231
|
+
"""Print the version."""
|
|
232
|
+
print(__version__)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__": # pragma: no cover
|
|
236
|
+
app()
|
showrun/export.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Export a demo as a Markdown runbook or a plain shell script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .model import Demo
|
|
8
|
+
from .player import _strip_ansi
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_markdown(demo: Demo, include_hidden: bool = False, include_output: bool = True) -> str:
|
|
12
|
+
out: List[str] = [f"# {demo.title or 'Demo'}", ""]
|
|
13
|
+
if demo.setup and include_hidden:
|
|
14
|
+
out += ["## Setup", "", "```bash", *demo.setup, "```", ""]
|
|
15
|
+
for step in demo.steps:
|
|
16
|
+
if step.hidden and not include_hidden:
|
|
17
|
+
continue
|
|
18
|
+
if step.title:
|
|
19
|
+
out += [f"## {step.title}", ""]
|
|
20
|
+
if step.say:
|
|
21
|
+
out += [step.say.strip(), ""]
|
|
22
|
+
if step.run:
|
|
23
|
+
label = " (hidden)" if step.hidden else (" (background)" if step.background else "")
|
|
24
|
+
if label:
|
|
25
|
+
out.append(f"*{label.strip(' ()')}:*")
|
|
26
|
+
out += ["```bash", step.run.rstrip(), "```", ""]
|
|
27
|
+
sample = step.output if not step.execute else step.recorded_output
|
|
28
|
+
if include_output and sample:
|
|
29
|
+
out += ["```text", _strip_ansi(sample).rstrip(), "```", ""]
|
|
30
|
+
if step.wait_for:
|
|
31
|
+
out += [f"_Wait until `{step.wait_for}` succeeds._", ""]
|
|
32
|
+
if demo.cleanup and include_hidden:
|
|
33
|
+
out += ["## Cleanup", "", "```bash", *demo.cleanup, "```", ""]
|
|
34
|
+
return "\n".join(out).rstrip() + "\n"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def to_script(demo: Demo) -> str:
|
|
38
|
+
out = ["#!/usr/bin/env bash", f"# {demo.title or 'demo'} — exported by showrun", "set -e", ""]
|
|
39
|
+
for cmd in demo.setup:
|
|
40
|
+
out.append(cmd)
|
|
41
|
+
for step in demo.steps:
|
|
42
|
+
if step.say:
|
|
43
|
+
out += ["", "# " + step.say.strip().replace("\n", "\n# ")]
|
|
44
|
+
if step.run and step.execute:
|
|
45
|
+
out.append(step.run.rstrip() + (" &" if step.background else ""))
|
|
46
|
+
if step.wait_for:
|
|
47
|
+
out.append(f"until {step.wait_for} >/dev/null 2>&1; do sleep 0.5; done")
|
|
48
|
+
if demo.cleanup:
|
|
49
|
+
out += ["", "# cleanup", *demo.cleanup]
|
|
50
|
+
return "\n".join(out) + "\n"
|
showrun/keys.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Keypress sources: the real terminal, a scripted list (tests), or auto-advance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import select
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import Iterable, Optional
|
|
10
|
+
|
|
11
|
+
ENTER = "enter"
|
|
12
|
+
ESC = "esc"
|
|
13
|
+
BACKSPACE = "backspace"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalise(ch: str) -> str:
|
|
17
|
+
if ch in ("\r", "\n"):
|
|
18
|
+
return ENTER
|
|
19
|
+
if ch == "\x1b":
|
|
20
|
+
return ESC
|
|
21
|
+
if ch in ("\x7f", "\x08"):
|
|
22
|
+
return BACKSPACE
|
|
23
|
+
if ch == "\x03":
|
|
24
|
+
raise KeyboardInterrupt
|
|
25
|
+
return ch
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KeySource:
|
|
29
|
+
def read(self, timeout: Optional[float] = None) -> Optional[str]: # pragma: no cover
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def poll(self) -> Optional[str]:
|
|
33
|
+
return self.read(timeout=0)
|
|
34
|
+
|
|
35
|
+
def suspend(self): # context used while handing the tty to a child
|
|
36
|
+
return _Null()
|
|
37
|
+
|
|
38
|
+
def __enter__(self):
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __exit__(self, *exc):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Null:
|
|
46
|
+
def __enter__(self):
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *exc):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TerminalKeys(KeySource):
|
|
54
|
+
"""Reads single keys from the controlling terminal in cbreak mode."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, stream=None):
|
|
57
|
+
self.stream = stream or sys.stdin
|
|
58
|
+
self.fd = self.stream.fileno()
|
|
59
|
+
self._saved = None
|
|
60
|
+
|
|
61
|
+
def __enter__(self):
|
|
62
|
+
import termios
|
|
63
|
+
import tty
|
|
64
|
+
|
|
65
|
+
if os.isatty(self.fd):
|
|
66
|
+
self._saved = termios.tcgetattr(self.fd)
|
|
67
|
+
tty.setcbreak(self.fd)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exc):
|
|
71
|
+
import termios
|
|
72
|
+
|
|
73
|
+
if self._saved is not None:
|
|
74
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self._saved)
|
|
75
|
+
self._saved = None
|
|
76
|
+
|
|
77
|
+
def suspend(self):
|
|
78
|
+
outer = self
|
|
79
|
+
|
|
80
|
+
class _Suspend:
|
|
81
|
+
def __enter__(self_inner):
|
|
82
|
+
outer.__exit__()
|
|
83
|
+
|
|
84
|
+
def __exit__(self_inner, *exc):
|
|
85
|
+
outer.__enter__()
|
|
86
|
+
|
|
87
|
+
return _Suspend()
|
|
88
|
+
|
|
89
|
+
def read(self, timeout: Optional[float] = None) -> Optional[str]:
|
|
90
|
+
r, _, _ = select.select([self.fd], [], [], timeout)
|
|
91
|
+
if not r:
|
|
92
|
+
return None
|
|
93
|
+
ch = os.read(self.fd, 1).decode(errors="ignore")
|
|
94
|
+
if ch == "\x1b":
|
|
95
|
+
# Swallow the rest of an escape sequence (arrow keys etc.).
|
|
96
|
+
seq = ""
|
|
97
|
+
while select.select([self.fd], [], [], 0.01)[0]:
|
|
98
|
+
seq += os.read(self.fd, 1).decode(errors="ignore")
|
|
99
|
+
return {"[C": "right", "[D": "left", "[A": "up", "[B": "down"}.get(seq, ESC)
|
|
100
|
+
return _normalise(ch)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ScriptedKeys(KeySource):
|
|
104
|
+
"""Feeds a fixed sequence of keys; returns ENTER forever (or 'q') when exhausted."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, keys: Iterable[str], then: str = ENTER):
|
|
107
|
+
self.keys = list(keys)
|
|
108
|
+
self.then = then
|
|
109
|
+
|
|
110
|
+
def read(self, timeout: Optional[float] = None) -> Optional[str]:
|
|
111
|
+
if self.keys:
|
|
112
|
+
return self.keys.pop(0)
|
|
113
|
+
return self.then
|
|
114
|
+
|
|
115
|
+
def poll(self) -> Optional[str]:
|
|
116
|
+
return None # never interrupts typing
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AutoKeys(KeySource):
|
|
120
|
+
"""Presses Enter by itself after *delay* seconds, but real keys still work."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, delay: float, inner: Optional[KeySource] = None):
|
|
123
|
+
self.delay = delay
|
|
124
|
+
self.inner = inner
|
|
125
|
+
|
|
126
|
+
def __enter__(self):
|
|
127
|
+
if self.inner:
|
|
128
|
+
self.inner.__enter__()
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *exc):
|
|
132
|
+
if self.inner:
|
|
133
|
+
self.inner.__exit__(*exc)
|
|
134
|
+
|
|
135
|
+
def suspend(self):
|
|
136
|
+
return self.inner.suspend() if self.inner else _Null()
|
|
137
|
+
|
|
138
|
+
def read(self, timeout: Optional[float] = None) -> Optional[str]:
|
|
139
|
+
if timeout == 0:
|
|
140
|
+
return self.inner.read(0) if self.inner else None
|
|
141
|
+
end = time.monotonic() + self.delay
|
|
142
|
+
while time.monotonic() < end:
|
|
143
|
+
if self.inner:
|
|
144
|
+
k = self.inner.read(timeout=min(0.05, max(0, end - time.monotonic())))
|
|
145
|
+
if k:
|
|
146
|
+
return k
|
|
147
|
+
else:
|
|
148
|
+
time.sleep(min(0.05, end - time.monotonic()))
|
|
149
|
+
return ENTER
|