Source code for simvx.editor.scene_diff

"""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 starts from the source's own ``add_child`` calls
(:func:`_source_children`), because every one of them is a child and this
layer's whole job is to say which runtime node each one built. Every one of
them means every statement, not every line: a semicolon writes several
statements on a line and parso folds them into one node, and an ``add_child``
missed that way is a child the save writes out a second time. A call the
emitter wrote passes a var, and that var name is matched against the name the
tab recorded for each runtime child when it read the file -- the name the file
really binds, rather than the one a fresh emission would choose, which is a
different rule and does not agree (:func:`_canonical_var_names`). A call the
*author* wrote usually passes the construction itself --
``self.add_child(Sprite2D(...))`` is how the documentation, the tutorials and
the examples build a scene -- and binds no name to match on. Those calls are
matched to the children they built by aligning the two sequences without
crossing, since the runtime tree was
produced by running the statements in the order they are written. A call whose
head is a class this layer can resolve rules out every child that class cannot
have produced; past that, what a call constructs, the values it already spells,
and which children the file itself yielded decide which pairing to prefer where
the two sequences diverge (:func:`_match_source_children`). Only a runtime child
no source call accounts for is added, and only a source call no runtime child
came from is removed.

Every node the file gives a name to is reconciled, not only the root's own
children: a grandchild is a statement written on its parent's own local
(``panel.add_child(label)``), so it is the same work with a different receiver
(:func:`_reconcile_receiver`). Two things stop the walk. A child the file builds
inside its ``add_child`` call binds no name, so there is nothing to write its
own children on, and the ones the editor added under it are announced rather
than dropped in silence. And a class that adds children where no statement
shows them is read at the root's depth and no deeper, for the reason the next
bullet gives: below the root, writing a child this layer cannot account for
would make the file build it twice on a save that changed nothing.

Limitations (locked down by tests):
  * Children built where no statement of ``__init__`` shows them -- inside a
    loop, a conditional, or a method ``__init__`` calls -- are not reconciled.
    This layer cannot tell one of those from a child added in the editor, so
    it writes each of them into ``__init__`` as a new construction and
    announces that the file now builds them twice
    (:func:`_adds_children_out_of_reach`). Refusing to save such a file at all
    is a policy for the caller to set, not one taken here.
  * A construction this layer will not write into -- one calling something it
    cannot show to be a class, or passing arguments it cannot place -- keeps
    its text, and the values the scene holds for that child are announced
    through ``report`` rather than written (:class:`_InlineChild`). An
    argument written by position is placed by reading the class's own
    signature (:func:`_positional_parameters`) and edited where it stands, so
    only a call whose arguments no signature accounts for is left alone.
  * Which child such a call built is decided by order and by what the file
    yielded when the tab opened (:func:`_match_source_children`). A caller with
    no baseline has only the order, so a child added in the editor *above* one
    of these calls is taken for the call's own: the call's child is written out
    a second time and the added child is silently dropped from the file. Every
    caller in the editor passes a baseline.
  * Children are put back into the runtime's order only when the file binds
    every one of them to a var, which :meth:`SceneClass.reorder_children` is
    the only mover for. A file that constructs a child inside its own
    ``add_child`` call keeps the order it has; the divergence is announced
    (:func:`_reconcile_order`) except when the alignment could not tell the
    swapped children apart -- two unwritable constructions of the same type
    pair up in file order, so swapping only them is kept silently. A child a
    semicolon put on a line with another statement is kept where it is for the
    same reason the mover works in whole lines.
  * Removing a child removes every statement written on ``__init__``'s own
    body that stands on it, from the line that binds its variable onwards: the
    statement that added it, the constructions of the children it had of its
    own, and the ``self.<name> =`` binding an author may have kept it in.
    Anything else in the file that used one of those names will notice, so
    every statement that went is named through ``report``, and the bindings
    they carried off with them.

    Two things stop the sweep. A block -- a ``for``, an ``if``, a ``with`` or a
    ``try`` naming the child -- is not one statement to take out and its body
    is not rewritten here. And a statement it would reach that some *other*
    child the scene still holds is written as (``sprite2d =
    Sprite2D(position=panel.position)``) cannot go, since the file has to go on
    building that one. Either way the removal is refused whole, the child stays
    in the file, and what held it back is named through ``report``.

    A class swap takes none of that road, wherever the line has one name
    standing for the type it builds (:meth:`_SourceChild.repointable`): the
    construction is repointed at the type the scene holds, in whichever shape
    the file wrote it (:meth:`_BoundChild.retype`, :meth:`_InlineChild.retype`),
    so nothing is removed, the children hanging off it stay, and the author's
    arguments and comments stay with them. Which node a construction stands for
    is what decides that a swap is what happened: the node the tab's identity
    hints say that very line built (:func:`_this_line_built_it`), or -- for a
    child built inside the ``add_child`` call -- a call naming a class the
    node's new one descends from, or one whose child the scene still keeps in
    the attribute the statement binds. A construction none of those speaks for
    built some other node: it is a deletion and an addition rather than a swap,
    and goes out the way a deletion goes out, with everything standing on it
    named -- or is refused where that sweep would reach too far
    (:func:`_lines_the_swap_would_take`). A line with no name standing for the
    type at all -- ``panel = simvx.core.Panel()``, ``panel = POOL[0]``,
    ``hero = factories.make_hero()`` -- states no type for the scene to disagree
    with (:func:`_bound_construction`), so it keeps its text whatever class the
    editor puts on the node, and the class the scene holds is announced instead
    (:meth:`_Declined.record_type`).
  * A kwarg the author wrote as anything other than a plain value
    (``texture=icon``, ``art.ICON``, ``ART['icon']``, ``make_icon()``,
    ``math.radians(45)``) is left exactly as it stands, so an edit made to
    such a value in the editor does not reach the file: see
    :func:`_is_reference`. The loss is announced through
    :func:`apply_runtime_diff`'s ``report``. Values the emitter cannot
    express keep their source line too (:func:`_stale_kwargs`), so
    "matches the runtime tree" means every kwarg this layer is entitled to
    speak for, not every kwarg in the file.
"""

from __future__ import annotations

import ast
import inspect
import sys
import weakref
from collections.abc import Callable, Iterator
from functools import lru_cache
from typing import Any, NamedTuple

from simvx.core import Node
from simvx.core.scene_io import (
    edits,
    emit_value,
    expression_describes,
    expression_is_opaque,
    expression_reads_a_shape,
    helper_import_module,
    iter_runtime_kwargs,
    structural_type_name,
)
from simvx.core.scene_io.emitter import var_name_base
from simvx.core.scene_io.scene_file import (
    Removed,
    SceneClass,
    _add_child_argument,
    _add_child_receiver,
    _add_child_var_name,
    _arglist_arguments,
    _argument_name,
    _bound_locals_and_attributes,
    _code_on_one_line,
    _remove_argument,
)

__all__ = ["DESTRUCTIVE", "INFORMATIONAL", "ReportEntry", "apply_runtime_diff", "file_baseline"]


# ---------------------------------------------------------------------------
# What a save says about itself
# ---------------------------------------------------------------------------

#: The save will take something out of the author's file, or put something into
#: it, that they did not ask for: statements swept out with a removed child, an
#: attribute left unbound, a child written a second time because the file builds
#: it where this layer cannot see it. The file after such a save is not the file
#: before it plus the user's edits, so this is the class a caller stops to ask
#: about.
DESTRUCTIVE = "destructive"

#: The save leaves the author's line exactly as it stands and the value, the
#: order or the class swap the scene holds does not reach the file. Nothing of
#: theirs is lost, so it is written and warned about rather than asked about --
#: which is what Godot, Unity and Unreal all do with a value their serialiser
#: cannot carry.
INFORMATIONAL = "informational"


[docs] class ReportEntry(str): """One thing a save would do beyond carrying the user's edits across. The message, and which class it belongs to. It *is* the message -- a :class:`str` subclass, the shape :class:`http.HTTPStatus` takes for the same reason -- so every reader that only wants to print it, log it or search it goes on doing exactly that, and only the one caller that has to decide whether to interrupt the user reads :attr:`category`. ``category`` is :data:`DESTRUCTIVE` or :data:`INFORMATIONAL`, and it is what that decision turns on: a save that would rewrite the author's own ``__init__`` is worth an interruption, and one that merely cannot carry a value into a line it will not touch is not -- it would fire on every save of a scene holding one such slot, and a prompt that always appears is a prompt nobody reads. The report carries both classes either way, so nothing goes unsaid; the category only decides who is told and how loudly. """ #: Which class this entry belongs to. category: str
[docs] def __new__(cls, message: str, category: str = INFORMATIONAL) -> ReportEntry: entry = super().__new__(cls, message) entry.category = category return entry
[docs] @property def destructive(self) -> bool: """Would this change the author's file beyond carrying their edits?""" return self.category == DESTRUCTIVE
# --------------------------------------------------------------------------- # Var-name canonicalisation # --------------------------------------------------------------------------- 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 _import_source(scene_class: SceneClass, node: Node) -> str | None: """Where this file must import ``node``'s class from, or ``None`` for none. A class the file defines itself is already in scope, and importing a name from the module that defines it is a file that will not load: the editor's convert-to-class flow writes the new class into the scene file when that is where the user put it, and every save afterwards would otherwise add ``from <this module> import <ThisClass>`` above the class statement. """ if scene_class._file.source_tree.find_class(structural_type_name(node)) is not None: return None return _resolve_module(node) def _canonical_var_names(children: list[Node]) -> list[str]: """The var names a fresh emission would most likely give ``children``. :func:`~simvx.core.scene_io.emitter.var_name_base` per child, with a ``_1``/``_2`` suffix where one sibling repeats another's base. It is the name a child added in the editor is written under, and the name a child the file has no name for is looked up by. It is *not* an identity, and nothing may be matched on it alone. The emitter allocates against every local it writes for the whole tree -- grandchildren, and the locals it hoists for shared textures, both of which are invisible here -- so the suffix it lands on for a given child is not a function of that child's siblings. A scene holding ``Root{A{B}, B}`` is emitted with the second ``B`` bound to ``b_1``, and a scene sharing one ``player.png`` between two sprites binds the texture to ``player`` and a child named "Player" to ``player_1``. Which name the file really binds is what the tab's identity hints carry, and that is what :func:`_reconcile_children` matches on. """ seen: dict[str, int] = {} out: list[str] = [] for child in children: base = var_name_base(child.name) if base in seen: seen[base] += 1 out.append(f"{base}_{seen[base]}") else: seen[base] = 0 out.append(base) return out def _emitter_could_have_named(var_name: str, node: Node) -> bool: """Could a fresh emission have bound ``node`` to ``var_name``? True for the node's own base and for every ``base_N`` the emitter's deduplication could have produced from it. Which suffix a node actually ends up with depends on everything the emission had already named, so a variable built on the node's own base is a name the file is entitled to keep and there is nothing to put right. """ base = var_name_base(node.name) if var_name == base: return True stem, sep, suffix = var_name.rpartition("_") return sep == "_" and stem == base and suffix.isdigit() def _free_var_name(scene_class: SceneClass, wanted: str) -> str: """``wanted``, or the next name off its base that ``__init__`` does not bind. A child added in the editor is written under the name a fresh emission would give it, and that name can already be spoken for by something no per-sibling rule can see: a grandchild's construction, a hoisted resource local, a variable the author wrote. Binding it twice is not a file this layer may write -- :meth:`SceneClass.add_child` refuses it outright -- so the suffix the emitter uses for a repeated name settles it here too. **Counting from the base, not from ``wanted``.** ``wanted`` may already carry the emitter's suffix, and appending another to it gives ``node_self_1_1``, which is not a name any emission produces: the rename pass then tidies it to ``node_self_1`` on the *next* save, and the file oscillates between two texts while the tree stays the same. The emitter counts ``node_self``, ``node_self_1``, ``node_self_2``; so does this. """ if not scene_class.has_child(wanted): return wanted base, separator, suffix = wanted.rpartition("_") if separator != "_" or not suffix.isdigit(): base, count = wanted, 0 else: count = int(suffix) while True: count += 1 if not scene_class.has_child(f"{base}_{count}"): return f"{base}_{count}" # --------------------------------------------------------------------------- # Public surface # ---------------------------------------------------------------------------
[docs] def apply_runtime_diff( scene_class: SceneClass, runtime_root: Node, *, identity_hints: dict[Node, str] | None = None, baseline: dict[Node, dict[str, _Slot]] | None = None, refresh_baseline: Callable[[], dict[Node, dict[str, _Slot]] | None] | None = None, report: list[ReportEntry] | None = None, ) -> dict[Node, set[str]]: """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 runtime child the source already adds (:func:`_match_source_children`), update the construction's kwargs in place, wherever in the statement the author put it. A construction building a type the scene no longer holds is put right first (:meth:`_SourceChild.superseded_by`), and the construction that built this very node is repointed where it stands -- nothing removed, the author's arguments and comments kept, the children hanging off it left alone. That is a binding the tab's identity hints name (:func:`_this_line_built_it`) whose line has one name standing for the type (:meth:`_SourceChild.repointable`), and the call written inside ``add_child``, which binds no var and is spoken for by what it constructs. Any other binding is removed for step 3 to write back in the emitter's shape, or the swap is refused with what held it up named through ``report``: a block naming the child, a sibling built from it, or a subtree the removal would carry off and step 3 cannot write back (:func:`_lines_the_swap_would_take`). 3. Append runtime children no source call accounts for via :meth:`SceneClass.add_child` (auto-importing the type via the file's :class:`ImportSet`). 4. Remove the statements that add children the runtime no longer has, then drop the imports of types the file no longer names anywhere. 5. If the surviving children are out of order relative to the runtime tree, call :meth:`SceneClass.reorder_children`. Those five steps run once per node the file gives a name to, root first: a grandchild is a statement written on its parent's own local (``panel.add_child(label)``), so reconciling one is the same work with a different receiver (:func:`_reconcile_receiver`). The walk stops only at a child the file builds inside its ``add_child`` call, which binds no name to write anything on, and says so. ``identity_hints`` is an optional ``{runtime_node: source_var_name}`` mapping captured at scene load for the root's own children, and it is what such a child is looked up by: the name the file itself binds the child to, so an author's ``hero = Panel()`` and an emitter's ``b_1 = Node()`` alike are found, and the child's kwargs and type are reconciled against the author's own line rather than through a remove + add seam (which loses the source position, the kwargs the runtime no longer carries explicitly, and every statement standing on the child). Where a hint names a child whose name has since changed, the variable follows the new name (:meth:`SceneClass.rename_child`) so the file goes on reading like the scene; that is cosmetic, and where ``__init__`` already binds the new name it is declined into ``report`` and nothing else changes. ``baseline`` is an optional record of what the file says about each slot (:func:`file_baseline`). It is the evidence ``report`` needs: a declined kwarg whose value has not moved is a kwarg the file still describes, and one whose value has moved is an edit this save will not carry, whatever the author spelled the slot as. Without it the file's own bindings are used instead, which can only speak for a reference written as a bare name -- see :class:`_Declined`. ``refresh_baseline`` is called at most once, after the reconciliation has established that at least one slot was left to the file's own text, and its result replaces ``baseline`` for the judging. It exists because a slot can stop diverging without its line changing -- the author repoints the binding the line names -- so the only honest question is what the file yields *now*, and the only cheap moment to ask is once there is something to ask about. The caller does the reading because only it knows where the file is and that it has not been overwritten yet. ``report`` is mutated in place with one :class:`ReportEntry` per value this layer declined to write that the file does not already carry, and it is the only way to hear about any of them: a kwarg the file spells as a reference keeps its source line (:func:`_is_reference`) and a value with no source form at all keeps its own (:func:`_stale_kwargs`), so in neither case does the runtime value reach the file. Unlike :func:`~simvx.core.scene_io.emit_scene`, omitting it does not raise. The emitter's refusal *drops* a value from a file being written from scratch; this one *keeps* the author's own line, which is a save that already works and must not start failing. A child order this save could not carry is announced on the same channel and for the same reason (:func:`_reconcile_order`), as is a file that builds children where this layer cannot see them (:func:`_adds_children_out_of_reach`). Returns ``{node: {kwarg}}`` for every slot left to the file's own text, which the caller needs to rebuild its baseline without recording the opposite of what the file says -- see :attr:`_Declined.kept`. """ declined = _Declined( report, baseline=baseline, bindings=_file_bindings(scene_class) if report is not None and baseline is None else None, ) unreadable = scene_class.unreadable_syntax() if unreadable is not None: # Nothing is written. Every edit below reads `__init__` as a flat list # of statements, and a construct the parser could not read leaves the # contents of its arms in that list looking exactly like the lines # around them: the save would reconcile a `match` arm's children as the # root's own, and take half an arm out on the next deletion. declined.note( f"{structural_type_name(runtime_root)} {runtime_root.name!r}: `{scene_class.name}.__init__` holds " f"{unreadable.describe()}, which this editor's parser cannot read, so the file is left exactly as it " "is and nothing in the scene has been written to it; edit that line by hand, or keep the scene's " "construction outside it.", DESTRUCTIVE, ) declined.resolve(None) return declined.kept _reconcile_root_kwargs(scene_class, runtime_root, declined=declined) _reconcile_children(scene_class, runtime_root, identity_hints=identity_hints, declined=declined) fresh = refresh_baseline() if refresh_baseline is not None and declined.kept and report is not None else None declined.resolve(fresh) return declined.kept
# --------------------------------------------------------------------------- # Kwarg removal # --------------------------------------------------------------------------- #: Distinguishes "this node has no such attribute" from a legitimate ``None``. _ABSENT: object = object() def _already_describes(source_expr: str, current: object) -> bool: """Does ``source_expr`` already say exactly what the runtime holds? Compared by value, not by notation. ``emit_value`` writes a string the way a formatter would, and the author's own file need not: ``filter="linear"`` and ``filter='linear'`` are the same kwarg and neither is stale, so neither is rewritten on a save nobody asked for. The same applies to any other spelling of one value -- ``0`` and ``0.0``, ``0x10`` and ``16``. A value the emitter writes as a constructor call with defaulted parameters has several honest spellings and no literal form to compare, so the emitter itself is asked (:func:`~simvx.core.scene_io.expression_describes`): the author's ``SphereShape3D(radius=0.5)`` describes the sphere a save would write as ``SphereShape3D()``, and neither the update path nor the removal path may touch that line. """ if expression_describes(source_expr, current): return True emitted = emit_value(current) if emitted is None: return False if source_expr == emitted: return True try: return bool(ast.literal_eval(source_expr) == ast.literal_eval(emitted)) except (ValueError, SyntaxError): # One side is not a literal (``Vec2(30, 40)``, a name, a call). There is # no cheap way to know they agree, so treat them as different. return False def _call_head(expr: ast.expr) -> str | None: """The bare name a call invokes, or ``None`` when it is not one.""" if isinstance(expr, ast.Call) and isinstance(expr.func, ast.Name): return expr.func.id return None def _emitted_call_head(emitted: str | None) -> str | None: """The bare name the emitter's own expression invokes, if it invokes one.""" if emitted is None: return None try: return _call_head(ast.parse(emitted, mode="eval").body) except SyntaxError: # pragma: no cover - the emitter writes parseable source return None def _is_reference(source_expr: str, emitted: str | None = None, *, value: object = _ABSENT) -> bool: """Is ``source_expr`` a reference to a value bound somewhere else? A reference is the expression this layer must not overwrite. ``icon``, ``art.ICON`` and ``ART['icon']`` may each denote a texture two children share -- one image, one backend slot, one :meth:`Texture.update` that changes both -- and rewriting one as the constructor call the emitter produces for that texture would load back two textures that merely look alike, leave the author's own binding unused, and do it on a save with no edits in it. A call is a reference too, unless it calls the very name the emitter would have written. That is a rule about spelling and not about identity -- ``math.radians(45)`` denotes a plain float with nothing to share -- but this layer cannot evaluate it either, so it is kept like any other expression it cannot read, and the value the editor holds instead is announced through ``report`` rather than dropped in silence. Calling that name is usually the emitter's own form, spelled the one way it spells it, so the text says what it builds and comparing the text is enough. A collision shape is the exception on both sides of that comparison. Calling the same name settles less than it looks: the constructor has defaults, so one shape has several spellings and the arguments are what say which one -- ``SphereShape3D(radius=RADIUS)`` names the class a save would name and then hides the radius behind a name only running the file would resolve. The emitter is asked (:func:`~simvx.core.scene_io.expression_is_opaque`), and such a call is a reference like any other expression this layer cannot read. Calling a *different* name is the collider whose KIND was changed in the editor, and where both sides spell out the shape they build (:func:`~simvx.core.scene_io.expression_reads_a_shape`) the difference in class is a disagreement the scene settles, not a value built elsewhere. Only where both do: a kind change over ``SphereShape3D(radius=RADIUS)`` still leaves the author's line alone, since rewriting it would throw away the one record there is of a geometry nothing here can resolve. The diff cannot evaluate the expression to find out; :func:`_already_describes` says the same thing from the other side, treating anything that is not a literal as unknowable rather than stale. ``emitted`` is the expression the emitter would write in this slot, which is what a call is compared against. Without it every call is a reference, which is the safe answer when there is nothing to compare. ``value`` is what the node holds in that slot, which is what a call naming the emitter's own head is read against; without it such a call is taken at its spelling. """ try: body = ast.parse(source_expr, mode="eval").body except SyntaxError: return False if isinstance(body, ast.Name | ast.Attribute | ast.Subscript): return True if isinstance(body, ast.Call): head = _call_head(body) if head is None: return True if head != _emitted_call_head(emitted): return not (expression_reads_a_shape(source_expr) and expression_reads_a_shape(emitted or "")) return value is not _ABSENT and expression_is_opaque(source_expr, value) return False def _kwarg_updates( node: Node, existing: dict[str, str], desired: dict[str, str], *, declined: _Declined, ) -> Iterator[tuple[str, str]]: """The ``(name, expr)`` pairs whose source text should actually be written. A kwarg that already says what the emitter would say needs no edit, and saying it another way is still saying it: ``position=Vec2(30, 40)`` is the emitter's ``Vec2(30.0, 40.0)``, and rewriting it would change the file on a save with no edits in it (:func:`_expressions_agree`). Saying it in a shape the text comparison cannot see through is still saying it, so the value the node holds is asked too (:func:`_already_describes`): ``SphereShape3D(0.5)`` is the sphere the emitter writes as ``SphereShape3D()``. One spelled as a reference is left alone entirely, for the reason :func:`_is_reference` gives -- and a shape written with its geometry behind a name (``SphereShape3D(radius=RADIUS)``) is one of those: it names the class this layer would write and hides the only part that says which sphere it is. A collider whose kind was changed in the editor is not: the file's own ``SphereShape3D(...)`` and the scene's box disagree in the one part of the line that is plain text, so the line is rewritten. Leaving one alone is not free: the value the editor holds does not reach the file, so it goes to ``declined`` -- see :class:`_Declined`. """ for name, value_expr in desired.items(): current = existing.get(name) if current is not None and _expressions_agree(current, value_expr): continue held = getattr(node, name, _ABSENT) if current is not None and held is not _ABSENT and _already_describes(current, held): continue if current is not None and _is_reference(current, value_expr, value=held): declined.record(node, name, current, value_expr, tail=_REWRITE) continue yield name, value_expr #: What a declined kwarg's message says, according to why the file and the scene disagree. _REWRITE = ( "the file writes this as `{expr}`, which this save will not overwrite; " "edit `{expr}` in the file to match the scene." ) _REWRITE_OR_DELETE = ( "the file writes this as `{expr}`, which this save will not overwrite; the scene has it at its default now, " "so edit `{expr}` in the file to match, or delete the kwarg." ) _INLINE_CONSTRUCTION = ( "the file builds this child as `{expr}`, which this save will not rewrite; edit that call to match the scene." ) _OPAQUE_CONSTRUCTION = ( "the file adds this child with `{expr}`, which does not name the type it builds, so this save will not write " "into it; change what that expression builds to match the scene." ) _NO_SOURCE_FORM = "the scene now holds a value no expression can carry, so give it a file or build it in `on_ready()`." _DECLINED_TYPE = ( "the file builds this child as `{expr}`, which this save cannot show to build `{value}`, so the line keeps the " "type it was written with; change that line by hand to build `{value}`." ) #: The key a node's place among its receiver's ``add_child`` statements is #: recorded under in :func:`file_baseline`, as a string like every other slot. #: It is the identity of a child the file names nothing at all -- one built #: inside its own ``add_child`` call -- and it has to be recorded when the file #: is READ, because by save time the editor may have moved that child and the #: statements can no longer be lined up against the tree. The baseline is the #: one record taken then and keyed by runtime object, and it is already dropped #: when a save is aimed at some other file, which is exactly the guard an #: ordinal needs: an ordinal from another file would always hit. _SOURCE_INDEX = "__source_index__" #: The key a node's own type is recorded under in :func:`file_baseline`. Not a #: kwarg and not a Property -- no class may declare one under this name -- so it #: rides in the same record without colliding with anything the file spells. It #: is what stops :meth:`_Declined.record_type` crying wolf: a construction that #: never named the child's type (a factory, a dotted call) disagrees with the #: scene on every save, and only a type that has MOVED since the file was read #: is news. _TYPE_SLOT = "__type__" class _Candidate(NamedTuple): """One kwarg this layer declined to write, awaiting judgement in :meth:`_Declined.resolve`.""" node: Node kwarg: str #: The text the file spells the slot as, or ``None`` when it has no such line. source_expr: str | None #: What the emitter would write for the value the scene holds now. value_expr: str | None #: What the message says, or ``None`` for a value with no source form at all, #: which is announced whatever the baseline says. tail: str | None def _slot_label(item: _Candidate) -> str: """How a message names the slot it is about.""" if item.kwarg == _TYPE_SLOT: return f"{structural_type_name(item.node)} {item.node.name!r}" return f"{structural_type_name(item.node)} {item.node.name!r}.{item.kwarg}" def _and_list(names: list[str]) -> str: """``a``, ``a and b``, ``a, b and c`` -- how a message lists what it names.""" quoted = [f"`{name}`" for name in names] if len(quoted) == 1: return quoted[0] return f"{', '.join(quoted[:-1])} and {quoted[-1]}" class _Slot(NamedTuple): """What a file said about one constructor kwarg the last time it was read.""" #: The expression :func:`~simvx.core.scene_io.emit_value` gives for the #: value that slot held, or ``None`` when that value has no source form. value: str | None #: The text the file spelled the slot as, or ``None`` when the file had no #: such kwarg (the property was sitting at its default, unwritten). source: str | None class _Declined: """Where a value this layer will not write is announced, and on what evidence. Keeping the author's own expression is right; keeping quiet about it is not, because a save that silently dropped an edit looks exactly like one that succeeded. But a message on every save would mark a scene nobody edited as modified forever and teach the user to ignore the channel, so something has to separate the two cases. Two things can: * ``baseline`` -- what the file says about each slot. A slot whose value has not moved is a slot the file still describes, whatever the author spelled it as, and one whose value has moved is an edit this save will not carry. The judging is deferred to :meth:`resolve` so the caller can hand over a *fresh* reading first, taken once this pass knows there is something to judge. That matters because the question is not what the file said when the tab opened, it is what the file yields now: the author can fix one of these slots without touching the line at all, by repointing the binding it names, and a record taken at open time would go on insisting for the rest of the session that an edit was missing when the file had already caught up. * ``bindings`` -- the fallback for a caller with no baseline, such as a tree built in memory and diffed against a file it never came from. Only the file's own text is then available, so only a reference written as a bare name the file binds itself can be judged (:func:`_file_bindings`), and everything else is passed over in silence: there is no evidence either way, and guessing is worse than the limitation. ``report`` is the caller's list, or ``None`` when the caller does not want to hear about any of it. ``kept`` accumulates every ``(node, kwarg)`` this layer refused to write, reported or not, so the caller knows both whether a fresh reading is worth its cost and which slots the file, not the live tree, still speaks for. """ def __init__( self, report: list[ReportEntry] | None, *, baseline: dict[Node, dict[str, _Slot]] | None = None, bindings: dict[str, str] | None = None, ) -> None: self.report = report self.kept: dict[Node, set[str]] = {} self._baseline = baseline self._bindings = bindings or {} self._candidates: list[_Candidate] = [] def record(self, node: Node, kwarg: str, source_expr: str, value_expr: str | None, *, tail: str) -> None: """Note one declined kwarg, to be judged by :meth:`resolve`.""" self.kept.setdefault(node, set()).add(kwarg) if self.report is not None: self._candidates.append(_Candidate(node, kwarg, source_expr, value_expr, tail)) def record_type(self, node: Node, source_expr: str, type_name: str) -> None: """Note a construction whose class the scene has moved off and this save keeps. The other half of a declined kwarg: a value the file does not carry is announced, and so is a *class* it does not build. Both go through the same baseline test, which is what makes the channel worth reading. A factory call and a dotted construction never agreed with the type of the node they produce and never will, so announcing the disagreement itself would fire on every save of every file that has one; announcing that the class has changed since the file was read fires exactly when an edit was dropped. """ self.kept.setdefault(node, set()).add(_TYPE_SLOT) if self.report is not None: self._candidates.append(_Candidate(node, _TYPE_SLOT, source_expr, type_name, _DECLINED_TYPE)) def source_index_of(self, node: Node) -> int | None: """Which of its receiver's ``add_child`` statements built ``node``, if the baseline says. The identity of a child the file gives no name to. Everything else about such a child is order, and order is exactly what an edit disturbs, so this is the only thing that can carry a reorder of two of them across a save (:func:`_match_source_children`). """ slot = None if self._baseline is None else self._baseline.get(node, {}).get(_SOURCE_INDEX) if slot is None or slot.value is None: return None try: return int(slot.value) except ValueError: # pragma: no cover - written by this module as a decimal string return None def nodes_the_file_yields(self) -> set[Node] | None: """Which nodes the file itself produced, or ``None`` when nothing says. The baseline was taken from the tree the file yields, so its keys are exactly the nodes that came out of the file's own statements; a node the editor has added since is not among them. That is the only evidence there is about where a node came from, and :func:`_match_source_children` needs it for the statements whose text says nothing about what they build. """ return None if self._baseline is None else set(self._baseline) def note(self, message: str, category: str = INFORMATIONAL) -> None: """Announce something that is not about one kwarg, judged already. The kwarg messages are held back for :meth:`resolve` because a fresh reading of the file can still acquit them. A note has no such second chance: it is passed a fact this pass established for itself, such as an ordering the save left as it found it. ``category`` says whether what is being announced changes the author's file: a removal that swept their statements out does, a refusal that left everything where it was does not. """ if self.report is not None: self.report.append(ReportEntry(message, category)) def record_formless(self, node: Node, kwarg: str, source_expr: str | None) -> None: """Note a non-default value that has no source form at all. Unlike a declined expression there is nothing to compare it against, so :meth:`resolve` announces it whatever the baseline says: no file carries a texture built from pixels, the emitter refuses one on every save from scratch, and this path owes the same answer every time. ``source_expr`` is the line the author wrote, which survives the save, or ``None`` when the file has no line for this slot at all -- the emitter dropped the kwarg on the way in, so there is nothing to keep and nothing to point at. """ self.kept.setdefault(node, set()).add(kwarg) if self.report is not None: self._candidates.append(_Candidate(node, kwarg, source_expr, None, None)) def resolve(self, baseline: dict[Node, dict[str, _Slot]] | None = None) -> None: """Judge what was recorded and append the messages, in the order recorded. ``baseline`` supersedes the one this was built with when it is not ``None``: it is the caller's chance to answer "what does the file yield *now*" once the diff has established that something needs answering. """ if self.report is None: return if baseline is not None: self._baseline = baseline for item in self._candidates: # A slot the file has no line for is announced rather than judged: # there is nothing to compare the scene's value against, which is # the same answer a value with no source form gets. Only # :meth:`record_formless` can leave the line out, and it leaves the # tail out with it, so the two conditions name one kind of entry. if item.tail is None or item.source_expr is None: held = ( f"the file writes this as `{item.source_expr}`, which this save cannot replace" if item.source_expr is not None else "the file has no line for it, and this save could not write one" ) self.report.append(ReportEntry(f"{_slot_label(item)}: {held}; {_NO_SOURCE_FORM}", INFORMATIONAL)) elif self._diverges(item.node, item.kwarg, item.source_expr, item.value_expr): message = item.tail.format(expr=item.source_expr, value=item.value_expr) self.report.append(ReportEntry(f"{_slot_label(item)}: {message}", INFORMATIONAL)) def _diverges(self, node: Node, kwarg: str, source_expr: str, value_expr: str | None) -> bool: """Does the scene now hold something this slot in the file does not carry?""" if self._baseline is not None: slot = self._baseline.get(node, {}).get(kwarg) if slot is None: # A node or a slot this baseline never saw: it cannot vouch for # anything here, and a runtime value with nowhere to have come # from is a value the file does not carry. return True if slot.source is not None and slot.source != source_expr: return False return slot.value != value_expr # Without a baseline a value with no source form cannot be judged at # all: there is nothing to compare a refusal against. if value_expr is None: return False try: body = ast.parse(source_expr, mode="eval").body except SyntaxError: # pragma: no cover - the expression came from parsed source return False if not isinstance(body, ast.Name): return False bound = self._bindings.get(body.id) return bound is not None and not _expressions_agree(bound, value_expr)
[docs] def file_baseline( runtime_root: Node, source_root: Node | None = None, scene_class: SceneClass | None = None, ) -> dict[Node, dict[str, _Slot]]: """``{node: {kwarg: what the file said about it}}`` for the tree this layer reconciles. Taken whenever a tab starts describing a file -- a scene load, a live-file import, the reload the watcher triggers, and each save, after which the file says what was just written to it -- and handed back to :func:`apply_runtime_diff` on the next save. It is what lets the diff tell an edit it cannot write from a spelling it merely cannot rewrite, for slots the file's own text says nothing about: ``rotation=math.radians(45)`` and ``texture=art.ICONS.hero`` are unreadable either way, but the value under them is not. ``source_root`` is the tree the *file* yields, when that differs from the one being edited: saving over a file this session never opened reads that file to find out what it says rather than assuming the scene in hand. It defaults to ``runtime_root``, which is the answer whenever the two agree, and then each child *is* the source child, since running those statements is what produced it. Only for two different trees is there anything to match, and the canonical var name is all there is to match unrelated trees on. ``scene_class`` is the parsed file, supplying the text each slot is spelled as. Which statement speaks for which child is read off the file itself: the ``n``-th ``add_child`` built the ``n``-th child, because running them in that order is what produced the tree. The name a fresh emission would have picked is *not* used for that -- it allocates against one sibling list while the file's own names were allocated against the whole tree, so the two disagree for any file the engine wrote, and a lookup under the wrong name silently records every slot with no source text at all. Without a ``scene_class`` a slot's :attr:`_Slot.source` is ``None`` and a hand-rewritten expression cannot be recognised as one. Every kwarg the diff may later ask about is captured, including slots at their default -- the removal path asks about those too (:func:`_stale_kwargs`) -- and including slots whose value has no source form, which are recorded as such rather than left out: "the file could not carry this either" and "this baseline never saw it" are different answers. A node's own type is captured beside them under :data:`_TYPE_SLOT`, which is what tells a class swapped in the editor from a construction that never named the child's type in the first place (:meth:`_Declined.record_type`), and its place among its receiver's statements under :data:`_SOURCE_INDEX`, which is the whole identity of a child the file names nothing. **The whole tree, not the root's own children.** A node the record has no entry for is one :meth:`_Declined._diverges` cannot vouch for, so it answers that every kwarg of it has moved; a save that reconciles every depth and a record that stops at the first would report every grandchild on every save and the tab could never go clean. """ source_root = runtime_root if source_root is None else source_root root_kwargs = _existing_super_init_kwargs(scene_class) if scene_class is not None else {} baseline = {runtime_root: _node_baseline(source_root, root_kwargs, None)} by_receiver = _source_children_by_receiver(scene_class) if scene_class is not None else {} _baseline_below(baseline, runtime_root, source_root, by_receiver, "self") return baseline
def _baseline_below( baseline: dict[Node, dict[str, _Slot]], runtime_node: Node, source_node: Node, by_receiver: dict[str, list[_SourceChild]], receiver: str | None, ) -> None: """Record every child of ``receiver``, and every child of theirs, in place. The whole tree, because that is the depth the save reconciles: a node the baseline has no entry for is a node :meth:`_Declined._diverges` cannot vouch for, so it answers that every kwarg of it has moved, and a tab whose grandchildren are not in the record can never go clean. The whole tree also means past the point where the statements run out. ``receiver`` is ``None`` for a node the file builds inside its own ``add_child`` call, or through a loop, or on an attribute: there is no name to read statements off, so those children get no source text. They are still recorded, because they still came out of the file, and "the file yielded this and says nothing about it" is a different answer from "this baseline never saw it". """ slots = by_receiver.get(receiver, []) if receiver is not None else [] source_children = list(source_node.children) #: The statement that built each source child, when the file states exactly #: one per child. Anything else -- a loop, a helper method, a child added #: since -- lines nothing up, and a wrong pairing is worse than none. built_by: dict[int, _SourceChild] = {} if len(slots) == len(source_children): built_by = {id(child): slot for child, slot in zip(source_children, slots, strict=True)} position_of = {id(child): index for index, child in enumerate(source_children)} if built_by else {} for runtime_child, source_child in _paired_with_the_source_tree(runtime_node, source_node): slot = built_by.get(id(source_child)) entry = _node_baseline( source_child, slot.kwargs() if slot is not None else {}, slot.text if slot is not None else None ) position = position_of.get(id(source_child)) if position is not None: entry[_SOURCE_INDEX] = _Slot(str(position), None) baseline[runtime_child] = entry _baseline_below( baseline, runtime_child, source_child, by_receiver, slot.receiver_var() if slot is not None else None ) def _paired_with_the_source_tree(runtime_root: Node, source_root: Node) -> list[tuple[Node, Node]]: """Each runtime child beside the source child it stands for. The two are the same tree whenever the file being described is the one this scene came out of, which is every save back to the tab's own file, and then each child stands for itself. A save over some *other* file has two unrelated trees and nothing but a name to go on, so the canonical var name decides -- the same rule a caller with no other evidence gets everywhere else, and one that speaks for no more than it knows. """ if runtime_root is source_root: return [(child, child) for child in runtime_root.children] source_children = list(source_root.children) by_var = dict(zip(_canonical_var_names(source_children), source_children, strict=True)) runtime_children = list(runtime_root.children) pairs: list[tuple[Node, Node]] = [] for child, var_name in zip(runtime_children, _canonical_var_names(runtime_children), strict=True): source_child = by_var.get(var_name) if source_child is not None: pairs.append((child, source_child)) return pairs def _node_baseline(node: Node, source_kwargs: dict[str, str], source_text: str | None) -> dict[str, _Slot]: """One node's entry in :func:`file_baseline`. ``source_text`` is how the file spells the construction that built it, which is what :data:`_TYPE_SLOT` is judged against. """ emitted = dict(iter_runtime_kwargs(node)) slots: dict[str, _Slot] = {} for name in {*emitted, *node.get_properties(), "name", *source_kwargs}: value = emitted.get(name) if value is None: current = getattr(node, name, _ABSENT) value = emit_value(current) if current is not _ABSENT else None slots[name] = _Slot(value, source_kwargs.get(name)) slots[_TYPE_SLOT] = _Slot(structural_type_name(node), source_text) return slots def _expressions_agree(left: str, right: str) -> bool: """Do two source expressions denote the same value, spelling aside? The author writes ``Vec2(5, 5)``; the emitter writes ``Vec2(5.0, 5.0)``. Those are one value, and reporting them as a difference would fire on every save of a file nobody edited. Literals are compared by value, and a call is compared head-first and then argument by argument, which covers everything the emitter itself writes as a call (``Vec2``, ``Quat``, ``Texture``, ``Path``, ``Resource``), each of which the author can only spell the same way round -- their sampling settings are keyword-only, their coordinates positional. An argument that is not a literal compares unequal, and so does a call written with its arguments in another shape. That direction costs a message the user did not need, not a value they cannot get back, which is the right way round for a channel whose job is to say what the file is missing. """ if left == right: return True left_body, right_body = _parsed(left), _parsed(right) if left_body is None or right_body is None: return False return _nodes_agree(left_body, right_body) @lru_cache(maxsize=2048) def _parsed(expression: str) -> ast.expr | None: """``expression`` as an AST, or ``None`` when it is not one. Cached because matching asks about the same handful of expressions once per statement per child (:func:`_match_affinity`), and a scene with a hundred children of one type would otherwise parse each of them a hundred times. The result is only ever read. """ try: return ast.parse(expression, mode="eval").body except SyntaxError: return None def _nodes_agree(left: ast.expr, right: ast.expr) -> bool: """One step of :func:`_expressions_agree`: two parsed expressions, same value?""" if isinstance(left, ast.Call) or isinstance(right, ast.Call): if not isinstance(left, ast.Call) or not isinstance(right, ast.Call): return False if _call_head(left) is None or _call_head(left) != _call_head(right): return False if len(left.args) != len(right.args) or len(left.keywords) != len(right.keywords): return False if not all(_nodes_agree(a, b) for a, b in zip(left.args, right.args, strict=True)): return False right_kw = {kw.arg: kw.value for kw in right.keywords} return all(kw.arg in right_kw and _nodes_agree(kw.value, right_kw[kw.arg]) for kw in left.keywords) try: return bool(ast.literal_eval(left) == ast.literal_eval(right)) except (ValueError, SyntaxError, TypeError): return False def _file_bindings(scene_class: SceneClass) -> dict[str, str]: """``{name: the expression the file binds it to}`` for the bindings it states plainly. Two scopes are read: the module, and the body of the scene class's own ``__init__`` (where the emitter puts the local it writes a shared texture into). Only a plain ``name = expression`` statement at the top of either body counts, and a name assigned twice in one body counts for nothing: there is then no single expression the file can be said to bind it to. A binding in ``__init__`` shadows one of the same name at module level, as it does when the file runs. This is a read of the source text and nothing more: no import is followed and nothing is evaluated. It exists so :class:`_Declined` can tell a save that dropped an edit from a save that had nothing to drop, for a caller that has no baseline to compare against. """ try: module = ast.parse(scene_class._file.source_tree.dump()) except SyntaxError: # pragma: no cover - the file parsed once already return {} scopes: list[list[ast.stmt]] = [module.body] for stmt in module.body: if isinstance(stmt, ast.ClassDef) and stmt.name == scene_class.name: for member in stmt.body: if isinstance(member, ast.FunctionDef) and member.name == "__init__": scopes.append(member.body) bindings: dict[str, str] = {} for body in scopes: seen: set[str] = set() for stmt in body: if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1: continue target = stmt.targets[0] if not isinstance(target, ast.Name): continue if target.id in seen: bindings.pop(target.id, None) continue seen.add(target.id) bindings[target.id] = ast.unparse(stmt.value) return bindings def _report_formless(node: Node, existing: dict[str, str], unemittable: set[str], *, declined: _Declined) -> None: """Announce the formless values the file has no line for either. :func:`_stale_kwargs` speaks for the ones the author wrote out, whose line survives this save. This is the other half, and it is the half a save from scratch creates: the emitter drops a kwarg it cannot write, so the file it produces has no line for that slot at all, and saving the same scene a second time goes down the round-trip path where a loop over the file's own kwargs can never reach it. That save used to come back clean over a value no file has ever carried. """ for name in sorted(unemittable - set(existing)): declined.record_formless(node, name, None) def _stale_kwargs( node: Node, existing: dict[str, str], desired: dict[str, str], unemittable: set[str], derived: set[str], *, declined: _Declined, ) -> list[str]: """Which of the source's kwargs the diff is entitled to delete. A kwarg the emitter did not produce is not, on its own, evidence that the author's line should go. It can mean four different things, and only one of them is a deletion: * The runtime holds a value no expression can carry -- a ``Texture``, raw bytes, a reference to another node. The source line is then the only surviving record of the author's intent, and deleting it destroys something the editor never held a replacement for. Keep it. * The value in the property is the engine's own arithmetic: a size a control measured for itself, a position its container placed it at (``derived``). The author's line is the only statement of what they asked for, and the engine will work the value out again on the next load, so the line stays exactly as written and nothing is announced -- nothing has gone wrong. * The kwarg is not a declared ``Property`` at all: a constructor parameter the class consumes itself, which the emitter never had an opinion about and cannot re-derive from the live node. Keep it. * The kwarg is written as a reference (:func:`_is_reference`). The update path will not overwrite one, and deleting one is the harsher version of the same guess, so it stays as well. * The property sits at its default and the source line does not already describe it. That line, and only that line, has gone stale: the user cleared the value in the editor and expects the file to follow. So an author who wrote a default out explicitly keeps their line whenever both their spelling and the emitter's own form are literals: ``'linear'`` and ``"linear"``, ``0`` and ``0.0``, are the same kwarg and neither is stale. A collision shape keeps its line too, whichever of its several honest spellings the author used, and a spelling this layer cannot read at all (``SphereShape3D(radius=RADIUS)``) is kept as the reference it is rather than removed on the strength of a radius nobody here can resolve. For the other call-valued properties (``Vec2``/``Vec3``/``Quat``) only a textually identical spelling is recognised, so ``position=Vec2(0.0, 0.0)`` survives while ``Vec2(0, 0)``, ``(0, 0)`` and ``[0.0, 0.0]`` are all removed even though they name the same value -- see :func:`_already_describes`. A line kept here is announced through ``declined`` on the same terms the update path uses: only when the value the node now holds is not the one that slot carried when the file was read. That covers the first case as well as the third -- a property that used to hold a file-backed texture and now holds one built from pixels has changed to something no file can carry, and a save that keeps the old line and says nothing is the same silent loss as any other. """ governed = set(node.get_properties()) | {"name"} stale: list[str] = [] for name, source_expr in existing.items(): if name in desired or name not in governed: continue if name in unemittable: # The runtime holds a non-default value with no source form at all. # The author's line is the only record of what this slot was for, so # it stays -- but the scene has moved past what it says, and the # emitter's own refusal channel never sees a save that took this # path. declined.record_formless(node, name, source_expr) continue if name in derived: continue current = getattr(node, name, _ABSENT) emitted = emit_value(current) if current is not _ABSENT else None if current is not _ABSENT and _already_describes(source_expr, current): # The author's own spelling of the value the node holds. Asked before # the reference question, which for a shape call reads the same # arguments: a line that says what the scene says is not a line this # layer could not read. continue if _is_reference(source_expr, emitted, value=current): # The same unknowable expression the update path leaves alone. # Deleting it is the harsher of the two guesses: it destroys a line # the author wrote and this layer cannot read. It is announced on # the same channel, for the same reason: the file goes on saying # something the scene no longer says. declined.record(node, name, source_expr, emitted, tail=_REWRITE_OR_DELETE) continue stale.append(name) return stale # --------------------------------------------------------------------------- # Root kwargs # --------------------------------------------------------------------------- def _reconcile_root_kwargs(scene_class: SceneClass, root: Node, *, declined: _Declined) -> None: """Update the root's ``super().__init__(...)`` kwargs to match ``root``. The ``name=...`` kwarg is never *introduced* 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, except where the author wrote a reference (:func:`_is_reference`). Removal is narrower than emission: see :func:`_stale_kwargs`. """ used_types: set[str] = set() unemittable: set[str] = set() derived: set[str] = set() desired = iter_runtime_kwargs(root, used_types=used_types, unemittable=unemittable, derived=derived) 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"] # Diff against existing kwargs on super().__init__. existing = _existing_super_init_kwargs(scene_class) updates = list(_kwarg_updates(root, existing, desired_dict, declined=declined)) _ensure_helper_imports(scene_class, used_types, updates) for name, value_expr in updates: scene_class.set_root_kwarg(name, value_expr) _report_formless(root, existing, unemittable, declined=declined) # Remove only the kwargs that have gone stale. ``*args``/``**kwargs`` are # already absent from ``existing``, which lists named arguments only. stale = _stale_kwargs(root, existing, desired_dict, unemittable, derived, declined=declined) for name in stale: scene_class.remove_root_kwarg(name) _prune_unused_imports(scene_class, _shapes_written_out_of(existing, updates, stale)) def _ensure_helper_imports( scene_class: SceneClass, used_types: set[str], written: list[tuple[str, str]], skip: str | None = None ) -> None: """Import the helpers the kwargs this save actually writes refer to. ``used_types`` is what the emitter's form for the whole node names, which is a superset: a kwarg written as a reference keeps its own spelling, and adding ``Texture`` for a ``texture=art.ICON`` line the file already has would leave an unused import behind -- and change a file on a save with no edits in it. Each helper is asked for by name from the module the emitter says it comes from: not everything it writes is a ``simvx.core`` export. """ referenced: set[str] = set() for _, expr in written: try: parsed = ast.parse(expr, mode="eval") except SyntaxError: # pragma: no cover - the emitter writes parseable source continue referenced.update(n.id for n in ast.walk(parsed) if isinstance(n, ast.Name)) for type_name in used_types & referenced: if type_name == skip: continue if not scene_class._file.imports.has_any_alias(type_name): scene_class._file.imports.ensure(type_name, from_=helper_import_module(type_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``.""" trailer = scene_class._super_init_trailer() return _trailer_kwargs(trailer) if trailer is not None else {} # --------------------------------------------------------------------------- # The children the source already adds # --------------------------------------------------------------------------- class _SourceChild: """One child the parsed ``__init__`` adds, in whatever shape it is written. The emitter writes a child as a binding and a call on it -- ``hero = Sprite2D(...)`` then ``self.add_child(hero)`` -- and that is the only shape :class:`~simvx.core.scene_io.SceneClass` can name, since every one of its child operations takes a var name. Authors write the other shapes: ``self.add_child(Sprite2D(...))`` and ``self.hero = self.add_child(Sprite2D(...))`` are how the tutorials, the examples and the documentation build a scene, and a factory call or a name from elsewhere is the same statement with something else inside it. Every one of them adds exactly one child, so every one of them is the source of one runtime node, and this is the view that says so. It answers what the file constructs (:attr:`type_name`) and with what (:meth:`kwargs`), and it is where an edit is written, a kwarg deleted or the whole child removed, so :func:`_reconcile_children` can do its work without knowing which shape it has in hand. """ #: The var the file binds the child to, for the shape the emitter writes. #: ``None`` when the file constructs the child inside the ``add_child`` #: call, which binds no name this layer can edit through. var_name: str | None = None #: The name the construction calls, when the statement plainly calls one. #: A caller decides whether that name is a type: syntax alone cannot tell #: ``Hero(...)`` from ``make_hero(...)``. type_name: str | None = None #: Does the construction pass nothing but keyword arguments? Whether a #: kwarg may be written into it is :meth:`writes_for`'s question, which is #: wider: an argument passed by position can be written into as well, once #: the class says which parameter that position fills (:meth:`positionals`). editable: bool = False #: How the file spells the construction, for messages about what it says. text: str = "" def __init__(self, scene_class: SceneClass, stmt) -> None: self.scene_class = scene_class #: The statement that adds the child, which removing the child removes. self.stmt = stmt @property def attached(self) -> bool: """Is the statement still in the file? Removing one child can take another's statements with it -- they name a variable that has gone (:meth:`remove`) -- and a second removal of the same statement is not one this layer should attempt. """ line = edits.enclosing_statement(self.stmt) return line is not None and line.parent is not None def trailer(self): """The construction's call trailer, or ``None`` when it is not a call.""" return None def positionals(self, child: Node) -> dict[str, _Positional] | None: """``{parameter: what fills it}`` for the arguments passed by position. ``{}`` for a call passing nothing but keyword arguments, and ``None`` when what a position fills cannot be established (:func:`_positional_parameters`), which is what makes a construction one this layer will not write into at all. """ trailer = self.trailer() if trailer is None: return {} return _positional_parameters(trailer, child, self.type_name) def set_positional(self, index: int, value_expr: str) -> None: """Rewrite the ``index``-th argument the construction passes by position.""" edits.set_call_positional(self.trailer(), index, value_expr) def writes_for(self, child: Node) -> bool: """May this save write the values ``child`` holds into this construction?""" return False def superseded_by(self, child: Node) -> bool: """Does this construction build a type the scene no longer holds? The question a class swap asks: Make Custom Class rebinds one node's class in place (``node.__class__ = Player``), and the file goes on building the type it was written with until this says so. """ return False def repointable(self) -> bool: """Can :meth:`retype` write a swap into this construction where it is? Only a call to a plain name has one name to move (``Sprite2D(...)`` -> ``Player(...)``). Anything else the file may build a child with -- an attribute call, a subscript, a comprehension, a name bound elsewhere -- has no such name, and rewriting the first one it happens to contain would produce a line that means something else entirely. Those go the removal road instead, which writes the new type back in the emitter's shape or refuses and says so, and is what the default answer here sends anything this layer cannot read a construction out of. """ return False def could_have_built(self, child: Node) -> bool: """Could running this statement have produced ``child``? The bar identity is held to: a statement paired with a child it cannot have built claims that child, so the child's own construction is never written and the statement is never removed -- the file then loads back without the child at all. ``True`` is therefore the answer whenever the statement says nothing about what it builds, which is what a shape this layer cannot read into always says. """ return True def receiver_var(self) -> str | None: """The local a child of *this* child would be written on, or ``None``. A grandchild is a statement on its parent's own local (``panel.add_child(label)``), so the file has to bind that parent to one before anything can be written under it. It is a wider question than :attr:`var_name`, which answers only for the shape :class:`~simvx.core.scene_io.SceneClass` will *edit through*: the file plainly binds ``hero`` in ``self.hero = self.add_child(hero)``, and ``hero.add_child(badge)`` is perfectly writable, even though no operation of ``SceneClass`` takes that statement's var. """ return self.var_name def kwargs(self) -> dict[str, str]: """``{kwarg: source expression}`` for the construction, as the file spells it.""" return {} def set_kwarg(self, name: str, value_expr: str) -> None: raise NotImplementedError def remove_kwarg(self, name: str) -> None: raise NotImplementedError def retype(self, type_name: str, from_module: str | None) -> None: """Make the construction build ``type_name`` instead, importing it. ``from_module`` of ``None`` is a class the file defines itself, which needs no import (:func:`_import_source`). """ raise NotImplementedError def remove(self) -> Removed: """Take the child out of the file, and say what went with it. The statements the removal had to take as well, and the attribute bindings they carried off (:meth:`~simvx.core.scene_io.SceneClass.remove_child`). """ raise NotImplementedError def own_statements(self) -> list: """The statements of ``__init__`` this child is written as. What a caller announcing its removal already speaks for, and what tells a sweep reaching some other child's lines from one reaching this child's own (:func:`_children_the_removal_would_take`). """ return [self.stmt] def removal_sweep(self) -> list: """Every statement :meth:`remove` would take, without taking any of them.""" return [self.stmt] def blocks_holding_it(self) -> list[str]: """The blocks of ``__init__`` that stop this child being taken out. Each as the line it opens with. A ``for``, an ``if``, a ``with`` or a ``try`` that names the child (or anything the removal would carry off with it) is not one statement to remove and its body is not this layer's to rewrite, so the removal is refused and the divergence announced rather than a file written that names a variable it no longer binds. Empty when :meth:`remove` would go through. """ return [] class _BoundChild(_SourceChild): """A child the file binds to a var, which is the shape the emitter writes.""" editable = True def __init__(self, scene_class: SceneClass, var_name: str, stmt) -> None: super().__init__(scene_class, stmt) self.var_name = var_name self.type_name, self.text, self._call_head = _bound_construction(scene_class, var_name) def trailer(self): return self.scene_class._child_ctor_trailer(self.var_name or "") def writes_for(self, child: Node) -> bool: # A binding naming a class the scene no longer holds is swapped rather # than written into, which the caller has already done by the time this # is asked; one whose head is not the child's type after that spells a # signature the arguments were not written against (a factory, a name # bound elsewhere), so it keeps its text and the values are announced # instead. What is left to ask is whether the construction passes # anything by position that this layer cannot place: writing a kwarg # beside such an argument would name one parameter twice and the file # would not load. if self.type_name != structural_type_name(child): return False return self.positionals(child) is not None def superseded_by(self, child: Node) -> bool: # Matched on its var name, so identity is settled without the head; what # is not settled is whether the head names a type at all. A name the # child's class descends from is the line a swap left behind. Beyond # that, a plain call is a swap only when its head can be shown to name # a class: `make_hero()` never agreed with the type of the node it # returns and never will, and reading that as a swap rewrites a # construction this layer has promised to leave as written. A shape with # no single name standing for what it builds (`simvx.core.Panel()`, # `POOL[0]`) states no type at all (:func:`_bound_construction`), so it # has nothing to disagree with the scene about and keeps its text; what # the scene holds instead is announced. if self.type_name is None or self.type_name == structural_type_name(child): return False if self.type_name in _class_names(child): return True return _names_a_class(self.scene_class, self.type_name) def kwargs(self) -> dict[str, str]: return _existing_child_kwargs(self.scene_class, self.var_name or "") def set_kwarg(self, name: str, value_expr: str) -> None: self.scene_class.set_child_kwarg(self.var_name or "", name, value_expr) def remove_kwarg(self, name: str) -> None: self.scene_class.remove_child_kwarg(self.var_name or "", name) def repointable(self) -> bool: # Only ``<var> = <Type>(...)`` has a name that stands for the type and # nothing else; see :func:`_bound_construction`. return self._call_head is not None def retype(self, type_name: str, from_module: str | None) -> None: """Point the construction at ``type_name``, keeping the rest of the line. The same edit :meth:`_InlineChild.retype` makes, on the other shape: ``player = CharacterBody3D(...)`` becomes ``player = Player(...)``. What it is instead of -- removing the assignment and letting the add pass write one back -- takes every statement standing on the variable with it, the constructions of the child's own children among them, and writes back one top-level construction and nothing under it. So this is what a swap does whenever this line can be shown to have built this very node (:func:`_this_line_built_it`) *and* it has one name to move (:meth:`repointable`); the removal is left to the case that is really a deletion, and to the line this cannot rewrite. """ if self._call_head is None: raise ValueError(f"`{self.text}` is not a construction this can repoint") self._call_head.value = type_name self.type_name = type_name _, self.text, self._call_head = _bound_construction(self.scene_class, self.var_name or "") if from_module is not None: self.scene_class._file.imports.ensure(type_name, from_=from_module) def remove(self) -> Removed: return self.scene_class.remove_child(self.var_name or "") def own_statements(self) -> list: found = ( self.scene_class._find_child_assignment(self.var_name or ""), self.scene_class._find_add_child_call(self.var_name or ""), ) return [stmt for stmt in found if stmt is not None] def removal_sweep(self) -> list: return self.scene_class._removal_sweep(self.var_name or "") def blocks_holding_it(self) -> list[str]: return self.scene_class._removal_blockers(self.var_name or "") class _InlineChild(_SourceChild): """A child the file builds inside the ``add_child`` call itself. Written into when the call names the child's own type and this layer can place every argument it passes. An argument written by position is placed by asking the class which parameter that position fills (:func:`_positional_parameters`), and is then rewritten where it stands rather than repeated as a keyword beside itself, which would name one parameter twice and produce a file that will not load. Two shapes are matched like any other -- they are still the source of the child they built, which is the whole point -- but keep their text: * a call this layer cannot place the arguments of: one unpacking a sequence or a mapping into them, or one whose class takes ``*args``, or one passing more of them than the class has parameters, none of which says which parameter carries what; * a call to something that is neither the child's type nor a class it descends from, which is a factory (``make_hero()``) whose arguments say nothing about the node it returns. One naming a class the child *does* descend from is a line a class swap left behind, and is repointed at the type the scene holds (:meth:`retype`). What the scene holds instead is announced (:func:`_decline_child_kwargs`). A statement that keeps its child in an attribute (``self.panel = self.add_child(Panel(...))``) is identified by more than what it constructs: running it left the scene holding that child under that attribute, and the scene in hand can be asked whether it still does (:meth:`_still_keeps`). That is what tells a class swapped in the editor from a child deleted and another added -- both leave a construction naming a class the scene no longer holds, and reading the first as the second takes the whole subtree standing on the attribute out of the file with the line (:meth:`could_have_built`, :meth:`superseded_by`). """ def __init__(self, scene_class: SceneClass, stmt, argument) -> None: super().__init__(scene_class, stmt) self.argument = argument self.type_name, self._trailer = _constructor_parts(argument) self._refresh_text() self.editable = self._trailer is not None and not _has_positional_arguments(self._trailer) #: The attribute the statement keeps the child in, or ``None``. A #: statement binds at most one, since only its assignment target counts. self.attribute: str | None = min(_bound_locals_and_attributes(stmt)[1], default=None) #: Whether :attr:`type_name` is a class, asked for once (:func:`_names_a_class`). self._head_is_a_class: bool | None = None def _calls_a_class(self) -> bool: """Is the name the construction invokes a class this layer can see?""" if self.type_name is None: return False if self._head_is_a_class is None: self._head_is_a_class = _names_a_class(self.scene_class, self.type_name) return self._head_is_a_class def _still_keeps(self, child: Node) -> bool: """Does the scene still hold ``child`` in the attribute this statement binds? ``self.panel = self.add_child(Panel(...))`` leaves the scene it built with ``panel`` naming the node the call made, and the editor's class swap rebinds that node's class on the node itself, so the attribute goes on naming it. A child deleted in the editor and another added leaves the attribute naming the deleted node, so the added one is not the one this statement built and answers ``False`` -- which is what keeps a deletion from being read as a swap. So does a scene that was never built by running this file, which has no such attribute to ask about and is owed the answer the construction alone gives. """ if self.attribute is None: return False root = child.parent return root is not None and getattr(root, self.attribute, None) is child def receiver_var(self) -> str | None: # The one inline shape with a local behind it: ``self.add_child(hero)``, # where the file bound ``hero`` on a line of its own. Anything built # inside the call itself -- ``self.add_child(Panel())`` -- binds nothing, # so there is no name to write a child of it on. from parso.tree import Leaf if not isinstance(self.argument, Leaf) or self.argument.type != "name": return None name = str(self.argument.value) return name if self.scene_class._find_child_assignment(name) is not None else None def _refresh_text(self) -> None: self.text = _code_on_one_line(self.argument) def trailer(self): return self._trailer def writes_for(self, child: Node) -> bool: if self._trailer is None or self.type_name != structural_type_name(child): return False return self.editable or self.positionals(child) is not None def superseded_by(self, child: Node) -> bool: # A name the class descends from is a line a swap left behind -- running # it would build the base, not the class the scene holds -- so the file # is out of date. Otherwise what the line constructs is the only evidence # of identity there is, and a name the scene's class does not descend # from is no evidence of a swap: ``make_hero()`` never agreed with the # type of the node it returns and never will. Unless the scene still # keeps this very child in the attribute the statement binds, which # identifies it without the type: a class it no longer has then means the # construction is out of date, provided the name it calls is a class at # all. if self.type_name is None or self.type_name == structural_type_name(child): return False if self.type_name in _class_names(child): return True return self._still_keeps(child) and self._calls_a_class() def could_have_built(self, child: Node) -> bool: # Running ``Sprite2D(...)`` yields a ``Sprite2D``, so a statement whose # head is a class the file can be shown to have in hand built a child of # that class and no other -- one whose class was swapped underneath it # included (:meth:`superseded_by`, which repoints the call). A head this # layer cannot show to be a class is a factory as far as it knows, and a # factory returns whatever it likes. So is a construction whose child the # scene still keeps in the attribute the statement binds: that binding # says the pair is right whatever class the editor has since put on the # node, and refusing it here would read a swap as a deletion and take the # node's own children out of the file with the line. A child the # attribute does *not* name is not the one this statement built, however # its class compares, so no such relaxation is owed it. if self.type_name is None or self.type_name == structural_type_name(child): return True if self.type_name in _class_names(child): return True return self._still_keeps(child) or not self._calls_a_class() def repointable(self) -> bool: # ``_constructor_parts`` yields a trailer only for a plain ``Name(...)``, # which is the shape with one name standing for the type. return self._trailer is not None def retype(self, type_name: str, from_module: str | None) -> None: """Point the construction at ``type_name``, keeping everything else. The alternative -- delete the statement and let it be written back in the emitter's shape -- would throw away the author's arguments, the comments inside their call, and any binding the statement keeps the child in (``self.hero = self.add_child(...)``, which the rest of the file goes on using). The name a call invokes is the one part of it this layer can be certain of, so it is the only part that moves. ``from_module`` is where the new name is imported from, and ``None`` means it needs no import: the file defines that class itself (:func:`_import_source`). """ head = self.argument.children[0] head.value = type_name self.type_name = type_name self._refresh_text() if from_module is not None: self.scene_class._file.imports.ensure(type_name, from_=from_module) def kwargs(self) -> dict[str, str]: return _trailer_kwargs(self._trailer) if self._trailer is not None else {} def set_kwarg(self, name: str, value_expr: str) -> None: edits.set_call_kwarg(self._trailer, name, value_expr) def remove_kwarg(self, name: str) -> None: for arg in _arglist_arguments(self._trailer): if _argument_name(arg) == name: _remove_argument(arg) return raise ValueError(f"kwarg {name!r} not found on `{self.text}`") def remove(self) -> Removed: return self.scene_class._remove_statement(self.stmt) def removal_sweep(self) -> list: return self.scene_class._statement_removal_sweep(self.stmt) def blocks_holding_it(self) -> list[str]: return self.scene_class._statement_removal_blockers(self.stmt) def _source_children(scene_class: SceneClass, *, receiver: str = "self") -> list[_SourceChild]: """Every child the parsed ``__init__`` adds to ``receiver``, in source order. One entry per ``<receiver>.add_child(...)`` statement in ``__init__``'s own body. ``"self"`` is the root's children; a local the file binds to a child is that child's own, and asking for each in turn walks the whole tree the file builds. A call inside a loop, a conditional or a helper method is still out of reach, since there is no statement here to match a child to (:func:`_adds_children_out_of_reach`). A statement, not a line: a semicolon writes several statements on one line, and each of them is read (:meth:`~simvx.core.scene_io.scene_file.SceneClass._init_statements`). A statement a later one supersedes is left out (:func:`_superseded_adds`). """ superseded = _superseded_adds(scene_class) out: list[_SourceChild] = [] for stmt in scene_class._init_statements(): argument = _add_child_argument(stmt, receiver=receiver) if argument is None or id(stmt) in superseded: continue var_name = _add_child_var_name(stmt, receiver=receiver) if var_name is not None: out.append(_BoundChild(scene_class, var_name, stmt)) else: out.append(_InlineChild(scene_class, stmt, argument)) return out def _source_children_by_receiver(scene_class: SceneClass) -> dict[str, list[_SourceChild]]: """Every child the parsed ``__init__`` adds, grouped by the local it is written on. What :func:`_source_children` answers for one receiver, in one pass for all of them. A read-only walk of the whole tree the file builds asks the same question once per node, and each of those calls reads every statement of ``__init__``; over a scene of a few hundred nodes that is the difference between milliseconds and a third of a second, paid on every open and every save. Only for a walk that does not mutate as it goes: the statements move under an edit, and a map built before one is stale after it. """ superseded = _superseded_adds(scene_class) out: dict[str, list[_SourceChild]] = {} for stmt in scene_class._init_statements(): if id(stmt) in superseded: continue receiver = _add_child_receiver(stmt) if receiver is None: continue argument = _add_child_argument(stmt, receiver=receiver) if argument is None: # pragma: no cover - the receiver was read off this very call continue var_name = _add_child_var_name(stmt, receiver=receiver) slot: _SourceChild = ( _BoundChild(scene_class, var_name, stmt) if var_name is not None else _InlineChild(scene_class, stmt, argument) ) out.setdefault(receiver, []).append(slot) return out def _superseded_adds(scene_class: SceneClass) -> set[int]: """The ``add_child`` statements a later one on the same variable has undone. :meth:`Node.add_child` reparents, so ``a.add_child(x)`` followed by ``b.add_child(x)`` is legal and leaves ``x`` under ``b`` alone. Only the last such call parents the variable; the earlier ones ran and were undone by the file's own next line. Reading one of them as a child of ``a`` would leave ``a`` holding a statement the scene has no child for, and the save would take the whole of ``x`` out of the file to reconcile it. Answered by statement identity, so a caller asking about one receiver still sees what another receiver's line did to the same variable. """ statements = scene_class._init_statements() parented_by: dict[str, int] = {} for index, stmt in enumerate(statements): var_name = _add_child_var_name(stmt, receiver=None) if var_name is not None: parented_by[var_name] = index superseded: set[int] = set() for index, stmt in enumerate(statements): var_name = _add_child_var_name(stmt, receiver=None) if var_name is not None and parented_by[var_name] != index: superseded.add(id(stmt)) return superseded def _adds_children_out_of_reach(scene_class: SceneClass) -> bool: """Does this class add children where :func:`_source_children` cannot see them? Two places, and both mean the same thing: children with no statement this layer reads, so it can tell none of them from a child added in the editor and every one of them looks like a child the file is missing. * inside a ``for``, a ``while`` or an ``if`` in ``__init__``; * inside a method ``__init__`` calls on itself, however deep the chain of such calls goes. Answered from the parsed class this layer is reconciling. :func:`~simvx.core.scene_io.has_procedural_construction` answers a narrower question about a file's text: it reads ``__init__``'s own body and no further, so the helper method is invisible to it. """ try: suite: Any = scene_class._init_suite() except ValueError: return False if any(stmt.type != "simple_stmt" and _adds_a_child(stmt) for stmt in suite.children): return True pending = _self_method_calls(suite) seen: set[str] = set() while pending: name = pending.pop() if name in seen: continue seen.add(name) body = _method_body(scene_class, name) if body is None: continue if _adds_a_child(body): return True pending |= _self_method_calls(body) return False def _adds_a_child(node) -> bool: """Is there an ``add_child(...)`` statement anywhere under ``node``? On any receiver, which is the whole point: ``for e in enemies: panel.add_child(e)`` builds children this layer cannot match against the scene just as surely as ``self.add_child(e)`` does, and a check that read only ``self`` called that file reachable and said nothing. """ from parso.tree import Leaf if isinstance(node, Leaf): return False if node.type == "simple_stmt" and _add_child_argument(node, receiver=None) is not None: return True return any(_adds_a_child(child) for child in node.children) def _self_method_calls(node) -> set[str]: """The names of the methods called as ``self.<name>(...)`` anywhere under ``node``.""" from parso.tree import Leaf out: set[str] = set() def walk(n) -> None: if isinstance(n, Leaf): return if n.type == "atom_expr" and len(n.children) >= 3: head, dot, call = n.children[0], n.children[1], n.children[2] if ( isinstance(head, Leaf) and head.value == "self" and dot.type == "trailer" and len(dot.children) == 2 and dot.children[0].value == "." and call.type == "trailer" and call.children[0].value == "(" ): out.add(dot.children[1].value) for child in n.children: walk(child) walk(node) return out def _method_body(scene_class: SceneClass, name: str) -> Any: """The body of the class's ``name`` method, or ``None`` when it has no such one.""" for funcdef in scene_class.node.iter_funcdefs(): if funcdef.name.value == name: return funcdef.children[-1] return None def _constructor_parts(argument) -> tuple[str | None, Any]: """``(called name, call trailer)`` for a plain ``Name(...)`` expression. ``(None, None)`` for everything else a file can pass to ``add_child``: a name bound elsewhere, an attribute call (``factories.hero()``), a subscript, a conditional. Those are children all the same; they are just children whose construction this layer can only leave where it is. """ from parso.tree import Leaf if getattr(argument, "type", None) != "atom_expr" or len(argument.children) != 2: return None, None head, trailer = argument.children if not isinstance(head, Leaf) or head.type != "name": return None, None if trailer.type != "trailer" or not trailer.children or trailer.children[0].value != "(": return None, None return head.value, trailer def _class_names(node: Node) -> set[str]: """Every class name ``node`` answers to, its own and those it inherits. A construction naming any of them could have produced this node: its own name because that is what it is, an inherited one because that is the line a class swap left behind (:meth:`_InlineChild.superseded_by`). """ return {cls.__name__ for cls in type(node).__mro__} def _names_a_class(scene_class: SceneClass, name: str | None) -> bool: """Is ``name`` a class where this file calls it? Asked of the head of a construction, to tell ``Sprite2D(...)`` -- which yields a ``Sprite2D`` and nothing else -- from ``make_hero()``, which yields whatever its author decided. Three things can answer: * the file defines ``class <name>`` or ``def <name>`` itself, which settles it either way, and settles it from the text in hand rather than from whatever was loaded before the author's last edit; * the scene's own module, when the session has run the file -- which it has whenever the editor is holding a tree that came out of it. Its namespace is the very one the construction is evaluated in, so it answers for every way a name can get there: a relative import, an alias, a name bound by an assignment; * failing that, the module the file imports the name from, if something else has already loaded it. That is always true of the engine's own modules, which is where the types in a scene overwhelmingly come from. Anything else is ``False``: unproven, and treated as the factory it may well be, which costs the ordering evidence a known class would have given and never claims a child on it. Importing the module to find out is deliberately not done. Reading a file is this layer's job; running one is the caller's decision, and a save is not the moment to execute a module the session has so far had no use for. """ if name is None: return False module = scene_class._file.source_tree.module for classdef in module.iter_classdefs(): if classdef.name.value == name: return True for funcdef in module.iter_funcdefs(): if funcdef.name.value == name: return False scene_module = _module_of(scene_class) if scene_module is not None and hasattr(scene_module, name): return isinstance(getattr(scene_module, name), type) for from_module, imported in scene_class._file.imports.names(): if imported != name or from_module is None: continue imported_from = sys.modules.get(from_module) if imported_from is not None: return isinstance(getattr(imported_from, name, None), type) return False def _module_of(scene_class: SceneClass) -> Any: """The loaded module this file was imported as, or ``None`` when it was not. Matched on the file's path, since a scene is imported under a name the loader makes up and nothing here is told what it was. """ path = scene_class._file.path if path is None: return None wanted = {str(path), str(path.resolve())} for module in list(sys.modules.values()): origin = getattr(module, "__file__", None) if origin is not None and origin in wanted: return module return None def _trailer_kwargs(trailer) -> dict[str, str]: """``{kwarg: source expression}`` for the named arguments of a call trailer.""" 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 _has_positional_arguments(trailer) -> bool: """Does this call pass anything other than a named argument? ``*args`` and ``**kwargs`` count: what they carry is decided when the file runs, so nothing read here says which parameter they fill. """ return any(not edits.is_named_argument(item) for item in edits.iter_call_items(trailer)) class _Positional(NamedTuple): """One argument a construction passes by position.""" #: Which of the call's positional arguments it is, counting from zero, #: which is what :meth:`_SourceChild.set_positional` is given. position: int #: The expression standing there, as the file spells it. expr: str def _positional_parameters(trailer, child: Node, type_name: str | None) -> dict[str, _Positional] | None: """``{parameter: what fills it}`` for the arguments this call passes by position. A file is entitled to write ``Sprite2D('art/hero.png', Vec2(1, 1))``, and the text alone does not say which parameters those two fill. The class does: the node the call built is in hand, so its own signature is read and the positions counted off against the parameters that accept one. An edit to such a slot is then written where the author put it, rather than added beside it as a keyword, which would pass the same parameter twice and leave a file that raises ``TypeError`` on import. ``{}`` for a call passing nothing but keyword arguments. ``None`` when nothing here can say what a position fills, and the construction is therefore one to leave exactly as written: * an unpacking (``*args``, ``**kwargs``), whose contents are decided when the file runs; * a signature that takes ``*args`` itself, or that cannot be read at all; * more positional arguments than the class declares parameters for, which is a file that does not load in the first place; * a call whose head is not the class the child was built from, since then the signature in hand is not the one the arguments were written against. """ items = list(edits.iter_call_items(trailer)) positions = [item for item in items if not edits.is_named_argument(item)] if not positions: return {} if any(item.type == "argument" for item in positions): # ``*args`` / ``**kwargs``: parso builds an ``argument`` node naming no # parameter, and what it carries is not known until the file runs. return None parameters = _positional_signature(_constructed_class(child, type_name)) if parameters is None or len(positions) > len(parameters): return None return { parameters[position]: _Positional(position, " ".join(item.get_code().split())) for position, item in enumerate(positions) } def _constructed_class(child: Node, type_name: str | None) -> type | None: """The class the file's construction names, taken from the node it built. The runtime child is what the statement produced, so the class is one of the classes it answers to; which one is settled by the name the file calls, since a script may have rebound ``type(child)`` to something the file has never heard of (:func:`~simvx.core.scene_io.structural_type_name`). """ if type_name is None: return None return next((cls for cls in type(child).__mro__ if cls.__name__ == type_name), None) #: What each class asked about accepts by position, kept only while the class is. #: The answer is a property of the class alone and a scene with a hundred #: children of one type asks for it a hundred times, so it is worth keeping; a #: strong cache would keep it too well -- load_scene imports a fresh class per #: open, so even a bounded LRU pins its window of discarded classes (and their #: modules) alive while the editor cycles scenes. _POSITIONAL_SIGNATURES: weakref.WeakKeyDictionary[type, tuple[str, ...] | None] = weakref.WeakKeyDictionary() def _positional_signature(cls: type | None) -> tuple[str, ...] | None: """The parameters ``cls`` accepts by position, in order, or ``None`` for none readable. Cached weakly on the class, so a class the editor has finished with is not kept alive by having been asked about once. """ if cls is None: return None if cls in _POSITIONAL_SIGNATURES: return _POSITIONAL_SIGNATURES[cls] signature = _read_positional_signature(cls) try: _POSITIONAL_SIGNATURES[cls] = signature except TypeError: # A class no weak reference can be taken to: one of the interpreter's # own static types, which nothing here can discard anyway. pass return signature def _read_positional_signature(cls: type) -> tuple[str, ...] | None: """:func:`_positional_signature` without the cache in front of it.""" try: parameters = list(inspect.signature(cls).parameters.values()) except (TypeError, ValueError): return None names: list[str] = [] for parameter in parameters: if parameter.kind is inspect.Parameter.VAR_POSITIONAL: return None if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD): names.append(parameter.name) return tuple(names) def _match_source_children( runtime_children: list[Node], hint_names: list[str | None], guess_names: list[str | None] | None, slots: list[_SourceChild], *, from_file: set[Node] | None = None, source_index: list[int | None] | None = None, ) -> list[_SourceChild | None]: """Which source child, if any, each runtime child came from. Four passes, strongest evidence first, each over what the one before it left: the name the file itself binds (``hint_names``, from the tab's record of what it read); the ordinal the same record kept for a child no name reaches (``source_index``); the name a fresh emission would guess (``guess_names``); and the alignment, which reads what the statements themselves construct and spell. The first two are free to cross, which is the point of them: the editor can move a child and :meth:`SceneClass.reorder_children_by_statement` can follow it, and no scoring pass could ever express a crossing, since a crossing is not an alignment. **A guess is weaker than it looks and is ranked accordingly.** :func:`_canonical_var_names` binds two siblings sharing a ``.name`` to ``a`` and ``a_1`` in whatever order the *runtime* holds them, so reordering them makes each recomputed name point at the other's line; the caller withholds the guess where a base repeats, and a guess is refused outright where the statement plainly builds another type (:func:`_a_guess_may_claim`). A child nothing names -- one the file constructs inline, one the editor added, one whose receiver has no record -- leaves the order of the statements as the evidence: the runtime tree was produced by running them, in this order, so the ``n``-th unclaimed statement built the ``n``-th unclaimed child unless something was added or removed between them. The remaining statements and the remaining children are therefore aligned **without crossing** (:func:`_align_in_order`), taking the alignment with the most evidence behind it, where the evidence for one pairing is what the statement constructs and how much of what the child holds it already spells (:func:`_match_affinity`). Not crossing is what stops a readable statement from being claimed by an earlier child while an unreadable statement sits unpaired in front of it -- a match that would write one child's values over another child's literal. Preferring the statement that already spells what a child holds is what keeps an edit on the line it was made on rather than shuffling values between identical siblings. A statement and a child left unpaired by an alignment that could have paired them were kept apart by order: the editor moved a child the file builds inline. Pairing them anyway keeps both the author's statement and the child, so a final pass does, most alike first, and the order the file is left in is announced by :func:`_reconcile_order`. What is unpaired after that is a child the file has no statement for (added in the editor, or built out of reach) or a statement the scene has no child for (deleted in the editor), which is exactly what :func:`_reconcile_children` writes and removes. ``from_file`` is the nodes the file itself yielded, from the baseline the caller was handed when the tab began describing this file, or ``None`` when no baseline says. It settles what nothing in the text can: a statement that names no type -- a factory, a binding from elsewhere -- says nothing about which child it built, and order alone will hand it whichever child the editor's additions and deletions have left standing in front of it. A child the file never yielded cannot be the one such a statement built, so that pairing is struck out of the table before an alignment is chosen (:func:`_refuse_claims_with_nothing_behind_them`), and the pass below reads the same table, so neither will make it. What is left is the truth of it: the statement goes out with the child it really built, and the added child is written as its own construction. Where evidence does back a claim the file's own children are preferred and no more: a child deleted and another added in the same slot leave a statement that plainly constructs that child's type, and the author's line is better kept than rewritten. With no baseline in hand nothing is struck out and order decides alone, which is all a caller who cannot say what the file yielded can be given: such a statement may then claim a child the editor added. Claiming one is not rewriting it -- a construction is repointed at another class only where the name it calls is one that child descends from, or where the scene still keeps that child in the attribute the statement binds, each of which is a line a class swap left behind (:meth:`_InlineChild.superseded_by`) -- so the file otherwise goes on building the child it really built. The editor reads the file it is about to write over when it has no record of its own, so it comes here empty-handed only for a file that will not load at all. """ matched: list[_SourceChild | None] = [None] * len(runtime_children) taken: set[int] = set() by_var: dict[str, _SourceChild] = {} for slot in slots: if slot.var_name is not None: by_var.setdefault(slot.var_name, slot) def take_names(names: list[str | None] | None, *, guessed: bool) -> None: for index, var_name in enumerate(names or []): if var_name is None or matched[index] is not None: continue bound = by_var.get(var_name) if bound is None or id(bound) in taken: continue if guessed and not _a_guess_may_claim(bound, runtime_children[index]): continue matched[index] = bound taken.add(id(bound)) take_names(hint_names, guessed=False) if _the_ordinals_are_a_permutation(source_index, len(slots)): # The receiver holds exactly the children the file built for it, in some # other order: the one case an ordinal can speak for, and the one case # nothing else can. A crossing is not an alignment, so no scoring pass # could ever express it, and a recomputed name reads the wrong way round # for two siblings that share one. for index, position in enumerate(source_index or []): if position is None or matched[index] is not None: continue candidate = slots[position] if id(candidate) not in taken and candidate.could_have_built(runtime_children[index]): matched[index] = candidate taken.add(id(candidate)) take_names(guess_names, guessed=True) free_slots = [slot for slot in slots if id(slot) not in taken] free_children = [index for index, slot in enumerate(matched) if slot is None] if not free_slots or not free_children: return matched runtime_types = {structural_type_name(child) for child in runtime_children} spelled = [slot.kwargs() for slot in free_slots] wanted = [ (runtime_children[i], structural_type_name(runtime_children[i]), dict(iter_runtime_kwargs(runtime_children[i]))) for i in free_children ] affinity = [ [_match_affinity(slot, spelled[pos], child, name, desired, runtime_types) for child, name, desired in wanted] for pos, slot in enumerate(free_slots) ] #: One per free child: does the file yield it, as far as the baseline knows? provenance = [0 if from_file is None else int(child in from_file) for child, _type_name, _desired in wanted] if from_file is not None: _refuse_claims_with_nothing_behind_them(affinity, provenance) for slot_pos, child_pos in _align_in_order(affinity, provenance): matched[free_children[child_pos]] = free_slots[slot_pos] taken.add(id(free_slots[slot_pos])) # Whatever the alignment could not reach crossed it: the editor moved a # child the file builds inline. Pair those anyway, most alike first, so both # the author's statement and the child survive the save. while True: best: tuple[tuple[int, int], int, int] | None = None for child_pos, index in enumerate(free_children): if matched[index] is not None: continue for pos, slot in enumerate(free_slots): claim = affinity[pos][child_pos] if claim is None or id(slot) in taken: continue alike = (claim, provenance[child_pos]) if best is None or alike > best[0]: best = (alike, child_pos, pos) if best is None: return matched _, child_pos, pos = best matched[free_children[child_pos]] = free_slots[pos] taken.add(id(free_slots[pos])) def _a_guess_may_claim(slot: _SourceChild, child: Node) -> bool: """May a recomputed name pair this statement with this child? The name a fresh emission would have chosen is a guess, and a guess must not claim a statement that plainly builds something else: ``node_self = Panel()`` and a child called "self" reduce to the same variable, so the guess hands the Panel's line -- and the subtree standing on it -- to a Label, and the save then refuses to rewrite the line and writes the Label out as a Panel. A statement whose head this layer cannot show to be a class says nothing about what it builds and is not ruled out, and neither is one naming a class the child descends from, which is the line a class swap left behind. Nothing stronger is asked, because a guess that is merely unlikely still beats no pairing at all: what is ruled out here falls to the alignment, which reads the same evidence and can pair it there if the order allows. """ if slot.type_name is None or slot.type_name == structural_type_name(child): return True if slot.type_name in _class_names(child): return True return not _names_a_class(slot.scene_class, slot.type_name) def _the_ordinals_are_a_permutation(source_index: list[int | None] | None, slot_count: int) -> bool: """May the recorded ordinals be used to pair these children with these statements? Only where the receiver still holds exactly the children the file built for it, one per statement, in some order: then each ordinal names a statement, every statement is named once, and the pairing is the permutation the editor made. Anything else and an ordinal is a fact about a tree that no longer exists -- a child added carries none, one deleted leaves a gap, and one reparented in carries the ordinal it had under its old parent, which would claim whichever statement happens to sit at that position here. All three show up as the ordinals failing to be exactly ``range(len(slots))``, which is the whole of the check. """ if not source_index or len(source_index) != slot_count: return False return sorted(position for position in source_index if position is not None) == list(range(slot_count)) def _refuse_claims_with_nothing_behind_them(affinity: list[list[int | None]], provenance: list[int]) -> None: """Forbid, in place, every pairing that rests on nothing but order. A statement scores zero (:func:`_match_affinity`) when its head is not this child's own type and nothing rules the pair out either: a factory call, a name bound elsewhere, a call naming a class the child merely descends from. Order is then the whole of the case for pairing it with this child in particular -- and order is exactly what an edit disturbs, so where the baseline knows the child came from somewhere else, the case collapses. Such a pairing is refused outright rather than merely ranked below the alternatives, because the alternative here is no pairing at all: a statement left holding a child the editor added survives the save, and the child it claimed is never written, which loses the child. Only the evidence-free pairings go. A statement that plainly constructs the child's own type keeps its claim on a child the file never yielded, since a child deleted and another added in the same slot is better served by editing the author's line than by deleting it and writing the same construction back. """ for row in affinity: for index, claim in enumerate(row): if claim == 0 and not provenance[index]: row[index] = None def _match_affinity( slot: _SourceChild, spelled: dict[str, str], child: Node, type_name: str, desired: dict[str, str], runtime_types: set[str], ) -> int | None: """How much ``slot`` looks like the statement that built this child. ``None`` when it cannot have built it, on either of two grounds: the statement constructs a type some *other* child in this tree has, which makes it that child's line, or it constructs a class this child is not (:meth:`_SourceChild.could_have_built`), which makes it nobody's line here -- the child it built has been deleted, and step 3 of :func:`_reconcile_children` takes the statement out with it. Otherwise a count of the evidence -- one point for constructing this child's own type, one more per value the line already spells the way the child holds it, and one for spelling all of them and nothing else, which only a call passing nothing but keyword arguments can be said to do (what a positional argument fills is a fact about the class, so ``spelled`` is not the whole of such a call). A statement that names no class this layer can read -- a factory, ``make_hero()``; a name bound elsewhere -- scores zero: absent a baseline it may claim any child, on order alone; with one, a claim the baseline cannot back is struck (:func:`_refuse_claims_with_nothing_behind_them`). ``spelled`` is ``slot.kwargs()``, passed in because this is asked once per statement per child and reading them back out of the tree is not free. """ if slot.type_name != type_name: if slot.type_name in runtime_types: return None return 0 if slot.could_have_built(child) else None agreed = sum(1 for name, expr in spelled.items() if name in desired and _expressions_agree(expr, desired[name])) exact = slot.editable and agreed == len(spelled) == len(desired) return 1 + agreed + int(exact) def _align_in_order(affinity: list[list[int | None]], provenance: list[int]) -> list[tuple[int, int]]: """Pair statements with children in order, for the most evidence in total. ``affinity[i][j]`` is what statement ``i`` scores against child ``j``, or ``None`` where the pair is not allowed (:func:`_match_affinity`), and ``provenance[j]`` is 1 for a child the file is known to yield. The result is a list of ``(i, j)`` pairs, both strictly increasing -- the same shape of answer a diff gives, for the same reason: two sequences where one came from the other are read by keeping their order and paying only for what changed between them. Of the alignments that order allows, the one with the most evidence behind it wins, and where two carry the same, the one with more pairs in it: a statement claiming a child it plainly built must not be talked out of it by a statement that says nothing about any child, but a statement that says nothing is still better paired than left over. Ties after that go to the alignment covering more children the file yielded, which is what separates two readings of a statement that could as well have built either child, and then to the earliest pairing. """ slot_count, child_count = len(affinity), len(affinity[0]) # best[i][j]: the (total affinity, pairs, children the file yielded) # reachable from statement i, child j on. best = [[(0, 0, 0)] * (child_count + 1) for _ in range(slot_count + 1)] for i in range(slot_count - 1, -1, -1): row, following = best[i], best[i + 1] for j in range(child_count - 1, -1, -1): claim = affinity[i][j] total, pairs, known = following[j + 1] paired = (total + claim, pairs + 1, known + provenance[j]) if claim is not None else (0, 0, 0) row[j] = max(paired, following[j], row[j + 1]) out: list[tuple[int, int]] = [] i = j = 0 while i < slot_count and j < child_count: claim = affinity[i][j] total, pairs, known = best[i + 1][j + 1] if claim is not None and (total + claim, pairs + 1, known + provenance[j]) == best[i][j]: out.append((i, j)) i += 1 j += 1 elif best[i + 1][j] >= best[i][j + 1]: i += 1 else: j += 1 return out # --------------------------------------------------------------------------- # Child reconciliation # --------------------------------------------------------------------------- def _children_the_removal_would_take(slot: _SourceChild, matched: list[_SourceChild | None]) -> list[str]: """The children the alignment kept whose statements this removal would sweep out. A removal takes every statement of ``__init__`` standing on the child (:meth:`_SourceChild.removal_sweep`), and a sibling built from it -- ``sprite2d = Sprite2D(position=panel.position)`` -- stands on it as surely as one of the child's own does. Taking that line would stop the file building a child the scene still holds, which is a node lost on the next load, so the removal is refused on the same terms a block refuses it and these are the children to name. """ kept: dict[int, _SourceChild] = {} for other in matched: if other is None or other is slot: continue for stmt in other.own_statements(): kept[id(stmt)] = other held: list[str] = [] for stmt in slot.removal_sweep(): other = kept.get(id(stmt)) if other is None: continue label = other.var_name or other.text if label not in held: held.append(label) return held def _this_line_built_it(child: Node, slot: _SourceChild, built_by: dict[Node, str]) -> bool: """Is this the very statement that produced ``child``? The question a class swap turns on. The line that built a node stands for that node whatever class the editor has since put on it, so it is repointed where it stands: nothing is removed, the children hanging off it stay, and the author's arguments and comments stay with them. A line that merely ended up beside the node in the alignment must not be rewritten into it -- a node deleted and another one left standing on its slot is a deletion and an addition, and the deleted node's own lines have to go the way a deletion takes them, announced. So it is the var the line binds that is compared, not the mere presence of the node in the record: ``built_by`` is the tab's identity hints, recorded when the file was read and moved along by the rename replay (:func:`_reconcile_children`), and a hint naming another statement's var is evidence about that statement and none at all about this one. Without hints -- a headless save, a caller that never opened the file -- nothing is named and the older road is taken, which refuses rather than guesses. So it is for a child the file builds inside the ``add_child`` call, which binds no var to compare: what speaks for that one is the call itself (:meth:`_InlineChild.superseded_by`). """ return slot.var_name is not None and built_by.get(child) == slot.var_name def _renamed_since_the_file_was_written(scene_class: SceneClass, var_name: str, child: Node) -> bool: """Does ``child`` hold a name the file's own construction does not build? What tells a node renamed in the editor from an author who simply picked their own variable name. Both look identical from the variable alone -- ``holder = Panel(name='Hero')`` and a "Hero" the author has just renamed are one shape -- and only the construction says which: it spells the name it builds wherever that differs from the class's own, and where it spells none it builds a node named after the class it calls. A head this file cannot show to be a class says nothing (a factory yields whatever its author decided), and neither does a name written as anything but a literal string; both are read as no evidence, which leaves the variable alone. """ spelled = scene_class.get_child_kwarg(var_name, "name") if spelled is None: head, _text, _leaf = _bound_construction(scene_class, var_name) return head is not None and head != child.name and _names_a_class(scene_class, head) parsed = _parsed(spelled) if not isinstance(parsed, ast.Constant) or not isinstance(parsed.value, str): return False return parsed.value != child.name def _lines_the_swap_would_take(slot: _SourceChild) -> list[str]: """The author's statements a class swap on this child would carry off. A swap is written by removing the construction and letting the add pass put the new type back in the emitter's shape, and that removal sweeps every statement standing on the child's variable -- the constructions of the children it had of its own among them. The add pass writes one top-level child and nothing under it, so those statements would go and never come back, and the node would load without the subtree it has in the scene. A deletion is entitled to take them and says so; a swap is not, since the child is still there. """ own = {id(stmt) for stmt in slot.own_statements()} return [_code_on_one_line(stmt) for stmt in slot.removal_sweep() if id(stmt) not in own] def _announce_removal(removed: Removed, label: str, root: Node, *, declined: _Declined) -> None: """Say what a removal took besides the child's own lines, if anything. Two things a reader of the file would otherwise find missing without being told: the author's statements that named the child, which are gone, and the attribute bindings they carried off, which any other method reading them no longer has. """ if removed.statements: those = "those statements name" if len(removed.statements) > 1 else "that statement names" declined.note( f"{structural_type_name(root)} {root.name!r}: removing `{label}` took " f"{_and_list(removed.statements)} out of `__init__` as well, because {those} it.", DESTRUCTIVE, ) if removed.attributes: it = "them" if len(removed.attributes) > 1 else "it" declined.note( f"{structural_type_name(root)} {root.name!r}: removing `{label}` left " f"{_and_list(removed.attributes)} unbound; any other method that reads {it} will not find {it}.", DESTRUCTIVE, ) def _reconcile_children( scene_class: SceneClass, root: Node, *, identity_hints: dict[Node, str] | None = None, declined: _Declined, ) -> None: """Add/update/remove the children of the root, and of theirs, to match the source. The whole tree, at every depth the file gives a name to. A grandchild is a statement written on its parent's own local (``panel.add_child(label)``), so reconciling one is the same work as reconciling a child, done with a different receiver -- which is what :func:`_reconcile_receiver` recurses on. **Except where the file builds children this layer cannot see.** A loop, a conditional or a helper method adds children with no statement here to match them against, and below the root that is not a limitation but a hazard: the children such a call makes are indistinguishable from ones added in the editor, so the walk would write every one of them out a second time and the file would build them twice on the next load. The root has always answered that shape by writing and announcing (the note below), and a file with one such call anywhere is read at that depth and no deeper. The announcement is made once here rather than per receiver: the condition is a property of the class, and what it needs from the walk is the count of children written back into it. """ out_of_reach = _adds_children_out_of_reach(scene_class) appended = _reconcile_receiver( scene_class, root, "self", identity_hints=identity_hints, declined=declined, deep=not out_of_reach ) # A file that builds children out of this layer's reach -- in a loop, in a # conditional, in a helper method -- has just had every one of them written # out as a new construction, because none of them has a statement this layer # reads. Say so: the file now builds more children than the scene has, and a # save that leaves it that way in silence is the one thing this channel # exists to prevent. if appended and out_of_reach: declined.note( f"{structural_type_name(root)} {root.name!r}: the file builds children in a loop, a conditional or a " f"helper method, which this save cannot match against the scene, so the {appended} it could not account " "for were written into `__init__` as new constructions; the ones the file already builds are now built " "twice, and only editing those lines by hand will settle it.", DESTRUCTIVE, ) def _names_to_match_on( runtime_children: list[Node], canonical_var_names: list[str], identity_hints: dict[Node, str] | None, source_set: set[str], ) -> tuple[list[str | None], list[str | None]]: """``(what the file says, what a re-emission would guess)`` per runtime child. The first list is the tab's own record: taken when the file was read, so it says what the file binds rather than what a re-emission would choose, and it is the only name that survives a rename. It settles identity. The second is the name a fresh emission would give the child, which is a guess and is used only where nothing better speaks -- **and only where that name cannot have come out in the wrong order.** :func:`_canonical_var_names` appends ``_1`` to the second sibling whose name reduces to the same base, counting in the order the *runtime* holds them, so two siblings called "A" are ``a`` and ``a_1`` one way round before an edit and the other way round after it. Matching on that pairs each with the other's line, and the save then writes one child's construction over the other's and moves the subtree hanging off it. Where a base occurs once among the siblings there is no suffix and no order in it, and the name is the plain evidence it looks like. ``None`` in both for the rest, which sends them to the alignment (:func:`_match_source_children`) to be paired on what the statements themselves construct and spell. """ bases = [var_name_base(child.name) for child in runtime_children] unrepeated = {base for base in bases if bases.count(base) == 1} hints = identity_hints or {} known: list[str | None] = [] guessed: list[str | None] = [] for index, child in enumerate(runtime_children): hint = hints.get(child) known.append(hint if hint is not None and hint in source_set else None) guessed.append(canonical_var_names[index] if bases[index] in unrepeated else None) return known, guessed def _reconcile_receiver( scene_class: SceneClass, root: Node, receiver: str, *, identity_hints: dict[Node, str] | None = None, declined: _Declined, deep: bool, ) -> int: """Reconcile the children ``__init__`` writes on ``receiver`` against ``root``'s. ``receiver`` is ``"self"`` for the scene root and the local a child is bound to for anything below it. Returns how many children this receiver and every receiver under it had to write back as new constructions, which is what :func:`_reconcile_children` needs to know whether the file is now building anything twice. ``identity_hints`` is what the tab recorded when it read the file, and it covers the root's own children only. Every deeper receiver, and every caller with no tab behind it, matches on what the statements themselves say (:func:`_match_source_children`). """ runtime_children = list(root.children) canonical_var_names = _canonical_var_names(runtime_children) slots = _source_children(scene_class, receiver=receiver) #: The variables ``__init__`` binds children of this receiver to, which is #: what a hint has to name to be about this file at all. source_set = set(scene_class.child_var_names(receiver=receiver)) #: Which source var built each runtime child, as the tab recorded it and as #: the rename pass below moves it along. Step 1 asks of a slot whether it is #: the line that built this very node (:func:`_this_line_built_it`) rather #: than merely one the alignment put beside it, and only a record taken #: before the editing began can answer that -- which is why nothing derived #: from the matching may be put in here. built_by: dict[Node, str] = dict(identity_hints) if identity_hints else {} hint_names, guess_names = _names_to_match_on(runtime_children, canonical_var_names, identity_hints, source_set) matched = _match_source_children( runtime_children, hint_names, guess_names, slots, from_file=declined.nodes_the_file_yields(), source_index=[declined.source_index_of(child) for child in runtime_children], ) # Rename pass, over the pairs the matching settled. It speaks for one thing # only: a child renamed in the editor, whose variable follows its new name so # the file goes on reading like the scene. Nothing is matched on the outcome, # so a name ``__init__`` already binds costs the cosmetic change and nothing # else -- and a variable the author simply chose to differ from the node's # name has not stopped following anything, so it stays where it is. # # After the matching rather than before it, because the name is what a rename # changes and identity must not rest on the thing under edit. That also makes # this the one place a slot's var moves, so the slot is told rather than # re-read. #: The name each runtime child ends up bound to in the file, which is what #: the mover and the removal pass are given. Taken from the matching for a #: child the file already builds, and from the emitter's own rule for one #: this save has to write. match_names = list(canonical_var_names) for index, (child, slot) in enumerate(zip(runtime_children, matched, strict=True)): if slot is None or slot.var_name is None: continue match_names[index] = slot.var_name canonical = canonical_var_names[index] if slot.var_name == canonical or _emitter_could_have_named(slot.var_name, child): continue if slot.type_name is None: # A construction this layer cannot read keeps its variable along # with its text. ``panel = POOL[0]`` for a node called "Pooled" says # nothing about why the author chose ``panel``, so there is no # reading under which the file has stopped following the scene, and # renaming it rewrites their line on a save with no edits in it. continue if scene_class.has_child(canonical): # Only a rename made in the editor is worth saying anything about: a # variable the author chose has not stopped following anything, and # a save that says so on every save says nothing. if _renamed_since_the_file_was_written(scene_class, slot.var_name, child): declined.note( f"{structural_type_name(root)} {root.name!r}: `{slot.var_name}` builds {child.name!r}, which a " f"rename would name `{canonical}` after, but `__init__` already binds that name, so the " "variable keeps the one it has; rename it by hand for the file to read like the scene." ) continue scene_class.rename_child(slot.var_name, canonical) if built_by.get(child) == slot.var_name: built_by[child] = canonical slot.var_name = canonical match_names[index] = canonical #: Where in the runtime tree each surviving source statement's child sits, #: which is what step 5 compares the file's order against. runtime_index = {id(slot.stmt): index for index, slot in enumerate(matched) if slot is not None} # Types a class swap left the file with no use for (step 1 below); they # go through the prune pass at step 4 with the ones step 3 removes. swap_removed_types: list[str] = [] #: Children whose line the file goes on building some other type with, #: because the swap that would have put it right was held up. Step 6 does #: not walk into one: the statements standing on that line belong to the #: node it really built, and reconciling them against the node the alignment #: put beside it would take that node's own subtree out of the file. swap_refused: set[int] = set() # 1. For each child present in both: if the source builds a type the scene # no longer holds (Convert to Custom Class swapped Sprite2D for Player on # this instance), put that right first # (:meth:`_SourceChild.superseded_by`) by repointing the construction at # the type the scene holds. Two constructions are removed instead, so # that step 2 writes the new type in the emitter's shape: one that did not # build this node at all (:func:`_this_line_built_it`), and one with no # single name standing for the type it builds # (:meth:`_SourceChild.repointable`). Then update kwargs in place. for index, (child, slot) in enumerate(zip(runtime_children, matched, strict=True)): if slot is None: continue source_type = slot.type_name if source_type is not None and slot.superseded_by(child): if _this_line_built_it(child, slot, built_by) and slot.repointable(): swap_removed_types.append(source_type) slot.retype(structural_type_name(child), _import_source(scene_class, child)) if slot.writes_for(child): _update_child_kwargs(slot, child, declined=declined) else: _decline_child_kwargs(slot, child, declined=declined) continue blocks = slot.blocks_holding_it() if slot.var_name is not None else [] if blocks: # The swap is written by removing the construction and letting # step 2 put the new type back, and the removal is the thing a # block holds up, so the line keeps the type it was written with. declined.note( f"{structural_type_name(root)} {root.name!r}: `{slot.var_name}` is built as " f"{source_type} and the scene now holds {structural_type_name(child)}, but " f"{_and_list(blocks)} in `__init__` names it and this save does not rewrite the body of a " "block, so the file still builds the type it was written with; change that line by hand." ) _decline_child_kwargs(slot, child, declined=declined) swap_refused.add(index) continue held = _children_the_removal_would_take(slot, matched) if slot.var_name is not None else [] if held: # Same again: the swap is written by removing the construction, # and this removal would carry off a sibling built from it. declined.note( f"{structural_type_name(root)} {root.name!r}: `{slot.var_name}` is built as " f"{source_type} and the scene now holds {structural_type_name(child)}, but " f"{_and_list(held)} in `__init__` is built from it and rewriting the line would take that " "with it, so the file still builds the type it was written with; change that line by hand." ) _decline_child_kwargs(slot, child, declined=declined) swap_refused.add(index) continue carried = _lines_the_swap_would_take(slot) if slot.var_name is not None else [] if carried: # And again: the removal that writes the swap would take the # child's own subtree with it, and step 2 writes one top-level # construction back and nothing under it. declined.note( f"{structural_type_name(root)} {root.name!r}: `{slot.var_name}` is built as " f"{source_type} and the scene now holds {structural_type_name(child)}, but rewriting that " f"line would take {_and_list(carried)} out of `__init__` with it and this save cannot write " "them back, so the file still builds the type it was written with; change that line by hand." ) _decline_child_kwargs(slot, child, declined=declined) swap_refused.add(index) continue swap_removed_types.append(source_type) if slot.var_name is not None: _announce_removal(slot.remove(), slot.var_name, root, declined=declined) matched[index] = None runtime_index.pop(id(slot.stmt), None) continue slot.retype(structural_type_name(child), _import_source(scene_class, child)) if slot.writes_for(child): _update_child_kwargs(slot, child, declined=declined) else: # Reached only where nothing above has already spoken: a line this # layer declines to repoint, whose class the scene may since have # moved off. The branches above each said what held their swap up # and would say it twice from here. if slot.type_name != structural_type_name(child): declined.record_type(child, slot.text, structural_type_name(child)) _decline_child_kwargs(slot, child, declined=declined) # 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. appended = 0 for index, (child, slot) in enumerate(zip(runtime_children, matched, strict=True)): if slot is not None: continue appended += 1 var_name = _free_var_name(scene_class, canonical_var_names[index]) match_names[index] = var_name type_name = structural_type_name(child) used_types: set[str] = set() kwargs_pairs = iter_runtime_kwargs(child, used_types=used_types) # Auto-import the helpers this construction names. Every kwarg is # written here -- the child is new to the file, so nothing of the # author's is being overwritten -- and the child's own type comes in # with ``add_child`` below. _ensure_helper_imports(scene_class, used_types, kwargs_pairs, skip=type_name) kwargs = dict(kwargs_pairs) scene_class.add_child( var_name, type_name, from_module=_import_source(scene_class, child), receiver=receiver, **kwargs ) #: The name the file binds for each child the scene holds, now that step 2 #: has written the ones it had to. Asked after the additions rather than #: before them, because before them an unmatched child stands under the name #: a fresh emission would GUESS for it, and step 3 would read that guess as #: "the runtime still has this name" and decline to remove the very line the #: addition was written to replace -- leaving the file building both. runtime_set = set(match_names) # 3. Remove source-only children. Capture their type before remove # so we can clean up unused imports afterwards. A var-bound child is # kept when the runtime still has that name, so a source that binds one # name twice keeps both lines rather than losing the second to the # first's match. A removal takes the statements standing on the child # with it -- its own children's constructions, an attribute the author # kept it in -- so what it took is announced, and a slot a previous # removal has already carried off is not removed twice. A sweep that # would reach a child the scene still holds is refused instead: the # file has to go on building that one. kept_slots = {id(slot) for slot in matched if slot is not None} removed_types: list[str] = [] for slot in slots: if id(slot) in kept_slots: continue if slot.var_name is not None and slot.var_name in runtime_set: continue if not slot.attached: continue # Asked before the removal, since a var-bound child reads its own text # off the statement that is about to go. label = slot.var_name or slot.text blocks = slot.blocks_holding_it() if blocks: declined.note( f"{structural_type_name(root)} {root.name!r}: `{label}` was deleted in the scene but " f"{_and_list(blocks)} in `__init__` names it, and this save does not rewrite the body of a " "block, so the file still builds it; delete those lines by hand to be rid of it." ) continue held = _children_the_removal_would_take(slot, matched) if held: declined.note( f"{structural_type_name(root)} {root.name!r}: `{label}` was deleted in the scene but " f"{_and_list(held)} in `__init__` is built from it, and this save does not delete a child the " "scene still holds, so the file still builds both; rewrite those lines by hand to be rid of it." ) continue if slot.type_name is not None: removed_types.append(slot.type_name) _announce_removal(slot.remove(), label, root, declined=declined) # 4. Drop the imports of types nothing in the file names any more. # 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. _reconcile_order(scene_class, root, receiver, match_names, runtime_index, declined=declined) # 6. And now the same work one level down, on each child the file gives a # name to. A child added in step 2 is reached through the name step 2 # bound it to; one the file already built, through the name the file # binds. Only a construction with no name behind it stops the walk, and # the children the editor is showing under such a node cannot be written # at all, so that is said rather than passed over. from_file = declined.nodes_the_file_yields() for index, child in enumerate(runtime_children): if not deep or index in swap_refused: continue slot = matched[index] child_receiver = slot.receiver_var() if slot is not None else match_names[index] if child_receiver is None: # Only a child the file never yielded is a child that has gone # missing. A file that adds to this node somewhere this layer cannot # read (``self.panel.add_child(...)``) goes on building the ones it # already had, and announcing those would fire on every save of a # scene nothing was added to. Without a baseline nothing says which # is which, and guessing is worse than the limitation. added = [] if from_file is None else [one for one in child.children if one not in from_file] if added: declined.note( f"{structural_type_name(child)} {child.name!r}: the file builds this child as " f"`{slot.text if slot is not None else child.name}`, which binds no variable, so this save " f"has no name to write {_and_list([one.name for one in added])} on and " f"{'they do' if len(added) > 1 else 'it does'} not reach the file; bind that construction to " "a variable for them to be written.", DESTRUCTIVE, ) continue appended += _reconcile_receiver(scene_class, child, child_receiver, declined=declined, deep=True) return appended def _reconcile_order( scene_class: SceneClass, root: Node, receiver: str, runtime_order: list[str], runtime_index: dict[int, int], *, declined: _Declined, ) -> None: """Put the file's children in the runtime's order, or say why they are not. ``runtime_index`` says where in the runtime tree each surviving statement's child sits, and ``runtime_order`` is the var names in that order, which is what places a child this save has just written into the file and has no statement recorded for. :meth:`SceneClass.reorder_children_by_statement` moves whole lines, so it speaks for a child the file constructs inside its own ``add_child`` call as well as for one it binds a variable to: both are lines, and moving either is as safe as moving the other. What it cannot move is a statement sharing a line with another one, since the line cannot follow the child without taking that statement along, and it cannot place a statement whose child the scene no longer has -- which is what a removal a block held up leaves behind. Either way the file keeps the order it has and the divergence goes to ``declined``: an order is part of what a scene is (it decides what draws over what), so a save that leaves the file disagreeing with the editor owes the same answer as one that leaves a value behind. """ slots = _source_children(scene_class, receiver=receiver) placed: list[tuple[int, _SourceChild]] = [] for slot in slots: index = runtime_index.get(id(slot.stmt)) if index is None and slot.var_name is not None and slot.var_name in runtime_order: index = runtime_order.index(slot.var_name) if index is None: # A statement the scene has no child for. The mover is given every # statement written on this receiver and has nowhere to put this one. _announce_order(root, declined=declined) return placed.append((index, slot)) if [index for index, _slot in placed] == sorted(index for index, _slot in placed): return if scene_class._children_share_lines(receiver=receiver): _announce_order(root, declined=declined) return scene_class.reorder_children_by_statement( [slot.stmt for _index, slot in sorted(placed, key=lambda pair: pair[0])], receiver=receiver ) def _announce_order(root: Node, *, declined: _Declined) -> None: """Say that the file adds its children in an order this save could not put right.""" declined.note( f"{structural_type_name(root)} {root.name!r}: the file adds its children in a different order from the " "scene, and this save will not move a child whose line is not the child's alone -- one a semicolon put on " "a line with another statement, or one whose statement the scene has no child for; reorder those lines in " "the file to match the scene." ) def _update_child_kwargs(slot: _SourceChild, child: Node, *, declined: _Declined) -> None: """Diff the constructor kwargs of one source child against the runtime node. Insert/update/remove kwargs as needed; leave the construction's own shape untouched. Updates skip a kwarg written as a reference (:func:`_is_reference`); removals are narrower still (:func:`_stale_kwargs`). An argument the author wrote by position is one of the construction's values like any other, and it is read and written where it stands (:func:`_positional_parameters`). It is never taken out, though: the positions after it are counted from where it sits, so a slot the scene has put back to its default is rewritten to say so rather than deleted. """ used_types: set[str] = set() unemittable: set[str] = set() derived: set[str] = set() desired = dict(iter_runtime_kwargs(child, used_types=used_types, unemittable=unemittable, derived=derived)) positionals = slot.positionals(child) or {} existing = {name: item.expr for name, item in positionals.items()} | slot.kwargs() updates = list(_kwarg_updates(child, existing, desired, declined=declined)) _ensure_helper_imports(slot.scene_class, used_types, updates, skip=structural_type_name(child)) for name, value_expr in updates: _write_child_kwarg(slot, positionals, name, value_expr) _report_formless(child, existing, unemittable, declined=declined) stale = _stale_kwargs(child, existing, desired, unemittable, derived, declined=declined) for name in stale: if name not in positionals: slot.remove_kwarg(name) continue default = emit_value(getattr(child, name, None)) if default is not None: _write_child_kwarg(slot, positionals, name, default) _prune_unused_imports(slot.scene_class, _shapes_written_out_of(existing, updates, stale)) def _shapes_written_out_of(existing: dict[str, str], written: list[tuple[str, str]], removed: list[str]) -> list[str]: """The collision-shape classes this save's edits stopped the line naming. Changing a collider's kind rewrites ``shape=SphereShape3D(radius=2.0)`` as a box, and putting one back to its class default deletes the kwarg outright; either way the file may have been left importing a shape it no longer builds. The import is only dropped if nothing else in the file names it (:func:`_prune_unused_imports`), so this is a list of candidates, not of removals. """ candidates: list[str] = [] for name in [name for name, _ in written] + removed: source_expr = existing.get(name) if source_expr is None or not expression_reads_a_shape(source_expr): continue parsed = _parsed(source_expr) head = _call_head(parsed) if parsed is not None else None if head is not None and head not in candidates: candidates.append(head) return candidates def _write_child_kwarg(slot: _SourceChild, positionals: dict[str, _Positional], name: str, value_expr: str) -> None: """Write one value into the construction, where the file already keeps it. Rewriting a positional argument does not move the ones after it, so the positions established before the first write hold for all of them. """ item = positionals.get(name) if item is None: slot.set_kwarg(name, value_expr) else: slot.set_positional(item.position, value_expr) def _decline_child_kwargs(slot: _SourceChild, child: Node, *, declined: _Declined) -> None: """Announce what the scene holds for a child whose construction stays as written. The file builds this one with something this layer will not write into: a call carrying positional arguments, a factory, a name bound elsewhere. The child is the author's either way, and the line stays -- but an edit made to it in the editor does not reach the file, and a save that says nothing about that looks exactly like one that carried it. Each value goes to ``declined``, which announces only the ones the file is not already known to yield. The message says only what the statement shows. A call naming the child's own type builds it *as* what it spells, and can be edited to say something else; an expression that names no type builds the child somewhere the file does not show, and telling the user to edit a construction they are not looking at would send them after a line that does not exist. """ names_the_type = slot.type_name == structural_type_name(child) tail = _INLINE_CONSTRUCTION if names_the_type else _OPAQUE_CONSTRUCTION unemittable: set[str] = set() for name, value_expr in iter_runtime_kwargs(child, unemittable=unemittable): declined.record(child, name, slot.text, value_expr, tail=tail) for name in sorted(unemittable): declined.record_formless(child, name, slot.text) def _existing_child_kwargs(scene_class: SceneClass, var_name: str) -> dict[str, str]: """Return ``{kwarg: expr}`` for the parsed child constructor.""" trailer = scene_class._child_ctor_trailer(var_name) return _trailer_kwargs(trailer) if trailer is not None else {} def _bound_construction(scene_class: SceneClass, var_name: str) -> tuple[str | None, str, Any]: """``(type name, source text, the name a swap would move)`` for ``<var> = ...``. Read in one pass, because a child needs all three and each costs the same walk of ``__init__``. The type name is what a save compares against the type the scene holds, and only a plain ``<Name>(...)`` has one: that name is the whole of the call and running it yields whatever it denotes. Every other shape answers ``None``, which is this layer saying it cannot read what the line builds -- ``simvx.core.Panel()`` (whose head is the package), ``POOL[0]``, ``factories.make_hero()``, ``panel`` bound to something else again. A ``None`` here is not "no type": it is "no evidence", and the difference matters because a disagreement between a type read out of the line and the type the scene holds sends the child down the removal road, which rewrites the author's line into the emitter's shape. A line nothing here can read is a line to keep, so it states no type to disagree with. The text falls back to the variable itself when the file binds it to something with no source to quote. The third is the ``Name`` leaf of that same plain ``<Type>(...)``, and it is the only part of a construction a swap can rewrite: the head of ``simvx.core.Panel()`` is the package, not the class (:meth:`_SourceChild.repointable`). """ from parso.tree import Leaf expr_stmt = scene_class._find_child_assignment(var_name) if expr_stmt is None or len(expr_stmt.children) < 3: return None, var_name, None rhs = expr_stmt.children[2] text = " ".join(rhs.get_code().split()) or var_name if rhs.type != "atom_expr" or not rhs.children: return None, text, None head = rhs.children[0] if not isinstance(head, Leaf) or head.type != "name": return None, text, None if len(rhs.children) != 2 or not _is_call_trailer(rhs.children[1]): return None, text, None return head.value, text, head def _is_call_trailer(node) -> bool: """Is this parso node the ``(...)`` of a call?""" return getattr(node, "type", None) == "trailer" and bool(node.children) and node.children[0].value == "(" def _prune_unused_imports(scene_class: SceneClass, removed_types: list[str]) -> None: """Drop each of ``removed_types`` the file no longer names anywhere. Asked of the whole file, because an import is not the children's to spend: a type this ``__init__`` has stopped building may still be named by a method the author wrote, by an annotation, by a class header, by a default argument. Pruning it out from under any of those leaves a file that raises ``NameError`` the moment that code runs -- and a helper type inside a kwarg value (``position=Vec2(1, 2)``) is kept by the same rule, without needing to know where such a value may hide. The statements this save deleted are out of the tree by the time this runs, so a name only they used is not found and its import goes with them. """ still_named = _names_outside_imports(scene_class._file.source_tree.module) for type_name in removed_types: if type_name in still_named: 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 _names_outside_imports(module) -> set[str]: """Every name the file mentions other than in an import statement.""" from parso.tree import Leaf out: set[str] = set() def walk(n) -> None: if n.type in ("import_name", "import_from"): return if isinstance(n, Leaf): if n.type == "name": out.add(n.value) return for child in n.children: walk(child) walk(module) return out