revision-engine 0.2.2__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,3 @@
1
+ """reVision: a static-site build tool for teaching code by example."""
2
+
3
+ __version__ = "0.2.2"
@@ -0,0 +1,163 @@
1
+ """Named code regions: load from anchors.yaml and resolve to concrete line ranges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from revision_engine.config import SLUG_RE
13
+ from revision_engine.errors import BuildError
14
+ from revision_engine.sources import SourceFile
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AnchorSpec:
19
+ name: str
20
+ file: str
21
+ from_: str | None
22
+ to: str | None
23
+ match: str | None
24
+ lines: tuple[int, int] | None
25
+ variant: bool
26
+ label: str
27
+ within: str | None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Anchor:
32
+ name: str
33
+ file: str
34
+ start: int
35
+ end: int
36
+ variant: bool
37
+ label: str
38
+
39
+
40
+ def _parse_lines(value: Any, name: str, where: str) -> tuple[int, int]:
41
+ if isinstance(value, str):
42
+ m = re.fullmatch(r"\s*(\d+)\s*-\s*(\d+)\s*", value)
43
+ if not m:
44
+ raise BuildError(f"{where}: anchor {name!r}: lines must look like '88-92'")
45
+ a, b = int(m.group(1)), int(m.group(2))
46
+ elif isinstance(value, list) and len(value) == 2 and all(isinstance(v, int) for v in value):
47
+ a, b = value
48
+ elif isinstance(value, int):
49
+ a = b = value
50
+ else:
51
+ raise BuildError(f"{where}: anchor {name!r}: lines must look like '88-92'")
52
+ if a < 1 or b < a:
53
+ raise BuildError(f"{where}: anchor {name!r}: lines {a}-{b} is not a valid range")
54
+ return (a, b)
55
+
56
+
57
+ def load_anchors(path: Path, default_file: str, where: str) -> dict[str, AnchorSpec]:
58
+ if not path.is_file():
59
+ return {}
60
+ try:
61
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
62
+ except yaml.YAMLError as e:
63
+ raise BuildError(f"{where}: invalid YAML: {e}") from e
64
+ if not isinstance(data, dict):
65
+ raise BuildError(f"{where}: top level must be a mapping of anchor name to definition")
66
+
67
+ specs: dict[str, AnchorSpec] = {}
68
+ for name, body in data.items():
69
+ if not isinstance(name, str) or not SLUG_RE.match(name):
70
+ raise BuildError(f"{where}: {name!r} is not a slug (lowercase letters, digits, hyphens)")
71
+ if not isinstance(body, dict):
72
+ raise BuildError(f"{where}: anchor {name!r} must be a mapping")
73
+ forms = [k for k in ("from", "match", "lines") if k in body]
74
+ if len(forms) != 1 or ("to" in body and "from" not in body):
75
+ raise BuildError(f"{where}: anchor {name!r} must use exactly one of from/to, match, lines")
76
+ if "from" in body and "to" not in body:
77
+ raise BuildError(f"{where}: anchor {name!r} has 'from' but no 'to'")
78
+ for key in ("from", "to", "match", "file", "label", "within"):
79
+ if key in body and not isinstance(body[key], str):
80
+ raise BuildError(f"{where}: anchor {name!r}: {key} must be a string")
81
+ if "to" in body:
82
+ try:
83
+ re.compile(body["to"])
84
+ except re.error as e:
85
+ raise BuildError(f"{where}: anchor {name!r}: 'to' is not a valid regex: {e}") from e
86
+ variant = body.get("variant", False)
87
+ if not isinstance(variant, bool):
88
+ raise BuildError(f"{where}: anchor {name!r}: variant must be true or false")
89
+ specs[name] = AnchorSpec(
90
+ name=name,
91
+ file=body.get("file", default_file).replace(chr(92), "/"),
92
+ from_=body.get("from"),
93
+ to=body.get("to"),
94
+ match=body.get("match"),
95
+ lines=_parse_lines(body["lines"], name, where) if "lines" in body else None,
96
+ variant=variant,
97
+ label=body.get("label", name),
98
+ within=body.get("within"),
99
+ )
100
+ return specs
101
+
102
+
103
+ def _scope(spec: AnchorSpec) -> str:
104
+ return f" within {spec.within!r}" if spec.within else ""
105
+
106
+
107
+ def _single_line(needle: str, source: SourceFile, spec: AnchorSpec, what: str, bounds: tuple[int, int]) -> int:
108
+ lo, hi = bounds
109
+ hits = [i + 1 for i in range(lo - 1, hi) if needle in source.lines[i]]
110
+ if not hits:
111
+ raise BuildError(f"anchor {spec.name!r} in {source.path}: no line contains {needle!r}{_scope(spec)} ({what})")
112
+ if len(hits) > 1:
113
+ listing = "; ".join(f"line {n}: {source.lines[n - 1].strip()}" for n in hits)
114
+ raise BuildError(
115
+ f"anchor {spec.name!r} in {source.path}: {what} {needle!r} matches {len(hits)} lines{_scope(spec)}, "
116
+ f"need exactly one: {listing}"
117
+ )
118
+ return hits[0]
119
+
120
+
121
+ def resolve(spec: AnchorSpec, source: SourceFile, bounds: tuple[int, int] | None = None) -> Anchor:
122
+ """Resolve one spec; ``bounds`` (1-based, inclusive) limits the search to an enclosing anchor."""
123
+ n = len(source.lines)
124
+ lo, hi = bounds or (1, n)
125
+ if spec.lines is not None:
126
+ start, end = spec.lines
127
+ if end > n:
128
+ raise BuildError(f"anchor {spec.name!r} in {source.path}: lines {start}-{end} exceed file length {n}")
129
+ if bounds and (start < lo or end > hi):
130
+ raise BuildError(
131
+ f"anchor {spec.name!r} in {source.path}: lines {start}-{end} fall outside {spec.within!r} ({lo}-{hi})"
132
+ )
133
+ elif spec.match is not None:
134
+ start = end = _single_line(spec.match, source, spec, "match", (lo, hi))
135
+ else:
136
+ assert spec.from_ is not None and spec.to is not None
137
+ start = _single_line(spec.from_, source, spec, "from", (lo, hi))
138
+ pattern = re.compile(spec.to)
139
+ end = next((i + 1 for i in range(start, hi) if pattern.search(source.lines[i])), None)
140
+ if end is None:
141
+ raise BuildError(
142
+ f"anchor {spec.name!r} in {source.path}: no line after line {start}{_scope(spec)} matches to={spec.to!r}"
143
+ )
144
+ return Anchor(name=spec.name, file=source.path, start=start, end=end, variant=spec.variant, label=spec.label)
145
+
146
+
147
+ def resolve_all(specs: dict[str, AnchorSpec], sources: dict[str, SourceFile]) -> dict[str, Anchor]:
148
+ out: dict[str, Anchor] = {}
149
+ for name, spec in specs.items():
150
+ if spec.file not in sources:
151
+ raise BuildError(f"anchor {name!r} refers to file {spec.file!r} which is not loaded")
152
+ bounds = None
153
+ if spec.within is not None:
154
+ if spec.within not in out:
155
+ raise BuildError(
156
+ f"anchor {name!r} is within {spec.within!r}, which is not defined earlier in anchors.yaml"
157
+ )
158
+ outer = out[spec.within]
159
+ if outer.file != spec.file:
160
+ raise BuildError(f"anchor {name!r} is within {spec.within!r} but they name different files")
161
+ bounds = (outer.start, outer.end)
162
+ out[name] = resolve(spec, sources[spec.file], bounds)
163
+ return out
@@ -0,0 +1,268 @@
1
+ """Orchestrate the seven build stages into a static site. Pure: same tree, same bytes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from revision_engine.anchors import Anchor, load_anchors, resolve_all
13
+ from revision_engine.config import (
14
+ Chapter,
15
+ Curriculum,
16
+ LessonRef,
17
+ ProjectConfig,
18
+ load_curriculum,
19
+ load_project_config,
20
+ )
21
+ from revision_engine.diagrams import render_dot, stamp_anchors, unlink_anchors
22
+ from revision_engine.errors import BuildError, Report
23
+ from revision_engine.highlight import highlight_source, token_css
24
+ from revision_engine.intrinsics import lookup
25
+ from revision_engine.lesson import LessonDoc, load_lesson
26
+ from revision_engine.markdown_ext import RenderContext, render_markdown
27
+ from revision_engine.narration import check_cues, load_cues, load_narration
28
+ from revision_engine.render import (
29
+ NavChapter,
30
+ NavLesson,
31
+ PageModel,
32
+ Variant,
33
+ render_page,
34
+ render_redirect,
35
+ site_index,
36
+ write_assets,
37
+ )
38
+ from revision_engine.sources import file_url, git_commit, read_source, short
39
+
40
+ VIDEO_SUFFIXES = (".mp4", ".webm", ".ogv")
41
+
42
+
43
+ @dataclass
44
+ class BuildResult:
45
+ output: Path
46
+ pages: list[Path]
47
+ report: Report
48
+ commit: str
49
+ unlinked: list[tuple[str, int, str]] = field(default_factory=list) # (lesson.md path, line, span text)
50
+
51
+
52
+ @dataclass
53
+ class _LessonBuild:
54
+ ref: LessonRef
55
+ chapter: Chapter
56
+ doc: LessonDoc
57
+ where: str
58
+ anchors: dict[str, Anchor]
59
+ code_html: str
60
+ lesson_html: str
61
+ media: str | None
62
+ cues: list[dict[str, Any]] | None
63
+ unlinked: list[tuple[str, int, str]]
64
+
65
+
66
+ def _rel(cfg: ProjectConfig, p: Path) -> str:
67
+ return p.relative_to(cfg.root).as_posix()
68
+
69
+
70
+ def _check_output(cfg: ProjectConfig, output: Path) -> None:
71
+ """Refuse to wipe the repository itself or any folder above it."""
72
+ output = output.resolve()
73
+ if output == cfg.root or output in cfg.root.parents:
74
+ raise BuildError("output folder must be inside the repository and not the repository itself")
75
+
76
+
77
+ def _build_lesson(cfg: ProjectConfig, chapter: Chapter, ref: LessonRef, report: Report) -> _LessonBuild:
78
+ where = _rel(cfg, ref.path)
79
+ doc = load_lesson(ref.path / "lesson.md", f"{where}/lesson.md")
80
+
81
+ specs = load_anchors(ref.path / "anchors.yaml", doc.file, f"{where}/anchors.yaml")
82
+ files = sorted({doc.file} | {s.file for s in specs.values()})
83
+ sources = {f: read_source(cfg.root, f) for f in files}
84
+ anchors = resolve_all(specs, sources)
85
+ # Version one shows one file per lesson. Every consumer of the anchor set (fragments,
86
+ # variants, cues, diagram nodes) would otherwise box foreign line numbers onto this file.
87
+ for name, a in anchors.items():
88
+ if a.file != doc.file:
89
+ raise BuildError(
90
+ f"{where}/anchors.yaml: anchor {name!r} resolves in {a.file!r} but this lesson shows {doc.file!r}"
91
+ )
92
+ if doc.start is not None and doc.start not in anchors:
93
+ raise BuildError(f"{where}/lesson.md: start anchor {doc.start!r} is not defined in anchors.yaml")
94
+
95
+ diagrams: dict[str, str] = {}
96
+ diagrams_dir = ref.path / "diagrams"
97
+ if diagrams_dir.is_dir():
98
+ for dot in sorted(diagrams_dir.glob("*.dot")):
99
+ dwhere = f"{where}/diagrams/{dot.name}"
100
+ svg, unlinked = unlink_anchors(stamp_anchors(render_dot(dot, dwhere), set(anchors)))
101
+ for node in unlinked:
102
+ report.warn(f"{dwhere}: node {node!r} is an anchor; its link is removed")
103
+ diagrams[dot.stem] = svg
104
+
105
+ ctx = RenderContext(
106
+ anchors={name: a.file for name, a in anchors.items()},
107
+ diagrams=diagrams,
108
+ lookup=lookup,
109
+ report=report,
110
+ where=f"{where}/lesson.md",
111
+ lesson_file=doc.file,
112
+ line_offset=doc.body_line - 1,
113
+ )
114
+ rendered = render_markdown(doc.body, ctx)
115
+
116
+ media: str | None = None
117
+ cues: list[dict[str, Any]] | None = None
118
+ narration_path = ref.path / "narration.yaml"
119
+ cues_path = ref.path / "cues.json"
120
+ if narration_path.is_file():
121
+ if not cues_path.is_file():
122
+ raise BuildError(f"{where}: narration.yaml present but cues.json missing; run 'rv2 narrate {where}'")
123
+ narration = load_narration(narration_path, f"{where}/narration.yaml")
124
+ data = load_cues(cues_path, f"{where}/cues.json")
125
+ check_cues(narration, data, where)
126
+ media = data["media"]
127
+ cues = data["segments"]
128
+ elif doc.media:
129
+ media = doc.media
130
+ if cues_path.is_file():
131
+ cues = load_cues(cues_path, f"{where}/cues.json")["segments"]
132
+
133
+ if media is not None:
134
+ media_path = (ref.path / media).resolve()
135
+ lesson_root = ref.path.resolve()
136
+ if Path(media).is_absolute() or lesson_root not in media_path.parents:
137
+ raise BuildError(f"{where}: media path {media!r} must be a relative path inside the lesson folder")
138
+ if not media_path.is_file():
139
+ raise BuildError(f"{where}: media file {media!r} not found")
140
+ for i, seg in enumerate(cues or [], start=1):
141
+ for name in seg.get("show", []):
142
+ if name not in anchors:
143
+ raise BuildError(f"{where}/cues.json: segment {i} shows unknown anchor {name!r}")
144
+ if "diagram" in seg and seg["diagram"] not in diagrams:
145
+ raise BuildError(f"{where}/cues.json: segment {i} refers to unknown diagram {seg['diagram']!r}")
146
+ if "node" in seg:
147
+ svg = diagrams.get(seg.get("diagram", ""), "")
148
+ if f'data-node="{seg["node"]}"' not in svg:
149
+ raise BuildError(
150
+ f"{where}/cues.json: segment {i} refers to node {seg['node']!r} "
151
+ f"which is not in diagram {seg.get('diagram')!r}"
152
+ )
153
+
154
+ return _LessonBuild(
155
+ ref=ref,
156
+ chapter=chapter,
157
+ doc=doc,
158
+ where=where,
159
+ anchors=anchors,
160
+ code_html=highlight_source(sources[doc.file]),
161
+ lesson_html=rendered.html,
162
+ media=media,
163
+ cues=cues,
164
+ unlinked=[(ctx.where, line, text) for line, text in rendered.unlinked_code],
165
+ )
166
+
167
+
168
+ def _nav(curriculum: Curriculum, current: LessonRef) -> tuple[NavChapter, ...]:
169
+ return tuple(
170
+ NavChapter(
171
+ slug=ch.slug,
172
+ title=ch.title,
173
+ lessons=tuple(
174
+ NavLesson(le.slug, le.title, f"{ch.slug}/{le.slug}/", le is current) for le in ch.lessons
175
+ ),
176
+ )
177
+ for ch in curriculum.chapters
178
+ )
179
+
180
+
181
+ def _media_kind(media: str | None) -> str | None:
182
+ if media is None:
183
+ return None
184
+ return "video" if media.lower().endswith(VIDEO_SUFFIXES) else "audio"
185
+
186
+
187
+ def build(
188
+ root: Path, output: Path | None = None, commit: str | None = None, check_only: bool = False
189
+ ) -> BuildResult:
190
+ cfg = load_project_config(root)
191
+ curriculum = load_curriculum(cfg)
192
+ output = (output or cfg.output).resolve()
193
+ _check_output(cfg, output)
194
+
195
+ commit = commit or git_commit(cfg.root)
196
+ if commit is None:
197
+ raise BuildError("not a git repository; pass --commit <sha> to identify the source revision")
198
+ if not re.fullmatch(r"[0-9a-f]{40}", commit):
199
+ raise BuildError(f"commit must be a 40-character hex sha (got {commit!r})")
200
+
201
+ report = Report()
202
+ flat = [(ch, le) for ch in curriculum.chapters for le in ch.lessons]
203
+ built = [_build_lesson(cfg, ch, le, report) for ch, le in flat]
204
+ unlinked = [u for lb in built for u in lb.unlinked]
205
+
206
+ if check_only:
207
+ return BuildResult(output=output, pages=[], report=report, commit=commit, unlinked=unlinked)
208
+
209
+ if output.exists():
210
+ if any(output.iterdir()) and not (output / "site.json").is_file():
211
+ raise BuildError(
212
+ f"output folder {output} exists and was not written by rv2 (no site.json); "
213
+ "delete it yourself or choose another --output"
214
+ )
215
+ shutil.rmtree(output)
216
+ output.mkdir(parents=True)
217
+ assets = write_assets(output, token_css())
218
+
219
+ pages: list[Path] = []
220
+ for i, lb in enumerate(built):
221
+ ch, le = lb.chapter, lb.ref
222
+ prev_href = f"{flat[i - 1][0].slug}/{flat[i - 1][1].slug}/" if i > 0 else None
223
+ next_href = f"{flat[i + 1][0].slug}/{flat[i + 1][1].slug}/" if i + 1 < len(flat) else None
224
+ variants = tuple(Variant(a.name, a.label, a.start) for a in lb.anchors.values() if a.variant)
225
+ rv_data = {
226
+ "anchors": {name: {"start": a.start, "end": a.end} for name, a in sorted(lb.anchors.items())},
227
+ "start": lb.doc.start,
228
+ "cues": lb.cues,
229
+ "variants": [{"name": v.name, "label": v.label, "start": v.start} for v in variants],
230
+ }
231
+ model = PageModel(
232
+ site_title=cfg.site.title,
233
+ root="../../",
234
+ assets=assets,
235
+ chapter_title=ch.title,
236
+ lesson_title=lb.doc.title,
237
+ summary=lb.doc.summary,
238
+ nav=_nav(curriculum, le),
239
+ prev_href=prev_href,
240
+ next_href=next_href,
241
+ file_path=lb.doc.file,
242
+ commit_short=short(commit),
243
+ file_url=file_url(cfg.repo, commit, lb.doc.file),
244
+ code_html=lb.code_html,
245
+ variants=variants,
246
+ lesson_html=lb.lesson_html,
247
+ media_url=lb.media,
248
+ media_kind=_media_kind(lb.media),
249
+ cues=lb.cues,
250
+ rv_data=rv_data,
251
+ )
252
+ page_dir = output / ch.slug / le.slug
253
+ page_dir.mkdir(parents=True)
254
+ page = page_dir / "index.html"
255
+ page.write_text(render_page(model), encoding="utf-8", newline="\n")
256
+ pages.append(page)
257
+ if lb.media:
258
+ dst = page_dir / lb.media
259
+ dst.parent.mkdir(parents=True, exist_ok=True)
260
+ shutil.copyfile(le.path / lb.media, dst)
261
+
262
+ (output / "site.json").write_text(
263
+ json.dumps(site_index(cfg, curriculum), indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n"
264
+ )
265
+ if flat:
266
+ first = f"{flat[0][0].slug}/{flat[0][1].slug}/"
267
+ (output / "index.html").write_text(render_redirect(first, cfg.site.title), encoding="utf-8", newline="\n")
268
+ return BuildResult(output=output, pages=pages, report=report, commit=commit, unlinked=unlinked)
revision_engine/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """Command-line entry point: rv2 build | check | serve | narrate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from revision_engine import __version__
10
+ from revision_engine.errors import BuildError
11
+
12
+
13
+ def _parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(prog="rv2", description="reVision static-site builder. Revision control, revisited.")
15
+ p.add_argument("--version", action="version", version=f"rv2 {__version__}")
16
+ sub = p.add_subparsers(dest="command", metavar="command")
17
+
18
+ def common(sp: argparse.ArgumentParser) -> None:
19
+ sp.add_argument("--root", type=Path, default=Path("."), help="repository root holding revision.yaml (default: .)")
20
+ sp.add_argument("--commit", help="40-char sha to label the build when not in a git checkout")
21
+
22
+ b = sub.add_parser("build", help="build the static site into the output folder")
23
+ common(b)
24
+ b.add_argument("--output", type=Path, help="override the output folder from revision.yaml")
25
+
26
+ c = sub.add_parser("check", help="run every build check without writing output")
27
+ common(c)
28
+ c.add_argument("--strict", action="store_true", help="exit 1 if the check reports any warning")
29
+ c.add_argument(
30
+ "--list-unlinked", action="store_true", help="list inline code spans that match no vendor intrinsic table"
31
+ )
32
+
33
+ s = sub.add_parser("serve", help="build, serve on localhost, rebuild on change")
34
+ common(s)
35
+ s.add_argument("--port", type=int, default=8000)
36
+
37
+ n = sub.add_parser("narrate", help="synthesize a lesson's narration.yaml into audio + cues.json")
38
+ n.add_argument("lesson_dir", type=Path)
39
+ n.add_argument("--model", type=Path, help="path to a Piper voice model (.onnx); default: <voice>.onnx in $RV2_VOICES or ~/piper-voices")
40
+ n.add_argument(
41
+ "--voice",
42
+ help="voice name, not a path (default: narration.yaml's voice, then the lesson's front matter voice, "
43
+ "then the project's narration.voice)",
44
+ )
45
+ n.add_argument("--silent", action="store_true", help="use the deterministic silent voice (fixtures and tests)")
46
+ n.add_argument("--mp3", action="store_true", help="also transcode to MP3 with ffmpeg and point cues at it")
47
+ return p
48
+
49
+
50
+ def _report(result) -> None:
51
+ for w in result.report.warnings:
52
+ print(f"warning: {w}")
53
+ if result.report.warnings:
54
+ print(f"{len(result.report.warnings)} warning(s)")
55
+
56
+
57
+ def _cmd_build(args) -> int:
58
+ from revision_engine.build import build
59
+
60
+ result = build(args.root, output=args.output, commit=args.commit)
61
+ print(f"built {len(result.pages)} pages at commit {result.commit[:7]} -> {result.output}")
62
+ _report(result)
63
+ return 0
64
+
65
+
66
+ def _cmd_check(args) -> int:
67
+ from revision_engine.build import build
68
+
69
+ result = build(args.root, commit=args.commit, check_only=True)
70
+ failed = args.strict and result.report.warnings
71
+ if not failed:
72
+ print(f"ok: all checks passed at commit {result.commit[:7]}")
73
+ if args.list_unlinked:
74
+ for where, line, text in result.unlinked:
75
+ print(f"unlinked: {where}:{line}: {text!r}")
76
+ if result.unlinked:
77
+ print(f"{len(result.unlinked)} unlinked code span(s)")
78
+ _report(result)
79
+ if failed:
80
+ print(f"error: --strict: {len(result.report.warnings)} warning(s)", file=sys.stderr)
81
+ return 1
82
+ return 0
83
+
84
+
85
+ def _cmd_serve(args) -> int:
86
+ from revision_engine.serve import serve
87
+
88
+ serve(args.root, port=args.port, commit=args.commit)
89
+ return 0
90
+
91
+
92
+ def _cmd_narrate(args) -> int:
93
+ from revision_engine.narration import PiperSynthesizer, SilentSynthesizer, narrate
94
+
95
+ if args.silent:
96
+ synth = SilentSynthesizer()
97
+ voice = args.voice or "silent-fixture"
98
+ else:
99
+ from revision_engine.narration import resolve_voice_model
100
+
101
+ voice, model = resolve_voice_model(args.lesson_dir, args.voice, args.model)
102
+ synth = PiperSynthesizer(model)
103
+ cues = narrate(args.lesson_dir, synth, voice=voice)
104
+ if args.mp3:
105
+ _transcode_mp3(args.lesson_dir, cues)
106
+ print(f"wrote {len(cues['segments'])} segments -> {args.lesson_dir / cues['media']}")
107
+ return 0
108
+
109
+
110
+ def _transcode_mp3(lesson_dir: Path, cues: dict) -> None:
111
+ import json
112
+ import shutil
113
+ import subprocess
114
+
115
+ if shutil.which("ffmpeg") is None:
116
+ raise BuildError("--mp3 needs ffmpeg on PATH")
117
+ wav = lesson_dir / cues["media"]
118
+ mp3 = wav.with_suffix(".mp3")
119
+ proc = subprocess.run(
120
+ ["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav), "-codec:a", "libmp3lame", "-q:a", "4", str(mp3)],
121
+ check=False,
122
+ )
123
+ if proc.returncode != 0:
124
+ raise BuildError("ffmpeg failed to transcode narration")
125
+ wav.unlink()
126
+ cues["media"] = f"media/{mp3.name}"
127
+ (lesson_dir / "cues.json").write_text(
128
+ json.dumps(cues, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n"
129
+ )
130
+
131
+
132
+ def main(argv: list[str] | None = None) -> int:
133
+ parser = _parser()
134
+ args = parser.parse_args(sys.argv[1:] if argv is None else argv)
135
+ if args.command is None:
136
+ parser.print_help(sys.stderr)
137
+ return 2
138
+ # A lesson's text can hold characters the console codec cannot encode (cp1252 on a
139
+ # redirected Windows stdout); escape them rather than crash mid-report. Line buffering
140
+ # keeps stdout ahead of a later stderr line when CI merges the two streams.
141
+ if hasattr(sys.stdout, "reconfigure"):
142
+ sys.stdout.reconfigure(errors="backslashreplace", line_buffering=True)
143
+ try:
144
+ if args.command == "build":
145
+ return _cmd_build(args)
146
+ if args.command == "check":
147
+ return _cmd_check(args)
148
+ if args.command == "serve":
149
+ return _cmd_serve(args)
150
+ if args.command == "narrate":
151
+ return _cmd_narrate(args)
152
+ except BuildError as e:
153
+ print(f"error: {e}", file=sys.stderr)
154
+ return 1
155
+ return 2
156
+
157
+
158
+ if __name__ == "__main__": # pragma: no cover
159
+ sys.exit(main())