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
81 changes: 78 additions & 3 deletions src/docgen/scene_asset_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,83 @@ def extract_class_source(scenes_text: str, class_name: str) -> str | None:
return None


_CANONICAL_HELPERS = ("_box", "_arrow", "_TimedScene", "_load_timing", "_load_timing_words")
_TIMED_SCENE_METHODS = frozenset({"timed_play", "wait_until_word"})


def _unique(names: list[str]) -> list[str]:
return list(dict.fromkeys(names))


def _note_renamed_timed_scene(node: ast.AST, renamed: list[str]) -> None:
if not isinstance(node, ast.ClassDef) or node.name == "_TimedScene":
return
methods = {
child.name
for child in node.body
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
}
if _TIMED_SCENE_METHODS.intersection(methods):
renamed.append(node.name)


def _note_helper_node(node: ast.AST, inlined: list[str], referenced: list[str], renamed: list[str]) -> None:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if node.name in _CANONICAL_HELPERS:
inlined.append(node.name)
_note_renamed_timed_scene(node, renamed)
elif isinstance(node, ast.Name) and node.id in _CANONICAL_HELPERS:
referenced.append(node.id)


def _helper_issue_parts(inlined: list[str], referenced: list[str], renamed: list[str]) -> list[str]:
parts: list[str] = []
if inlined:
parts.append("inlined " + ", ".join(_unique(inlined)))
if renamed:
parts.append("renamed _TimedScene-style " + ", ".join(_unique(renamed)))
missing = [name for name in _unique(referenced) if name not in inlined]
if missing:
parts.append("referenced but not top-level " + ", ".join(missing))
return parts


def _inlined_or_renamed_helper_issue(tree: ast.AST) -> str | None:
"""Fail closed when canonical helpers are not top-level defs.

Nested ``_box`` / ``_TimedScene`` bodies, calls or bases that name them, and
classes that carry ``timed_play`` / ``wait_until_word`` under another name
used to skip :func:`helper_needs_refresh` entirely.
"""
inlined: list[str] = []
referenced: list[str] = []
renamed: list[str] = []
for node in ast.walk(tree):
_note_helper_node(node, inlined, referenced, renamed)
if not inlined and not referenced and not renamed:
return None
detail = "; ".join(_helper_issue_parts(inlined, referenced, renamed))
return (
"helpers: canonical _box / _arrow / _TimedScene / _load_timing / "
"_load_timing_words are missing or inlined "
f"({detail}) — stale-helper check cannot pass silently; "
"run `docgen scene-compile` to restore top-level helper bodies"
)


def _missing_top_level_helper_issues(tree: ast.AST) -> list[str]:
issue = _inlined_or_renamed_helper_issue(tree)
if issue:
return [issue]
return []


def helper_api_violations(scenes_text: str) -> list[str]:
"""Stale ``_box`` / ``_arrow`` / ``_TimedScene`` that will mis-render new specs."""
"""Stale ``_box`` / ``_arrow`` / ``_TimedScene`` that will mis-render new specs.

Helpers must be top-level. Inlined or renamed ``_TimedScene``-style helpers
fail closed with an explicit missing/inlined message.
"""
from docgen.manim_scene_support import helper_needs_refresh

try:
Expand All @@ -262,8 +337,8 @@ def helper_api_violations(scenes_text: str) -> list[str]:
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
defined.add(node.name)
if not defined.intersection({"_box", "_arrow", "_TimedScene", "_load_timing", "_load_timing_words"}):
return []
if not defined.intersection(set(_CANONICAL_HELPERS)):
return _missing_top_level_helper_issues(tree)
issues: list[str] = []
if "MANIM_FONT" not in scenes_text:
issues.append(
Expand Down
34 changes: 34 additions & 0 deletions tests/test_scene_asset_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,40 @@ def test_helper_api_clean_for_current_bootstrap() -> None:
assert helper_api_violations(BOOTSTRAP_HEADER) == []


def test_helper_api_flags_inlined_or_renamed_helpers() -> None:
inlined = """
class OverviewScene:
class _TimedScene:
def timed_play(self, *a, run_time=1.0):
pass
def wait_until_word(self, words, index):
return
def construct(self):
_box("Alpha", "#fff")
"""
inlined_issues = helper_api_violations(inlined)
assert any(
"missing or inlined" in issue and "inlined _TimedScene" in issue
for issue in inlined_issues
)
assert any("_box" in issue for issue in inlined_issues)

renamed = """
class SceneClock:
def timed_play(self, *a, run_time=1.0):
pass
def wait_until_word(self, words, index):
return
"""
renamed_issues = helper_api_violations(renamed)
assert any(
"missing or inlined" in issue and "renamed _TimedScene-style SceneClock" in issue
for issue in renamed_issues
)

assert helper_api_violations("def render():\n return 1\n") == []


def test_compiled_sync_passes_when_scenes_match_compile() -> None:
spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)])
words = _wide_words()
Expand Down
Loading