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
9 changes: 6 additions & 3 deletions milestones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ 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:** **[wizard-narration-paths.md](wizard-narration-paths.md)** — wizard
generate-narration must not drop missing sources; timestamps must not wipe
corrupt `timing.json`.
**Active:** **[manim-lint-config.md](manim-lint-config.md)** — missing
`scenes.py` must fail `validate`; invalid `docgen.yaml` must not traceback
or be rewritten as `{}`; TTS empty-all must not succeed.

**Shipped:**
- **[wizard-narration-paths.md](wizard-narration-paths.md)** — wizard
generate-narration must not drop missing sources; timestamps must not wipe
corrupt `timing.json` (#81).
- **[validate-fail-closed.md](validate-fail-closed.md)** — validate
narration/timing skips, empty Manim lists, missing context paths.
- **[scene-compile-pace.md](scene-compile-pace.md)** — paced `scene-compile`
Expand Down
32 changes: 32 additions & 0 deletions milestones/manim-lint-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Milestone — Manim lint, config YAML, TTS empty-all

**Goal:** `docgen validate` must not pass a Manim segment when
`animations/scenes.py` is missing. Invalid or non-mapping `docgen.yaml` must
not traceback or get rewritten as `{}`. `docgen tts` must not exit 0 when
`segments.all` is empty.

**Branch / PR:** `cursor/manim-lint-config-2ccd`

Follows **[wizard-narration-paths.md](wizard-narration-paths.md)** (#81).

## Shipped

- [x] `validate` `manim_scene_lint` fails when `animations/scenes.py` is
missing (this check only runs for manim `visual_map` rows)
- [x] `Config.from_yaml` rejects invalid YAML and a non-mapping root
(`ConfigError`); CLI maps that to `ClickException`
- [x] `yaml-generate` uses the same loader (no traceback, no `or {}` wipe)
- [x] Wizard hint-save yaml-generate does not replace a non-mapping
`docgen.yaml` with `{}`
- [x] `docgen tts` with empty `segments.all` (no `--segment`) raises
`TTSError`
- [x] `scene_asset_validate` reports corrupt / non-object `timing.json`
instead of skipping word checks

## Not this milestone

- Missing recordings stay a pre-push **warning** (WIP bundles)
- `timestamps` with empty `segments.all` still leaves `timing.json`
unchanged (#78)
- GET `/api/narration` empty 200 for a new segment (authoring UI)
- Archived Playwright / Embabel / slides
10 changes: 8 additions & 2 deletions src/docgen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import click
import yaml

from docgen.config import Config
from docgen.config import Config, ConfigError
from docgen.yaml_generate import DEFAULT_LLM_MODEL


Expand Down Expand Up @@ -202,6 +202,8 @@ def main(
"to your demos bundle directory.",
err=True,
)
except ConfigError as exc:
raise click.ClickException(str(exc)) from exc
ctx.obj["config"] = cfg
_load_env(cfg)

Expand Down Expand Up @@ -1179,10 +1181,14 @@ def yaml_generate_cmd(
Rewrites the config file with PyYAML (comments are not preserved). Use Git to review.
"""
from docgen import yaml_generate as yg
from docgen.config import load_yaml_mapping

cfg = _require_config(ctx)
path = cfg.yaml_path
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
try:
raw = load_yaml_mapping(path)
except ConfigError as exc:
raise click.ClickException(str(exc)) from exc

if list_gaps:
gaps = yg.narration_not_in_segments(raw, cfg.narration_dir)
Expand Down
28 changes: 26 additions & 2 deletions src/docgen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@
_YAML_FILENAME = "docgen.yaml"


class ConfigError(ValueError):
"""Raised when ``docgen.yaml`` cannot be parsed as a mapping."""


def load_yaml_mapping(path: Path) -> dict[str, Any]:
"""Load *path* as a YAML mapping.

An empty document becomes ``{}``. Invalid YAML or a non-mapping root
(list, scalar) raises :class:`ConfigError`.
"""
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except OSError as exc:
raise ConfigError(f"could not read {path}: {exc}") from exc
except yaml.YAMLError as exc:
raise ConfigError(f"{path.name} is not valid YAML: {exc}") from exc
if raw is None:
return {}
if not isinstance(raw, dict):
raise ConfigError(
f"{path.name} root must be a YAML mapping, not {type(raw).__name__}"
)
return raw


@dataclass
class Config:
"""Parsed and validated project configuration."""
Expand Down Expand Up @@ -468,8 +493,7 @@ def from_yaml(cls, path: str | Path) -> "Config":
path = path / _YAML_FILENAME
if not path.exists():
raise FileNotFoundError(f"Config not found: {path}")
with open(path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw = load_yaml_mapping(path)
return cls(yaml_path=path, base_dir=path.parent, raw=raw)

@classmethod
Expand Down
12 changes: 10 additions & 2 deletions src/docgen/scene_asset_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,16 @@ def scene_asset_violations_for_segment(cfg: "Config", seg_id: str) -> list[str]:

try:
data = json.loads(timing_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
except (OSError, json.JSONDecodeError) as exc:
issues.append(
f"timing.json is not valid JSON ({exc}) — run `docgen timestamps`"
)
data = None
if data is not None and not isinstance(data, dict):
issues.append(
f"timing.json root must be a JSON object, not {type(data).__name__}"
)
data = None
raw_block = data.get(stem) if isinstance(data, dict) else None
if isinstance(raw_block, dict):
block = raw_block
Expand Down
10 changes: 9 additions & 1 deletion src/docgen/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,15 @@ def __init__(self, config: Config) -> None:
self.config = config

def generate(self, segment: str | None = None, dry_run: bool = False) -> None:
segments = [segment] if segment else self.config.segments_all
if segment is not None:
segments = [segment]
else:
segments = list(self.config.segments_all)
if not segments:
raise TTSError(
"segments.all is empty — add segment ids in docgen.yaml "
"(or hints + yaml-generate) before TTS"
)
for seg_id in segments:
self._generate_one(seg_id, dry_run)

Expand Down
9 changes: 8 additions & 1 deletion src/docgen/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,14 @@ def _check_manim_scene_lint(self) -> CheckResult:

scenes = self.config.animations_dir / "scenes.py"
if not scenes.exists():
result = CheckResult("manim_scene_lint", True, ["No animations/scenes.py (skipped)"])
result = CheckResult(
"manim_scene_lint",
False,
[
"animations/scenes.py is missing — run `docgen scene-compile` "
"before validate (this check only runs for manim visual_map rows)"
],
)
self._manim_lint_cache = result
return result

Expand Down
8 changes: 2 additions & 6 deletions src/docgen/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,19 +780,15 @@ def api_put_focus(segment_id: str):
try:
import copy

import yaml as _yaml

from docgen.config import Config, load_yaml_mapping
from docgen.yaml_generate import default_header, merge_defaults, write_docgen_yaml

raw = _yaml.safe_load(cfg.yaml_path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raw = {}
raw = load_yaml_mapping(cfg.yaml_path)
# Work on a deep copy so a failed merge never leaves half-mutated state
# in memory before we rewrite the file.
working = copy.deepcopy(raw)
yaml_changes.extend(merge_defaults(working, cfg))
write_docgen_yaml(cfg.yaml_path, working, header=default_header(cfg.yaml_path))
from docgen.config import Config
app.config["DOCGEN"] = Config.from_yaml(cfg.yaml_path)
except Exception as exc:
return jsonify({
Expand Down
31 changes: 31 additions & 0 deletions tests/test_cli_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,34 @@ def boom(self, **_kwargs): # noqa: ANN001
],
)
assert result.exit_code == 1


def test_cli_invalid_yaml_is_click_error(tmp_path: Path) -> None:
from click.testing import CliRunner

from docgen.cli import main

p = tmp_path / "docgen.yaml"
p.write_text("segments: [\n unclosed\n", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(main, ["--config", str(p), "lint"])
assert result.exit_code != 0
combined = result.output + result.stderr
assert "valid YAML" in combined
assert "Traceback" not in combined
assert not isinstance(result.exception, yaml.YAMLError)


def test_cli_list_root_yaml_is_click_error(tmp_path: Path) -> None:
from click.testing import CliRunner

from docgen.cli import main

p = tmp_path / "docgen.yaml"
p.write_text("- not a mapping\n", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(main, ["--config", str(p), "yaml-generate", "--dry-run"])
assert result.exit_code != 0
combined = result.output + result.stderr
assert "mapping" in combined
assert "AttributeError" not in combined
23 changes: 22 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest
import yaml

from docgen.config import Config
from docgen.config import Config, ConfigError


@pytest.fixture
Expand Down Expand Up @@ -177,3 +177,24 @@ def test_find_segment_asset_does_not_match_substring_ids(tmp_path: Path) -> None
assert found.name == "01-intro.mp3"
(audio / "01-intro.mp3").unlink()
assert c.find_segment_asset(audio, "01", ".mp3") is None


def test_from_yaml_invalid_yaml_raises(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text("segments: [\n unclosed\n", encoding="utf-8")
with pytest.raises(ConfigError, match="not valid YAML"):
Config.from_yaml(p)


def test_from_yaml_list_root_raises(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text("- just a list\n", encoding="utf-8")
with pytest.raises(ConfigError, match="mapping"):
Config.from_yaml(p)


def test_from_yaml_empty_document_is_empty_mapping(tmp_path: Path) -> None:
p = tmp_path / "docgen.yaml"
p.write_text("", encoding="utf-8")
c = Config.from_yaml(p)
assert c.raw == {}
26 changes: 26 additions & 0 deletions tests/test_scene_asset_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,29 @@ def test_bundle_preflight_returns_segment_prefixed_issues(tmp_path: Path) -> Non
issues = bundle_scene_asset_violations(cfg)
assert issues
assert all(i.startswith("[01]") for i in issues)


def test_corrupt_timing_json_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"), _box("Beta")])),
encoding="utf-8",
)
(cfg.animations_dir / "timing.json").write_text("{not json", encoding="utf-8")
issues = scene_asset_violations_for_segment(cfg, "01")
assert any("timing.json is not valid JSON" in i for i in issues)


def test_list_root_timing_json_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("[]\n", encoding="utf-8")
issues = scene_asset_violations_for_segment(cfg, "01")
assert any("JSON object" in i for i in issues)
12 changes: 12 additions & 0 deletions tests/test_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ def test_tts_finds_named_stem_not_substring_id(tmp_path: Path) -> None:
TTSGenerator(cfg).generate(segment="01", dry_run=True)


def test_tts_empty_segments_all_raises(tmp_path: Path) -> None:
raw = {
"dirs": {"narration": "narration", "audio": "audio"},
"segments": {"all": [], "default": []},
}
p = tmp_path / "docgen.yaml"
p.write_text(yaml.dump(raw), encoding="utf-8")
cfg = Config.from_yaml(p)
with pytest.raises(TTSError, match="segments.all is empty"):
TTSGenerator(cfg).generate(dry_run=True)


def test_probe_duration_returns_none_for_missing_file(tmp_path):
result = _probe_duration(tmp_path / "nonexistent.mp3")
assert result is None
Expand Down
14 changes: 14 additions & 0 deletions tests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,20 @@ def construct(self):
assert check["passed"]
assert "skipped" in " ".join(check["details"]).lower()

def test_missing_scenes_py_fails(self, cfg_dir):
config = self._configure_manim(
cfg_dir,
"from manim import *\n\nclass Demo(Scene):\n def construct(self):\n pass\n",
)
(cfg_dir / "animations" / "scenes.py").unlink()
v = Validator(config)
report = v.validate_segment("01")
check = next(c for c in report["checks"] if c["name"] == "manim_scene_lint")
assert not check["passed"]
details = " ".join(check["details"])
assert "scenes.py" in details
assert "missing" in details.lower()

def test_flags_small_font_size(self, cfg_dir):
"""Font sizes below the configured minimum should be flagged."""
config = self._configure_manim(
Expand Down
17 changes: 17 additions & 0 deletions tests/test_wizard_focus.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,20 @@ def test_wizard_scan_api_returns_extensions(tmp_path: Path) -> None:
paths = {f["path"] for f in data["files"]}
assert "src/app.py" in paths
assert ".py" in data["extensions"]


def test_wizard_focus_yaml_generate_does_not_wipe_list_root(tmp_path: Path) -> None:
cfg = _bundle_cfg(tmp_path)
app = create_app(cfg)
client = app.test_client()
yaml_path = tmp_path / "docgen.yaml"
yaml_path.write_text("- not a mapping\n", encoding="utf-8")
before = yaml_path.read_text(encoding="utf-8")
put_res = client.put(
"/api/segments/01/focus",
json={"paths": ["README.md"], "yaml_generate": True},
)
assert put_res.status_code == 500
err = put_res.get_json()["error"]
assert "mapping" in err.lower() or "yaml-generate failed" in err.lower()
assert yaml_path.read_text(encoding="utf-8") == before