"""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