scriptcast 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.
- scriptcast/__init__.py +0 -0
- scriptcast/__main__.py +371 -0
- scriptcast/assets/__init__.py +0 -0
- scriptcast/assets/fonts/DMSans-Regular.ttf +0 -0
- scriptcast/assets/fonts/Pacifico.ttf +0 -0
- scriptcast/assets/themes/aurora.sh +20 -0
- scriptcast/assets/themes/dark.sh +19 -0
- scriptcast/assets/themes/light.sh +19 -0
- scriptcast/config.py +199 -0
- scriptcast/directives.py +444 -0
- scriptcast/export.py +595 -0
- scriptcast/generator.py +265 -0
- scriptcast/recorder.py +212 -0
- scriptcast/shell/__init__.py +20 -0
- scriptcast/shell/adapter.py +13 -0
- scriptcast/shell/bash.py +11 -0
- scriptcast/shell/zsh.py +11 -0
- scriptcast-0.1.0.dist-info/METADATA +21 -0
- scriptcast-0.1.0.dist-info/RECORD +33 -0
- scriptcast-0.1.0.dist-info/WHEEL +5 -0
- scriptcast-0.1.0.dist-info/entry_points.txt +2 -0
- scriptcast-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_cli.py +304 -0
- tests/test_config.py +400 -0
- tests/test_directives.py +606 -0
- tests/test_export.py +986 -0
- tests/test_generator.py +434 -0
- tests/test_integration.py +97 -0
- tests/test_recorder.py +462 -0
- tests/test_registry.py +57 -0
- tests/test_shell.py +34 -0
- tests/test_theme.py +204 -0
scriptcast/generator.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# scriptcast/generator.py
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
from collections import deque
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import ScriptcastConfig
|
|
9
|
+
from .directives import build_directives
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_sc_header(
|
|
13
|
+
lines: list[str],
|
|
14
|
+
base: ScriptcastConfig | None = None,
|
|
15
|
+
) -> ScriptcastConfig:
|
|
16
|
+
"""Parse .sc JSONL header + pre-scene set directives into a ScriptcastConfig.
|
|
17
|
+
|
|
18
|
+
Reads the first line as a JSON header for width/height/directive-prefix.
|
|
19
|
+
Then walks subsequent lines applying 'set' directives until the first 'scene'.
|
|
20
|
+
If *base* is provided, its fields serve as defaults overridden by the header.
|
|
21
|
+
"""
|
|
22
|
+
if not lines:
|
|
23
|
+
return base.copy() if base is not None else ScriptcastConfig()
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
header = json.loads(lines[0])
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
return base.copy() if base is not None else ScriptcastConfig()
|
|
29
|
+
|
|
30
|
+
if base is not None:
|
|
31
|
+
config = base.copy()
|
|
32
|
+
config.directive_prefix = header.get("directive-prefix", config.directive_prefix)
|
|
33
|
+
config.width = header.get("width", config.width)
|
|
34
|
+
config.height = header.get("height", config.height)
|
|
35
|
+
else:
|
|
36
|
+
config = ScriptcastConfig(
|
|
37
|
+
directive_prefix=header.get("directive-prefix", "SC"),
|
|
38
|
+
width=header.get("width", 100),
|
|
39
|
+
height=header.get("height", 28),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
for raw in lines[1:]:
|
|
43
|
+
raw = raw.strip()
|
|
44
|
+
if not raw:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
row = json.loads(raw)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
continue
|
|
50
|
+
if not isinstance(row, list) or len(row) < 3:
|
|
51
|
+
continue
|
|
52
|
+
_, typ, text = row[0], row[1], row[2]
|
|
53
|
+
if typ != "dir":
|
|
54
|
+
continue
|
|
55
|
+
parts = shlex.split(str(text))
|
|
56
|
+
if not parts:
|
|
57
|
+
continue
|
|
58
|
+
if parts[0] == "scene":
|
|
59
|
+
break
|
|
60
|
+
if parts[0] == "set" and len(parts) >= 3:
|
|
61
|
+
config.apply("set", parts[1:])
|
|
62
|
+
|
|
63
|
+
return config
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_config_from_sc_text(
|
|
67
|
+
sc_text: str,
|
|
68
|
+
base: ScriptcastConfig | None = None,
|
|
69
|
+
) -> ScriptcastConfig:
|
|
70
|
+
"""Parse .sc JSONL header and pre-scene set directives into a ScriptcastConfig."""
|
|
71
|
+
return _parse_sc_header(sc_text.splitlines(), base)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generate_from_sc(
|
|
75
|
+
sc_path: Path,
|
|
76
|
+
output_dir: Path,
|
|
77
|
+
output_stem: str | None = None,
|
|
78
|
+
*,
|
|
79
|
+
split_scenes: bool = False,
|
|
80
|
+
base: ScriptcastConfig | None = None,
|
|
81
|
+
) -> list[Path]:
|
|
82
|
+
"""Read a JSONL .sc file and write .cast file(s) to output_dir."""
|
|
83
|
+
return generate_from_sc_text(
|
|
84
|
+
sc_path.read_text(),
|
|
85
|
+
output_dir,
|
|
86
|
+
output_stem=output_stem or sc_path.stem,
|
|
87
|
+
split_scenes=split_scenes,
|
|
88
|
+
base=base,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def generate_from_sc_text(
|
|
93
|
+
sc_text: str,
|
|
94
|
+
output_dir: Path,
|
|
95
|
+
output_stem: str = "output",
|
|
96
|
+
*,
|
|
97
|
+
split_scenes: bool = False,
|
|
98
|
+
base: ScriptcastConfig | None = None,
|
|
99
|
+
) -> list[Path]:
|
|
100
|
+
"""Convert JSONL .sc text to .cast file(s). Returns list of written paths.
|
|
101
|
+
|
|
102
|
+
If *base* is provided, its fields serve as defaults for the generated config.
|
|
103
|
+
Pre-scene ``set`` directives in the .sc text still override the base, so a
|
|
104
|
+
theme passed as *base* acts as a template that the script can override.
|
|
105
|
+
"""
|
|
106
|
+
lines = sc_text.splitlines()
|
|
107
|
+
if not lines:
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
header = json.loads(lines[0])
|
|
111
|
+
pipeline_version = header.get("pipeline-version", 1)
|
|
112
|
+
if pipeline_version not in (1, 2, 3):
|
|
113
|
+
raise ValueError(f"Unsupported .sc pipeline-version: {pipeline_version}")
|
|
114
|
+
|
|
115
|
+
config = _parse_sc_header(lines, base)
|
|
116
|
+
config.split_scenes = split_scenes
|
|
117
|
+
|
|
118
|
+
events: list[tuple] = []
|
|
119
|
+
for line in lines[1:]:
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line:
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
ts, typ, text = json.loads(line)
|
|
125
|
+
events.append((float(ts), str(typ), str(text)))
|
|
126
|
+
except (json.JSONDecodeError, ValueError):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
scenes = _split_scenes(events)
|
|
130
|
+
|
|
131
|
+
if config.split_scenes:
|
|
132
|
+
paths = []
|
|
133
|
+
for scene_name, scene_events in scenes:
|
|
134
|
+
content = _render_scene(scene_events, config.copy(), scene_name)
|
|
135
|
+
path = output_dir / f"{scene_name}.cast"
|
|
136
|
+
path.write_text(content)
|
|
137
|
+
paths.append(path)
|
|
138
|
+
return paths
|
|
139
|
+
|
|
140
|
+
cast_header = {
|
|
141
|
+
"version": 2,
|
|
142
|
+
"width": config.width,
|
|
143
|
+
"height": config.height,
|
|
144
|
+
"timestamp": 0,
|
|
145
|
+
"env": {"TERM": "xterm-256color"},
|
|
146
|
+
}
|
|
147
|
+
all_lines = [json.dumps(cast_header)]
|
|
148
|
+
cursor = 0.0
|
|
149
|
+
for scene_name, scene_events in scenes:
|
|
150
|
+
scene_lines, cursor = _render_scene_lines(scene_events, config.copy(), scene_name, cursor)
|
|
151
|
+
all_lines.extend(scene_lines)
|
|
152
|
+
path = output_dir / f"{output_stem}.cast"
|
|
153
|
+
path.write_text("\n".join(all_lines) + "\n")
|
|
154
|
+
return [path]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _split_scenes(events: list[tuple]) -> list[tuple[str, list[tuple]]]:
|
|
158
|
+
scenes: list[tuple[str, list[tuple]]] = []
|
|
159
|
+
current_name: str | None = None
|
|
160
|
+
current_events: list[tuple] = []
|
|
161
|
+
|
|
162
|
+
for event in events:
|
|
163
|
+
_, typ, text = event
|
|
164
|
+
if typ == "dir" and text.split()[0] == "scene":
|
|
165
|
+
if current_name is not None:
|
|
166
|
+
scenes.append((current_name, current_events))
|
|
167
|
+
parts = text.split(maxsplit=1)
|
|
168
|
+
current_name = parts[1] if len(parts) > 1 else "unnamed"
|
|
169
|
+
current_events = []
|
|
170
|
+
else:
|
|
171
|
+
if current_name is None:
|
|
172
|
+
current_name = "main"
|
|
173
|
+
current_events.append(event)
|
|
174
|
+
|
|
175
|
+
if current_name is not None:
|
|
176
|
+
scenes.append((current_name, current_events))
|
|
177
|
+
|
|
178
|
+
_overhead = {"set"}
|
|
179
|
+
|
|
180
|
+
def _is_overhead_only(evts: list[tuple]) -> bool:
|
|
181
|
+
return all(
|
|
182
|
+
typ == "dir" and text.split()[0] in _overhead
|
|
183
|
+
for _, typ, text in evts
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return [
|
|
187
|
+
(name, evts)
|
|
188
|
+
for name, evts in scenes
|
|
189
|
+
if not (name == "main" and _is_overhead_only(evts))
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _render_scene(
|
|
194
|
+
events: list[tuple],
|
|
195
|
+
config: ScriptcastConfig,
|
|
196
|
+
scene_name: str,
|
|
197
|
+
) -> str:
|
|
198
|
+
cast_header = {
|
|
199
|
+
"version": 2,
|
|
200
|
+
"width": config.width,
|
|
201
|
+
"height": config.height,
|
|
202
|
+
"timestamp": 0,
|
|
203
|
+
"env": {"TERM": "xterm-256color"},
|
|
204
|
+
}
|
|
205
|
+
lines, _ = _render_scene_lines(events, config, scene_name, 0.0)
|
|
206
|
+
return "\n".join([json.dumps(cast_header)] + lines) + "\n"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _render_scene_lines(
|
|
210
|
+
events: list[tuple],
|
|
211
|
+
config: ScriptcastConfig,
|
|
212
|
+
scene_name: str,
|
|
213
|
+
initial_cursor: float = 0.0,
|
|
214
|
+
) -> tuple[list[str], float]:
|
|
215
|
+
lines: list[str] = []
|
|
216
|
+
cursor = initial_cursor
|
|
217
|
+
active = config.copy()
|
|
218
|
+
# Build gen registry: directive name → directive instance
|
|
219
|
+
# Uses handles attribute; directives without handles are recorder-only.
|
|
220
|
+
all_directives = build_directives(active.directive_prefix, active.trace_prefix)
|
|
221
|
+
gen_registry = {d.handles: d for d in all_directives if d.handles is not None}
|
|
222
|
+
|
|
223
|
+
lines.append(json.dumps([round(cursor, 6), "o", "\x1b[2J\x1b[H"]))
|
|
224
|
+
cursor += active.enter_wait / 1000.0
|
|
225
|
+
|
|
226
|
+
queue: deque[tuple] = deque(events)
|
|
227
|
+
while queue:
|
|
228
|
+
event = queue.popleft()
|
|
229
|
+
_, typ, text = event
|
|
230
|
+
|
|
231
|
+
if typ == "dir":
|
|
232
|
+
parts = text.split()
|
|
233
|
+
name = parts[0] if parts else ""
|
|
234
|
+
d = gen_registry.get(name)
|
|
235
|
+
if d is not None:
|
|
236
|
+
cursor, new_lines = d.gen(event, queue, active, cursor)
|
|
237
|
+
lines.extend(new_lines)
|
|
238
|
+
|
|
239
|
+
elif typ == "cmd":
|
|
240
|
+
lines.append(json.dumps([round(cursor, 6), "o", active.prompt]))
|
|
241
|
+
cursor += active.cmd_wait / 1000.0
|
|
242
|
+
for char in text:
|
|
243
|
+
cursor += active.type_speed / 1000.0
|
|
244
|
+
lines.append(json.dumps([round(cursor, 6), "o", char]))
|
|
245
|
+
if char == " ":
|
|
246
|
+
cursor += active.effective_word_pause_s
|
|
247
|
+
cursor += active.type_speed / 1000.0
|
|
248
|
+
lines.append(json.dumps([round(cursor, 6), "o", "\r\n"]))
|
|
249
|
+
|
|
250
|
+
elif typ == "out":
|
|
251
|
+
if active.cr_delay > 0 and '\r' in text:
|
|
252
|
+
# Split before each bare \r (not \r\n) for progress-bar animation
|
|
253
|
+
parts = re.split(r'(?=\r(?!\n))', text)
|
|
254
|
+
for j, part in enumerate(parts):
|
|
255
|
+
if part:
|
|
256
|
+
lines.append(json.dumps([round(cursor, 6), "o", part]))
|
|
257
|
+
if j < len(parts) - 1:
|
|
258
|
+
cursor += active.cr_delay / 1000.0
|
|
259
|
+
elif text:
|
|
260
|
+
lines.append(json.dumps([round(cursor, 6), "o", text]))
|
|
261
|
+
|
|
262
|
+
lines.append(json.dumps([round(cursor, 6), "o", active.prompt]))
|
|
263
|
+
cursor += active.exit_wait / 1000.0
|
|
264
|
+
lines.append(json.dumps([round(cursor, 6), "o", ""]))
|
|
265
|
+
return lines, cursor
|
scriptcast/recorder.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# scriptcast/recorder.py
|
|
2
|
+
import errno
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import pty
|
|
8
|
+
import re
|
|
9
|
+
import struct
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import termios
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import ScriptcastConfig
|
|
17
|
+
from .directives import ScEvent, build_directives
|
|
18
|
+
from .shell import get_adapter
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_raw(
|
|
24
|
+
raw_text: str,
|
|
25
|
+
trace_prefix: str = "+",
|
|
26
|
+
directive_prefix: str = "SC",
|
|
27
|
+
) -> list[ScEvent]:
|
|
28
|
+
"""Parse raw xtrace log text into an initial list of ScEvents.
|
|
29
|
+
|
|
30
|
+
Each record has the form: "<ts> <content><term>" where <term> is either
|
|
31
|
+
\\n or empty (last record with no terminator). Bare \\r within content
|
|
32
|
+
is preserved verbatim — it is not a record separator.
|
|
33
|
+
|
|
34
|
+
Classification rules (per record):
|
|
35
|
+
"+ : SC <rest>" → ScEvent(ts, "dir", rest) — terminator discarded, trailing \\r stripped
|
|
36
|
+
"+ <cmd>" → ScEvent(ts, "cmd", cmd) — terminator discarded, trailing \\r stripped
|
|
37
|
+
"<text>" → ScEvent(ts, "out", text + term) — verbatim, \\r preserved
|
|
38
|
+
|
|
39
|
+
Lines with non-float timestamps are skipped.
|
|
40
|
+
"""
|
|
41
|
+
sc_prefix = f"{trace_prefix} : {directive_prefix} "
|
|
42
|
+
trace_prefix_sp = f"{trace_prefix} "
|
|
43
|
+
events: list[ScEvent] = []
|
|
44
|
+
|
|
45
|
+
# Split on any line terminator, keeping each terminator as a token.
|
|
46
|
+
# re.split with a capturing group gives [c0, t0, c1, t1, ..., cN].
|
|
47
|
+
parts = re.split(r'(\n)', raw_text)
|
|
48
|
+
|
|
49
|
+
i = 0
|
|
50
|
+
while i < len(parts):
|
|
51
|
+
entry = parts[i]
|
|
52
|
+
term = parts[i + 1] if i + 1 < len(parts) else ''
|
|
53
|
+
i += 2
|
|
54
|
+
|
|
55
|
+
if not entry:
|
|
56
|
+
continue
|
|
57
|
+
ts_str, _, content = entry.partition(' ')
|
|
58
|
+
try:
|
|
59
|
+
ts = float(ts_str)
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if content.startswith(sc_prefix):
|
|
64
|
+
events.append(ScEvent(ts, "dir", content[len(sc_prefix):].removesuffix('\r')))
|
|
65
|
+
elif content.startswith(trace_prefix_sp):
|
|
66
|
+
events.append(ScEvent(ts, "cmd", content[len(trace_prefix_sp):].removesuffix('\r')))
|
|
67
|
+
else:
|
|
68
|
+
events.append(ScEvent(ts, "out", content + term))
|
|
69
|
+
|
|
70
|
+
return events
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _serialise(events: list[ScEvent]) -> str:
|
|
74
|
+
"""Convert a list of ScEvents to JSONL text (no trailing newline if empty)."""
|
|
75
|
+
lines = [json.dumps([e.ts, e.type, e.text]) for e in events]
|
|
76
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _preprocess(script_text: str, directive_prefix: str = "SC") -> str:
|
|
80
|
+
"""Rewrite script lines using directive pre-phase before shell execution."""
|
|
81
|
+
directives = build_directives(directive_prefix)
|
|
82
|
+
lines = script_text.splitlines(keepends=True)
|
|
83
|
+
for d in directives:
|
|
84
|
+
lines = d.pre(lines)
|
|
85
|
+
return "".join(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _postprocess(
|
|
89
|
+
raw_text: str,
|
|
90
|
+
trace_prefix: str = "+",
|
|
91
|
+
directive_prefix: str = "SC",
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Convert raw .log text to JSONL .sc body (no header line)."""
|
|
94
|
+
directives = build_directives(directive_prefix, trace_prefix)
|
|
95
|
+
events = _parse_raw(raw_text, trace_prefix, directive_prefix)
|
|
96
|
+
for d in directives:
|
|
97
|
+
events = d.post(events)
|
|
98
|
+
return _serialise(events)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def record(
|
|
102
|
+
script_path: str | Path,
|
|
103
|
+
sc_path: str | Path,
|
|
104
|
+
config: ScriptcastConfig,
|
|
105
|
+
shell: str,
|
|
106
|
+
xtrace_log: bool = False,
|
|
107
|
+
) -> int:
|
|
108
|
+
"""Run script_path in shell with tracing, write cleaned output to sc_path.
|
|
109
|
+
|
|
110
|
+
If xtrace_log is True, the raw PTY capture (before postprocessing) is
|
|
111
|
+
written to a file beside sc_path with the suffix ``.xtrace``.
|
|
112
|
+
|
|
113
|
+
Returns the shell exit code. Warns (does not raise) on non-zero exit.
|
|
114
|
+
"""
|
|
115
|
+
script_path = Path(script_path)
|
|
116
|
+
sc_path = Path(sc_path)
|
|
117
|
+
adapter = get_adapter(shell)
|
|
118
|
+
|
|
119
|
+
preamble = adapter.tracing_preamble(config.trace_prefix)
|
|
120
|
+
|
|
121
|
+
script_content = script_path.read_text()
|
|
122
|
+
if script_content.startswith("#!"):
|
|
123
|
+
_, _, script_content = script_content.partition("\n")
|
|
124
|
+
|
|
125
|
+
script_content = _preprocess(script_content, config.directive_prefix)
|
|
126
|
+
logger.debug(
|
|
127
|
+
"Recording %s (shell=%s, width=%d, height=%d)",
|
|
128
|
+
script_path.name, shell, config.width, config.height,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
master_fd = -1
|
|
132
|
+
proc = None
|
|
133
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
134
|
+
mode="w", suffix=".sh", delete=False, dir=tempfile.gettempdir()
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
tmp.write(preamble)
|
|
138
|
+
tmp.write(script_content)
|
|
139
|
+
tmp.flush()
|
|
140
|
+
tmp.close()
|
|
141
|
+
|
|
142
|
+
master_fd, slave_fd = pty.openpty()
|
|
143
|
+
try:
|
|
144
|
+
fcntl.ioctl(
|
|
145
|
+
slave_fd, termios.TIOCSWINSZ,
|
|
146
|
+
struct.pack('HHHH', config.height, config.width, 0, 0),
|
|
147
|
+
)
|
|
148
|
+
stdin_fd = os.open('/dev/null', os.O_RDONLY)
|
|
149
|
+
try:
|
|
150
|
+
proc = subprocess.Popen(
|
|
151
|
+
[shell, tmp.name],
|
|
152
|
+
stdin=stdin_fd,
|
|
153
|
+
stdout=slave_fd,
|
|
154
|
+
stderr=slave_fd,
|
|
155
|
+
close_fds=True,
|
|
156
|
+
cwd=script_path.parent,
|
|
157
|
+
)
|
|
158
|
+
finally:
|
|
159
|
+
os.close(slave_fd)
|
|
160
|
+
os.close(stdin_fd)
|
|
161
|
+
except Exception:
|
|
162
|
+
os.close(master_fd)
|
|
163
|
+
master_fd = -1
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
raw_lines: list[str] = []
|
|
167
|
+
try:
|
|
168
|
+
with open(master_fd, 'rb', closefd=False) as f:
|
|
169
|
+
for line in f:
|
|
170
|
+
raw_lines.append(f"{time.time():.3f} {line.decode('utf-8', errors='replace')}")
|
|
171
|
+
except OSError as e:
|
|
172
|
+
if e.errno != errno.EIO:
|
|
173
|
+
raise
|
|
174
|
+
# Note: output without a trailing newline (e.g. printf "text") is not
|
|
175
|
+
# captured — the BufferedReader discards partial data when EIO fires.
|
|
176
|
+
os.close(master_fd)
|
|
177
|
+
master_fd = -1 # signal to finally: already closed
|
|
178
|
+
proc.wait()
|
|
179
|
+
|
|
180
|
+
logger.debug("PTY capture complete: %d raw lines", len(raw_lines))
|
|
181
|
+
|
|
182
|
+
raw_text = "".join(raw_lines)
|
|
183
|
+
if xtrace_log:
|
|
184
|
+
xtrace_path = sc_path.with_suffix('.xtrace')
|
|
185
|
+
xtrace_path.write_text(raw_text)
|
|
186
|
+
logger.info("Saved: %s", xtrace_path)
|
|
187
|
+
clean_text = _postprocess(raw_text, config.trace_prefix, config.directive_prefix)
|
|
188
|
+
logger.debug("Post-processed to %d events", clean_text.count("\n"))
|
|
189
|
+
|
|
190
|
+
header = json.dumps({
|
|
191
|
+
"version": 1,
|
|
192
|
+
"shell": adapter.name,
|
|
193
|
+
"width": config.width,
|
|
194
|
+
"height": config.height,
|
|
195
|
+
"directive-prefix": config.directive_prefix,
|
|
196
|
+
"pipeline-version": 3,
|
|
197
|
+
})
|
|
198
|
+
sc_path.write_text(header + "\n" + clean_text)
|
|
199
|
+
|
|
200
|
+
if proc.returncode != 0:
|
|
201
|
+
logger.warning(
|
|
202
|
+
"Script exited with non-zero status %d; .sc file written anyway.",
|
|
203
|
+
proc.returncode,
|
|
204
|
+
)
|
|
205
|
+
return proc.returncode
|
|
206
|
+
finally:
|
|
207
|
+
if master_fd != -1:
|
|
208
|
+
os.close(master_fd)
|
|
209
|
+
if proc is not None and proc.poll() is None:
|
|
210
|
+
proc.terminate()
|
|
211
|
+
proc.wait()
|
|
212
|
+
os.unlink(tmp.name)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# scriptcast/shell/__init__.py
|
|
2
|
+
from .adapter import ShellAdapter
|
|
3
|
+
from .bash import BashAdapter
|
|
4
|
+
from .zsh import ZshAdapter
|
|
5
|
+
|
|
6
|
+
_ADAPTERS: dict[str, ShellAdapter] = {
|
|
7
|
+
"bash": BashAdapter(),
|
|
8
|
+
"zsh": ZshAdapter(),
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
def get_adapter(shell: str) -> ShellAdapter:
|
|
12
|
+
"""Return adapter for shell name or full path (e.g. '/bin/bash' → BashAdapter).
|
|
13
|
+
Raises ValueError for unsupported shells.
|
|
14
|
+
"""
|
|
15
|
+
name = shell.split("/")[-1]
|
|
16
|
+
if name not in _ADAPTERS:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"Unsupported shell: {shell!r}. Supported: {list(_ADAPTERS)}"
|
|
19
|
+
)
|
|
20
|
+
return _ADAPTERS[name]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# scriptcast/shell/adapter.py
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ShellAdapter(ABC):
|
|
6
|
+
@property
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def name(self) -> str: ...
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def tracing_preamble(self, trace_prefix: str) -> str:
|
|
12
|
+
"""Return shell code to enable tracing with the given prefix."""
|
|
13
|
+
...
|
scriptcast/shell/bash.py
ADDED
scriptcast/shell/zsh.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# scriptcast/shell/zsh.py
|
|
2
|
+
from .adapter import ShellAdapter
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ZshAdapter(ShellAdapter):
|
|
6
|
+
@property
|
|
7
|
+
def name(self) -> str:
|
|
8
|
+
return "zsh"
|
|
9
|
+
|
|
10
|
+
def tracing_preamble(self, trace_prefix: str) -> str:
|
|
11
|
+
return f'PS4="{trace_prefix} "\nsetopt xtrace\n'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scriptcast
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate terminal demos (asciinema casts & GIFs) from simple shell-like scripts.
|
|
5
|
+
Author-email: Nasser Alansari <dacrystal@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: click>=8.0
|
|
14
|
+
Requires-Dist: pillow>=9.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: asciinema>=2.4.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
21
|
+
Requires-Dist: git-cliff>=2.0; extra == "dev"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
scriptcast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
scriptcast/__main__.py,sha256=XX2_fh8fXEDW5c67IPuYc4m9LUDHkycUuygryntMnSs,13415
|
|
3
|
+
scriptcast/config.py,sha256=COMFJLg5IPMlhzFXY6Sk7bi7mavV6XJAqt2_uHLjah8,7610
|
|
4
|
+
scriptcast/directives.py,sha256=nYXW8cFCuZYj6i5wfiTu1e9NfxPVsE4OCb8-9-3mXjA,15050
|
|
5
|
+
scriptcast/export.py,sha256=vPXNHvdK7kXsyxBImHwehWNfl2GlRBEjUyBNP5Lx90g,20085
|
|
6
|
+
scriptcast/generator.py,sha256=MxKc2zoTfzGwLC6xzg5Ni_chE1lhKkpy90UvUGSpNJ8,8661
|
|
7
|
+
scriptcast/recorder.py,sha256=-TLo_oFF_CE2GViDMHYCKoo6Q3tzd3a1PDHu8YbBD6Y,6974
|
|
8
|
+
scriptcast/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
scriptcast/assets/fonts/DMSans-Regular.ttf,sha256=qVoziUKqNXFFQnvnyWP_aR-f6FnDUS2cBIPhkU1Xogg,78256
|
|
10
|
+
scriptcast/assets/fonts/Pacifico.ttf,sha256=W2wNUzSnv3fepSuXXFoMQIh4wPcRXtW2-xUfY0t79wE,329380
|
|
11
|
+
scriptcast/assets/themes/aurora.sh,sha256=futWVqJ2tH2WgbQ-Pj-3ONQM-5o2zJ8M3AX57MGxWHU,666
|
|
12
|
+
scriptcast/assets/themes/dark.sh,sha256=-nqvnitACQ_dHgHZYCUvuED1bZ1GTUTlBLOljHUywCw,631
|
|
13
|
+
scriptcast/assets/themes/light.sh,sha256=bFi3WDgI_ppotocaTox76JBJAkec_xcO1jgiQfVnTI0,631
|
|
14
|
+
scriptcast/shell/__init__.py,sha256=iCT6AUH6s1ljS4KkC5YwxZ4mCmaU1cFuOLwpkp5_-mc,602
|
|
15
|
+
scriptcast/shell/adapter.py,sha256=aswPs514-jaFTyETxnze9EKXv4c8Ahh50oYrDjBDTWI,322
|
|
16
|
+
scriptcast/shell/bash.py,sha256=VRvVeKAEb5Og6uXpWeLp2xha9_UzQ3v8t5HCBJvlu14,268
|
|
17
|
+
scriptcast/shell/zsh.py,sha256=47F44D0q5QlmcANgIDUbkgIgT5EnwgRSiW7zEWoutl4,272
|
|
18
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
tests/test_cli.py,sha256=5vtfdy5MgjqD_bS1CTizSfnQXfXip1QpfmwKWKV9l8E,11380
|
|
20
|
+
tests/test_config.py,sha256=RPaP0LqfOKEfxuP4NG0__ZDuAtbaiuPrRl8bOaTewZ0,13111
|
|
21
|
+
tests/test_directives.py,sha256=n4Tz2gp0NnWWuCua49qsKJWNuXNq_J66E-50AV5a6Gc,19032
|
|
22
|
+
tests/test_export.py,sha256=GlwGho6rWjrq5SKauxNKdgCRqtEGlqxUHSIP0m4T3-E,38461
|
|
23
|
+
tests/test_generator.py,sha256=_HFmZH3ajctEKBMz0Kw6kRw6p2SgVIIK9R0XinpFjSk,14490
|
|
24
|
+
tests/test_integration.py,sha256=VBFBUNHQLWiUkb7cLWWdW95R7HraHIDNt78BUN6btPk,3284
|
|
25
|
+
tests/test_recorder.py,sha256=ZN5MiwmYbsXpXXfZCr8wx8NmxaRU8bZgrrJx0lziTCU,15802
|
|
26
|
+
tests/test_registry.py,sha256=zqRMdR0M4TsUsx4EPB6x6Dgv5YuxxDZY5yKWrURHulI,1471
|
|
27
|
+
tests/test_shell.py,sha256=IU8FpX7RoyreabtcassC87cO6zzUpojoruAvjQ9QwzM,933
|
|
28
|
+
tests/test_theme.py,sha256=mR0uxvznJE9XYHwPWzXYT0L3TfabsonRewqkdXe5yV0,6898
|
|
29
|
+
scriptcast-0.1.0.dist-info/METADATA,sha256=1krzrP7aIfIKUZUayOMjmqRuXXFeDeJLZECjxCOk1tY,835
|
|
30
|
+
scriptcast-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
31
|
+
scriptcast-0.1.0.dist-info/entry_points.txt,sha256=-GcwrQR9RHWFrD3kgAeEOViuc_02JmHoHBCEfw6m0w0,55
|
|
32
|
+
scriptcast-0.1.0.dist-info/top_level.txt,sha256=q338tb0EgIDmLUyS3fcLPWbYG8C7BIvo-isL-yRsq7k,17
|
|
33
|
+
scriptcast-0.1.0.dist-info/RECORD,,
|
tests/__init__.py
ADDED
|
File without changes
|