"""Reconcile a parsed scene class with a live runtime ``Node`` tree.
The editor mutates the runtime tree directly while the user works (drag a
node, change a Property, add a child); on save we need to push those
mutations *back* into the on-disk source without losing comments, blank
lines, hand-written helper functions, or import ordering.
:func:`apply_runtime_diff` is the integration point: given a
:class:`~simvx.core.scene_io.SceneClass` (a parso-backed view of the class
in the user's ``.py`` file) and the live runtime root, it issues the
minimum set of structural edits via the SceneClass API so the next
:meth:`SceneFile.save` writes a file whose ``__init__`` matches the
runtime tree exactly.
Identity matching uses the same name-sanitisation rule the greenfield
emitter applies (:func:`structural_type_name` + ``_canonical_var_names``
below): each runtime child gets its var name computed; if the parsed
source already has a child with that name, the two are treated as the
same node and reconciled in place. Otherwise it's an add (for runtime-
only nodes) or a remove (for source-only nodes).
v1 limitations (locked down by tests):
* Renaming a runtime child mid-session manifests as a remove + add at
save time. Proper rename detection requires a stable identity beyond
the user-visible ``.name``, which is a v2 concern.
* Procedural ``__init__`` bodies (children built inside loops or
conditionals) are not safe to diff-reconcile; callers should detect
that case via :func:`~simvx.core.scene_io.has_procedural_construction`
and fall back to greenfield save.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from simvx.core import Node
from simvx.core.scene_io import iter_runtime_kwargs, structural_type_name
if TYPE_CHECKING:
from simvx.core.scene_io import SceneClass
__all__ = ["apply_runtime_diff"]
# ---------------------------------------------------------------------------
# Var-name canonicalisation (mirrors emitter._EmitterContext._unique_var)
# ---------------------------------------------------------------------------
def _resolve_module(node: Node) -> str:
"""Return the import-source module for ``type(node)``.
Built-in classes (anywhere under ``simvx.core``) collapse to ``simvx.core``
so the existing umbrella re-export line is reused. User classes return
their actual ``__module__`` so the emitted ``from`` statement points at
the file the class lives in (e.g., ``player.attack`` for
``src/player/attack.py``).
"""
module = type(node).__module__ or "simvx.core"
if module.startswith("simvx.core"):
return "simvx.core"
return module
def _canonical_var_names(children: list[Node]) -> list[str]:
"""Compute the var names the greenfield emitter would assign to ``children``.
Mirrors :class:`simvx.core.scene_io.emitter._EmitterContext._unique_var`
exactly: lowercase, replace spaces/hyphens with ``_``, drop non-alnum,
prefix ``node_`` when the cleaned name is empty or starts with a digit,
and dedup duplicates with a ``_2``/``_3`` suffix in the order the
emitter visits them. The result is the canonical "what would the
source look like if we re-emitted from scratch" name list, used by
:func:`apply_runtime_diff` for source ↔ runtime identity matching.
"""
seen: dict[str, int] = {}
out: list[str] = []
for child in children:
base = child.name.lower().replace(" ", "_").replace("-", "_")
base = "".join(c for c in base if c.isalnum() or c == "_")
if not base or base[0].isdigit():
base = f"node_{base}"
if base == "self":
base = "node_self"
if base in seen:
seen[base] += 1
out.append(f"{base}_{seen[base]}")
else:
seen[base] = 0
out.append(base)
return out
# ---------------------------------------------------------------------------
# Public surface
# ---------------------------------------------------------------------------
[docs]
def apply_runtime_diff(
scene_class: SceneClass,
runtime_root: Node,
*,
identity_hints: dict[Node, str] | None = None,
) -> None:
"""Reconcile ``scene_class`` so its ``__init__`` matches ``runtime_root``.
Mutation strategy:
1. Update root ``super().__init__`` kwargs to reflect non-default
Property values on the runtime root (insert/update/remove).
2. For each top-level runtime child whose canonical var name already
exists in the source, update its constructor kwargs in place.
3. Append runtime children whose canonical name has no source
counterpart via :meth:`SceneClass.add_child` (auto-importing the
type via the file's :class:`ImportSet`).
4. Remove source-only children via :meth:`SceneClass.remove_child`,
then drop now-unused imports for types no longer referenced by
any remaining child constructor.
5. If the surviving children are out of order relative to the runtime
tree, call :meth:`SceneClass.reorder_children`.
Only top-level children of the root are reconciled in v1 (matching
the emitter's depth: nested ``add_child`` calls aren't expressed in
the current scene-class shape). Deeper sub-tree reconciliation is a
v2 concern.
``identity_hints`` is an optional ``{runtime_node: source_var_name}``
mapping captured at scene load. When a runtime node has a hint and
its current canonical var name differs from the hint, the diff
treats this as a *rename* — the source var is renamed in place via
:meth:`SceneClass.rename_child` and kwargs/type-swap are reconciled
against the renamed source — rather than emitting a remove + add
seam (which would lose the original source position and any kwargs
the source had that the runtime no longer carries explicitly).
"""
_reconcile_root_kwargs(scene_class, runtime_root)
_reconcile_children(scene_class, runtime_root, identity_hints=identity_hints)
# ---------------------------------------------------------------------------
# Root kwargs
# ---------------------------------------------------------------------------
def _reconcile_root_kwargs(scene_class: SceneClass, root: Node) -> None:
"""Update the root's ``super().__init__(...)`` kwargs to match ``root``.
The ``name=...`` kwarg is suppressed when it would equal the class
name itself (matching the emitter's canonical form); spatial and
Property kwargs are emitted whenever the runtime value deviates from
the default the emitter would consider canonical.
"""
used_types: set[str] = set()
desired = iter_runtime_kwargs(root, used_types=used_types)
desired_dict = dict(desired)
# The emitter suppresses ``name=<ClassName>`` when the class name and
# node.name match — mirror that here so we don't reintroduce it.
if "name" in desired_dict and desired_dict["name"].strip("'\"") == scene_class.name:
del desired_dict["name"]
# Auto-import any helper types the new kwargs need (Vec2/Vec3/Quat).
for type_name in used_types:
if not scene_class._file.imports.has_any_alias(type_name):
scene_class._file.imports.ensure(type_name, from_="simvx.core")
# Diff against existing kwargs on super().__init__.
existing = _existing_super_init_kwargs(scene_class)
for name, value_expr in desired_dict.items():
if existing.get(name) != value_expr:
scene_class.set_root_kwarg(name, value_expr)
# Remove kwargs that the runtime no longer carries. Preserve
# ``**kwargs`` (existing[name] is None for that case — never appears
# in our parsed kwargs map because get_root_kwarg returns None for
# absent names, but the parsed-kwargs map below only includes named
# arguments).
for name in list(existing):
if name not in desired_dict:
scene_class.remove_root_kwarg(name)
def _existing_super_init_kwargs(scene_class: SceneClass) -> dict[str, str]:
"""Return ``{kwarg_name: source_expr}`` for the existing
``super().__init__(...)`` call, excluding ``*args``/``**kwargs``."""
from simvx.core.scene_io.scene_file import _arglist_arguments, _argument_name
trailer = scene_class._super_init_trailer()
if trailer is None:
return {}
out: dict[str, str] = {}
for arg in _arglist_arguments(trailer):
name = _argument_name(arg)
if name is None:
continue
out[name] = arg.children[2].get_code().strip()
return out
# ---------------------------------------------------------------------------
# Child reconciliation
# ---------------------------------------------------------------------------
def _reconcile_children(
scene_class: SceneClass,
root: Node,
*,
identity_hints: dict[Node, str] | None = None,
) -> None:
"""Add/update/remove top-level children of the root to match the source."""
runtime_children = list(root.children)
runtime_var_names = _canonical_var_names(runtime_children)
source_var_names = scene_class.child_var_names()
source_set = set(source_var_names)
# Rename pass: when the editor passed identity hints, walk each
# runtime child whose hinted source-var name differs from its current
# canonical name. Rename the source var in place so subsequent steps
# match by canonical name. Only rename when the hinted source var
# actually exists AND the new name doesn't already exist (avoiding
# collisions with sibling renames).
if identity_hints:
for i, child in enumerate(runtime_children):
hint = identity_hints.get(child)
if hint is None:
continue
current_canonical = runtime_var_names[i]
if hint == current_canonical:
continue
if hint not in source_set:
continue
if current_canonical in source_set:
# Sibling collision — leave it for the remove+add path.
continue
scene_class.rename_child(hint, current_canonical)
source_set.discard(hint)
source_set.add(current_canonical)
# Update source_var_names in place so reorder check below
# uses the renamed sequence.
try:
idx = source_var_names.index(hint)
source_var_names[idx] = current_canonical
except ValueError:
pass
runtime_set = set(runtime_var_names)
# Types removed via class-swap (step 1 below) — their imports may no
# longer be referenced; pass them through the prune pass at step 4.
swap_removed_types: list[str] = []
# 1. For each child present in both: if the source's child constructor
# is the wrong type (e.g. Make Custom Class swapped Sprite2D for
# Player on this instance), remove the source child and let step 2
# re-add it with the new type. Otherwise update kwargs in place.
for child, var_name in zip(runtime_children, runtime_var_names, strict=True):
if var_name not in source_set:
continue
source_type = _child_type_name(scene_class, var_name)
runtime_type = structural_type_name(child)
if source_type is not None and source_type != runtime_type:
scene_class.remove_child(var_name)
source_set.discard(var_name)
swap_removed_types.append(source_type)
continue
_update_child_kwargs(scene_class, var_name, child)
# 2. Add runtime-only children. Insert in runtime order, anchored
# after the last existing source child (the SceneClass.add_child
# default), so the resulting order tends toward the runtime order
# without needing a follow-up reorder for the common "appended at
# the end" case.
for child, var_name in zip(runtime_children, runtime_var_names, strict=True):
if var_name in source_set:
continue
type_name = structural_type_name(child)
used_types: set[str] = set()
kwargs_pairs = iter_runtime_kwargs(child, used_types=used_types)
# Auto-import helper types the new construction needs (always simvx.core).
for helper in used_types:
if helper == type_name:
continue
if not scene_class._file.imports.has_any_alias(helper):
scene_class._file.imports.ensure(helper, from_="simvx.core")
kwargs = dict(kwargs_pairs)
scene_class.add_child(var_name, type_name, from_module=_resolve_module(child), **kwargs)
# 3. Remove source-only children. Capture their type before remove
# so we can clean up unused imports afterwards.
removed_types: list[str] = []
for var_name in list(source_var_names):
if var_name in runtime_set:
continue
type_name = _child_type_name(scene_class, var_name)
scene_class.remove_child(var_name)
if type_name is not None:
removed_types.append(type_name)
# 4. Drop imports that are no longer referenced by any surviving
# child constructor or the root's own type / super().__init__
# kwargs. Best-effort: ImportSet.remove ignores absent names.
all_removed = removed_types + swap_removed_types
if all_removed:
_prune_unused_imports(scene_class, all_removed)
# 5. Reorder if the surviving children don't already match runtime
# order. ``add_child`` appends new entries at the end, so this
# handles mid-list inserts and runtime-side reorderings.
current_order = scene_class.child_var_names()
if current_order != runtime_var_names:
scene_class.reorder_children(runtime_var_names)
def _update_child_kwargs(scene_class: SceneClass, var_name: str, child: Node) -> None:
"""Diff the constructor kwargs of one source child against the runtime
node. Insert/update/remove kwargs as needed; leave ``var = Type(...)``
structure untouched."""
used_types: set[str] = set()
desired = dict(iter_runtime_kwargs(child, used_types=used_types))
# Auto-import helper types if the new value introduces one.
for helper in used_types:
if helper == structural_type_name(child):
continue
if not scene_class._file.imports.has_any_alias(helper):
scene_class._file.imports.ensure(helper, from_="simvx.core")
existing = _existing_child_kwargs(scene_class, var_name)
for name, value_expr in desired.items():
if existing.get(name) != value_expr:
scene_class.set_child_kwarg(var_name, name, value_expr)
for name in list(existing):
if name not in desired:
scene_class.remove_child_kwarg(var_name, name)
def _existing_child_kwargs(scene_class: SceneClass, var_name: str) -> dict[str, str]:
"""Return ``{kwarg: expr}`` for the parsed child constructor."""
from simvx.core.scene_io.scene_file import _arglist_arguments, _argument_name
trailer = scene_class._child_ctor_trailer(var_name)
if trailer is None:
return {}
out: dict[str, str] = {}
for arg in _arglist_arguments(trailer):
name = _argument_name(arg)
if name is None:
continue
out[name] = arg.children[2].get_code().strip()
return out
def _child_type_name(scene_class: SceneClass, var_name: str) -> str | None:
"""Type name from ``<var> = <Type>(...)`` in the source, or ``None`` when
the parsed shape isn't a simple call."""
from parso.tree import Leaf
assignment = scene_class._find_child_assignment(var_name)
if assignment is None:
return None
expr_stmt = assignment.children[0]
if len(expr_stmt.children) < 3:
return None
rhs = expr_stmt.children[2]
if rhs.type != "atom_expr" or not rhs.children:
return None
head = rhs.children[0]
if not isinstance(head, Leaf) or head.type != "name":
return None
return head.value
def _prune_unused_imports(scene_class: SceneClass, removed_types: list[str]) -> None:
"""Drop imports for ``removed_types`` no longer referenced by any
remaining child constructor or by the root's class header.
The root class's base type is always referenced (via the ``class
Foo(Bar):`` header) and never removed. Helper types in kwarg values
(Vec2/Vec3/Quat) survive because :func:`iter_runtime_kwargs` re-adds
them via ``imports.ensure`` whenever they're still in use; the prune
pass below runs *after* the update pass that re-ensures them.
"""
# Collect every type name still referenced by a child constructor.
in_use: set[str] = set()
for var_name in scene_class.child_var_names():
type_name = _child_type_name(scene_class, var_name)
if type_name is not None:
in_use.add(type_name)
# Inspect kwarg values for helper types like Vec2(...).
from simvx.core.scene_io.scene_file import _arglist_arguments
trailer = scene_class._child_ctor_trailer(var_name)
if trailer is not None:
for arg in _arglist_arguments(trailer):
in_use.update(_collect_call_heads(arg))
# Spatial helper types may live in the root's super().__init__ args.
super_trailer = scene_class._super_init_trailer()
if super_trailer is not None:
from simvx.core.scene_io.scene_file import _arglist_arguments
for arg in _arglist_arguments(super_trailer):
in_use.update(_collect_call_heads(arg))
# The root's base class header is always referenced.
base_name = _root_base_name(scene_class)
if base_name is not None:
in_use.add(base_name)
# Now remove each removed_type that is no longer referenced.
for type_name in removed_types:
if type_name in in_use:
continue
# The ImportSet API needs the source module. Walk the existing
# imports to find which ``from <mod> import <type_name>`` line
# carries this name and remove it from there.
for from_module, imported_name in scene_class._file.imports.names():
if imported_name == type_name:
scene_class._file.imports.remove(type_name, from_=from_module)
break
def _collect_call_heads(node) -> set[str]:
"""Collect bare names that appear as the head of a call inside ``node``.
Used by import pruning to find ``Vec2``/``Quat``/etc. references hiding
inside kwarg values like ``position=Vec2(1, 2)``.
"""
from parso.tree import Leaf
out: set[str] = set()
def walk(n) -> None:
if isinstance(n, Leaf):
return
# An ``atom_expr`` is a sequence of ``name``, ``trailer*``. If the
# second child is a call trailer ``(...)``, the head name is being
# invoked.
if n.type == "atom_expr" and len(n.children) >= 2:
head = n.children[0]
second = n.children[1]
if (
isinstance(head, Leaf)
and head.type == "name"
and second.type == "trailer"
and second.children
and second.children[0].value == "("
):
out.add(head.value)
for c in getattr(n, "children", ()):
walk(c)
walk(node)
return out
def _root_base_name(scene_class: SceneClass) -> str | None:
"""Return the immediate base class name of ``scene_class`` from its
``class Foo(Bar):`` header, or ``None`` if no base."""
from parso.tree import Leaf
cls = scene_class.node
# children: ['class', name, '(', arglist|name, ')', ':', suite] for
# subclasses; ['class', name, ':', suite] for bare classes.
saw_lparen = False
for c in cls.children:
if isinstance(c, Leaf) and c.type == "operator" and c.value == "(":
saw_lparen = True
continue
if saw_lparen:
if isinstance(c, Leaf):
if c.type == "name":
return c.value
if c.type == "operator" and c.value == ")":
return None
elif c.type == "arglist":
# Multiple bases — return the first name.
for sub in c.children:
if isinstance(sub, Leaf) and sub.type == "name":
return sub.value
return None
return None