scriptcast 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (60) hide show
  1. {scriptcast-0.2.0 → scriptcast-0.3.0}/.github/workflows/publish-pages.yml +2 -1
  2. {scriptcast-0.2.0 → scriptcast-0.3.0}/CHANGELOG.md +6 -0
  3. {scriptcast-0.2.0 → scriptcast-0.3.0}/PKG-INFO +1 -1
  4. scriptcast-0.3.0/assets/demo.png +0 -0
  5. scriptcast-0.3.0/assets/showcase-aurora.png +0 -0
  6. {scriptcast-0.2.0 → scriptcast-0.3.0}/assets/showcase-dark.png +0 -0
  7. {scriptcast-0.2.0 → scriptcast-0.3.0}/assets/showcase-light.png +0 -0
  8. scriptcast-0.3.0/assets/tutorial.png +0 -0
  9. {scriptcast-0.2.0 → scriptcast-0.3.0}/examples/demo.sh +0 -1
  10. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/__main__.py +1 -1
  11. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/assets/themes/aurora.sh +1 -1
  12. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/assets/themes/dark.sh +1 -0
  13. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/assets/themes/light.sh +1 -0
  14. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/recorder.py +8 -2
  15. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/shell/adapter.py +6 -0
  16. scriptcast-0.3.0/scriptcast/shell/zsh.py +72 -0
  17. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_directives.py +1 -1
  18. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_recorder.py +53 -0
  19. scriptcast-0.3.0/tests/test_shell.py +94 -0
  20. scriptcast-0.2.0/assets/demo.png +0 -0
  21. scriptcast-0.2.0/assets/showcase-aurora.png +0 -0
  22. scriptcast-0.2.0/assets/tutorial.png +0 -0
  23. scriptcast-0.2.0/scriptcast/shell/zsh.py +0 -11
  24. scriptcast-0.2.0/tests/test_shell.py +0 -34
  25. {scriptcast-0.2.0 → scriptcast-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  26. {scriptcast-0.2.0 → scriptcast-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  27. {scriptcast-0.2.0 → scriptcast-0.3.0}/.github/pull_request_template.md +0 -0
  28. {scriptcast-0.2.0 → scriptcast-0.3.0}/.github/workflows/ci.yml +0 -0
  29. {scriptcast-0.2.0 → scriptcast-0.3.0}/.github/workflows/publish-pypi.yml +0 -0
  30. {scriptcast-0.2.0 → scriptcast-0.3.0}/.gitignore +0 -0
  31. {scriptcast-0.2.0 → scriptcast-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  32. {scriptcast-0.2.0 → scriptcast-0.3.0}/CONTRIBUTING.md +0 -0
  33. {scriptcast-0.2.0 → scriptcast-0.3.0}/DIRECTIVES.md +0 -0
  34. {scriptcast-0.2.0 → scriptcast-0.3.0}/Makefile +0 -0
  35. {scriptcast-0.2.0 → scriptcast-0.3.0}/README.md +0 -0
  36. {scriptcast-0.2.0 → scriptcast-0.3.0}/cliff.toml +0 -0
  37. {scriptcast-0.2.0 → scriptcast-0.3.0}/examples/.gitignore +0 -0
  38. {scriptcast-0.2.0 → scriptcast-0.3.0}/examples/fake-db +0 -0
  39. {scriptcast-0.2.0 → scriptcast-0.3.0}/examples/fake-myapp +0 -0
  40. {scriptcast-0.2.0 → scriptcast-0.3.0}/examples/showcase.sh +0 -0
  41. {scriptcast-0.2.0 → scriptcast-0.3.0}/examples/tutorial.sh +0 -0
  42. {scriptcast-0.2.0 → scriptcast-0.3.0}/pyproject.toml +0 -0
  43. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/__init__.py +0 -0
  44. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/assets/__init__.py +0 -0
  45. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/assets/fonts/DMSans-Regular.ttf +0 -0
  46. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/assets/fonts/Pacifico.ttf +0 -0
  47. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/config.py +0 -0
  48. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/directives.py +0 -0
  49. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/export.py +0 -0
  50. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/generator.py +0 -0
  51. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/shell/__init__.py +0 -0
  52. {scriptcast-0.2.0 → scriptcast-0.3.0}/scriptcast/shell/bash.py +0 -0
  53. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/__init__.py +0 -0
  54. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_cli.py +0 -0
  55. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_config.py +0 -0
  56. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_export.py +0 -0
  57. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_generator.py +0 -0
  58. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_integration.py +0 -0
  59. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_registry.py +0 -0
  60. {scriptcast-0.2.0 → scriptcast-0.3.0}/tests/test_theme.py +0 -0
@@ -2,7 +2,7 @@
2
2
  name: Deploy GitHub Pages
3
3
  on:
4
4
  workflow_run:
5
- workflows: ["publish-pypi"]
5
+ workflows: ["Publish to PyPI"]
6
6
  types: [completed]
7
7
 
8
8
  # Allows you to run this workflow manually from the Actions tab
@@ -24,6 +24,7 @@ jobs:
24
24
  # Build job
25
25
  build:
26
26
  runs-on: ubuntu-latest
27
+ if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
27
28
  steps:
28
29
  - name: Checkout
29
30
  uses: actions/checkout@v4
@@ -1,3 +1,9 @@
1
+ ## [0.3.0] - 2026-04-07
2
+
3
+ ### Added
4
+
5
+ - Fix default theme loading and zsh xtrace ESC byte corruption ([#1](https://github.com/dacrystal/scriptcast/issues/1))
6
+
1
7
  ## [0.2.0] - 2026-04-06
2
8
 
3
9
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptcast
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Generate terminal demos (asciinema casts & GIFs) from simple shell-like scripts.
5
5
  Project-URL: Homepage, https://dacrystal.github.io/scriptcast
6
6
  Project-URL: Source Code, https://github.com/dacrystal/scriptcast
Binary file
Binary file
@@ -5,7 +5,6 @@
5
5
  : SC set type_speed 40
6
6
  : SC set cmd_wait 80
7
7
  : SC set cr_delay 10
8
- : SC set prompt "$ "
9
8
 
10
9
  # ── Scene: demo ───────────────────────────────
11
10
  : SC scene demo
@@ -227,7 +227,7 @@ def cli(
227
227
  out_dir = Path(output_dir) if output_dir else in_path.parent
228
228
  out_dir.mkdir(parents=True, exist_ok=True)
229
229
  resolved_shell = shell or _default_shell()
230
- theme_path = _resolve_theme(theme) if theme else None
230
+ theme_path = _resolve_theme(theme or "aurora")
231
231
 
232
232
  config = build_config(
233
233
  script_path=in_path if suffix != ".cast" else None,
@@ -17,4 +17,4 @@
17
17
  : SC set theme-shadow-radius 20
18
18
  : SC set theme-shadow-offset-y 21
19
19
  : SC set theme-shadow-offset-x 0
20
- : SC set prompt "\x1b[92m> \x1b[0m"
20
+ : SC set prompt $'\x1b[92m> \x1b[0m'
@@ -17,3 +17,4 @@
17
17
  : SC set theme-shadow-radius 20
18
18
  : SC set theme-shadow-offset-y 21
19
19
  : SC set theme-shadow-offset-x 0
20
+ : SC set prompt $'\x1b[92m> \x1b[0m'
@@ -17,3 +17,4 @@
17
17
  : SC set theme-shadow-radius 20
18
18
  : SC set theme-shadow-offset-y 21
19
19
  : SC set theme-shadow-offset-x 0
20
+ : SC set prompt $'\x1b[92m> \x1b[0m'
@@ -15,7 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from .config import ScriptcastConfig
17
17
  from .directives import ScEvent, build_directives
18
- from .shell import get_adapter
18
+ from .shell import ShellAdapter, get_adapter
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -89,10 +89,16 @@ def _postprocess(
89
89
  raw_text: str,
90
90
  trace_prefix: str = "+",
91
91
  directive_prefix: str = "SC",
92
+ adapter: ShellAdapter | None = None,
92
93
  ) -> str:
93
94
  """Convert raw .log text to JSONL .sc body (no header line)."""
94
95
  directives = build_directives(directive_prefix, trace_prefix)
95
96
  events = _parse_raw(raw_text, trace_prefix, directive_prefix)
97
+ if adapter is not None:
98
+ events = [
99
+ ScEvent(e.ts, e.type, adapter.unescape_xtrace(e.text)) if e.type == "dir" else e
100
+ for e in events
101
+ ]
96
102
  for d in directives:
97
103
  events = d.post(events)
98
104
  return _serialise(events)
@@ -184,7 +190,7 @@ def record(
184
190
  xtrace_path = sc_path.with_suffix('.xtrace')
185
191
  xtrace_path.write_text(raw_text)
186
192
  logger.info("Saved: %s", xtrace_path)
187
- clean_text = _postprocess(raw_text, config.trace_prefix, config.directive_prefix)
193
+ clean_text = _postprocess(raw_text, config.trace_prefix, config.directive_prefix, adapter)
188
194
  logger.debug("Post-processed to %d events", clean_text.count("\n"))
189
195
 
190
196
  header = json.dumps({
@@ -11,3 +11,9 @@ class ShellAdapter(ABC):
11
11
  def tracing_preamble(self, trace_prefix: str) -> str:
12
12
  """Return shell code to enable tracing with the given prefix."""
13
13
  ...
14
+
15
+ def unescape_xtrace(self, text: str) -> str:
16
+ """Decode shell-specific quoting in a directive text from xtrace output.
17
+ Default implementation is identity (correct for bash).
18
+ """
19
+ return text
@@ -0,0 +1,72 @@
1
+ # scriptcast/shell/zsh.py
2
+ import re
3
+
4
+ from .adapter import ShellAdapter
5
+
6
+ _ANSI_C_RE = re.compile(r"\$'((?:[^'\\]|\\.)*)'")
7
+ _SIMPLE_ESCAPES = {
8
+ 'n': '\n', 'r': '\r', 't': '\t', 'a': '\a',
9
+ 'b': '\b', 'f': '\f', 'v': '\v', "'": "'", '\\': '\\',
10
+ }
11
+
12
+
13
+ def _decode_ansi_c_body(body: str) -> str:
14
+ """Decode the interior of a $'...' ANSI-C quoted string as produced by zsh xtrace."""
15
+ out: list[str] = []
16
+ i = 0
17
+ while i < len(body):
18
+ ch = body[i]
19
+ if ch != '\\' or i + 1 >= len(body):
20
+ out.append(ch)
21
+ i += 1
22
+ continue
23
+ nxt = body[i + 1]
24
+ if nxt in _SIMPLE_ESCAPES:
25
+ out.append(_SIMPLE_ESCAPES[nxt])
26
+ i += 2
27
+ elif nxt in ('e', 'E'):
28
+ out.append('\x1b')
29
+ i += 2
30
+ elif nxt in ('C', 'c') and i + 3 < len(body) and body[i + 2] == '-':
31
+ # \C-X → Ctrl+X; e.g. \C-[ → chr(91-64) = chr(27) = ESC
32
+ code = ord(body[i + 3].upper()) - 64
33
+ if 0 <= code <= 127:
34
+ out.append(chr(code))
35
+ i += 4
36
+ else:
37
+ out.append('\\')
38
+ out.append(nxt)
39
+ i += 2
40
+ elif nxt == 'x':
41
+ hex_str = body[i + 2:i + 4]
42
+ if len(hex_str) == 2 and all(c in '0123456789abcdefABCDEF' for c in hex_str):
43
+ out.append(chr(int(hex_str, 16)))
44
+ i += 4
45
+ else:
46
+ out.append('\\')
47
+ out.append(nxt)
48
+ i += 2
49
+ elif nxt in '01234567':
50
+ j = i + 1
51
+ while j < len(body) and j < i + 4 and body[j] in '01234567':
52
+ j += 1
53
+ out.append(chr(int(body[i + 1:j], 8)))
54
+ i = j
55
+ else:
56
+ out.append('\\')
57
+ out.append(nxt)
58
+ i += 2
59
+ return ''.join(out)
60
+
61
+
62
+ class ZshAdapter(ShellAdapter):
63
+ @property
64
+ def name(self) -> str:
65
+ return "zsh"
66
+
67
+ def tracing_preamble(self, trace_prefix: str) -> str:
68
+ return f'PS4="{trace_prefix} "\nsetopt xtrace\n'
69
+
70
+ def unescape_xtrace(self, text: str) -> str:
71
+ """Expand $'...' ANSI-C spans in zsh xtrace directive text."""
72
+ return _ANSI_C_RE.sub(lambda m: _decode_ansi_c_body(m.group(1)), text)
@@ -29,7 +29,7 @@ def test_sc_event_fields():
29
29
  def test_sc_event_is_frozen():
30
30
  e = ScEvent(ts=1.0, type="cmd", text="echo hi")
31
31
  with pytest.raises(dataclasses.FrozenInstanceError):
32
- e.ts = 2.0 # type: ignore[misc]
32
+ e.ts = 2.0
33
33
 
34
34
 
35
35
  def test_directive_pre_passthrough():
@@ -2,6 +2,9 @@
2
2
  import json
3
3
  import logging
4
4
  import shutil
5
+ from unittest.mock import MagicMock
6
+
7
+ import pytest
5
8
 
6
9
  from scriptcast.config import ScriptcastConfig
7
10
  from scriptcast.directives import ScEvent
@@ -460,3 +463,53 @@ def test_record_no_xtrace_log_by_default(tmp_path):
460
463
  xtrace_path = tmp_path / "demo.xtrace"
461
464
  assert not xtrace_path.exists()
462
465
 
466
+
467
+ def test_postprocess_applies_unescape_to_dir_events():
468
+ """adapter.unescape_xtrace is called on dir events, not cmd or out events."""
469
+ adapter = MagicMock()
470
+ adapter.unescape_xtrace.side_effect = lambda t: t.replace("BEFORE", "AFTER")
471
+
472
+ raw = (
473
+ "1.0 + : SC scene main\n"
474
+ "1.1 + echo hi\n"
475
+ "1.2 hello\n"
476
+ )
477
+ _postprocess(raw, adapter=adapter)
478
+
479
+ # called once for the dir event "scene main", not for cmd or out
480
+ assert adapter.unescape_xtrace.call_count == 1
481
+ adapter.unescape_xtrace.assert_called_with("scene main")
482
+
483
+
484
+ def test_postprocess_unescape_transforms_dir_text():
485
+ """unescape result is used as the directive text in the .sc output."""
486
+ adapter = MagicMock()
487
+ adapter.unescape_xtrace.return_value = "set prompt \x1b[92m> \x1b[0m"
488
+
489
+ raw = "1.0 + : SC set prompt $'\\C-[[92m> \\C-[[0m'\n"
490
+ sc_body = _postprocess(raw, adapter=adapter)
491
+
492
+ events = [json.loads(line) for line in sc_body.strip().splitlines()]
493
+ assert events[0][1] == "dir"
494
+ assert events[0][2] == "set prompt \x1b[92m> \x1b[0m"
495
+
496
+
497
+ def test_record_zsh_prompt_esc_bytes(tmp_path):
498
+ """zsh $'...' prompt survives record → .sc with correct ESC bytes."""
499
+ zsh = shutil.which("zsh")
500
+ if zsh is None:
501
+ pytest.skip("zsh not available")
502
+
503
+ script = tmp_path / "t.sh"
504
+ # $'\x1b[92m> \x1b[0m' expands to ESC bytes; zsh xtrace uses $'\C-[...'
505
+ script.write_text(": SC set prompt $'\\x1b[92m> \\x1b[0m'\n")
506
+ sc_path = tmp_path / "t.sc"
507
+ record(script, sc_path, ScriptcastConfig(), zsh)
508
+
509
+ lines = sc_path.read_text().splitlines()
510
+ dir_events = [
511
+ json.loads(ln) for ln in lines[1:]
512
+ if json.loads(ln)[1] == "dir" and json.loads(ln)[2].startswith("set prompt")
513
+ ]
514
+ assert dir_events, "no 'set prompt' dir event found in .sc"
515
+ assert dir_events[0][2] == "set prompt \x1b[92m> \x1b[0m"
@@ -0,0 +1,94 @@
1
+ # tests/test_shell.py
2
+ import pytest
3
+
4
+ from scriptcast.shell import get_adapter
5
+ from scriptcast.shell.bash import BashAdapter
6
+ from scriptcast.shell.zsh import ZshAdapter
7
+
8
+
9
+ def test_get_bash():
10
+ assert isinstance(get_adapter("bash"), BashAdapter)
11
+
12
+
13
+ def test_get_zsh():
14
+ assert isinstance(get_adapter("zsh"), ZshAdapter)
15
+
16
+
17
+ def test_get_full_path():
18
+ assert isinstance(get_adapter("/bin/bash"), BashAdapter)
19
+
20
+
21
+ def test_get_unsupported_raises():
22
+ with pytest.raises(ValueError, match="Unsupported shell"):
23
+ get_adapter("fish")
24
+
25
+
26
+ def test_bash_preamble_contains_set_x():
27
+ p = BashAdapter().tracing_preamble("+")
28
+ assert "set -x" in p
29
+ assert 'PS4="+ "' in p
30
+
31
+
32
+ def test_bash_preamble_custom_prefix():
33
+ p = BashAdapter().tracing_preamble(">>")
34
+ assert 'PS4=">> "' in p
35
+
36
+
37
+ def test_zsh_preamble_contains_xtrace():
38
+ p = ZshAdapter().tracing_preamble("+")
39
+ assert "setopt xtrace" in p
40
+ assert 'PS4="+ "' in p
41
+
42
+
43
+ def test_bash_unescape_xtrace_is_identity():
44
+ adapter = BashAdapter()
45
+ text = "set prompt $'\\C-[[92m> \\C-[[0m'"
46
+ assert adapter.unescape_xtrace(text) == text
47
+
48
+
49
+ def test_zsh_unescape_ctrl_bracket_to_esc():
50
+ # \C-[ is zsh's notation for ESC (Ctrl+[, chr 27)
51
+ result = ZshAdapter().unescape_xtrace("set prompt $'\\C-[[92m> \\C-[[0m'")
52
+ assert result == "set prompt \x1b[92m> \x1b[0m"
53
+
54
+
55
+ def test_zsh_unescape_octal():
56
+ result = ZshAdapter().unescape_xtrace("set prompt $'\\033[92m> \\033[0m'")
57
+ assert result == "set prompt \x1b[92m> \x1b[0m"
58
+
59
+
60
+ def test_zsh_unescape_hex():
61
+ result = ZshAdapter().unescape_xtrace("set prompt $'\\x1b[92m> \\x1b[0m'")
62
+ assert result == "set prompt \x1b[92m> \x1b[0m"
63
+
64
+
65
+ def test_zsh_unescape_escape_letter():
66
+ result = ZshAdapter().unescape_xtrace("set prompt $'\\e[92m> \\e[0m'")
67
+ assert result == "set prompt \x1b[92m> \x1b[0m"
68
+
69
+
70
+ def test_zsh_unescape_standard_escapes():
71
+ result = ZshAdapter().unescape_xtrace("$'\\n\\t\\r\\\\'")
72
+ assert result == "\n\t\r\\"
73
+
74
+
75
+ def test_zsh_unescape_no_dollar_quote_unchanged():
76
+ text = "set prompt '\\033[92m> \\033[0m'"
77
+ assert ZshAdapter().unescape_xtrace(text) == text
78
+
79
+
80
+ def test_zsh_unescape_multiple_spans():
81
+ result = ZshAdapter().unescape_xtrace("$'\\C-['foo$'\\C-['")
82
+ assert result == "\x1bfoo\x1b"
83
+
84
+
85
+ def test_zsh_unescape_unknown_escape_passthrough():
86
+ # \q is not a known escape — passes through unchanged
87
+ result = ZshAdapter().unescape_xtrace("$'\\q'")
88
+ assert result == "\\q"
89
+
90
+
91
+ def test_zsh_unescape_ctrl_out_of_range_passthrough():
92
+ # \C-? would give chr(-1) — should passthrough, not crash
93
+ result = ZshAdapter().unescape_xtrace("$'\\C-?'")
94
+ assert result == "\\C-?"
Binary file
Binary file
Binary file
@@ -1,11 +0,0 @@
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'
@@ -1,34 +0,0 @@
1
- # tests/test_shell.py
2
- import pytest
3
-
4
- from scriptcast.shell import get_adapter
5
- from scriptcast.shell.bash import BashAdapter
6
- from scriptcast.shell.zsh import ZshAdapter
7
-
8
-
9
- def test_get_bash():
10
- assert isinstance(get_adapter("bash"), BashAdapter)
11
-
12
- def test_get_zsh():
13
- assert isinstance(get_adapter("zsh"), ZshAdapter)
14
-
15
- def test_get_full_path():
16
- assert isinstance(get_adapter("/bin/bash"), BashAdapter)
17
-
18
- def test_get_unsupported_raises():
19
- with pytest.raises(ValueError, match="Unsupported shell"):
20
- get_adapter("fish")
21
-
22
- def test_bash_preamble_contains_set_x():
23
- p = BashAdapter().tracing_preamble("+")
24
- assert "set -x" in p
25
- assert 'PS4="+ "' in p
26
-
27
- def test_bash_preamble_custom_prefix():
28
- p = BashAdapter().tracing_preamble(">>")
29
- assert 'PS4=">> "' in p
30
-
31
- def test_zsh_preamble_contains_xtrace():
32
- p = ZshAdapter().tracing_preamble("+")
33
- assert "setopt xtrace" in p
34
- assert 'PS4="+ "' in p
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes