simvx.editor.scene_diff

Reconcile a parsed scene class with a live runtime Node tree.

The editor mutates the runtime tree directly while the user works (drag a node, change a Property, add a child); on save we need to push those mutations back into the on-disk source without losing comments, blank lines, hand-written helper functions, or import ordering.

func:

apply_runtime_diff is the integration point: given a

class:

~simvx.core.scene_io.SceneClass (a parso-backed view of the class in the user’s .py file) and the live runtime root, it issues the minimum set of structural edits via the SceneClass API so the next

meth:

SceneFile.save writes a file whose __init__ matches the runtime tree exactly.

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

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

Limitations (locked down by tests):

  • Children built where no statement of __init__ shows them – inside a loop, a conditional, or a method __init__ calls – are not reconciled. This layer cannot tell one of those from a child added in the editor, so it writes each of them into __init__ as a new construction and announces that the file now builds them twice (:func:_adds_children_out_of_reach). Refusing to save such a file at all is a policy for the caller to set, not one taken here.

  • A construction this layer will not write into – one calling something it cannot show to be a class, or passing arguments it cannot place – keeps its text, and the values the scene holds for that child are announced through report rather than written (:class:_InlineChild). An argument written by position is placed by reading the class’s own signature (:func:_positional_parameters) and edited where it stands, so only a call whose arguments no signature accounts for is left alone.

  • Which child such a call built is decided by order and by what the file yielded when the tab opened (:func:_match_source_children). A caller with no baseline has only the order, so a child added in the editor above one of these calls is taken for the call’s own: the call’s child is written out a second time and the added child is silently dropped from the file. Every caller in the editor passes a baseline.

  • Children are put back into the runtime’s order only when the file binds every one of them to a var, which :meth:SceneClass.reorder_children is the only mover for. A file that constructs a child inside its own add_child call keeps the order it has; the divergence is announced (:func:_reconcile_order) except when the alignment could not tell the swapped children apart – two unwritable constructions of the same type pair up in file order, so swapping only them is kept silently. A child a semicolon put on a line with another statement is kept where it is for the same reason the mover works in whole lines.

  • Removing a child removes every statement written on __init__’s own body that stands on it, from the line that binds its variable onwards: the statement that added it, the constructions of the children it had of its own, and the self.<name> = binding an author may have kept it in. Anything else in the file that used one of those names will notice, so every statement that went is named through report, and the bindings they carried off with them.

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

    A class swap takes none of that road, wherever the line has one name standing for the type it builds (:meth:_SourceChild.repointable): the construction is repointed at the type the scene holds, in whichever shape the file wrote it (:meth:_BoundChild.retype, :meth:_InlineChild.retype), so nothing is removed, the children hanging off it stay, and the author’s arguments and comments stay with them. Which node a construction stands for is what decides that a swap is what happened: the node the tab’s identity hints say that very line built (:func:_this_line_built_it), or – for a child built inside the add_child call – a call naming a class the node’s new one descends from, or one whose child the scene still keeps in the attribute the statement binds. A construction none of those speaks for built some other node: it is a deletion and an addition rather than a swap, and goes out the way a deletion goes out, with everything standing on it named – or is refused where that sweep would reach too far (:func:_lines_the_swap_would_take). A line with no name standing for the type at all – panel = simvx.core.Panel(), panel = POOL[0], hero = factories.make_hero() – states no type for the scene to disagree with (:func:_bound_construction), so it keeps its text whatever class the editor puts on the node, and the class the scene holds is announced instead (:meth:_Declined.record_type).

  • A kwarg the author wrote as anything other than a plain value (texture=icon, art.ICON, ART['icon'], make_icon(), math.radians(45)) is left exactly as it stands, so an edit made to such a value in the editor does not reach the file: see

    func:

    _is_reference. The loss is announced through

    func:

    apply_runtime_diff’s report. Values the emitter cannot express keep their source line too (:func:_stale_kwargs), so “matches the runtime tree” means every kwarg this layer is entitled to speak for, not every kwarg in the file.

Module Contents

Classes

ReportEntry

One thing a save would do beyond carrying the user’s edits across.

Functions

apply_runtime_diff

Reconcile scene_class so its __init__ matches runtime_root.

file_baseline

{node: {kwarg: what the file said about it}} for the tree this layer reconciles.

Data

API

simvx.editor.scene_diff.__all__

[‘DESTRUCTIVE’, ‘INFORMATIONAL’, ‘ReportEntry’, ‘apply_runtime_diff’, ‘file_baseline’]

simvx.editor.scene_diff.DESTRUCTIVE

‘destructive’

simvx.editor.scene_diff.INFORMATIONAL

‘informational’

class simvx.editor.scene_diff.ReportEntry[source]

Bases: 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.

Initialization

Initialize self. See help(type(self)) for accurate signature.

category: str

None

__new__(message: str, category: str = INFORMATIONAL) simvx.editor.scene_diff.ReportEntry[source]
property destructive: bool[source]

Would this change the author’s file beyond carrying their edits?

__add__()
__contains__()
__delattr__()
__dir__()
__eq__()
__format__()
__ge__()
__getattribute__()
__getitem__()
__getnewargs__()
__getstate__()
__gt__()
__hash__()
__iter__()
__le__()
__len__()
__lt__()
__mod__()
__mul__()
__ne__()
__reduce__()
__reduce_ex__()
__repr__()
__rmod__()
__rmul__()
__setattr__()
__sizeof__()
__str__()
__subclasshook__()
capitalize()
casefold()
center()
count()
encode()
endswith()
expandtabs()
find()
format()
format_map()
index()
isalnum()
isalpha()
isascii()
isdecimal()
isdigit()
isidentifier()
islower()
isnumeric()
isprintable()
isspace()
istitle()
isupper()
join()
ljust()
lower()
lstrip()
partition()
removeprefix()
removesuffix()
replace()
rfind()
rindex()
rjust()
rpartition()
rsplit()
rstrip()
split()
splitlines()
startswith()
strip()
swapcase()
title()
translate()
upper()
zfill()
simvx.editor.scene_diff.apply_runtime_diff(scene_class: simvx.core.scene_io.scene_file.SceneClass, runtime_root: simvx.core.Node, *, identity_hints: dict[simvx.core.Node, str] | None = None, baseline: dict[simvx.core.Node, dict[str, _Slot]] | None = None, refresh_baseline: collections.abc.Callable[[], dict[simvx.core.Node, dict[str, _Slot]] | None] | None = None, report: list[simvx.editor.scene_diff.ReportEntry] | None = None) dict[simvx.core.Node, set[str]][source]

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.

simvx.editor.scene_diff.file_baseline(runtime_root: simvx.core.Node, source_root: simvx.core.Node | None = None, scene_class: simvx.core.scene_io.scene_file.SceneClass | None = None) dict[simvx.core.Node, dict[str, simvx.editor.scene_diff._Slot]][source]

{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.