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
6 changes: 4 additions & 2 deletions milestones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ 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:** **[timing-json-parse.md](timing-json-parse.md)** —
corrupt `timing.json` must not be treated as empty words at compile/validate.
**Active:** **[context-path-lists.md](context-path-lists.md)** —
`pages.segments` and narration/manim context path lists must be typed.

**Shipped:**
- **[timing-json-parse.md](timing-json-parse.md)** — corrupt `timing.json`
must not be treated as empty words at compile/validate (#89).
- **[lint-empty-narration.md](lint-empty-narration.md)** — lint / validate fail
when narration has no spoken prose after markdown stripping (#88).
- **[segment-id-strings.md](segment-id-strings.md)** — unquoted YAML `01`
Expand Down
40 changes: 40 additions & 0 deletions milestones/context-path-lists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Milestone: context path lists and `pages.segments` mapping

**Status:** Active
**PR:** pending
**Depends on:** `milestones/config-mapping-keys.md` (PR #83),
`milestones/segment-id-strings.md` (PR #87)

## Problem

1. **`pages.segments` as a list** — `_resolve_segments_cfg` only used the block
when it was a non-empty mapping. A list fell through to discovery and
silently ignored maintainer titles.
2. **`narration_from_source.context.paths` as a string** — `_as_str_list`
wrapped a single string as one path (OK) but a mapping or other type
became `[]`, so narration-generate ran with no focus files.
3. **`manim_scene_generation` as a list** — was not in the Config mapping-key
gate; later code treated it as `{}`.

## Goal

Fail closed at `Config.from_yaml` for those nested types.

## Done when

- [x] `pages.segments` if present must be a mapping (keys still quoted strings).
- [x] `narration_from_source` / `manim_scene_generation` `hints`,
`context.paths`, `context.globs` must be string lists; `segments` a mapping
of mappings.
- [x] Tests for list `pages.segments`, string `context.paths`, list
`narration_from_source.segments`, string per-segment row, list
`manim_scene_generation`.
- [ ] `ruff check src/ tests/`
- [ ] `pytest tests/`
- [ ] `docgen benchmark` (no clock change)

## Out of scope

- `_as_str_list` still wraps a single string as a one-item list for library
callers that do not go through `Config.from_yaml`
- Empty `pages.segments: {}` still falls back to discovery
10 changes: 5 additions & 5 deletions milestones/timing-json-parse.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Milestone: corrupt `timing.json` must not look like empty words

**Status:** Active
**PR:** pending
**Status:** Shipped
**PR:** #89
**Depends on:** `milestones/wizard-narration-paths.md` (PR #81)

## Problem
Expand All @@ -22,9 +22,9 @@ One loader: missing file → `{}`; garbage JSON or a non-object root → error.
- [x] validate `timing_sync` / `story_end` / `av_sync` fail with the parse error
- [x] `scene_asset_validate` and wizard timestamps reuse the loader
- [x] Tests: corrupt JSON and list-root, including `pace: none`
- [ ] `ruff check src/ tests/`
- [ ] `pytest tests/`
- [ ] `docgen benchmark` (no clock change)
- [x] `ruff check src/ tests/`
- [x] `pytest tests/`
- [x] `docgen benchmark` (no clock change)

## Out of scope

Expand Down
51 changes: 51 additions & 0 deletions src/docgen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,42 @@ def string_list_block(
]


def require_hint_and_context_lists(
block: dict[str, Any],
*,
prefix: str,
source: str,
) -> None:
"""Fail closed when hints/context.paths/globs or per-segment blocks have the wrong YAML type."""
if block.get("hints") is not None:
string_list_block(block, "hints", label=f"{prefix}.hints", source=source)
ctx = block.get("context")
if ctx is not None:
if not isinstance(ctx, dict):
raise ConfigError(
f"{source}: {prefix}.context must be a YAML mapping, not {type(ctx).__name__}"
)
string_list_block(ctx, "paths", label=f"{prefix}.context.paths", source=source)
string_list_block(ctx, "globs", label=f"{prefix}.context.globs", source=source)
segs = block.get("segments")
if segs is None:
return
if not isinstance(segs, dict):
raise ConfigError(
f"{source}: {prefix}.segments must be a YAML mapping, not {type(segs).__name__}"
)
for sid, spec in segs.items():
sid_s = require_yaml_string(sid, label=f"{prefix}.segments key", source=source)
if spec is None:
continue
if not isinstance(spec, dict):
raise ConfigError(
f"{source}: {prefix}.segments.{sid_s} must be a YAML mapping, "
f"not {type(spec).__name__}"
)
require_hint_and_context_lists(spec, prefix=f"{prefix}.segments.{sid_s}", source=source)


@dataclass
class Config:
"""Parsed and validated project configuration."""
Expand Down Expand Up @@ -151,6 +187,7 @@ def __post_init__(self) -> None:
"image_generation",
"ai",
"narration_from_source",
"manim_scene_generation",
"discovery",
):
self._block(key)
Expand Down Expand Up @@ -184,9 +221,23 @@ def __post_init__(self) -> None:
for key in self._block("segment_names"):
require_yaml_string(key, label="segment_names key", source=src)
pages_segs = self._block("pages").get("segments")
if pages_segs is not None and not isinstance(pages_segs, dict):
raise ConfigError(
f"{src}: pages.segments must be a YAML mapping, not {type(pages_segs).__name__}"
)
if isinstance(pages_segs, dict):
for key in pages_segs:
require_yaml_string(key, label="pages.segments key", source=src)
require_hint_and_context_lists(
self._block("narration_from_source"),
prefix="narration_from_source",
source=src,
)
require_hint_and_context_lists(
self._block("manim_scene_generation"),
prefix="manim_scene_generation",
source=src,
)
vm = self._block("visual_map")
for sid, spec in vm.items():
sid_s = require_yaml_string(sid, label="visual_map key", source=src)
Expand Down
4 changes: 4 additions & 0 deletions src/docgen/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ def _resolve_segments_cfg(self) -> dict[str, dict]:
a parallel hand-curated block in ``docgen.yaml``.
"""
explicit = self.pages_cfg.get("segments")
if explicit is not None and not isinstance(explicit, dict):
raise RuntimeError(
f"pages.segments must be a YAML mapping, not {type(explicit).__name__}"
)
if isinstance(explicit, dict) and explicit:
for sid, meta in explicit.items():
if not isinstance(meta, dict):
Expand Down
43 changes: 43 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,46 @@ def test_from_yaml_unquoted_pages_segments_key_raises(tmp_path: Path) -> None:
p.write_text("pages:\n segments:\n 01:\n title: Overview\n", encoding="utf-8")
with pytest.raises(ConfigError, match="pages.segments key must be a YAML string"):
Config.from_yaml(p)


def test_from_yaml_list_pages_segments_raises(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text('pages:\n segments: ["01"]\n', encoding="utf-8")
with pytest.raises(ConfigError, match="pages.segments must be a YAML mapping"):
Config.from_yaml(p)


def test_from_yaml_string_context_paths_raises(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text(
"narration_from_source:\n context:\n paths: README.md\n",
encoding="utf-8",
)
with pytest.raises(ConfigError, match="narration_from_source.context.paths must be a YAML list"):
Config.from_yaml(p)


def test_from_yaml_list_nfs_segments_raises(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text("narration_from_source:\n segments: []\n", encoding="utf-8")
with pytest.raises(ConfigError, match="narration_from_source.segments must be a YAML mapping"):
Config.from_yaml(p)


def test_from_yaml_string_nfs_segment_row_raises(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text(
'narration_from_source:\n segments:\n "01": intro\n',
encoding="utf-8",
)
with pytest.raises(
ConfigError, match="narration_from_source.segments.01 must be a YAML mapping"
):
Config.from_yaml(p)


def test_from_yaml_list_manim_scene_generation_raises(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text("manim_scene_generation: []\n", encoding="utf-8")
with pytest.raises(ConfigError, match="manim_scene_generation must be a YAML mapping"):
Config.from_yaml(p)