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 ADDED
@@ -0,0 +1,6 @@
1
+ """showrun: scripted, keypress-driven terminal demos."""
2
+
3
+ from .model import Demo, Settings, Step, load_demo
4
+
5
+ __all__ = ["Demo", "Settings", "Step", "load_demo"]
6
+ __version__ = "0.1.0"
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