Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions milestones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ repositories that install `docgen` and maintain their own demo bundle. The
library no longer ships an in-repo dogfood; consumers are the integration test
of record.

**Active:** **[cli-segments-all.md](cli-segments-all.md)** —
`narration-generate --all` / `scene-spec-generate --all` must use
`Config.segments_all` (missing `all` falls back to `default`).
**Active:** **[timing-stem-objects.md](timing-stem-objects.md)** —
`timing.json` per-stem values must be JSON objects (not lists/scalars).

**Shipped:**
- **[cli-segments-all.md](cli-segments-all.md)** —
`narration-generate --all` / `scene-spec-generate --all` must use
`Config.segments_all` (missing `all` falls back to `default`) (#125).
- **[empty-segments-all.md](empty-segments-all.md)** —
explicit empty `segments.all: []` must not fall through to
`segments.default` in yaml-generate (#124).
Expand Down
2 changes: 1 addition & 1 deletion milestones/cli-segments-all.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Milestone: CLI --all must use Config.segments_all

**Status:** Active
**Status:** Shipped
**PR:** [#125](https://github.com/jmjava/documentation-generator/pull/125)
**Depends on:** `milestones/empty-segments-all.md` (PR #124)

Expand Down
42 changes: 42 additions & 0 deletions milestones/timing-stem-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Milestone: timing.json per-stem values must be objects

**Status:** Active
**PR:** [#126](https://github.com/jmjava/documentation-generator/pull/126)
**Depends on:** `milestones/timing-json-parse.md` (PR #89),
`milestones/cli-segments-all.md` (PR #125)

## Problem

``load_bundle_timing`` required the ``timing.json`` **root** to be a JSON
object. Per-stem values were untyped. A list, string, number, or null under
a stem then:

1. ``scene-spec-generate`` / compile treated the stem as missing words
(``pace: none`` compiled as if timestamps had never been run).
2. ``sync_audio_tail_waits_in_scenes`` called ``.get`` on a list and
raised ``AttributeError``.
3. Wizard freshness treated a list stem as a present timestamps entry.

## Goal

Every present stem value must be a JSON object. Missing ``timing.json``
is still ``{}``. A present non-object stem raises ``TimestampError``.

## Done when

- [x] ``load_bundle_timing`` rejects list / string / number / null stems
- [x] paced and ``pace: none`` compile fail with the parse error
- [x] validate ``timing_sync`` and scene-asset checks surface the error
- [x] ``extract_all`` / wizard statuses do not merge or treat list stems
as valid
- [x] `ruff check src/ tests/`
- [x] `pytest tests/` (725 passed, 1 skipped)
- [x] `docgen benchmark` (no clock change; meets baseline)

## Out of scope

- Empty ``segments.all`` still leaves ``timing.json`` unchanged (#78)
- Inner ``words`` / ``segments`` list typing (paced compile already
fails when ``words`` is missing)
- Bootstrap ``_load_timing`` helpers inside compiled ``scenes.py``
(Manim render still uses ``json.loads``; compile/validate gate first)
7 changes: 4 additions & 3 deletions src/docgen/asset_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ def _find_asset(directory: Path, seg_name: str, seg_id: str, ext: str) -> Path |
def _timing_entry_exists(cfg: "Config", seg_name: str, audio: Path | None) -> bool:
"""True when ``timing.json`` has a stem for this segment.

A missing file is ``False`` (timestamps not run yet). Corrupt JSON or a
non-object root raises :class:`~docgen.timestamps.TimestampError` so the
wizard cannot treat garbage as “no entry”.
A missing file is ``False`` (timestamps not run yet). Corrupt JSON, a
non-object root, or a non-object per-stem value raises
:class:`~docgen.timestamps.TimestampError` so the wizard cannot treat
garbage as “no entry”.
"""
from docgen.timestamps import load_bundle_timing

Expand Down
3 changes: 2 additions & 1 deletion src/docgen/manim_scene_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,8 @@ def sync_audio_tail_waits_in_scenes(cfg: "Config") -> list[str]:
if not isinstance(vm, dict) or str(vm.get("type", "")).lower() != "manim":
continue
stem = cfg.resolve_segment_name(sid)
if not timing.get(stem, {}).get("segments"):
block = timing.get(stem)
if not isinstance(block, dict) or not block.get("segments"):
continue

block_re = re.compile(
Expand Down
12 changes: 9 additions & 3 deletions src/docgen/timestamps.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ class TimestampError(RuntimeError):
def load_bundle_timing(config: "Config") -> dict[str, Any]:
"""Load ``animations/timing.json``.

A missing file is ``{}``. Corrupt JSON or a non-object root raises
:class:`TimestampError` so compile/validate cannot treat garbage as empty
``words``.
A missing file is ``{}``. Corrupt JSON, a non-object root, or a non-object
per-stem value raises :class:`TimestampError` so compile/validate cannot
treat garbage as empty ``words``.
"""
path = config.animations_dir / "timing.json"
if not path.is_file():
Expand All @@ -51,6 +51,12 @@ def load_bundle_timing(config: "Config") -> dict[str, Any]:
raise TimestampError(
f"{path.name} root must be a JSON object, not {type(data).__name__}"
)
for stem, payload in data.items():
if not isinstance(payload, dict):
kind = "null" if payload is None else type(payload).__name__
raise TimestampError(
f"{path.name}[{stem!r}] must be a JSON object, not {kind}"
)
return data


Expand Down
12 changes: 12 additions & 0 deletions tests/test_asset_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ def test_segment_statuses_non_object_timing_json_raises(tmp_path: Path) -> None:
segment_step_statuses(cfg, "01")


def test_segment_statuses_non_object_timing_stem_raises(tmp_path: Path) -> None:
from docgen.timestamps import TimestampError

cfg = _bundle(tmp_path)
(cfg.animations_dir / "timing.json").write_text(
json.dumps({"01-demo": "not-an-object"}), encoding="utf-8"
)
with pytest.raises(TimestampError, match=r"timing.json\['01-demo'\] must be a JSON object"):
segment_step_statuses(cfg, "01")



def test_api_segments_rejects_corrupt_timing_json(tmp_path: Path) -> None:
cfg = _bundle(tmp_path)
(cfg.animations_dir / "timing.json").write_text("{not-json", encoding="utf-8")
Expand Down
34 changes: 34 additions & 0 deletions tests/test_manim_scene_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,40 @@ def construct(self):
assert sync_audio_tail_waits_in_scenes(cfg) == []


def test_sync_audio_tail_waits_rejects_list_stem(tmp_path: Path) -> None:
(tmp_path / "animations").mkdir(parents=True)
(tmp_path / "animations" / "scenes.py").write_text(
"# ── BEGIN GENERATED SCENE: 01 (OverviewScene) ──\n"
"class OverviewScene(_TimedScene):\n"
" def construct(self):\n"
" self.timed_play(Write(Text('x', font_size=24)), run_time=1.0)\n"
"# ── END GENERATED SCENE: 01 ──\n",
encoding="utf-8",
)
(tmp_path / "animations" / "timing.json").write_text(
json.dumps({"01-test": [{"start": 0.0, "end": 1.0}]}) + "\n",
encoding="utf-8",
)
raw = {
"dirs": {
"narration": "n",
"audio": "a",
"animations": "animations",
"recordings": "r",
},
"segments": {"all": ["01"], "default": ["01"]},
"segment_names": {"01": "01-test"},
"visual_map": {
"01": {"type": "manim", "scene": "OverviewScene", "source": "OverviewScene.mp4"}
},
}
(tmp_path / "docgen.yaml").write_text(yaml.dump(raw), encoding="utf-8")
cfg = Config.from_yaml(tmp_path / "docgen.yaml")
with pytest.raises(SceneGenerationError, match=r"timing.json\['01-test'\] must be a JSON object"):
sync_audio_tail_waits_in_scenes(cfg)



_GOOD_CLASS = (
"class DemoFunctionScene(_TimedScene):\n"
" def construct(self):\n"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_scene_asset_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,3 +353,19 @@ def test_list_root_timing_json_is_reported(tmp_path: Path) -> None:
(cfg.animations_dir / "timing.json").write_text("[]\n", encoding="utf-8")
issues = scene_asset_violations_for_segment(cfg, "01")
assert any("JSON object" in i for i in issues)


def test_non_object_timing_stem_is_reported(tmp_path: Path) -> None:
cfg = _bundle(tmp_path)
specs = cfg.animations_dir / "specs"
specs.mkdir(parents=True, exist_ok=True)
(specs / "01-x.scene.yaml").write_text(
yaml.dump(_spec([_box("Alpha")])),
encoding="utf-8",
)
(cfg.animations_dir / "timing.json").write_text(
json.dumps({"01-x": None}) + "\n", encoding="utf-8"
)
issues = scene_asset_violations_for_segment(cfg, "01")
assert any("timing.json['01-x'] must be a JSON object, not null" in i for i in issues)

31 changes: 31 additions & 0 deletions tests/test_scene_retime.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,37 @@ def test_linted_class_block_fails_on_list_root_timing_json(tmp_path: Path) -> No
linted_class_block_from_spec(cfg, spec, timing_key="01-demo")


def test_linted_class_block_fails_on_non_object_timing_stem(tmp_path: Path) -> None:
cfg = _cfg(tmp_path)
(tmp_path / "animations" / "timing.json").write_text(
json.dumps({"01-demo": ["not", "an", "object"]}) + "\n",
encoding="utf-8",
)
spec = {
"segment_id": "01",
"class_name": "DemoScene",
"title": {"text": "Demo", "font_size": 36, "color": "C_WHITE"},
"rows": [
{
"run_time": 1.0,
"boxes": [
{
"label": "Hello",
"color": "C_GREEN",
"width": 3.0,
"height": 0.9,
"font_size": 18,
"pace": "none",
}
],
}
],
}
with pytest.raises(SceneGenerationError, match=r"timing.json\['01-demo'\] must be a JSON object"):
linted_class_block_from_spec(cfg, spec, timing_key="01-demo")



def test_retime_compile_spec_rewrites_scenes_py(tmp_path: Path) -> None:
cfg = _cfg(tmp_path)
path = _write_spec(tmp_path, label="Hello")
Expand Down
41 changes: 41 additions & 0 deletions tests/test_timestamps_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,44 @@ def test_extract_all_rejects_non_object_timing_json(self, cfg, monkeypatch) -> N
with pytest.raises(TimestampError, match="JSON object"):
TimestampExtractor(cfg).extract_all()
assert out.read_text(encoding="utf-8") == "[1, 2]\n"


def test_extract_all_rejects_non_object_timing_stem(self, cfg, monkeypatch) -> None:
_fake_audio_env(monkeypatch)
(cfg.narration_dir / "01-x.md").write_text("Alpha begins the story.\n", encoding="utf-8")
(cfg.audio_dir / "01-x.mp3").write_bytes(b"fake-mp3")
out = cfg.animations_dir / "timing.json"
out.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"legacy-stem": ["not", "an", "object"]}) + "\n"
out.write_text(payload, encoding="utf-8")
from docgen.timestamps import TimestampError

with pytest.raises(TimestampError, match=r"timing.json\['legacy-stem'\] must be a JSON object"):
TimestampExtractor(cfg).extract_all()
assert out.read_text(encoding="utf-8") == payload


def test_load_bundle_timing_rejects_scalar_stems(self, cfg) -> None:
from docgen.timestamps import TimestampError, load_bundle_timing

out = cfg.animations_dir / "timing.json"
out.parent.mkdir(parents=True, exist_ok=True)
cases = (
({"01-x": "whisper-dump"}, "str"),
({"01-x": 3}, "int"),
({"01-x": None}, "null"),
)
for payload, kind in cases:
out.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(TimestampError, match=rf"timing.json\['01-x'\] must be a JSON object, not {kind}"):
load_bundle_timing(cfg)

def test_load_bundle_timing_accepts_object_stems(self, cfg) -> None:
from docgen.timestamps import load_bundle_timing

out = cfg.animations_dir / "timing.json"
out.parent.mkdir(parents=True, exist_ok=True)
payload = {"01-x": {"text": "ok", "words": [], "segments": []}}
out.write_text(json.dumps(payload), encoding="utf-8")
assert load_bundle_timing(cfg) == payload

10 changes: 10 additions & 0 deletions tests/test_validate_timing_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ def test_corrupt_timing_json_fails_for_manim(self, cfg, monkeypatch) -> None:
assert not check.passed
assert any("not valid JSON" in d for d in check.details)

def test_non_object_timing_stem_fails_for_manim(self, cfg, monkeypatch) -> None:
(cfg.animations_dir / "timing.json").write_text(
json.dumps({"01-x": [1, 2, 3]}) + "\n", encoding="utf-8"
)
_patch_audio_duration(monkeypatch, 10.0)
check = Validator(cfg)._check_timing_sync("01")
assert not check.passed
assert any("timing.json['01-x'] must be a JSON object" in d for d in check.details)


def test_missing_timing_entry_skips_for_non_manim(self, tmp_path, monkeypatch) -> None:
cfg = _bundle(tmp_path, visual_type="still")
(cfg.audio_dir / "01-x.mp3").write_bytes(b"fake mp3 bytes")
Expand Down