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/mcp_server.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""MCP server: lets AI agents write, validate, rehearse, export and render showrun demos.
|
|
2
|
+
|
|
3
|
+
Run it with `showrun mcp` (stdio). Needs the `mcp` extra: pip install 'showrun-cli[mcp]'.
|
|
4
|
+
Note that check_demo and render_demo run the demo's shell commands on this machine, just as
|
|
5
|
+
`showrun check` does, so an MCP client should ask before calling them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
try: # mcp >= 2
|
|
18
|
+
from mcp.server.mcpserver import MCPServer as _Server
|
|
19
|
+
except ImportError: # mcp 1.x
|
|
20
|
+
from mcp.server.fastmcp import FastMCP as _Server
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .model import Demo, DemoError, load_demo, parse_demo
|
|
24
|
+
|
|
25
|
+
INSTRUCTIONS = """\
|
|
26
|
+
showrun turns a YAML file of shell steps into a keypress-driven terminal presentation.
|
|
27
|
+
Typical agent workflow:
|
|
28
|
+
1. read the format with `demo_format` (or start from `starter_demo`),
|
|
29
|
+
2. write the YAML with `write_demo` (it is validated before it is saved),
|
|
30
|
+
3. rehearse it with `check_demo` (runs every command and reports failures),
|
|
31
|
+
4. optionally `render_demo` to produce an asciinema .cast, and `read_cast` to see it as text.
|
|
32
|
+
The human then presents it with `showrun play <file>` (Enter advances).
|
|
33
|
+
Most tools accept either `path` (a YAML file) or `yaml_text` (inline YAML)."""
|
|
34
|
+
|
|
35
|
+
server = _Server("showrun", instructions=INSTRUCTIONS)
|
|
36
|
+
|
|
37
|
+
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\r")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _demo(path: Optional[str], yaml_text: Optional[str], variables: Optional[Dict[str, str]]) -> Demo:
|
|
41
|
+
if bool(path) == bool(yaml_text):
|
|
42
|
+
raise ValueError("pass exactly one of `path` or `yaml_text`")
|
|
43
|
+
if path:
|
|
44
|
+
return load_demo(os.path.expanduser(path), variables)
|
|
45
|
+
return parse_demo(yaml_text or "", variables, base_dir=Path.cwd())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _kind(st) -> str:
|
|
49
|
+
return ("hidden" if st.hidden else "background" if st.background else "canned" if not st.execute
|
|
50
|
+
else "run" if st.run else "wait" if st.wait_for else "say" if st.say else "pause")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _steps(demo: Demo) -> List[Dict[str, Any]]:
|
|
54
|
+
return [{"n": n, "kind": _kind(st), "label": st.label()} for n, st in enumerate(demo.steps, 1)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@server.tool()
|
|
58
|
+
def demo_format() -> str:
|
|
59
|
+
"""Reference for the demo YAML format: settings, vars, setup/cleanup and every step option."""
|
|
60
|
+
from .model import Settings, Step
|
|
61
|
+
|
|
62
|
+
def fields(model) -> Dict[str, str]:
|
|
63
|
+
return {name: str(f.annotation).replace("typing.", "") + (f" — {f.description}" if f.description else "")
|
|
64
|
+
for name, f in model.model_fields.items()}
|
|
65
|
+
|
|
66
|
+
return json.dumps({
|
|
67
|
+
"top_level": {"title": "str", "settings": "mapping (see settings)", "vars": "mapping; use as {{name}}",
|
|
68
|
+
"setup": "list of commands run silently before step 1",
|
|
69
|
+
"cleanup": "list of commands that always run at the end", "steps": "list of steps"},
|
|
70
|
+
"settings": fields(Settings),
|
|
71
|
+
"step": fields(Step),
|
|
72
|
+
"notes": ["A step can be a plain string, which means `run: <string>`.",
|
|
73
|
+
"execute: false + output: shows canned output without running the command.",
|
|
74
|
+
"background: true + wait_for: <cmd> starts a service and waits until <cmd> exits 0.",
|
|
75
|
+
"expect_output is a regex checked by check_demo; expect_exit may be an int or 'any'."],
|
|
76
|
+
"example": files("showrun").joinpath("starter.yaml").read_text(),
|
|
77
|
+
}, indent=2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@server.tool()
|
|
81
|
+
def starter_demo() -> str:
|
|
82
|
+
"""A complete starter demo (YAML) that uses most features; a good template to edit."""
|
|
83
|
+
return files("showrun").joinpath("starter.yaml").read_text()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@server.tool()
|
|
87
|
+
def validate_demo(path: Optional[str] = None, yaml_text: Optional[str] = None,
|
|
88
|
+
variables: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
89
|
+
"""Parse a demo (from `path` or `yaml_text`) and list its steps. Runs nothing."""
|
|
90
|
+
try:
|
|
91
|
+
d = _demo(path, yaml_text, variables)
|
|
92
|
+
except DemoError as e:
|
|
93
|
+
return {"ok": False, "error": str(e)}
|
|
94
|
+
return {"ok": True, "title": d.title, "steps": _steps(d), "setup": len(d.setup), "cleanup": len(d.cleanup)}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@server.tool()
|
|
98
|
+
def write_demo(path: str, yaml_text: str, overwrite: bool = False) -> Dict[str, Any]:
|
|
99
|
+
"""Validate `yaml_text` and save it to `path`. Refuses to replace an existing file unless overwrite=true."""
|
|
100
|
+
p = Path(os.path.expanduser(path))
|
|
101
|
+
if p.exists() and not overwrite:
|
|
102
|
+
return {"ok": False, "error": f"{p} exists; pass overwrite=true to replace it"}
|
|
103
|
+
try:
|
|
104
|
+
d = parse_demo(yaml_text, base_dir=p.parent)
|
|
105
|
+
except DemoError as e:
|
|
106
|
+
return {"ok": False, "error": str(e)}
|
|
107
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
p.write_text(yaml_text)
|
|
109
|
+
return {"ok": True, "path": str(p.resolve()), "steps": _steps(d),
|
|
110
|
+
"next": f"check it with check_demo, then present with: showrun play {p}"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@server.tool()
|
|
114
|
+
def check_demo(path: Optional[str] = None, yaml_text: Optional[str] = None,
|
|
115
|
+
variables: Optional[Dict[str, str]] = None, fail_fast: bool = False) -> Dict[str, Any]:
|
|
116
|
+
"""Rehearse a demo: RUNS every command in a real shell and reports failures, timeouts and
|
|
117
|
+
missing expected output (same as `showrun check`). Canned steps are skipped."""
|
|
118
|
+
from .check import rehearse
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
d = _demo(path, yaml_text, variables)
|
|
122
|
+
except DemoError as e:
|
|
123
|
+
return {"ok": False, "error": str(e)}
|
|
124
|
+
results = rehearse(d, stop_on_fail=fail_fast)
|
|
125
|
+
rows = [{"n": r.n, "step": r.label, "status": r.status, "seconds": round(r.duration, 2),
|
|
126
|
+
**({"detail": r.detail} if r.detail else {})} for r in results]
|
|
127
|
+
fails = sum(r.status == "fail" for r in results)
|
|
128
|
+
return {"ok": fails == 0, "failing": fails, "results": rows}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@server.tool()
|
|
132
|
+
def export_demo(path: Optional[str] = None, yaml_text: Optional[str] = None, format: str = "md",
|
|
133
|
+
include_hidden: bool = False, variables: Optional[Dict[str, str]] = None) -> str:
|
|
134
|
+
"""Export a demo as a Markdown runbook (format='md') or a plain bash script (format='sh')."""
|
|
135
|
+
from .export import to_markdown, to_script
|
|
136
|
+
|
|
137
|
+
d = _demo(path, yaml_text, variables)
|
|
138
|
+
if format == "md":
|
|
139
|
+
return to_markdown(d, include_hidden=include_hidden)
|
|
140
|
+
if format == "sh":
|
|
141
|
+
return to_script(d)
|
|
142
|
+
raise ValueError("format must be 'md' or 'sh'")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@server.tool()
|
|
146
|
+
def render_demo(output: str, path: Optional[str] = None, yaml_text: Optional[str] = None,
|
|
147
|
+
offline: bool = False, delay: float = 1.0, width: int = 100, height: int = 30,
|
|
148
|
+
variables: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
149
|
+
"""Present the demo unattended and save it as an asciinema v2 .cast at `output`. RUNS the
|
|
150
|
+
commands unless offline=true (then recorded/canned output is shown instead)."""
|
|
151
|
+
from .render import render_cast
|
|
152
|
+
|
|
153
|
+
d = _demo(path, yaml_text, variables)
|
|
154
|
+
out = Path(os.path.expanduser(output))
|
|
155
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
r = render_cast(d, str(out), offline=offline, delay=delay, width=width, height=height)
|
|
157
|
+
return {"ok": r.failures == 0, "path": str(out.resolve()), "steps": r.steps, "failures": r.failures,
|
|
158
|
+
"warnings": r.warnings, "replay": f"showrun replay {out}"}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@server.tool()
|
|
162
|
+
def read_cast(path: str, max_chars: int = 20000) -> str:
|
|
163
|
+
"""Read a .cast recording as plain text, with a `── step: … ──` line at each step marker."""
|
|
164
|
+
lines = Path(os.path.expanduser(path)).read_text(encoding="utf-8").splitlines()
|
|
165
|
+
out: List[str] = []
|
|
166
|
+
for line in lines[1:]:
|
|
167
|
+
if not line.strip():
|
|
168
|
+
continue
|
|
169
|
+
_, kind, data = json.loads(line)
|
|
170
|
+
if kind == "m":
|
|
171
|
+
out.append(f"\n── step: {data} ──\n")
|
|
172
|
+
elif kind == "o":
|
|
173
|
+
out.append(_ANSI.sub("", data))
|
|
174
|
+
text = "".join(out)
|
|
175
|
+
return text if len(text) <= max_chars else text[:max_chars] + f"\n… ({len(text) - max_chars} more chars)"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def guide_text() -> str:
|
|
179
|
+
"""The project README (shipped as the package's long description)."""
|
|
180
|
+
from importlib.metadata import PackageNotFoundError, metadata
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
return metadata("showrun-cli").get_payload() or metadata("showrun-cli")["Description"] or ""
|
|
184
|
+
except PackageNotFoundError:
|
|
185
|
+
return "showrun guide: https://github.com/honnuanand/showrun#readme"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@server.resource("showrun://guide", name="showrun guide", mime_type="text/markdown",
|
|
189
|
+
description="The one-page showrun guide: commands, keys, demo format, MCP tools.")
|
|
190
|
+
def guide() -> str:
|
|
191
|
+
return guide_text()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@server.tool()
|
|
195
|
+
def showrun_version() -> str:
|
|
196
|
+
"""The installed showrun version."""
|
|
197
|
+
return __version__
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main() -> None:
|
|
201
|
+
server.run()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__": # pragma: no cover
|
|
205
|
+
main()
|
showrun/model.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Demo script format: YAML -> validated pydantic models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
12
|
+
|
|
13
|
+
VAR_PATTERN = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DemoError(Exception):
|
|
17
|
+
"""Raised for invalid demo files."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Settings(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
prompt: str = "[bold green]demo[/] [bold blue]{cwd}[/] [bold]$[/] "
|
|
24
|
+
typing_speed: float = Field(0.04, ge=0, description="Seconds per character.")
|
|
25
|
+
jitter: float = Field(0.5, ge=0, le=1, description="Random +/- fraction of typing_speed.")
|
|
26
|
+
wait_before_typing: bool = False
|
|
27
|
+
shell: str = "bash"
|
|
28
|
+
cwd: Optional[str] = None
|
|
29
|
+
env: Dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
syntax_highlight: bool = True
|
|
31
|
+
theme: str = "monokai"
|
|
32
|
+
typo_rate: float = Field(0.0, ge=0, le=0.3, description="Chance per letter of a fake typo.")
|
|
33
|
+
timeout: Optional[float] = Field(None, gt=0, description="Default per-step timeout (s).")
|
|
34
|
+
highlight_style: str = "bold black on yellow"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Step(BaseModel):
|
|
38
|
+
"""One demo step.
|
|
39
|
+
|
|
40
|
+
Kinds of step:
|
|
41
|
+
- run a command shown to the audience (default)
|
|
42
|
+
- run a command silently (hidden: true)
|
|
43
|
+
- show a command and canned output without running it (execute: false + output)
|
|
44
|
+
- narration only (say without run)
|
|
45
|
+
- pause until a keypress (pause: true)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
run: Optional[str] = None
|
|
51
|
+
say: Optional[str] = None
|
|
52
|
+
hidden: bool = False
|
|
53
|
+
type: bool = True
|
|
54
|
+
wait: bool = True
|
|
55
|
+
execute: bool = True
|
|
56
|
+
output: Optional[str] = None
|
|
57
|
+
expect_exit: Union[int, str] = 0 # int, or "any"
|
|
58
|
+
timeout: Optional[float] = Field(None, gt=0)
|
|
59
|
+
speed: Optional[float] = Field(None, ge=0)
|
|
60
|
+
clear: bool = False
|
|
61
|
+
pause: bool = False
|
|
62
|
+
title: Optional[str] = None
|
|
63
|
+
notes: Optional[str] = None # presenter-only notes
|
|
64
|
+
typos: Optional[bool] = None # override settings.typo_rate on/off
|
|
65
|
+
highlight: List[str] = Field(default_factory=list) # regexes to highlight in output
|
|
66
|
+
expect_output: Optional[str] = None # regex checked in rehearsal (and flagged live)
|
|
67
|
+
background: bool = False # start command in background (servers etc.)
|
|
68
|
+
wait_for: Optional[str] = None # poll until this command exits 0
|
|
69
|
+
wait_timeout: float = Field(30, gt=0)
|
|
70
|
+
recorded_output: Optional[str] = None # captured by `showrun record`; used by --offline
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _coerce(cls, data: Any) -> Any:
|
|
75
|
+
if isinstance(data, dict) and isinstance(data.get("highlight"), str):
|
|
76
|
+
data = dict(data)
|
|
77
|
+
data["highlight"] = [data["highlight"]]
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def _check(self) -> "Step":
|
|
82
|
+
if not (self.run or self.say or self.pause or self.clear or self.title or self.wait_for):
|
|
83
|
+
raise ValueError("step needs at least one of: run, say, pause, clear, title, wait_for")
|
|
84
|
+
if self.background and not self.run:
|
|
85
|
+
raise ValueError("background steps need a 'run' command")
|
|
86
|
+
if self.background and self.output is not None:
|
|
87
|
+
raise ValueError("background steps can't have canned output")
|
|
88
|
+
for pat in self.highlight + ([self.expect_output] if self.expect_output else []):
|
|
89
|
+
try:
|
|
90
|
+
re.compile(pat)
|
|
91
|
+
except re.error as e:
|
|
92
|
+
raise ValueError(f"bad regex {pat!r}: {e}")
|
|
93
|
+
if isinstance(self.expect_exit, str) and self.expect_exit != "any":
|
|
94
|
+
raise ValueError("expect_exit must be an integer or 'any'")
|
|
95
|
+
if self.output is not None and self.execute:
|
|
96
|
+
raise ValueError("'output' (canned output) requires execute: false")
|
|
97
|
+
if self.hidden and not self.run:
|
|
98
|
+
raise ValueError("hidden steps need a 'run' command")
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def visible(self) -> bool:
|
|
103
|
+
return not self.hidden
|
|
104
|
+
|
|
105
|
+
def exit_ok(self, code: int) -> bool:
|
|
106
|
+
return self.expect_exit == "any" or code == self.expect_exit
|
|
107
|
+
|
|
108
|
+
def label(self) -> str:
|
|
109
|
+
if self.title:
|
|
110
|
+
return self.title
|
|
111
|
+
if self.run:
|
|
112
|
+
first = self.run.strip().splitlines()[0]
|
|
113
|
+
return first if len(first) <= 60 else first[:57] + "..."
|
|
114
|
+
if self.say:
|
|
115
|
+
return "say: " + self.say.strip().splitlines()[0][:50]
|
|
116
|
+
return "pause"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Demo(BaseModel):
|
|
120
|
+
model_config = ConfigDict(extra="forbid")
|
|
121
|
+
|
|
122
|
+
title: Optional[str] = None
|
|
123
|
+
settings: Settings = Field(default_factory=Settings)
|
|
124
|
+
vars: Dict[str, Any] = Field(default_factory=dict)
|
|
125
|
+
setup: List[str] = Field(default_factory=list)
|
|
126
|
+
cleanup: List[str] = Field(default_factory=list)
|
|
127
|
+
steps: List[Step]
|
|
128
|
+
|
|
129
|
+
@model_validator(mode="before")
|
|
130
|
+
@classmethod
|
|
131
|
+
def _normalise_steps(cls, data: Any) -> Any:
|
|
132
|
+
# Allow plain strings as shorthand for {run: "..."}.
|
|
133
|
+
if isinstance(data, dict) and isinstance(data.get("steps"), list):
|
|
134
|
+
data = dict(data)
|
|
135
|
+
data["steps"] = [{"run": s} if isinstance(s, str) else s for s in data["steps"]]
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def substitute(text: str, variables: Dict[str, Any]) -> str:
|
|
140
|
+
def repl(m: re.Match) -> str:
|
|
141
|
+
name = m.group(1)
|
|
142
|
+
# Unknown names are left alone so Go/Jinja templates in commands
|
|
143
|
+
# (e.g. docker --format '{{ json . }}') pass through untouched.
|
|
144
|
+
return str(variables[name]) if name in variables else m.group(0)
|
|
145
|
+
|
|
146
|
+
return VAR_PATTERN.sub(repl, text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _sub_all(obj: Any, variables: Dict[str, Any]) -> Any:
|
|
150
|
+
if isinstance(obj, str):
|
|
151
|
+
return substitute(obj, variables)
|
|
152
|
+
if isinstance(obj, list):
|
|
153
|
+
return [_sub_all(x, variables) for x in obj]
|
|
154
|
+
if isinstance(obj, dict):
|
|
155
|
+
return {k: _sub_all(v, variables) for k, v in obj.items()}
|
|
156
|
+
return obj
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_demo(text: str, overrides: Optional[Dict[str, str]] = None, base_dir: Optional[Path] = None) -> Demo:
|
|
160
|
+
try:
|
|
161
|
+
raw = yaml.safe_load(text)
|
|
162
|
+
except yaml.YAMLError as e:
|
|
163
|
+
raise DemoError(f"YAML error: {e}") from e
|
|
164
|
+
if not isinstance(raw, dict):
|
|
165
|
+
raise DemoError("demo file must be a mapping with a 'steps' list")
|
|
166
|
+
|
|
167
|
+
# Resolve variables: file defaults < SHOWRUN_VAR_<NAME> env < CLI overrides.
|
|
168
|
+
variables: Dict[str, Any] = dict(raw.get("vars") or {})
|
|
169
|
+
for key in list(variables):
|
|
170
|
+
env_val = os.environ.get(f"SHOWRUN_VAR_{key.upper()}")
|
|
171
|
+
if env_val is not None:
|
|
172
|
+
variables[key] = env_val
|
|
173
|
+
variables.update(overrides or {})
|
|
174
|
+
raw["vars"] = variables
|
|
175
|
+
|
|
176
|
+
for section in ("steps", "setup", "cleanup", "settings", "title"):
|
|
177
|
+
if section in raw:
|
|
178
|
+
raw[section] = _sub_all(raw[section], variables)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
demo = Demo.model_validate(raw)
|
|
182
|
+
except ValidationError as e:
|
|
183
|
+
raise DemoError(_format_validation(e)) from e
|
|
184
|
+
|
|
185
|
+
if base_dir is not None:
|
|
186
|
+
cwd = demo.settings.cwd
|
|
187
|
+
demo.settings.cwd = str((base_dir / os.path.expanduser(cwd)).resolve()) if cwd else str(base_dir.resolve())
|
|
188
|
+
return demo
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_demo(path: Union[str, Path], overrides: Optional[Dict[str, str]] = None) -> Demo:
|
|
192
|
+
p = Path(path)
|
|
193
|
+
if not p.exists():
|
|
194
|
+
raise DemoError(f"file not found: {p}")
|
|
195
|
+
return parse_demo(p.read_text(), overrides, base_dir=p.parent)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _format_validation(e: ValidationError) -> str:
|
|
199
|
+
lines = ["invalid demo file:"]
|
|
200
|
+
for err in e.errors():
|
|
201
|
+
loc = ".".join(str(x) for x in err["loc"])
|
|
202
|
+
lines.append(f" {loc or '<root>'}: {err['msg']}")
|
|
203
|
+
return "\n".join(lines)
|
showrun/notes.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Presenter notes: the player publishes its position to a small JSON file and
|
|
2
|
+
`showrun notes` renders it live in a second terminal (or on a second screen)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from rich.console import Console, Group
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.syntax import Syntax
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from .model import Demo
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_notes_path(demo_path: str) -> str:
|
|
23
|
+
stem = Path(demo_path).resolve().as_posix().replace("/", "_").strip("_")
|
|
24
|
+
return os.path.join(tempfile.gettempdir(), f"showrun-notes-{stem}.json")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotesPublisher:
|
|
28
|
+
def __init__(self, path: Optional[str], demo: Demo):
|
|
29
|
+
self.path = path
|
|
30
|
+
self.demo = demo
|
|
31
|
+
self.started = time.time()
|
|
32
|
+
|
|
33
|
+
def publish(self, index: int, state: str = "ready") -> None:
|
|
34
|
+
if not self.path:
|
|
35
|
+
return
|
|
36
|
+
steps = self.demo.steps
|
|
37
|
+
|
|
38
|
+
def info(i: int) -> Optional[dict]:
|
|
39
|
+
if 0 <= i < len(steps):
|
|
40
|
+
s = steps[i]
|
|
41
|
+
return {"n": i + 1, "label": s.label(), "run": s.run, "say": s.say,
|
|
42
|
+
"notes": s.notes, "hidden": s.hidden}
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
nxt = next((j for j in range(index + 1, len(steps)) if not steps[j].hidden), -1)
|
|
46
|
+
payload = {"title": self.demo.title, "total": len(steps), "state": state,
|
|
47
|
+
"started": self.started, "current": info(index), "next": info(nxt)}
|
|
48
|
+
tmp = self.path + ".tmp"
|
|
49
|
+
with open(tmp, "w") as f:
|
|
50
|
+
json.dump(payload, f)
|
|
51
|
+
os.replace(tmp, self.path)
|
|
52
|
+
|
|
53
|
+
def finish(self) -> None:
|
|
54
|
+
self.publish(len(self.demo.steps), state="finished")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _render(data: dict) -> Panel:
|
|
58
|
+
elapsed = int(time.time() - data.get("started", time.time()))
|
|
59
|
+
cur = data.get("current")
|
|
60
|
+
parts = []
|
|
61
|
+
header = Text()
|
|
62
|
+
header.append(data.get("title") or "showrun", style="bold")
|
|
63
|
+
header.append(f" ⏱ {elapsed // 60:02d}:{elapsed % 60:02d}", style="cyan")
|
|
64
|
+
if data.get("state") == "finished":
|
|
65
|
+
header.append(" ✓ finished", style="bold green")
|
|
66
|
+
parts.append(header)
|
|
67
|
+
if cur:
|
|
68
|
+
parts.append(Text(f"\nNOW step {cur['n']}/{data['total']}: {cur['label']}", style="bold yellow"))
|
|
69
|
+
if cur.get("run"):
|
|
70
|
+
parts.append(Syntax(cur["run"], "bash", theme="monokai", background_color="default"))
|
|
71
|
+
if cur.get("say"):
|
|
72
|
+
parts.append(Text("Say: " + cur["say"], style="italic"))
|
|
73
|
+
if cur.get("notes"):
|
|
74
|
+
parts.append(Panel(cur["notes"], title="notes", border_style="magenta"))
|
|
75
|
+
nxt = data.get("next")
|
|
76
|
+
if nxt:
|
|
77
|
+
parts.append(Text(f"\nNEXT step {nxt['n']}: {nxt['label']}", style="dim"))
|
|
78
|
+
if nxt.get("notes"):
|
|
79
|
+
parts.append(Text(" " + nxt["notes"].splitlines()[0], style="dim magenta"))
|
|
80
|
+
return Panel(Group(*parts), title="presenter notes", border_style="blue")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def watch(path: str, console: Optional[Console] = None) -> None:
|
|
84
|
+
console = console or Console()
|
|
85
|
+
console.print(f"[dim]watching {path} — start `showrun play` in another terminal. Ctrl-C to quit.[/]")
|
|
86
|
+
last = None
|
|
87
|
+
with Live(Panel("waiting for the demo to start…"), console=console, refresh_per_second=4) as live:
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
data = json.loads(Path(path).read_text())
|
|
91
|
+
last = data
|
|
92
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
93
|
+
data = last
|
|
94
|
+
if data:
|
|
95
|
+
live.update(_render(data))
|
|
96
|
+
time.sleep(0.25)
|