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.
@@ -0,0 +1,444 @@
1
+ import json
2
+ import re
3
+ import shlex
4
+ import subprocess
5
+ import warnings
6
+ from collections import deque
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from importlib.metadata import entry_points
10
+ from typing import Literal
11
+
12
+ from .config import ScriptcastConfig
13
+
14
+ EventType = Literal["cmd", "out", "dir"]
15
+
16
+
17
+ def _iter_heredoc(
18
+ lines: list[str],
19
+ pattern: re.Pattern[str],
20
+ ) -> Iterator[str | tuple[re.Match[str], list[str], str]]:
21
+ """Iterate lines, yielding either pass-through strings or heredoc block tuples.
22
+
23
+ For each line that matches *pattern*, collects the body lines up to the
24
+ heredoc delimiter and yields ``(match, body_lines, closing_line)``.
25
+ Non-matching lines are yielded as plain strings.
26
+ """
27
+ i = 0
28
+ while i < len(lines):
29
+ m = pattern.match(lines[i].rstrip("\n\r"))
30
+ if not m:
31
+ yield lines[i]
32
+ i += 1
33
+ continue
34
+ delim = m.group(3)
35
+ i += 1
36
+ body: list[str] = []
37
+ while i < len(lines) and lines[i].rstrip("\n\r") != delim:
38
+ body.append(lines[i])
39
+ i += 1
40
+ closing = lines[i] if i < len(lines) else delim + "\n"
41
+ i += 1
42
+ yield m, body, closing
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ScEvent:
47
+ ts: float
48
+ type: EventType
49
+ text: str
50
+
51
+
52
+ class Directive:
53
+ priority: int = 50
54
+ handles: str | None = None # gen-phase: directive name matched in .sc events
55
+
56
+ def __init__(self, dp: str = "SC", tp: str = "+"):
57
+ self.dp = dp
58
+ self.tp = tp
59
+
60
+ def pre(self, lines: list[str]) -> list[str]:
61
+ """Pre-phase: rewrite script lines before shell execution.
62
+
63
+ Receive the full list of script lines, return a transformed list.
64
+ Lines not recognised by this directive must be passed through unchanged.
65
+ """
66
+ return lines
67
+
68
+ def post(self, events: list[ScEvent]) -> list[ScEvent]:
69
+ """Post-phase: transform ScEvents.
70
+
71
+ Receive the full list, return a transformed list.
72
+ Events not recognised by this directive must be passed through unchanged.
73
+ """
74
+ return events
75
+
76
+ def gen(
77
+ self,
78
+ event: tuple,
79
+ queue: deque[tuple],
80
+ active: ScriptcastConfig,
81
+ cursor: float,
82
+ ) -> tuple[float, list[str]]:
83
+ """Gen-phase: emit cast lines for a directive event."""
84
+ return cursor, []
85
+
86
+
87
+ class MockDirective(Directive):
88
+ priority = 20
89
+
90
+ def __init__(self, dp: str = "SC", tp: str = "+"):
91
+ super().__init__(dp, tp)
92
+ self._mock_re = re.compile(
93
+ rf"^:\s+{re.escape(dp)}\s+mock\s+(.+?)\s*<<(['\"]?)(\w+)\2\s*$"
94
+ )
95
+
96
+ def pre(self, lines: list[str]) -> list[str]:
97
+ out: list[str] = []
98
+ for item in _iter_heredoc(lines, self._mock_re):
99
+ if isinstance(item, str):
100
+ out.append(item)
101
+ continue
102
+ m, body, closing = item
103
+ cmd_args = m.group(1).strip()
104
+ quote = m.group(2)
105
+ delim = m.group(3)
106
+ out.extend([
107
+ f"(: {self.dp} mark mock; set +x; echo + {cmd_args}; cat)"
108
+ f" <<{quote}{delim}{quote}\n",
109
+ *body,
110
+ closing,
111
+ ])
112
+ return out
113
+
114
+ def post(self, events: list[ScEvent]) -> list[ScEvent]:
115
+ out: list[ScEvent] = []
116
+ i = 0
117
+ while i < len(events):
118
+ e = events[i]
119
+ if e.type == "dir" and e.text == "mark mock":
120
+ i += 1
121
+ # drop following "set +x" cmd event if present
122
+ if i < len(events) and events[i].type == "cmd" and events[i].text == "set +x":
123
+ i += 1
124
+ else:
125
+ out.append(e)
126
+ i += 1
127
+ return out
128
+
129
+
130
+ class ExpectDirective(Directive):
131
+ priority = 30
132
+ handles = "expect-input"
133
+
134
+ def __init__(self, dp: str = "SC", tp: str = "+"):
135
+ super().__init__(dp, tp)
136
+ self._expect_re = re.compile(
137
+ rf"^:\s+{re.escape(dp)}\s+expect\s+(.+?)\s*<<(['\"]?)(\w+)\2\s*$"
138
+ )
139
+ self._send_re = re.compile(r"""^\s*send\s+(['"])(.*?)\1\s*$""")
140
+ self._mark_input_re = re.compile(
141
+ rf"(.*?): {re.escape(dp)} mark input\s*(.*)$"
142
+ )
143
+
144
+ def pre(self, lines: list[str]) -> list[str]:
145
+ out: list[str] = []
146
+ for item in _iter_heredoc(lines, self._expect_re):
147
+ if isinstance(item, str):
148
+ out.append(item)
149
+ continue
150
+ m, body, closing = item
151
+ cmd_args = m.group(1).strip()
152
+ delim = m.group(3)
153
+ new_body: list[str] = [f"spawn {cmd_args}\n"]
154
+ for bl in body:
155
+ sm = self._send_re.match(bl.rstrip("\n\r"))
156
+ if sm:
157
+ input_text = sm.group(2)
158
+ if input_text.endswith("\\r"):
159
+ input_text = input_text[:-2]
160
+ new_body.append(
161
+ f'send_user ": {self.dp} mark input {input_text}\\n"\n'
162
+ )
163
+ elif re.match(r"^\s*send\s+", bl):
164
+ new_body.append(f'send_user ": {self.dp} mark input\\n"\n')
165
+ new_body.append(bl)
166
+ out.extend([
167
+ f": {self.dp} mark expect {cmd_args}\n",
168
+ f"expect <<'{delim}'\n",
169
+ *new_body,
170
+ closing,
171
+ ])
172
+ return out
173
+
174
+ def post(self, events: list[ScEvent]) -> list[ScEvent]:
175
+ out: list[ScEvent] = []
176
+ i = 0
177
+ while i < len(events):
178
+ e = events[i]
179
+ # Pattern 1: SC expect directive marker
180
+ if e.type == "dir" and e.text.startswith("mark expect "):
181
+ cmd = e.text[len("mark expect "):].strip()
182
+ out.append(ScEvent(e.ts, "cmd", cmd))
183
+ i += 1
184
+ i, session_events = self._consume_session(events, i)
185
+ out.extend(session_events)
186
+ # Pattern 2: raw expect call (not via SC expect directive)
187
+ elif e.type == "cmd" and (e.text == "expect" or e.text.startswith("expect ")):
188
+ i += 1
189
+ if (i < len(events) and events[i].type == "out"
190
+ and events[i].text.startswith("spawn ")):
191
+ cmd = events[i].text[len("spawn "):]
192
+ out.append(ScEvent(e.ts, "cmd", cmd))
193
+ i += 1
194
+ i, session_events = self._consume_session(events, i)
195
+ out.extend(session_events)
196
+ else:
197
+ out.append(e)
198
+ i += 1
199
+ return out
200
+
201
+ def gen(
202
+ self,
203
+ event: tuple,
204
+ queue: deque[tuple],
205
+ active: ScriptcastConfig,
206
+ cursor: float,
207
+ ) -> tuple[float, list[str]]:
208
+ _, _, text = event
209
+ # text is "expect-input <input_text>" or just "expect-input"
210
+ parts = text.split(maxsplit=1)
211
+ input_text = parts[1] if len(parts) > 1 else ""
212
+ lines: list[str] = []
213
+ cursor += active.input_wait / 1000.0
214
+ for char in input_text:
215
+ cursor += active.type_speed / 1000.0
216
+ lines.append(json.dumps([round(cursor, 6), "o", char]))
217
+ if char == " ":
218
+ cursor += active.effective_word_pause_s
219
+ return cursor, lines
220
+
221
+ def _consume_session(
222
+ self, events: list[ScEvent], i: int
223
+ ) -> tuple[int, list[ScEvent]]:
224
+ session: list[ScEvent] = []
225
+ while i < len(events):
226
+ e = events[i]
227
+ # Terminator: outer shell cmd or SC directive
228
+ if e.type == "cmd":
229
+ # Inner expect calls within the session — skip
230
+ if e.text == "expect" or e.text.startswith("expect "):
231
+ i += 1
232
+ continue
233
+ break
234
+ if e.type == "dir":
235
+ break
236
+ # Check for mark input pattern in out events
237
+ mi = self._mark_input_re.match(e.text)
238
+ if mi:
239
+ prefix_out = mi.group(1)
240
+ input_text = mi.group(2).strip()
241
+ if prefix_out.strip():
242
+ session.append(ScEvent(e.ts, "out", prefix_out))
243
+ i += 1
244
+ # Check for PTY echo on next line
245
+ if i < len(events) and events[i].type == "out":
246
+ next_e = events[i]
247
+ if next_e.text.rstrip("\r\n") == input_text:
248
+ # Strip the echoed characters; keep the trailing \r\n
249
+ # (Enter echo) so the cast shows a newline after typing.
250
+ remaining = next_e.text[len(input_text):]
251
+ i += 1
252
+ session.append(ScEvent(e.ts, "dir", f"expect-input {input_text}"))
253
+ if remaining:
254
+ session.append(ScEvent(next_e.ts, "out", remaining))
255
+ else:
256
+ session.append(ScEvent(e.ts, "dir", f"expect-input {input_text}"))
257
+ else:
258
+ session.append(ScEvent(e.ts, "dir", f"expect-input {input_text}"))
259
+ continue
260
+ # Skip spawn line
261
+ if e.text.startswith("spawn "):
262
+ i += 1
263
+ continue
264
+ session.append(ScEvent(e.ts, "out", e.text))
265
+ i += 1
266
+ return i, session
267
+
268
+
269
+ class FilterDirective(Directive):
270
+ priority = 40
271
+
272
+ def __init__(self, dp: str = "SC", tp: str = "+"):
273
+ super().__init__(dp, tp)
274
+ self._filters: list[list[str]] = []
275
+
276
+ def post(self, events: list[ScEvent]) -> list[ScEvent]:
277
+ out: list[ScEvent] = []
278
+ for e in events:
279
+ if e.type == "dir" and e.text.startswith("filter "):
280
+ argv = shlex.split(e.text[len("filter "):])
281
+ if argv:
282
+ self._filters = [argv]
283
+ # consumed — not emitted
284
+ elif e.type == "dir" and e.text.startswith("filter-add "):
285
+ argv = shlex.split(e.text[len("filter-add "):])
286
+ if argv:
287
+ self._filters.append(argv)
288
+ # consumed — not emitted
289
+ elif e.type in ("out", "cmd"):
290
+ out.append(ScEvent(e.ts, e.type, self.apply(e.text)))
291
+ else:
292
+ out.append(e)
293
+ return out
294
+
295
+ def apply(self, text: str) -> str:
296
+ # Separate terminator so filters operate on content only
297
+ if text.endswith('\r\n'):
298
+ term, body = '\r\n', text[:-2]
299
+ elif text.endswith('\n'):
300
+ term, body = '\n', text[:-1]
301
+ elif text.endswith('\r'):
302
+ term, body = '\r', text[:-1]
303
+ else:
304
+ term, body = '', text
305
+ for argv in self._filters:
306
+ try:
307
+ result = subprocess.run(argv, input=body.encode(), capture_output=True)
308
+ body = result.stdout.decode('utf-8', errors='replace').rstrip('\n')
309
+ except OSError:
310
+ body = ''
311
+ return body + term
312
+
313
+
314
+ class RecordDirective(Directive):
315
+ priority = 10
316
+
317
+ def post(self, events: list[ScEvent]) -> list[ScEvent]:
318
+ out: list[ScEvent] = []
319
+ i = 0
320
+ while i < len(events):
321
+ e = events[i]
322
+ if e.type == "dir" and e.text == "record pause":
323
+ i += 1
324
+ while i < len(events):
325
+ if events[i].type == "dir" and events[i].text == "record resume":
326
+ i += 1
327
+ break
328
+ i += 1
329
+ else:
330
+ out.append(e)
331
+ i += 1
332
+ return out
333
+
334
+
335
+ class HelpersDirective(Directive):
336
+ priority = 5
337
+
338
+ def __init__(self, dp: str = "SC", tp: str = "+"):
339
+ super().__init__(dp, tp)
340
+ self._helpers_re = re.compile(rf"^:\s+{re.escape(dp)}\s+helpers\s*$")
341
+
342
+ def pre(self, lines: list[str]) -> list[str]:
343
+ out: list[str] = []
344
+ for line in lines:
345
+ if self._helpers_re.match(line.rstrip("\n\r")):
346
+ out.extend([
347
+ f": {self.dp} record pause\n",
348
+ "RED=$'\\033[31m'\n",
349
+ "YELLOW=$'\\033[33m'\n",
350
+ "GREEN=$'\\033[32m'\n",
351
+ "CYAN=$'\\033[36m'\n",
352
+ "BOLD=$'\\033[1m'\n",
353
+ "RESET=$'\\033[0m'\n",
354
+ f": {self.dp} record resume\n",
355
+ ])
356
+ else:
357
+ out.append(line)
358
+ return out
359
+
360
+
361
+ class CommentDirective(Directive):
362
+ """Converts `: SC '\\' comment` directive events to cmd events with # comment.
363
+
364
+ Script syntax: `: SC '\\' This is a comment`
365
+ After _parse_raw strips the prefix, dir event text is: `'\\' This is a comment`
366
+ Emits: `ScEvent(ts, "cmd", "# This is a comment")`
367
+ """
368
+ priority = 45
369
+
370
+ def post(self, events: list[ScEvent]) -> list[ScEvent]:
371
+ out: list[ScEvent] = []
372
+ for e in events:
373
+ if e.type == "dir" and e.text.startswith("'\\\' "):
374
+ out.append(ScEvent(e.ts, "cmd", f"# {e.text[4:]}"))
375
+ elif e.type == "dir" and e.text == "'\\\'":
376
+ out.append(ScEvent(e.ts, "cmd", "#"))
377
+ else:
378
+ out.append(e)
379
+ return out
380
+
381
+
382
+ class SetDirective(Directive):
383
+ priority = 50 # gen-only; ordering relative to SleepDirective is irrelevant
384
+ handles = "set"
385
+
386
+ def gen(
387
+ self,
388
+ event: tuple,
389
+ queue: deque[tuple],
390
+ active: ScriptcastConfig,
391
+ cursor: float,
392
+ ) -> tuple[float, list[str]]:
393
+ _, _, text = event
394
+ parts = shlex.split(text)
395
+ args = parts[1:]
396
+ if len(args) >= 2:
397
+ active.apply("set", args)
398
+ return cursor, []
399
+
400
+
401
+ class SleepDirective(Directive):
402
+ priority = 50
403
+ handles = "sleep"
404
+
405
+ def gen(
406
+ self,
407
+ event: tuple,
408
+ queue: deque[tuple],
409
+ active: ScriptcastConfig,
410
+ cursor: float,
411
+ ) -> tuple[float, list[str]]:
412
+ _, _, text = event
413
+ parts = text.split()
414
+ if len(parts) >= 2:
415
+ cursor += int(parts[1]) / 1000.0
416
+ return cursor, []
417
+
418
+
419
+ def build_directives(dp: str = "SC", tp: str = "+") -> list[Directive]:
420
+ """Build the full sorted directive list for the given prefix settings."""
421
+ core: list[Directive] = [
422
+ RecordDirective(dp, tp),
423
+ HelpersDirective(dp, tp),
424
+ MockDirective(dp, tp),
425
+ ExpectDirective(dp, tp),
426
+ FilterDirective(dp, tp),
427
+ CommentDirective(dp, tp),
428
+ SetDirective(dp, tp),
429
+ SleepDirective(dp, tp),
430
+ ]
431
+
432
+ eps = entry_points(group="scriptcast.directives")
433
+ plugins: list[Directive] = []
434
+ for ep in eps:
435
+ try:
436
+ plugins.append(ep.load()(dp, tp))
437
+ except Exception as exc: # noqa: BLE001
438
+ warnings.warn(
439
+ f"Failed to load scriptcast directive plugin {ep.name!r}: {exc}",
440
+ UserWarning,
441
+ stacklevel=2,
442
+ )
443
+
444
+ return sorted(core + plugins, key=lambda d: d.priority)