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