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