Source code for simvx.core.scene_io.loader
"""Runtime loading: import a scene `.py` file or folder and instantiate its primary Node.
`load_scene` is the canonical way to take a path on disk and return a live
:class:`~simvx.core.Node` tree. For *editing* the source, use
:class:`SceneFile` / :class:`SceneModule`; this module is the runtime side.
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from .detection import primary_node_class_from_source
from .scene_module import SceneModule
if TYPE_CHECKING:
from ..node import Node
__all__ = ["load_scene"]
[docs]
def load_scene(path: str | Path) -> Node:
"""Load a scene from a ``.py`` file or scene-module folder.
File path → imports the module (under a unique synthetic name) and
instantiates the primary :class:`Node` subclass.
Folder path → imports the package via :func:`importlib.import_module`
(after pinning ``path.parent`` onto :data:`sys.path`) and
instantiates the primary class declared in
``__init__.py`` or, as a fallback, ``<folder>/<folder>.py``.
In both cases :meth:`ScriptManager.load_tree` is run on the result so
nodes carrying ``script`` references resolve relative to the project
layout (``project/scenes/foo.py`` → ``project/`` is the script root).
"""
p = Path(path)
if p.is_dir():
return _load_folder(p)
return _load_file(p)
def _load_file(path: Path) -> Node:
from ..node import Node
from ..script import ScriptManager
class_name = primary_node_class_from_source(path.read_text(encoding="utf-8"), path=path)
if class_name is None:
raise ValueError(f"No Node subclass found in {path}")
module_name = f"_simvx_scene_{path.stem}_{id(path)}"
spec = importlib.util.spec_from_file_location(module_name, str(path))
if spec is None or spec.loader is None:
raise ValueError(f"Cannot load scene from {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
node_cls = getattr(module, class_name, None)
if node_cls is None or not isinstance(node_cls, type) or not issubclass(node_cls, Node):
raise ValueError(f"Class {class_name!r} in {path} is not a Node subclass")
root = node_cls()
project_dir = str(path.parent.parent) if path.parent.name == "scenes" else str(path.parent)
ScriptManager.load_tree(root, project_dir)
return root
def _load_folder(path: Path) -> Node:
from ..node import Node
from ..script import ScriptManager
if not SceneModule.is_folder_scene(path):
raise ValueError(f"{path} is not a scene module")
parent_str = str(path.parent.resolve())
if parent_str not in sys.path:
sys.path.insert(0, parent_str)
package = importlib.import_module(path.name)
init = path / "__init__.py"
namespaced = path / f"{path.name}.py"
class_name: str | None = None
holder = package
if init.is_file():
text = init.read_text(encoding="utf-8")
candidate = primary_node_class_from_source(text, path=init)
if candidate is not None and hasattr(package, candidate):
class_name = candidate
if class_name is None and namespaced.is_file():
text = namespaced.read_text(encoding="utf-8")
candidate = primary_node_class_from_source(text, path=namespaced)
if candidate is not None:
sub = importlib.import_module(f"{path.name}.{path.name}")
if hasattr(sub, candidate):
class_name = candidate
holder = sub
if class_name is None:
raise ValueError(f"No Node subclass resolvable in scene module {path}")
node_cls = getattr(holder, class_name)
if not isinstance(node_cls, type) or not issubclass(node_cls, Node):
raise ValueError(f"Class {class_name!r} in {path} is not a Node subclass")
root = node_cls()
ScriptManager.load_tree(root, str(path))
return root