Source code for simvx.editor.extract

"""Extract a node subtree to a separate Python file.

Generates a new Python module containing a class definition for a node
and its children, and modifies the original source to import the new class.

Public API:
    node_to_class_source(node, class_name)  -- node subtree -> Python class source
    extract_node_to_file(node, source, output_path, class_name)
        -- full extraction: new file + modified original
"""

import ast
import logging
from pathlib import Path

from simvx.core.descriptors import declared_default
from simvx.core.node import Node
from simvx.core.scene_io.emitter import emit_value, helper_import_module, iter_runtime_kwargs, var_name_base

log = logging.getLogger(__name__)

# Core node types known to ship from simvx.core. Mirrors the set used by the
# scene-detection rules in simvx.core.scene_io.detection (Tier 3); kept here
# locally because extract.py is the only consumer that needs it for import
# generation and we don't want to leak this set as a public engine surface.
_CORE_NODE_TYPES = {
    "Node",
    "Node2D",
    "Node3D",
    "Camera2D",
    "Camera3D",
    "OrbitCamera3D",
    "MeshInstance3D",
    "Light3D",
    "DirectionalLight3D",
    "PointLight3D",
    "SpotLight3D",
    "Text2D",
    "Text3D",
    "Timer",
    "Line2D",
    "Polygon2D",
    "CharacterBody2D",
    "CharacterBody3D",
    "CollisionShape2D",
    "CollisionShape3D",
    "Area2D",
    "Area3D",
    "GravityArea2D",
    "GravityArea3D",
    "CanvasLayer",
    "YSortContainer",
    "AudioPlayer",
    "AudioPlayer2D",
    "AudioPlayer3D",
    "Sprite2D",
    "AnimatedSprite2D",
    "ParticleEmitter",
    "PhysicsBody2D",
    "PhysicsBody3D",
    "TileMap",
}


def _get_base_class_name(node: Node) -> str:
    """Return the best simvx.core base class name for code generation.

    If the node's own type is a core type (e.g. Node2D), return that.
    Otherwise walk the MRO to find the nearest core ancestor.
    """
    cls = type(node)
    # If the node's own class is a core type, use it directly
    if cls.__name__ in _CORE_NODE_TYPES or cls.__name__ == "Node":
        return cls.__name__
    # Otherwise find the nearest core ancestor
    for base in cls.__mro__[1:]:
        if base.__name__ in _CORE_NODE_TYPES or base.__name__ == "Node":
            return base.__name__
    return "Node"


def _collect_imports(node: Node) -> set[str]:
    """Recursively collect the type names needed to reconstruct *node*.

    Node types the extracted file constructs, plus every helper name the
    emitted values refer to (``Vec2``, ``Texture``, ``Path``, a collision
    shape). Which module each of those comes from is
    :func:`~simvx.core.scene_io.helper_import_module`'s answer, not this one's.
    """
    types: set[str] = set()
    cls_name = type(node).__name__
    if cls_name in _CORE_NODE_TYPES or cls_name == "Node":
        types.add(cls_name)
    # Also need the base class
    base = _get_base_class_name(node)
    types.add(base)
    for child in node.children:
        types |= _collect_imports(child)
    return types


def _names_in(expr: str | None) -> set[str]:
    """Every bare name an emitted expression refers to."""
    if expr is None:
        return set()
    try:
        parsed = ast.parse(expr, mode="eval")
    except SyntaxError:  # pragma: no cover - the emitter writes parseable source
        return set()
    return {node.id for node in ast.walk(parsed) if isinstance(node, ast.Name)}


def _import_lines(names: set[str]) -> list[str]:
    """The ``from <module> import ...`` lines that make ``names`` resolve.

    Grouped by the module each name comes from, since not everything the
    emitter writes is a ``simvx.core`` export: a ``Path`` inside a texture
    source comes from :mod:`pathlib`, and a file importing it from the engine
    fails on the name it has just introduced.
    """
    by_module: dict[str, list[str]] = {}
    for name in sorted(names):
        by_module.setdefault(helper_import_module(name), []).append(name)
    core = by_module.pop("simvx.core", [])
    lines = [f"from {module} import {', '.join(sorted(group))}" for module, group in sorted(by_module.items())]
    if core:
        lines.append(f"from simvx.core import {', '.join(core)}")
    return lines


def _format_value(val) -> str | None:
    """Format a property value as Python source, or ``None`` when it has none.

    The one formatter, shared with the scene emitter, so a number written here
    carries the same digits it would carry in a saved scene and a value neither
    of them can express is refused by both.
    """
    return emit_value(val)


_FACTORY_BUILTIN_NAMES = {list: "list", dict: "dict", set: "set", bytearray: "bytearray"}


def _format_default_factory(factory) -> str | None:
    """Return the source repr of a Property's ``default_factory`` if known.

    Only built-in container constructors are supported; arbitrary lambdas
    and user callables can't be safely round-tripped to source.
    """
    return _FACTORY_BUILTIN_NAMES.get(factory)


def _is_property_default(prop, val) -> bool:
    """Compare a runtime value to the Property's effective default."""
    import numpy as np

    try:
        ref = declared_default(prop)
    except Exception:
        return False
    try:
        eq = val == ref
        return bool(eq) if not isinstance(eq, np.ndarray) else bool(eq.all())
    except Exception:
        return False


def _refused(node: Node, prop_name: str, val) -> None:
    """Say that a value has no source form, so it is left out of the new file.

    The same answer the scene emitter gives, on the same values: a texture over
    pixels in memory, a node reference, a callable. Writing ``repr(val)`` in its
    place would put ``<Texture object at 0x...>`` into a module that then fails
    to import.
    """
    log.warning(
        "extract: %s %r.%s holds a %s, which has no source form; it is left out of the extracted class.",
        type(node).__name__,
        node.name,
        prop_name,
        type(val).__name__,
    )


def _emit_child_setup(
    node: Node,
    indent: str = "        ",
    used_types: set[str] | None = None,
    seen: dict[str, int] | None = None,
) -> list[str]:
    """Generate lines that reconstruct *node*'s children inside an __init__ body.

    Which keyword arguments a child is written with is
    :func:`~simvx.core.scene_io.iter_runtime_kwargs`'s answer, the same one the
    scene emitter takes, so an extracted class spells a node exactly as a saved
    scene would and there is one place that decides what counts as a default.
    ``used_types`` accumulates the helper names those expressions refer to, for
    the import line the new module needs.

    Which local each child is bound to is
    :func:`~simvx.core.scene_io.emitter.var_name_base`'s answer, for the same
    reason. ``seen`` carries the de-duplication counter down the recursion:
    every line written here lands in one ``__init__``, so a name repeated
    anywhere in the subtree would bind one node twice and leave the other out
    of the tree entirely.
    """
    lines: list[str] = []
    if seen is None:
        seen = {}
    for child in node.children:
        child_type = type(child).__name__
        refused: set[str] = set()
        pairs = iter_runtime_kwargs(child, used_types=used_types, unemittable=refused)
        for prop_name in sorted(refused):
            _refused(child, prop_name, getattr(child, prop_name, None))
        kwargs_str = ", ".join(f"{name}={expr}" for name, expr in pairs)
        base = var_name_base(child.name)
        if base in seen:
            seen[base] += 1
            var = f"{base}_{seen[base]}"
        else:
            seen[base] = 0
            var = base

        lines.append(f"{indent}{var} = {child_type}({kwargs_str})")
        lines.append(f"{indent}self.add_child({var})")

        # Recurse into grandchildren
        grandchild_lines = _emit_child_setup(child, indent, used_types, seen)
        for gl in grandchild_lines:
            # Replace self.add_child with var.add_child for grandchildren
            lines.append(gl.replace("self.add_child(", f"{var}.add_child(", 1) if "self.add_child(" in gl else gl)

    return lines


[docs] def node_to_class_source(node: Node, class_name: str | None = None) -> str: """Generate Python source for a class that recreates *node* and its children. Args: node: The node whose subtree to convert. class_name: Name for the generated class. Defaults to the node's class name. Returns: Complete Python source string for the new file. """ cls_name = class_name or type(node).__name__ base_name = _get_base_class_name(node) imports = _collect_imports(node) # Remove the class_name from imports if it matches a custom type imports.discard(cls_name) # Always include the base imports.add(base_name) # Build property declarations + post-init overrides for non-default values. prop_lines: list[str] = [] init_overrides: list[str] = [] for prop_name, prop in node.get_properties().items(): val = getattr(node, prop_name, prop.default) is_default = _is_property_default(prop, val) expr = None if is_default else _format_value(val) if not is_default and expr is None: _refused(node, prop_name, val) imports |= _names_in(expr) if prop.default_factory is not None: factory_src = _format_default_factory(prop.default_factory) if factory_src is None: # Unknown factory (lambda / user callable): can't round-trip safely. log.warning( "extract: skipping Property %r on %r: default_factory %r " "is not a built-in container constructor and cannot be " "emitted as source.", prop_name, type(node).__name__, prop.default_factory, ) continue prop_lines.append(f" {prop_name} = Property(default_factory={factory_src})") if expr is not None: init_overrides.append(f" self.{prop_name} = {expr}") elif expr is not None: prop_lines.append(f" {prop_name} = Property({expr})") if prop_lines: imports.add("Property") # Build __init__ init_lines = [ f' def __init__(self, name: str = "{node.name}", **kwargs):', " super().__init__(name=name, **kwargs)", ] if init_overrides: init_lines.extend(init_overrides) # Written before the header: the children's own values decide which helper # names the new module has to import. child_lines = _emit_child_setup(node, used_types=imports) if child_lines: init_lines.extend(child_lines) imports.discard(cls_name) header = "\n".join(_import_lines(imports)) # Assemble parts = [ f'"""Scene node: {cls_name} -- extracted from parent scene."""', "", header, "", "", f"class {cls_name}({base_name}):", ] if prop_lines: parts.extend(prop_lines) parts.append("") parts.extend(init_lines) parts.append("") return "\n".join(parts)
[docs] def extract_node_to_file( node: Node, source: str, output_path: str, class_name: str | None = None, ) -> tuple[str, str]: """Extract a node subtree to a new Python file. Args: node: The node to extract (must be a child of the scene root). source: The original Python source of the parent scene. output_path: File path for the new module (used to derive import path). class_name: Name for the extracted class. Returns: ``(new_file_source, modified_original_source)`` where: - *new_file_source* contains the class definition with the node's children. - *modified_original_source* has the inline construction replaced with an import and instantiation of the new class. """ cls_name = class_name or type(node).__name__ new_source = node_to_class_source(node, cls_name) # Derive module name from output path out = Path(output_path) module_name = out.stem # Modify original source: # 1. Add import for the new class # 2. Try to find and replace the inline node construction modified = source # Add import statement after existing imports import_line = f"from .{module_name} import {cls_name}" # Find the last import line and insert after it lines = modified.split("\n") last_import_idx = -1 for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith("import ") or stripped.startswith("from "): last_import_idx = i if last_import_idx >= 0: lines.insert(last_import_idx + 1, import_line) else: lines.insert(0, import_line) modified = "\n".join(lines) return new_source, modified