Source code for simvx.core.scene_io.detection

"""Scene-file detection helpers — recognise SimVX scene scripts and modules.

These predicates use stdlib ``ast`` for speed and parser-version stability;
parso is unnecessary for the structural checks they perform. The recognised
Node base-class names are listed in :data:`_NODE_BASE_CLASSES`; any class
whose direct base name is in that set qualifies as a Node subclass for
detection purposes.

A scene file/module exposes exactly one Node subclass that either defines
``__init__`` or declares at least one ``Property(...)`` descriptor at class
scope. Multiple Node subclasses raise :class:`AmbiguousSceneError` from
:func:`primary_node_class_from_source`; :func:`is_scene_path` instead returns
``False`` so callers can use it as a cheap filesystem filter without
exception handling.
"""

from __future__ import annotations

import ast
from pathlib import Path

__all__ = [
    "AmbiguousSceneError",
    "has_procedural_construction",
    "is_scene_path",
    "primary_node_class_from_source",
]


_NODE_BASE_CLASSES: frozenset[str] = frozenset(
    {
        "Node",
        "Node2D",
        "Node3D",
        "Camera2D",
        "Camera3D",
        "OrbitCamera3D",
        "MeshInstance3D",
        "Light3D",
        "DirectionalLight3D",
        "PointLight3D",
        "SpotLight3D",
        "Text2D",
        "Text3D",
        "Timer",
        "Line2D",
        "Polygon2D",
        "CharacterBody2D",
        "CharacterBody3D",
        "CollisionShape2D",
        "CollisionShape3D",
        "Area2D",
        "Area3D",
        "CanvasLayer",
        "ParallaxBackground",
        "ParallaxLayer",
        "CanvasModulate",
        "YSortContainer",
        "AudioStreamPlayer",
        "AudioStreamPlayer2D",
        "AudioStreamPlayer3D",
        "Sprite2D",
        "AnimatedSprite2D",
        "ParticleEmitter",
        "RigidBody2D",
        "RigidBody3D",
        "StaticBody2D",
        "StaticBody3D",
        "Control",
        "Panel",
        "Label",
        "Button",
        "TextEdit",
    }
)


[docs] class AmbiguousSceneError(ValueError): """Raised when a scene source contains more than one Node subclass. Detection treats this as a user error rather than a missing-detection case: the editor cannot pick a primary class on the user's behalf and must surface a remediation message ("split into separate files, or promote one class to the top-level scene"). """
[docs] def is_scene_path(path: str | Path) -> bool: """Return ``True`` iff ``path`` is a SimVX scene script or module folder. A file qualifies when it parses as Python and exposes exactly one Node-derived class with either ``__init__`` or one or more ``Property(...)`` descriptors. Multiple Node subclasses → ``False`` (the file is ambiguous; use :func:`primary_node_class_from_source` if a precise diagnostic is needed). A folder qualifies when either ``folder/__init__.py`` or — if that file is missing or has no qualifying class — ``folder/<folder_name>.py`` qualifies under the same rule. Returns ``False`` for non-existent paths, parse errors, multi-class files, or files with no qualifying class. Never raises. """ p = Path(path) if p.is_dir(): init = p / "__init__.py" if init.is_file() and _file_has_single_scene_class(init): return True namespaced = p / f"{p.name}.py" if namespaced.is_file() and _file_has_single_scene_class(namespaced): return True return False if not p.is_file(): return False return _file_has_single_scene_class(p)
[docs] def primary_node_class_from_source(source: str, *, path: Path | None = None) -> str | None: """Return the single Node subclass name in ``source``, else ``None``. A class qualifies when it inherits a recognised Node base (see :data:`_NODE_BASE_CLASSES`) and either defines ``__init__`` or exposes at least one class-body ``Property(...)`` assignment. With multiple qualifying classes, raises :class:`AmbiguousSceneError`; with zero, returns ``None``. ``path`` is used only to enrich error messages. """ try: tree = ast.parse(source) except SyntaxError: return None matches = [cls.name for cls in _iter_classdefs(tree) if _is_scene_class(cls)] if not matches: return None if len(matches) > 1: where = f" in {path}" if path is not None else "" raise AmbiguousSceneError( f"multiple Node subclasses{where}: {', '.join(matches)}; " "scene files must define exactly one Node subclass" ) return matches[0]
[docs] def has_procedural_construction(source: str, class_name: str | None = None) -> bool: """Return ``True`` iff a scene class builds children procedurally. A class is procedural when its ``__init__`` body — or a method it calls — contains an ``add_child(...)`` invocation nested inside ``for``, ``while``, or ``if`` control flow. Such trees cannot be safely diff-reconciled by the round-trip layer; the editor should fall back to a greenfield overwrite (with a warning) for these. With ``class_name=None``, every Node subclass in the source is checked and ``True`` is returned if any is procedural. Malformed source returns ``True`` — the safer default for a tool that gates whether edits are reversible. """ try: tree = ast.parse(source) except SyntaxError: return True for cls in _iter_classdefs(tree): if class_name is not None and cls.name != class_name: continue if not _has_node_base(cls): continue for item in cls.body: if isinstance(item, ast.FunctionDef) and item.name == "__init__": if _function_has_procedural_add_child(item): return True return False
# --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _file_has_single_scene_class(path: Path) -> bool: try: source = path.read_text() except OSError: return False try: tree = ast.parse(source) except SyntaxError: return False matches = [cls for cls in _iter_classdefs(tree) if _is_scene_class(cls)] return len(matches) == 1 def _iter_classdefs(tree: ast.AST): for node in ast.walk(tree): if isinstance(node, ast.ClassDef): yield node def _is_scene_class(cls: ast.ClassDef) -> bool: if not _has_node_base(cls): return False return _has_init(cls) or _has_property_descriptors(cls) def _has_node_base(cls: ast.ClassDef) -> bool: for base in cls.bases: name = _ast_name(base) if name is not None and name in _NODE_BASE_CLASSES: return True return False def _has_init(cls: ast.ClassDef) -> bool: return any(isinstance(item, ast.FunctionDef) and item.name == "__init__" for item in cls.body) def _has_property_descriptors(cls: ast.ClassDef) -> bool: for item in cls.body: if not isinstance(item, ast.Assign): continue if not isinstance(item.value, ast.Call): continue if _ast_name(item.value.func) != "Property": continue if any(isinstance(t, ast.Name) for t in item.targets): return True return False def _ast_name(node: ast.expr) -> str | None: if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Attribute): return node.attr return None def _function_has_procedural_add_child(func: ast.FunctionDef) -> bool: for node in ast.walk(func): if not isinstance(node, ast.For | ast.While | ast.If): continue for inner in ast.walk(node): if isinstance(inner, ast.Call): fn = inner.func if isinstance(fn, ast.Attribute) and fn.attr == "add_child": return True return False