Source code for simvx.core.script

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