Skip to content

Commit 912cfa0

Browse files
authored
Merge pull request #87 from jmjava/cursor/segment-id-strings-2ccd
Fail closed when YAML parses segment ids as integers
2 parents 03fc3d6 + 446434f commit 912cfa0

7 files changed

Lines changed: 129 additions & 12 deletions

File tree

‎milestones/README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ repositories that install `docgen` and maintain their own demo bundle. The
55
library no longer ships an in-repo dogfood; consumers are the integration test
66
of record.
77

8-
**Active:** **[visual-map-row-types.md](visual-map-row-types.md)** —
9-
`visual_map` rows and `pages.segments` entries must be mappings; unknown
10-
compose `type` must fail closed.
8+
**Active:** **[segment-id-strings.md](segment-id-strings.md)** —
9+
unquoted YAML `01` must not become integer segment ids.
1110

1211
**Shipped:**
12+
- **[visual-map-row-types.md](visual-map-row-types.md)** — `visual_map` rows
13+
and `pages.segments` entries must be mappings; unknown compose `type`
14+
fails closed (#86).
1315
- **[timestamps-empty-words.md](timestamps-empty-words.md)** — heading-only
1416
/ empty-word timestamps fail closed (#85).
1517
- **[concat-segment-lists.md](concat-segment-lists.md)** — concat targets

‎milestones/segment-id-strings.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Milestone: quoted string segment ids
2+
3+
**Status:** Active
4+
**PR:** pending
5+
**Depends on:** `milestones/visual-map-row-types.md` (PR #86)
6+
7+
## Problem
8+
9+
PyYAML parses unquoted `01` as integer `1`. `segments.all`, `visual_map` keys,
10+
`segment_names` keys, `concat` items, and `pages.segments` keys then silently
11+
mismatch quoted `"01"` used everywhere else (`str(1)` becomes `"1"`, not `"01"`).
12+
13+
`yaml.dump` from Python string keys writes `'01':`, so tool-generated configs
14+
are fine. Hand-authored `visual_map: { 01: { type: manim } }` looks mapped and
15+
is treated as unmapped.
16+
17+
## Goal
18+
19+
Fail closed: those ids must be YAML strings. Tell the author to write `"01"`.
20+
21+
## Done when
22+
23+
- [x] `string_list_block` rejects non-string items (`segments.all`, `manim.scenes`).
24+
- [x] `visual_map`, `segment_names`, and `pages.segments` keys must be strings.
25+
- [x] `concat.<name>` list items must be strings (`ConfigError` + `ConcatError`).
26+
- [x] Tests for unquoted `01` in each of those places.
27+
- [ ] `ruff check src/ tests/`
28+
- [ ] `pytest tests/`
29+
- [ ] `docgen benchmark` (no clock change)
30+
31+
## Out of scope
32+
33+
- Zero-padding integer `1` → `"01"` (lossy; `1` and `01` are the same YAML int)
34+
- Changing PyYAML dump style (already quotes `'01'`)

‎milestones/visual-map-row-types.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Milestone: `visual_map` row types and pages segment entries
22

3-
**Status:** Active
4-
**PR:** pending
3+
**Status:** Shipped
4+
**PR:** #86
55
**Depends on:** `milestones/config-mapping-keys.md` (PR #83)
66

77
## Problem
@@ -33,9 +33,9 @@ Fail closed at config load, yaml-generate discovery, compose, and pages.
3333
`test_compose_unknown_visual_type_raises`,
3434
`test_cli_compose_unknown_visual_type_is_click_error`,
3535
`test_index_html_rejects_non_mapping_pages_segments`.
36-
- [ ] `ruff check src/ tests/`
37-
- [ ] `pytest tests/`
38-
- [ ] `docgen benchmark` (no clock change)
36+
- [x] `ruff check src/ tests/`
37+
- [x] `pytest tests/`
38+
- [x] `docgen benchmark` (no clock change)
3939

4040
## Out of scope
4141

‎src/docgen/concat.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ def build(self, name: str | None = None) -> None:
3939
f"[concat] {out_name}: segment list must be a YAML list, "
4040
f"not {type(seg_ids).__name__}"
4141
)
42-
self._build_one(out_name, [str(s) for s in seg_ids])
42+
ids: list[str] = []
43+
for i, item in enumerate(seg_ids):
44+
if not isinstance(item, str) or not item.strip():
45+
raise ConcatError(
46+
f"[concat] {out_name}[{i}] must be a quoted string segment id, "
47+
f"not {type(item).__name__} ({item!r})"
48+
)
49+
ids.append(item)
50+
self._build_one(out_name, ids)
4351

4452
def _build_one(self, out_name: str, seg_ids: list[str]) -> None:
4553
recordings_dir = self.config.recordings_dir

‎src/docgen/config.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ def mapping_sub_block(
7878
return val
7979

8080

81+
def require_yaml_string(value: Any, *, label: str, source: str) -> str:
82+
"""Require a YAML string so unquoted ``01`` is not silently coerced to ``\"1\"``."""
83+
if isinstance(value, str):
84+
if not value.strip():
85+
raise ConfigError(f"{source}: {label} must be a non-empty string")
86+
return value
87+
raise ConfigError(
88+
f"{source}: {label} must be a YAML string, not {type(value).__name__} "
89+
f"({value!r}). Unquoted 01 is integer 1; write \"01\"."
90+
)
91+
92+
8193
def string_list_block(
8294
raw: dict[str, Any],
8395
key: str,
@@ -95,7 +107,10 @@ def string_list_block(
95107
raise ConfigError(
96108
f"{source}: {name} must be a YAML list, not {type(val).__name__}"
97109
)
98-
return [str(x) for x in val]
110+
return [
111+
require_yaml_string(x, label=f"{name}[{i}]", source=source)
112+
for i, x in enumerate(val)
113+
]
99114

100115

101116
@dataclass
@@ -155,21 +170,31 @@ def __post_init__(self) -> None:
155170
_ = self.segments_all
156171
_ = self.manim_scenes
157172
concat = self._block("concat")
173+
src = self._source_label()
158174
for name, segs in concat.items():
159175
if segs is None:
160176
continue
161177
if not isinstance(segs, list):
162178
raise ConfigError(
163-
f"{self._source_label()}: concat.{name} must be a YAML list, "
179+
f"{src}: concat.{name} must be a YAML list, "
164180
f"not {type(segs).__name__}"
165181
)
182+
for i, item in enumerate(segs):
183+
require_yaml_string(item, label=f"concat.{name}[{i}]", source=src)
184+
for key in self._block("segment_names"):
185+
require_yaml_string(key, label="segment_names key", source=src)
186+
pages_segs = self._block("pages").get("segments")
187+
if isinstance(pages_segs, dict):
188+
for key in pages_segs:
189+
require_yaml_string(key, label="pages.segments key", source=src)
166190
vm = self._block("visual_map")
167191
for sid, spec in vm.items():
192+
sid_s = require_yaml_string(sid, label="visual_map key", source=src)
168193
if spec is None:
169194
continue
170195
if not isinstance(spec, dict):
171196
raise ConfigError(
172-
f"{self._source_label()}: visual_map.{sid} must be a YAML mapping, "
197+
f"{src}: visual_map.{sid_s} must be a YAML mapping, "
173198
f"not {type(spec).__name__}"
174199
)
175200

‎tests/test_concat.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,16 @@ def test_concat_builder_rejects_non_list_target() -> None:
5858
)
5959
with pytest.raises(ConcatError, match="must be a YAML list"):
6060
ConcatBuilder(cfg).build() # type: ignore[arg-type]
61+
62+
63+
def test_concat_builder_rejects_integer_segment_id() -> None:
64+
from pathlib import Path as P
65+
from types import SimpleNamespace
66+
67+
cfg = SimpleNamespace(
68+
concat_map={"full": [1]},
69+
recordings_dir=P("/tmp"),
70+
find_segment_asset=lambda *a, **k: None,
71+
)
72+
with pytest.raises(ConcatError, match="quoted string segment id"):
73+
ConcatBuilder(cfg).build() # type: ignore[arg-type]

‎tests/test_config.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,38 @@ def test_from_yaml_string_visual_map_row_raises(tmp_path: Path) -> None:
241241
p.write_text('visual_map:\n "01": FirstScene\n', encoding="utf-8")
242242
with pytest.raises(ConfigError, match="visual_map.01 must be a YAML mapping"):
243243
Config.from_yaml(p)
244+
245+
246+
def test_from_yaml_unquoted_segments_all_raises(tmp_path: Path) -> None:
247+
p = tmp_path / "docgen.yaml"
248+
p.write_text("segments:\n all: [01]\n", encoding="utf-8")
249+
with pytest.raises(ConfigError, match="segments.all\\[0\\] must be a YAML string"):
250+
Config.from_yaml(p)
251+
252+
253+
def test_from_yaml_unquoted_visual_map_key_raises(tmp_path: Path) -> None:
254+
p = tmp_path / "docgen.yaml"
255+
p.write_text("visual_map:\n 01:\n type: manim\n", encoding="utf-8")
256+
with pytest.raises(ConfigError, match="visual_map key must be a YAML string"):
257+
Config.from_yaml(p)
258+
259+
260+
def test_from_yaml_unquoted_concat_item_raises(tmp_path: Path) -> None:
261+
p = tmp_path / "docgen.yaml"
262+
p.write_text("concat:\n full: [01]\n", encoding="utf-8")
263+
with pytest.raises(ConfigError, match="concat.full\\[0\\] must be a YAML string"):
264+
Config.from_yaml(p)
265+
266+
267+
def test_from_yaml_unquoted_segment_names_key_raises(tmp_path: Path) -> None:
268+
p = tmp_path / "docgen.yaml"
269+
p.write_text("segment_names:\n 01: 01-intro\n", encoding="utf-8")
270+
with pytest.raises(ConfigError, match="segment_names key must be a YAML string"):
271+
Config.from_yaml(p)
272+
273+
274+
def test_from_yaml_unquoted_pages_segments_key_raises(tmp_path: Path) -> None:
275+
p = tmp_path / "docgen.yaml"
276+
p.write_text("pages:\n segments:\n 01:\n title: Overview\n", encoding="utf-8")
277+
with pytest.raises(ConfigError, match="pages.segments key must be a YAML string"):
278+
Config.from_yaml(p)

0 commit comments

Comments
 (0)