"""ScriptManager — file-based class script loading for nodes.
Scripts are standard Python classes that extend the node's type. ScriptManager
imports the module, finds the matching class, and swaps `node.__class__` so
the node gains the script's methods (ready, process, etc.).
Inline scripts (exec-based) are preserved for backward compatibility with demos.
Public API:
ScriptManager.load(node, project_dir)
ScriptManager.unload(node)
ScriptManager.reload(node, project_dir)
ScriptManager.exec_inline(node)
ScriptManager.load_tree(root, project_dir)
"""
from __future__ import annotations
import importlib
import importlib.util
import logging
from pathlib import Path
from types import ModuleType
from .node import Node
log = logging.getLogger(__name__)
__all__ = ["ScriptManager"]
[docs]
class ScriptManager:
"""Static manager for loading/unloading file-based class scripts on nodes."""
_module_cache: dict[str, ModuleType] = {}
[docs]
@classmethod
def load(cls, node: Node, project_dir: str = "") -> bool:
"""Load a file-based or embedded script onto *node*.
For file-backed scripts: resolves ``node.script`` as a path relative to
*project_dir*, imports the module, finds the class that best matches the
node's type, and swaps ``node.__class__``.
For embedded scripts: registers source with EmbeddedScriptFinder, imports
it, and applies the same class swap.
Returns True on success, False on error (logged, not raised).
"""
# Embedded script takes priority if no file path
embedded = getattr(node, "_script_embedded", None)
if embedded and not node.script:
return cls._load_embedded(node, embedded)
if not node.script:
return False
path = cls._resolve_path(node.script, project_dir)
if path is None or not path.is_file():
log.error("ScriptManager: file not found: %s", node.script)
return False
module = cls._import_module(str(path))
if module is None:
return False
# Find the best matching class
target_cls = cls._find_best_class(module, type(node))
if target_cls is None:
log.error("ScriptManager: no matching class in %s for %s", path.name, type(node).__name__)
return False
# Store original class for unload
node._script_original_class = type(node)
node._script_module = module
node.__class__ = target_cls
return True
[docs]
@classmethod
def unload(cls, node: Node) -> None:
"""Restore the node's original class, removing the script behavior."""
original = getattr(node, "_script_original_class", None)
if original is not None:
node.__class__ = original
node._script_original_class = None
node._script_module = None
[docs]
@classmethod
def reload(cls, node: Node, project_dir: str = "") -> bool:
"""Reload a node's script (re-import module, re-swap class).
Preserves Property values across the reload.
"""
if not node.script:
return False
# Serialize property state
state = {}
for name, prop in node.get_properties().items():
try:
state[name] = getattr(node, prop.attr, prop.default)
except (AttributeError, TypeError):
pass
# Invalidate cache and reload
path = cls._resolve_path(node.script, project_dir)
if path:
cls.invalidate(str(path))
cls.unload(node)
ok = cls.load(node, project_dir)
# Restore properties
if ok:
for name, val in state.items():
try:
if name in node.get_properties():
setattr(node, name, val)
except (TypeError, AttributeError, ValueError):
pass
return ok
[docs]
@classmethod
def exec_inline(cls, node: Node) -> None:
"""Execute a node's inline script string via exec() (backward compat)."""
code = getattr(node, "_script_inline", None)
if not code:
return
ns: dict = {"self": node, "__builtins__": __builtins__}
# Inject common engine types
from . import Signal, Vec2
from .ui.core import Colour
ns["Signal"] = Signal
ns["Vec2"] = Vec2
ns["Colour"] = Colour
try:
exec(code, ns)
except Exception as e:
log.error("Inline script error on '%s': %s", node.name, e)
[docs]
@classmethod
def load_tree(cls, root: Node, project_dir: str = "") -> list[Node]:
"""Walk *root*'s tree: load file-based scripts, then exec inline scripts.
Returns list of nodes that had file-based scripts loaded (class swapped),
so the caller can invoke ``ready()`` or other lifecycle methods.
"""
loaded: list[Node] = []
cls._walk_load(root, project_dir, loaded)
cls._walk_inline(root)
return loaded
[docs]
@classmethod
def invalidate(cls, abs_path: str) -> None:
"""Remove a module from the cache so it will be re-imported on next load."""
cls._module_cache.pop(abs_path, None)
[docs]
@classmethod
def clear_cache(cls) -> None:
"""Clear the entire module cache."""
cls._module_cache.clear()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@classmethod
def _resolve_path(cls, script_path: str, project_dir: str) -> Path | None:
"""Resolve a script path (absolute or relative to project_dir)."""
p = Path(script_path)
if p.is_absolute():
return p
if project_dir:
return Path(project_dir) / p
return p
@classmethod
def _import_module(cls, abs_path: str) -> ModuleType | None:
"""Import a .py file, caching the result."""
abs_path = str(Path(abs_path).resolve())
if abs_path in cls._module_cache:
return cls._module_cache[abs_path]
# Synthetic module name to avoid sys.modules collisions
mod_name = f"_simvx_script_{Path(abs_path).stem}_{id(abs_path)}"
spec = importlib.util.spec_from_file_location(mod_name, abs_path)
if spec is None or spec.loader is None:
log.error("ScriptManager: cannot create module spec for %s", abs_path)
return None
try:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
cls._module_cache[abs_path] = module
return module
except Exception as e:
log.error("ScriptManager: import failed for %s: %s", abs_path, e)
return None
@classmethod
def _find_best_class(cls, module: ModuleType, node_type: type) -> type | None:
"""Find the class in *module* that best matches *node_type*.
Prefers the most specific subclass of the node's type. If node is Node2D,
a class inheriting Node2D is preferred over one inheriting Node.
"""
candidates: list[tuple[int, type]] = []
for obj in vars(module).values():
if not isinstance(obj, type) or not issubclass(obj, Node):
continue
if obj is Node:
continue
# Check if this class is compatible: it must be a subclass of the node's
# type OR the node's type must be a subclass of its bases
for base in obj.__mro__:
if base is obj:
continue
if base is node_type:
# obj extends node_type — score by MRO distance
depth = obj.__mro__.index(node_type)
candidates.append((depth, obj))
break
if issubclass(node_type, base) and base is not Node and base is not object:
# obj extends a parent of node_type — still usable, lower priority
depth = 100 + obj.__mro__.index(base)
candidates.append((depth, obj))
break
if not candidates:
return None
# Prefer smallest depth (most specific match)
candidates.sort(key=lambda x: x[0])
return candidates[0][1]
@classmethod
def _load_embedded(cls, node: Node, source: str) -> bool:
"""Load an embedded script by registering it as a virtual module."""
from .script_embed import EmbeddedScriptFinder
# Deterministic module name from node identity
mod_name = f"_simvx_embed_{id(node):x}"
EmbeddedScriptFinder.register(mod_name, source)
try:
module = importlib.import_module(mod_name)
except Exception as e:
log.error("ScriptManager: embedded script failed for '%s': %s", node.name, e)
EmbeddedScriptFinder.unregister(mod_name)
return False
target_cls = cls._find_best_class(module, type(node))
if target_cls is None:
log.error("ScriptManager: no matching class in embedded script for %s", type(node).__name__)
EmbeddedScriptFinder.unregister(mod_name)
return False
node._script_original_class = type(node)
node._script_module = module
node.__class__ = target_cls
return True
@classmethod
def _walk_load(cls, node: Node, project_dir: str, loaded: list[Node]) -> None:
"""Recursively load file-based and embedded scripts (children first, then node)."""
for child in list(node.children):
cls._walk_load(child, project_dir, loaded)
has_embedded = getattr(node, "_script_embedded", None)
if (node.script or has_embedded) and not getattr(node, "_script_inline", None):
if cls.load(node, project_dir):
loaded.append(node)
@classmethod
def _walk_inline(cls, node: Node) -> None:
"""Recursively exec inline scripts."""
for child in list(node.children):
cls._walk_inline(child)
if getattr(node, "_script_inline", None):
cls.exec_inline(node)