Source code for simvx.core.scene_io.scene_file

"""High-level scene-shaped editing surface for parso-parsed sources.

This is Tier 3b of the scene I/O layer. It composes the lossless parse
(:mod:`source_tree`) and prefix-preserving primitives (:mod:`edits`) with
greenfield emission (:mod:`emitter`) and structural detection
(:mod:`detection`) into a small public API:

    SceneFile : a parsed Python file with byte-perfect round-trip save.
    SceneClass: an editable view of one Node-subclass in the file.
    ImportSet : an editable view of the file's top-level imports.

The editor's save/load path and the IDE's refactor tools build on this
surface. Lower tiers remain available for callers that need finer control.
"""

from __future__ import annotations

import re
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple

from parso.python.tree import Class
from parso.tree import Leaf, NodeOrLeaf

from ..io import atomic_write_text
from . import edits as _edits
from .detection import primary_node_class_from_source
from .edits import (
    _get_prefix,
    _indent_of,
    _iter_leaves,
    _set_prefix,
    enclosing_statement,
    line_statements,
    relayout_statement,
)
from .emitter import emit_scene
from .layout import wrap_import, wrap_statement
from .source_tree import SourceTree, _children, is_node, parse_snippet, parse_source
from .syntax import SyntaxIssue, explain_at, first_unexplained, issue_lines, refusal

if TYPE_CHECKING:
    from ..node import Node


__all__ = ["ImportSet", "Removed", "SceneClass", "SceneFile"]

#: A comment, which runs to the end of its line and no further. What quoting a
#: statement on one line has to leave out (:func:`_code_on_one_line`).
_COMMENT = re.compile(r"#[^\r\n]*")


def _parse_or_refuse(text: str, *, subject: str) -> SourceTree:
    """Parse ``text``, opening what the format documents and refusing the rest.

    A scene holding one of the constructs parso cannot read
    (:mod:`simvx.core.scene_io.syntax`) opens: recovery keeps every token, so
    the tree re-emits byte for byte and the viewer, the diff and the read-only
    inspector all work on it. What it cannot do is be rewritten, and each edit
    refuses itself by name (:meth:`SceneClass.unreadable_syntax`).

    Source broken in any other way still raises here, as it always has. There
    is nothing to show a reader of a truncated file, and telling them it "loads
    and runs" would be false.
    """
    tree = parse_source(text, error_recovery=True)
    unexplained = first_unexplained(tree.issues)
    if unexplained is not None:
        raise refusal(
            [unexplained, *(other for other in tree.issues if other is not unexplained)],
            subject=subject,
            consequence="",
        )
    return tree


[docs] class Removed(NamedTuple): """What taking a child out of ``__init__`` carried off besides its own lines. A removal takes every statement standing on the child, and a caller with somewhere to say so should say what those were: the author wrote them and they are not coming back (:meth:`SceneClass.remove_child`). """ #: Each statement the sweep took beyond the child's own, quoted whole and #: run onto one line when the file wrapped it over several, without the #: comments the author wrote inside it (:func:`_code_on_one_line`). The #: child's own lines are left out: a caller announcing a removal has already #: named the child, and these are what it did not ask for. statements: list[str] #: The attribute bindings that went with them, spelled ``self.<name>``. The #: kind the rest of the file notices, since a method reading ``self.<name>`` #: no longer has one. attributes: list[str]
# --------------------------------------------------------------------------- # SceneFile # ---------------------------------------------------------------------------
[docs] class SceneFile: """A parsed Python scene file with byte-perfect round-trip save. Holds a parso tree plus the original on-disk text. All edits operate on the parso tree; :meth:`save` writes ``tree.get_code()``. Round-trip identity is guaranteed when no edits were made. """ __slots__ = ("_source_tree", "_path", "_imports", "_original_text") def __init__(self, source_tree: SourceTree, *, path: Path | None) -> None: self._source_tree = source_tree self._path = path self._original_text = source_tree.original_text self._imports = ImportSet(source_tree) # -- constructors --------------------------------------------------------
[docs] @classmethod def load(cls, path: str | Path) -> SceneFile: """Read ``path`` and parse it. A scene using one of the constructs the format documents as unreadable (:mod:`simvx.core.scene_io.syntax`) opens here and refuses its edits; source broken any other way raises :class:`~simvx.core.scene_io.syntax.UnsupportedSceneSyntaxError`, naming the place rather than surfacing parso's own token position. Raises :class:`FileNotFoundError` when the file is absent. """ p = Path(path) text = p.read_text(encoding="utf-8") source_tree = _parse_or_refuse(text, subject=str(p)) return cls(source_tree, path=p)
[docs] @classmethod def from_source(cls, text: str, *, path: Path | None = None) -> SceneFile: """Parse already-loaded source ``text``. ``path`` is recorded for :meth:`save` and error messages but is not read. Refuses unreadable source the way :meth:`load` does. """ source_tree = _parse_or_refuse(text, subject=str(path) if path is not None else "this source") return cls(source_tree, path=path)
[docs] @classmethod def from_runtime(cls, root: Node, *, class_name: str | None = None, report: list[str] | None = None) -> SceneFile: """Greenfield: emit source for a live :class:`Node` tree, then parse it. The returned :class:`SceneFile` has no ``path`` until :meth:`save` is called with one. A value the emitter cannot write raises :class:`~simvx.core.scene_io.UnemittableValueError`, because a file saved without it would load back as a different scene. Pass ``report`` to take the other policy instead: the file is built without those values and one message per refusal is appended to the list, which is what an editor wants -- it can show them and leave the document marked unsaved rather than lose the user's work to an exception. """ text = emit_scene(root, class_name=class_name, report=report) return cls.from_source(text, path=None)
# -- properties ----------------------------------------------------------
[docs] @property def path(self) -> Path | None: """Path the file was loaded from / will be saved to, or ``None``.""" return self._path
[docs] @property def source_tree(self) -> SourceTree: """Underlying lossless :class:`SourceTree`.""" return self._source_tree
[docs] @property def imports(self) -> ImportSet: """Editable view of the file's top-level imports.""" return self._imports
# -- scene class lookup --------------------------------------------------
[docs] def scene_class(self) -> SceneClass: """The single primary Node subclass in the file. Raises :class:`AmbiguousSceneError` if the file contains multiple Node subclasses; raises :class:`ValueError` if it contains none. """ text = self._source_tree.dump() # `primary_node_class_from_source` raises AmbiguousSceneError on # multiple matches; passes through (`None`) on zero matches. name = primary_node_class_from_source(text, path=self._path) if name is None: where = f" in {self._path}" if self._path is not None else "" raise ValueError(f"no Node subclass found{where}") cls_node = self._source_tree.find_class(name) if cls_node is None: # Detection found a class via ast walk (which descends into # nested scopes); the parso lookup is top-level only. Surface # the discrepancy clearly rather than silently failing. raise ValueError( f"primary class {name!r} is not at module top-level; " "scene classes must be defined at the top of the file" ) return SceneClass(self, cls_node)
[docs] def all_scene_classes(self) -> list[SceneClass]: """Every Node subclass defined in the file, in source order. Used for diagnostics and IDE features. The typical scene has one. Detection mirrors :func:`primary_node_class_from_source`'s rule (Node base + ``__init__`` or class-body ``Property`` descriptors). """ import ast from .detection import _is_scene_class try: ast_tree = ast.parse(self._source_tree.dump()) except SyntaxError: return [] names: list[str] = [] for node in ast.iter_child_nodes(ast_tree): if isinstance(node, ast.ClassDef) and _is_scene_class(node): names.append(node.name) out: list[SceneClass] = [] for name in names: cls_node = self._source_tree.find_class(name) if cls_node is not None: out.append(SceneClass(self, cls_node)) return out
# -- top-level class insertion ------------------------------------------
[docs] def insert_top_level_class( self, name: str, base: str, *, body: str = "pass", before: str | None = None, ) -> SceneClass: """Insert ``class <name>(<base>): <body>`` at module scope. Auto-imports ``base`` via :class:`ImportSet` (defaults to ``simvx.core``). Placement is just before the existing scene class (or before ``before`` when given) so the new definition sits between imports and the scene that uses it. Returns the new :class:`SceneClass` view. Raises :class:`ValueError` if a top-level class with the same name already exists. """ if self._source_tree.find_class(name) is not None: raise ValueError(f"top-level class {name!r} already exists") self._imports.ensure(base, from_="simvx.core") snippet_text = f"class {name}({base}):\n {body}\n" new_class = parse_snippet(snippet_text) new_class.parent = None module = self._source_tree.module children = module.children anchor_name = before if anchor_name is None: existing = list(self._source_tree.iter_classes()) if existing: anchor_name = existing[0].name.value if anchor_name is not None: anchor_class = self._source_tree.find_class(anchor_name) if anchor_class is None: raise ValueError(f"anchor class {anchor_name!r} not found") # Find the wrapping top-level child (classdef or decorated). target = anchor_class while target.parent is not None and target.parent is not module: target = target.parent idx = children.index(target) anchor_prefix = target.get_first_leaf().prefix # Inserted class inherits the anchor's prefix (preserving any # leading blank line the user had above it); the anchor moves # down with a single-newline separator. new_class.get_first_leaf().prefix = anchor_prefix target.get_first_leaf().prefix = "\n\n" new_class.parent = module children.insert(idx, new_class) else: # No existing classes: append at end (before endmarker). endmarker_idx = next( (i for i, c in enumerate(children) if c.type == "endmarker"), len(children), ) # Pad with one blank line if there's prior content. new_class.get_first_leaf().prefix = "\n\n" if endmarker_idx > 0 else "" new_class.parent = module children.insert(endmarker_idx, new_class) # Resolve the freshly inserted SceneClass. cls_node = self._source_tree.find_class(name) if cls_node is None: raise RuntimeError(f"insert_top_level_class: failed to locate {name!r} after insertion") return SceneClass(self, cls_node)
# -- serialisation -------------------------------------------------------
[docs] def dump(self) -> str: """Current source as a string.""" return self._source_tree.dump()
[docs] def is_dirty(self) -> bool: """True iff :meth:`dump` differs from the original input text. Used by :class:`SceneModule` to skip writes for files that were opened but never edited. """ return self.dump() != self._original_text
[docs] def save(self, path: str | Path | None = None) -> Path: """Write to ``path`` (or to ``self.path`` if not given). Returns the path written. Raises :class:`ValueError` if neither is set. ``self._path`` is updated on success so subsequent saves without an argument reuse the last destination. """ target = Path(path) if path is not None else self._path if target is None: raise ValueError("save() requires a path: SceneFile has no recorded path") text = self.dump() atomic_write_text(target, text) self._path = target self._original_text = text return target
[docs] def assert_idempotent(self) -> None: """Assert that :meth:`dump` equals the original input text. Useful for tests that verify no accidental edits leaked into a load/save round-trip. """ current = self.dump() if current != self._original_text: import difflib diff = "".join( difflib.unified_diff( self._original_text.splitlines(keepends=True), current.splitlines(keepends=True), fromfile="original", tofile="current", ) ) raise AssertionError(f"SceneFile is not idempotent:\n{diff}")
# --------------------------------------------------------------------------- # SceneClass # ---------------------------------------------------------------------------
[docs] class SceneClass: """An editable view of one Node-subclass class definition inside a scene.""" __slots__ = ("_file", "_class") def __init__(self, file: SceneFile, class_node: Class) -> None: self._file = file self._class = class_node # -- identity ------------------------------------------------------------
[docs] @property def name(self) -> str: """Class name (e.g. ``"Arena"``).""" return self._class.name.value
[docs] @property def node(self) -> Class: """Underlying parso :class:`Class` node.""" return self._class
# -- syntax the editor's parser could not read ---------------------------
[docs] def unreadable_syntax(self) -> SyntaxIssue | None: """The first construct in ``__init__`` the parser could not read, if any. ``None`` for the ordinary scene, which is every scene that stays inside the documented subset (:mod:`simvx.core.scene_io.syntax`). A caller about to rewrite ``__init__`` as a whole -- the editor's save -- asks this first and leaves the file alone, because the individual edits it would make each look reasonable and the file they add up to does not run. """ try: suite = self._init_suite() except ValueError: return None issues = self._file.source_tree.issues if not issues: return None first, last = suite.start_pos[0], suite.end_pos[0] for issue in issues: if first <= issue.line <= last: return explain_at(issues, issue.line) return None
def _body_indent(self) -> int | None: """The column ``__init__``'s own statements start at, or ``None`` for an empty body. The minimum, which is that column: a suite the parser read has every statement at it, and one it recovered from has the lifted contents of a block sitting deeper. """ columns = [stmt.get_first_leaf().start_pos[1] for stmt in self._init_body_stmts()] return min(columns) if columns else None def _unreadable_statement(self, stmt: NodeOrLeaf) -> SyntaxIssue | None: """The parse issue that puts ``stmt`` inside a construct the parser could not read. Two shapes, because recovery leaves the block it could not read in two pieces. The header and the first line of each arm carry an issue of their own, so a statement is caught by its line. The rest of the arm does not: parso lifts those statements into the enclosing suite, where they stand as siblings of the lines around them and only their column still says they were nested. Both are asked, or a removal takes half of a ``match`` arm and leaves a file that raises ``IndentationError``. """ issues = self._file.source_tree.issues if not issues: return None line, column = stmt.get_first_leaf().start_pos body_indent = self._body_indent() if line not in issue_lines(issues) and (body_indent is None or column <= body_indent): return None return explain_at(issues, line) def _refuse_unreadable(self, statements: list[NodeOrLeaf], *, action: str) -> None: """Refuse ``action`` when any of ``statements`` sits in an unreadable construct. Asked of every statement the edit would rewrite -- for a removal that is the whole sweep, not the child's own two lines -- since a sweep reaches lines the caller never named. """ for stmt in statements: issue = self._unreadable_statement(stmt) if issue is None: continue issues = self._file.source_tree.issues where = str(self._file.path) if self._file.path is not None else f"{self.name}.__init__" raise refusal( [issue, *(other for other in issues if other is not issue)], subject=where, consequence=f"{action} rewrites lines inside it, and the file that came out would not run.", ) # -- class-level Property descriptors ------------------------------------
[docs] def has_property(self, name: str) -> bool: return self._find_class_property(name) is not None
[docs] def get_property_default(self, name: str) -> str | None: """Source text of the Property's default expression, or ``None``. Inherited Properties are not visible: use the runtime tree to observe inherited values. """ node = self._find_class_property(name) if node is None: return None # node is the expr_stmt: name = atom_expr(Property(...)) atom_expr = self._property_atom_expr(node) trailer = atom_expr.children[-1] first_arg = _first_positional_value(trailer) if first_arg is None: return None return first_arg.get_code().strip()
[docs] def add_property(self, name: str, default_expr: str) -> None: """Insert ``name = Property(default_expr)`` into the class body. Inserted after existing class-level Property declarations. Auto- imports ``Property`` via the file's :class:`ImportSet`. """ if self.has_property(name): raise ValueError(f"property {name!r} already declared on {self.name}") self._file.imports.ensure("Property", from_="simvx.core") suite = self._class_suite() # Find the last existing Property simple_stmt to anchor after; if # none, anchor after the class header (suite's first NEWLINE). last_prop = self._last_class_property_stmt() snippet = parse_snippet(f"{name} = Property({default_expr})\n") if last_prop is not None: indent = _edits._indent_of(_get_prefix(last_prop)) _set_prefix(snippet, indent) snippet.parent = suite children = suite.children children.insert(children.index(last_prop) + 1, snippet) else: anchor = self._suite_anchor(suite) _edits.insert_after(anchor, snippet)
[docs] def remove_property(self, name: str) -> None: node = self._find_class_property(name) if node is None: raise ValueError(f"property {name!r} not declared on {self.name}") _edits.remove_node(node)
[docs] def set_property_default(self, name: str, default_expr: str) -> None: node = self._find_class_property(name) if node is None: raise ValueError(f"property {name!r} not declared on {self.name}") atom_expr = self._property_atom_expr(node) trailer = atom_expr.children[-1] old_value = _first_positional_value(trailer) new_value = parse_snippet(default_expr) if old_value is None: # Property() with no default: append as positional. new_value.parent = None _set_prefix(new_value, "") # parens: trailer.children = [(, ... ,)]; insert before close. trailer.children.insert(-1, new_value) new_value.parent = trailer return _edits.replace_node(old_value, new_value, preserve_prefix=True)
# -- root super().__init__ kwargs ---------------------------------------
[docs] def get_root_kwarg(self, name: str) -> str | None: trailer = self._super_init_trailer() if trailer is None: return None val = _edits.get_call_kwarg(trailer, name) return val.get_code().strip() if val is not None else None
[docs] def set_root_kwarg(self, name: str, value_expr: str) -> None: """Update or insert a kwarg in the root ``super().__init__(...)`` call.""" trailer = self._super_init_trailer() if trailer is None: raise ValueError(f"{self.name}.__init__ has no super().__init__() call to edit") _edits.set_call_kwarg(trailer, name, value_expr)
[docs] def remove_root_kwarg(self, name: str) -> None: trailer = self._super_init_trailer() if trailer is None: raise ValueError(f"{self.name}.__init__ has no super().__init__() call") for arg in _arglist_arguments(trailer): if _argument_name(arg) == name: _remove_argument(arg) return raise ValueError(f"kwarg {name!r} not found on super().__init__()")
# -- children: assignments + add_child calls ---------------------------
[docs] def has_child(self, var_name: str) -> bool: return self._find_child_assignment(var_name) is not None
[docs] def child_var_names(self, *, receiver: str | None = "self") -> list[str]: """Variable names of all children added via ``<receiver>.add_child(<var>)``, in source order. ``receiver`` defaults to ``"self"``, the root's own children. Pass ``None`` for the whole tree the file builds: a grandchild is added on the variable its parent is bound to (``panel.add_child(label)``), so a reader that asks only about ``self`` reports a file with children as a file with none. """ out: list[str] = [] for stmt in self._init_statements(): var = _add_child_var_name(stmt, receiver=receiver) if var is not None: out.append(var) return out
[docs] def add_child( self, var_name: str, type_name: str, *, before: str | None = None, after: str | None = None, from_module: str | None = "simvx.core", receiver: str = "self", **kwarg_exprs: str, ) -> None: """Insert a child construction + ``<receiver>.add_child`` pair into ``__init__``. Position: appended at the end of the existing child block by default; ``before=`` or ``after=`` (mutually exclusive) places relative to another child. ``receiver`` is the local the ``add_child`` call is written on, and ``"self"`` -- the root's own children -- is the default. Anything else is a local ``__init__`` already binds to a child, and the pair is appended after the last child that local already has, or after the statement that parented the local itself when it has none yet. A local the file does not bind is refused: the file would name a variable that does not exist. Auto-imports ``type_name`` from ``from_module`` (defaults to ``simvx.core``) when the name is not already imported under any alias. Callers placing user classes should pass ``from_module=type(node).__module__``. ``from_module=None`` imports nothing, which is what a class this file defines itself is owed: an import of it either names a module nothing can resolve or runs this very file a second time, and the class the second run defines is not the one the scene was built from. Raises :class:`ValueError` if ``var_name`` already exists in the ``__init__`` body or if both ``before`` and ``after`` are passed. A construction too wide for the line limit is written across several lines, one keyword argument each, as a formatter would leave it: the column it is being inserted at is known here, and that is what the layout is measured against (:func:`~simvx.core.scene_io.layout.wrap_statement`). Note: when the source is procedural (children built inside loops or conditionals: see :func:`has_procedural_construction`), the inserted statements are appended at the top level of ``__init__`` and may execute in a surprising order relative to the procedural code. """ if before is not None and after is not None: raise ValueError("add_child: pass at most one of `before` or `after`") if self.has_child(var_name): raise ValueError(f"child variable {var_name!r} already exists in {self.name}.__init__") if receiver != "self" and not self.has_child(receiver): raise ValueError(f"receiver {receiver!r} is not bound in {self.name}.__init__") # Where the pair lands is settled first: the construction is laid out # for the column it will sit at, and the anchor is what says which one. insert = self._insert_pair_after if before is not None: anchor = self._find_child_assignment(before) if anchor is None: raise ValueError(f"child {before!r} not found") insert = self._insert_pair_before elif after is not None: anchor = self._find_add_child_call(after) if anchor is None: raise ValueError(f"child {after!r} not found") elif receiver == "self": # Append after the last existing add_child call, or after # super().__init__() if none. anchor = self._last_add_child_stmt() or self._super_init_stmt() if anchor is None: anchor = self._suite_anchor(self._init_suite()) else: # Below the receiver's own children if it has any, and otherwise # below the statement that parented the receiver: the pair has to # come after the line that binds the local it is written on, and # after the line that gave that local a parent, or the file adds a # child to a node the tree does not hold yet. anchor = ( self._last_add_child_stmt(receiver=receiver) or self._find_add_child_call(receiver) or self._find_child_assignment(receiver) ) if anchor is None: # pragma: no cover - `has_child` refused this above anchor = self._suite_anchor(self._init_suite()) # The pair takes the anchor's indentation, so an anchor the parser # lifted out of a block it could not read would put the new child # inside that block, at a depth this layer only thinks it understands. self._refuse_unreadable([anchor], action="Adding a child here") if from_module is not None: self._file.imports.ensure(type_name, from_=from_module) # The column the pair lands at is the column of the anchor's line: a # statement a semicolon put in the middle of one carries a space for a # prefix, which says nothing about where the line starts. anchor = _statement_line(anchor) or anchor indent = len(_indent_of(_get_prefix(anchor))) kwargs_str = ", ".join(f"{k}={v}" for k, v in kwarg_exprs.items()) assignment_src = wrap_statement(f"{var_name} = {type_name}({kwargs_str})", indent=indent) + "\n" add_child_src = wrap_statement(f"{receiver}.add_child({var_name})", indent=indent) + "\n" assignment = parse_snippet(assignment_src) add_child = parse_snippet(add_child_src) insert(anchor, assignment, add_child)
[docs] def remove_child(self, var_name: str) -> Removed: """Remove the child's own statements and everything left standing on them. The assignment and the ``self.add_child`` line are the two the emitter writes, but they are not the whole of what a child can own. A child of its own is written as a statement on *its* variable (``child_0.add_child(label_0)``), and so is anything else the author hung off it, so taking only the two would leave statements naming a variable that is gone and a file that raises on import. Every statement written on ``__init__``'s own body goes if it names the variable, and so do the constructions those statements were the only use of, however far that propagates. A block is where this stops. A name used inside a ``for``, an ``if``, a ``with`` or a ``try`` is not one statement to take out, and taking the block whole would delete work of the author's that the child is only a part of, so the removal is refused: nothing is taken out, and :class:`ValueError` names the blocks that hold it back. :meth:`_removal_blockers` asks the same question without attempting the removal, for a caller that would rather announce it than raise. Returns what went with it beyond the child's own two lines (:class:`Removed`): every other statement the sweep took, and the attribute bindings they carried off. Both are what a caller with somewhere to say so should say, since the author wrote those lines and a method reading ``self.<name>`` no longer has one. Does not auto-clean unused imports: that is the caller's responsibility (use :meth:`ImportSet.remove`). Raises :class:`ValueError` if ``var_name`` is absent. """ assignment = self._find_child_assignment(var_name) add_child = self._find_add_child_call(var_name) if assignment is None and add_child is None: raise ValueError(f"child {var_name!r} not found in {self.name}.__init__") own = [stmt for stmt in (assignment, add_child) if stmt is not None] return self._remove_statements_standing_on({var_name}, set(), label=f"child {var_name!r}", own=own)
def _removal_blockers(self, var_name: str) -> list[str]: """The blocks of ``__init__`` that stop ``var_name`` from being removed. Each as the line it opens with (``for i in range(3):``), so a caller can say which lines it is that keep the child in the file. Empty when :meth:`remove_child` would go through. The bindings the removal would carry off are followed first, so a block naming one of *those* counts too: a block reading a grandchild is held up by the grandchild's construction going. """ _statements, _taken, variables, attributes = self._sweep_standing_on({var_name}, set()) return self._blocks_standing_on(variables, attributes) def _remove_statement(self, stmt: NodeOrLeaf) -> Removed: """Remove one statement of ``__init__``, and whatever it held up. The counterpart of :meth:`remove_child` for a statement that binds no child variable to remove it by -- ``self.add_child(Hero())``, or the same kept in an attribute. What it binds is what other statements can be standing on, so that is what the sweep starts from. Returns the same :class:`Removed` :meth:`remove_child` does, with the statement's own attribute binding at the head of the attributes when it had one, and refuses on the same terms: a block of ``__init__`` naming one of those bindings stops the removal rather than being rewritten. """ variables, attributes = _bound_locals_and_attributes(stmt) label = f"statement `{_code_on_one_line(stmt)}`" own_attributes = [name for name in _bound_names(stmt) if name.startswith("self.")] removed = self._remove_statements_standing_on(variables, attributes, label=label, own=[stmt], also=stmt) return Removed(removed.statements, own_attributes + removed.attributes) def _statement_removal_blockers(self, stmt: NodeOrLeaf) -> list[str]: """The blocks of ``__init__`` that stop ``stmt`` from being removed. What :meth:`_removal_blockers` is to a child, this is to a statement that binds no child variable. """ variables, attributes = _bound_locals_and_attributes(stmt) _statements, _taken, dead_variables, dead_attributes = self._sweep_standing_on(variables, attributes) return self._blocks_standing_on(dead_variables, dead_attributes) def _removal_sweep(self, var_name: str) -> list[NodeOrLeaf]: """The statements :meth:`remove_child` would take, without taking any. What :meth:`_removal_blockers` is for the blocks that would refuse the removal, this is for the lines it would carry off, so a caller that knows what those lines mean can refuse it for a reason of its own. """ doomed, _taken, _variables, _attributes = self._sweep_standing_on({var_name}, set()) return doomed def _statement_removal_sweep(self, stmt: NodeOrLeaf) -> list[NodeOrLeaf]: """The statements removing ``stmt`` would take, ``stmt`` included. What :meth:`_removal_sweep` is to a child, this is to a statement that binds no child variable. """ variables, attributes = _bound_locals_and_attributes(stmt) doomed, _taken, _variables, _attributes = self._sweep_standing_on(variables, attributes) return doomed if any(taken is stmt for taken in doomed) else [stmt, *doomed] def _sweep_standing_on( self, variables: set[str], attributes: set[str] ) -> tuple[list[NodeOrLeaf], list[str], set[str], set[str]]: """Work out what a removal would take, without taking any of it. One statement at a time, because taking one settles what the next pass sees: a statement that names a dead variable is itself dead, and what *it* bound dies with it, so the names propagate outwards until nothing in the body names anything that has gone. Returns the statements to take, the bindings they carry off beyond the ones given, and the two sets of dead names the propagation closed over. A name is only dead from the line that binds it onwards (:func:`_first_bindings`). Above that line the same word means whatever else the file has in scope -- a parameter of ``__init__``, most of all, which is what ``super().__init__(**kwargs)`` reads and what a child the editor happened to name "kwargs" would otherwise take out with it. ``super().__init__`` is never taken in any case: it is how the node being edited is built, not a statement standing on one of its children. """ statements = self._init_statements() bound_at = _first_bindings(statements) dead_variables, dead_attributes = set(variables), set(attributes) doomed: list[NodeOrLeaf] = [] taken: list[str] = [] standing = list(enumerate(statements)) while True: for position, (index, stmt) in enumerate(standing): if _is_super_init_stmt(stmt): continue if not _stands_on(stmt, dead_variables, dead_attributes, index=index, bound_at=bound_at): continue for name in _bound_names(stmt): if name.startswith("self."): if name.removeprefix("self.") not in dead_attributes: dead_attributes.add(name.removeprefix("self.")) taken.append(name) elif name not in dead_variables: dead_variables.add(name) taken.append(name) doomed.append(stmt) del standing[position] break else: return doomed, taken, dead_variables, dead_attributes def _blocks_standing_on(self, variables: set[str], attributes: set[str]) -> list[str]: """The blocks of ``__init__`` naming one of these bindings, as they open. Asked without regard to where the block sits, unlike the sweep over the body's own statements: a block runs its lines any number of times and in an order of its own, so a name it reads above the line binding it is still a name it may be reading from there. """ return [_first_line(stmt) for stmt in self._init_blocks() if _stands_on(stmt, variables, attributes)] def _remove_statements_standing_on( self, variables: set[str], attributes: set[str], *, label: str, own: list[NodeOrLeaf], also: NodeOrLeaf | None = None, ) -> Removed: """Sweep out every statement of ``__init__`` that names a binding being removed. ``own`` is the statements the caller is already speaking for -- the child's own two lines -- which are left out of what comes back, since what a caller wants to be told is the rest. ``also`` is a statement to take with them that the sweep may not reach on its own, and ``label`` names what is being removed for the refusal message. Nothing is taken out at all when a block of ``__init__`` names one of the bindings the sweep closes over: the block is not one statement to remove and its body is not this layer's to rewrite, so the removal is refused whole (:meth:`remove_child`). Returns what went beyond ``own``. """ doomed, taken, dead_variables, dead_attributes = self._sweep_standing_on(variables, attributes) self._refuse_unreadable([*own, *doomed, *([] if also is None else [also])], action="Removing it") blockers = self._blocks_standing_on(dead_variables, dead_attributes) if blockers: blocks = ", ".join(f"`{line}`" for line in blockers) raise ValueError( f"cannot remove {label} from {self.name}.__init__: {blocks} names it, " "and the body of a block is not rewritten here" ) spoken_for = {id(stmt) for stmt in own} statements = [_code_on_one_line(stmt) for stmt in doomed if id(stmt) not in spoken_for] if also is not None and not any(stmt is also for stmt in doomed): _edits.remove_statement(also) for stmt in doomed: _edits.remove_statement(stmt) return Removed(statements, [name for name in taken if name.startswith("self.")])
[docs] def rename_child(self, old: str, new: str) -> None: """Rename the local variable a child is bound to, everywhere ``__init__`` names it. Everywhere, not just on the two statements the emitter writes: an author may have hung a child of its own off the variable (``child_0.add_child(label_0)``), read it further down, or used it inside a ``for`` or an ``if``, and a rename that reached only the binding and the ``add_child`` call would leave those naming a variable the file no longer has. A local is one name for the whole function, so every place ``__init__`` writes it, at whatever depth, is the same variable and follows. """ if self.has_child(new): raise ValueError(f"cannot rename {old!r}: target name {new!r} already exists") assignment = self._find_child_assignment(old) add_child = self._find_add_child_call(old) if assignment is None or add_child is None: raise ValueError(f"child {old!r} not found in {self.name}.__init__") for kind, leaf in _binding_leaves(self._init_suite()): if kind == "local" and leaf.value == old: leaf.value = new
[docs] def get_child_kwarg(self, var_name: str, kwarg: str) -> str | None: ctor_trailer = self._child_ctor_trailer(var_name) if ctor_trailer is None: return None val = _edits.get_call_kwarg(ctor_trailer, kwarg) return val.get_code().strip() if val is not None else None
[docs] def set_child_kwarg(self, var_name: str, kwarg: str, value_expr: str) -> None: ctor_trailer = self._child_ctor_trailer(var_name) if ctor_trailer is None: raise ValueError(f"child {var_name!r} not found") _edits.set_call_kwarg(ctor_trailer, kwarg, value_expr)
[docs] def remove_child_kwarg(self, var_name: str, kwarg: str) -> None: ctor_trailer = self._child_ctor_trailer(var_name) if ctor_trailer is None: raise ValueError(f"child {var_name!r} not found") for arg in _arglist_arguments(ctor_trailer): if _argument_name(arg) == kwarg: _remove_argument(arg) return raise ValueError(f"kwarg {kwarg!r} not found on child {var_name!r}")
[docs] def reorder_children(self, order: list[str], *, receiver: str = "self") -> None: """Reorder child assignment + ``add_child`` line pairs to match ``order``. Every existing child of ``receiver`` that the file binds a variable to must appear in ``order`` exactly once. A child the file constructs inside its own ``add_child`` call binds no variable and so cannot be named here at all: :meth:`reorder_children_by_statement` is the mover that can move one, and this is a thin way of asking for it by name. ``receiver`` says whose children are being reordered. """ existing = self.child_var_names(receiver=receiver) if sorted(existing) != sorted(order) or len(existing) != len(order): raise ValueError(f"reorder_children: order {order!r} does not match existing children {existing!r}") if existing == order: return adds: dict[str, NodeOrLeaf] = {} for var in existing: add_child = self._find_add_child_call(var, receiver=receiver) if add_child is None: # pragma: no cover - `child_var_names` just found it raise ValueError(f"child {var!r} structurally inconsistent during reorder") adds[var] = add_child self.reorder_children_by_statement([adds[var] for var in order], receiver=receiver)
[docs] def reorder_children_by_statement(self, order: list[NodeOrLeaf], *, receiver: str = "self") -> None: """Reorder ``receiver``'s children so their ``add_child`` statements run in ``order``. ``order`` holds every ``add_child`` statement written on ``receiver``, exactly once each, in the order the file should call them. What moves with each is the child's own lines and nothing else: the construction the file binds to a variable, where there is one, and the ``add_child`` line itself. **A child the file constructs inside the call is movable too**, which is why this exists beside :meth:`reorder_children`. ``self.add_child( make_sprite("A"))`` binds no variable to name it by, but it is a whole line and moving it is exactly as safe as moving a bound child's pair. What genuinely blocks a move is a statement sharing a line with another one, since the line cannot follow the child without taking that statement along, and that is refused here rather than written wrong. :meth:`_children_share_lines` asks the same question without attempting the move. A statement of the author's written *between* two children ends up after all of them: the children are re-inserted contiguously, which is the shape the emitter writes and the only one this can restore. That is a real cost and the round-trip design turns it into a refusal; until then it is what a reorder does. """ statements = self._init_statements() existing = [stmt for stmt in statements if _add_child_argument(stmt, receiver=receiver) is not None] if sorted(id(stmt) for stmt in existing) != sorted(id(stmt) for stmt in order): raise ValueError("reorder_children_by_statement: order does not match the statements written on receiver") if [id(stmt) for stmt in existing] == [id(stmt) for stmt in order]: return self._refuse_unreadable(existing, action="Reordering the children") # Snapshot each child's own lines, and the prefix of the first of them # (which we will keep on whatever ends up first). lines_of: dict[int, list[NodeOrLeaf]] = {} for stmt in existing: lines: list[NodeOrLeaf] = [] var = _add_child_var_name(stmt, receiver=receiver) if var is not None: assignment = _statement_line(self._find_child_assignment(var)) if assignment is None: raise ValueError(f"child {var!r} structurally inconsistent during reorder") lines.append(assignment) add_child = _statement_line(stmt) if add_child is None: # pragma: no cover - a statement is always on a line raise ValueError("reorder_children_by_statement: a child has no line to move") lines.append(add_child) if any(len(line_statements(line)) > 1 for line in lines): label = repr(var) if var is not None else _code_on_one_line(stmt) raise ValueError(f"reorder_children: child {label} shares a line with another statement") lines_of[id(stmt)] = lines first_line = lines_of[id(existing[0])][0] first_prefix = _get_prefix(first_line) # Anchor on the statement immediately before the first child. suite = self._init_suite() children = suite.children anchor = children[children.index(first_line) - 1] # Detach every child's lines from the suite (in reverse to keep indices # stable), then re-insert them in ``order``. Pack tightly: every line # gets the original indent only, no extra blank lines. for stmt in reversed(existing): for line in reversed(lines_of[id(stmt)]): children.remove(line) line.parent = None indent = _edits._indent_of(first_prefix) cursor = anchor for stmt in order: for line in lines_of[id(stmt)]: _set_prefix(line, indent) line.parent = suite children.insert(children.index(cursor) + 1, line) cursor = line # Restore the original prefix (including blank lines / comments) # onto whatever now sits at the head of the block. _set_prefix(lines_of[id(order[0])][0], first_prefix)
# -- internal helpers ---------------------------------------------------- def _class_suite(self) -> NodeOrLeaf: return self._class.children[-1] def _init_funcdef(self) -> NodeOrLeaf | None: suite = self._class_suite() for child in suite.children: if child.type == "funcdef": name_leaf = child.children[1] if isinstance(name_leaf, Leaf) and name_leaf.value == "__init__": return child return None def _init_suite(self) -> NodeOrLeaf: funcdef = self._init_funcdef() if funcdef is None: raise ValueError(f"{self.name} has no __init__") return funcdef.children[-1] def _suite_anchor(self, suite: NodeOrLeaf) -> NodeOrLeaf: # The first child of a suite is the introductory NEWLINE. return suite.children[0] def _init_body_stmts(self) -> list[NodeOrLeaf]: """The lines of ``__init__``'s body, which is what an insertion anchors on.""" try: suite = self._init_suite() except ValueError: return [] return [c for c in suite.children if c.type == "simple_stmt"] def _init_statements(self) -> list[NodeOrLeaf]: """The statements of ``__init__``'s body, a semicolon-joined line counting as several. What a line *does* is asked of this rather than of :meth:`_init_body_stmts`, because a semicolon writes two statements on one line and a reader that stops at the first of them sees half of what the file builds (:func:`~simvx.core.scene_io.edits.line_statements`). """ out: list[NodeOrLeaf] = [] for line in self._init_body_stmts(): out.extend(line_statements(line)) return out def _init_blocks(self) -> list[NodeOrLeaf]: """The statements of ``__init__``'s body that carry a block of their own. A ``for``, a ``while``, an ``if``, a ``with``, a ``try``, a nested ``def``: everything a suite holds that is not one line of statements. What they do is read (a child is added inside a loop, a variable is named there) but never rewritten, so they are what the removal sweep has to stop at (:meth:`remove_child`). """ try: suite: Any = self._init_suite() except ValueError: return [] return [c for c in suite.children if not isinstance(c, Leaf) and c.type != "simple_stmt"] def _children_share_lines(self, *, receiver: str | None = "self") -> bool: """Does any child's construction or ``add_child`` share its line with another statement? The question :meth:`reorder_children` cannot work around: it moves whole lines, and a line holding a statement that is not the child's own cannot follow the child without taking that statement along. Every ``add_child`` is asked, not only the ones binding a variable: a child constructed inside the call (``self.add_child(make_sprite("A"))``) is on a line like any other, and it is the shape a semicolon is most likely to be found on. ``receiver`` says whose children are being asked about, and ``None`` asks about the whole tree the file builds. """ for line in self._init_body_stmts(): if len(line_statements(line)) > 1 and _add_child_argument(line, receiver=receiver) is not None: return True for var in self.child_var_names(receiver=receiver): line = _statement_line(self._find_child_assignment(var)) if line is not None and len(line_statements(line)) > 1: return True return False def _find_class_property(self, name: str) -> NodeOrLeaf | None: suite = self._class_suite() for stmt in suite.children: if stmt.type != "simple_stmt": continue atom_expr = self._property_atom_expr(stmt) if atom_expr is None: continue target_leaf = stmt.children[0].children[0] if isinstance(target_leaf, Leaf) and target_leaf.value == name: return stmt return None def _last_class_property_stmt(self) -> NodeOrLeaf | None: last = None suite = self._class_suite() for stmt in suite.children: if stmt.type != "simple_stmt": continue if self._property_atom_expr(stmt) is not None: last = stmt return last @staticmethod def _property_atom_expr(stmt: NodeOrLeaf) -> NodeOrLeaf | None: """If ``stmt`` is ``<name> = Property(...)``, return the call atom_expr; otherwise None.""" if stmt.type != "simple_stmt" or not stmt.children: return None expr_stmt = stmt.children[0] if expr_stmt.type != "expr_stmt": return None if len(expr_stmt.children) < 3: return None target, eq, value = expr_stmt.children[0], expr_stmt.children[1], expr_stmt.children[2] if target.type != "name" or getattr(eq, "type", None) != "operator" or eq.value != "=": return None if value.type != "atom_expr" or not value.children: return None head = value.children[0] if head.type != "name" or head.value != "Property": return None return value def _super_init_stmt(self) -> NodeOrLeaf | None: for stmt in self._init_statements(): if _is_super_init_stmt(stmt): return stmt return None def _super_init_trailer(self) -> NodeOrLeaf | None: stmt = self._super_init_stmt() if stmt is None: return None return stmt.children[-1] def _find_child_assignment(self, var_name: str) -> NodeOrLeaf | None: for stmt in self._init_statements(): name = _assignment_target_name(stmt) if name == var_name and not _is_super_init_stmt(stmt): return stmt return None def _find_add_child_call(self, var_name: str, *, receiver: str | None = None) -> NodeOrLeaf | None: """The statement that parents ``var_name``, on ``receiver`` or on any. Receiver-agnostic by default, because a variable is parented by exactly one statement wherever that statement is written: a grandchild is added on its own parent's local (``panel.add_child(label)``) and a reader restricted to ``self`` reports it as no child at all. The *last* such statement is the answer, not the first. :meth:`Node.add_child` reparents, so ``a.add_child(x)`` followed by ``b.add_child(x)`` leaves ``x`` under ``b``, and the earlier call is superseded rather than wrong. Where a variable is added once -- every file the emitter writes, and nearly every one an author writes -- the two are the same statement. """ found = None for stmt in self._init_statements(): if _add_child_var_name(stmt, receiver=receiver) == var_name: found = stmt return found def _last_add_child_stmt(self, *, receiver: str | None = "self") -> NodeOrLeaf | None: """The last statement that adds a child to ``receiver``, in whatever shape. The anchor a new child is appended after, so it lands below every child the file already adds to that node -- including the ones written as a call this class cannot edit through (``self.add_child(Hero())``), which are still children, and still come first at load time. ``None`` asks for the last statement that adds a child to anything. """ last = None for stmt in self._init_statements(): if _add_child_argument(stmt, receiver=receiver) is not None: last = stmt return last def _insert_pair_after(self, anchor: NodeOrLeaf, assignment: NodeOrLeaf, add_child: NodeOrLeaf) -> None: """Insert ``assignment`` then ``add_child`` directly after ``anchor``. Both inserted statements sit at ``anchor``'s indent with no extra blank line: children pack tightly like the corpus convention. ``anchor`` may be a statement rather than a line, since that is what the finders hand back; what the suite holds is the line it sits on, so that is what the insertion counts from. """ suite = self._init_suite() children = suite.children anchor = _statement_line(anchor) or anchor idx = children.index(anchor) indent = _edits._indent_of(_get_prefix(anchor)) _set_prefix(assignment, indent) _set_prefix(add_child, indent) assignment.parent = suite add_child.parent = suite children.insert(idx + 1, assignment) children.insert(idx + 2, add_child) def _insert_pair_before(self, anchor: NodeOrLeaf, assignment: NodeOrLeaf, add_child: NodeOrLeaf) -> None: """Insert ``assignment`` then ``add_child`` directly before ``anchor``. Inserted lines inherit ``anchor``'s indent; the original ``anchor`` prefix (which may carry a leading blank line / comment) is kept on ``anchor`` itself. As for :meth:`_insert_pair_after`, a statement anchor is taken to mean the line it is written on. """ suite = self._init_suite() children = suite.children anchor = _statement_line(anchor) or anchor idx = children.index(anchor) indent = _edits._indent_of(_get_prefix(anchor)) _set_prefix(assignment, indent) _set_prefix(add_child, indent) assignment.parent = suite add_child.parent = suite children.insert(idx, assignment) children.insert(idx + 1, add_child) def _child_ctor_trailer(self, var_name: str) -> NodeOrLeaf | None: expr_stmt = self._find_child_assignment(var_name) if expr_stmt is None: return None if expr_stmt.type != "expr_stmt" or len(expr_stmt.children) < 3: return None rhs = expr_stmt.children[2] if rhs.type != "atom_expr": return None last = rhs.children[-1] if last.type == "trailer" and last.children and last.children[0].value == "(": return last return None
# --------------------------------------------------------------------------- # ImportSet # ---------------------------------------------------------------------------
[docs] class ImportSet: """Editable view of the file's top-level imports.""" __slots__ = ("_source_tree",) def __init__(self, source_tree: SourceTree) -> None: self._source_tree = source_tree
[docs] def has(self, name: str, *, from_: str | None = None) -> bool: for from_module, imported_name in self.names(): if imported_name == name and from_module == from_: return True return False
[docs] def has_any_alias(self, name: str) -> bool: """True iff ``name`` is imported from anywhere (any module).""" for _from_module, imported_name in self.names(): if imported_name == name: return True return False
[docs] def ensure(self, name: str, *, from_: str | None = None) -> None: """Add ``import name`` or ``from <from_> import name`` if absent. When ``from_`` matches an existing ``from <from_> import …`` line, the new name is merged into that line (sorted, deduplicated) instead of creating a separate import line, and that line is laid out to the width the rest of the file is written at. """ if self.has(name, from_=from_): return if from_ is not None: # Try to merge into an existing "from <from_> import ..." line. existing = self._find_import_from(from_) if existing is not None and self._merge_into_import_from(existing, name): return snippet_text = f"from {from_} import {name}\n" if from_ is not None else f"import {name}\n" new_stmt = parse_snippet(snippet_text) self._insert_import_line(new_stmt)
[docs] def remove(self, name: str, *, from_: str | None = None) -> None: """Remove an import. If the line becomes empty, remove it. No-op if ``name`` is not imported (with the given ``from_``). """ if from_ is None: stmt = self._find_plain_import(name) if stmt is not None: _edits.remove_node(stmt) return import_from = self._find_import_from(from_) if import_from is None: return names_node = self._import_from_names_node(import_from) if names_node is None: return if names_node.type == "name": if names_node.value == name: # Sole imported name → remove the whole simple_stmt line. stmt = self._stmt_for_import(import_from) if stmt is not None: _edits.remove_node(stmt) return # `import_as_names`: children are alternating `name` / `,`. children = names_node.children for idx, child in enumerate(children): if child.type == "name" and child.value == name: # Remove this name and one neighbouring comma so the list # stays well-formed. If we're removing the first name, the # second name needs to inherit the original first's prefix # so the leading space after ``import`` is preserved. if idx == 0 and len(children) >= 3 and children[1].type == "operator": # Remove name + comma; let next name keep its own prefix # but copy the leading space. leading = child.prefix del children[0:2] if children and isinstance(children[0], Leaf): children[0].prefix = leading elif idx + 1 < len(children) and children[idx + 1].type == "operator": del children[idx : idx + 2] elif idx > 0 and children[idx - 1].type == "operator": del children[idx - 1 : idx + 1] else: del children[idx] # If only one name remains, collapse import_as_names into a # bare name (matches parso's parsed shape for single-name # imports). remaining_names = [c for c in children if c.type == "name"] if len(remaining_names) == 1 and len(children) == 1: sole = children[0] sole_idx = import_from.children.index(names_node) sole.parent = import_from import_from.children[sole_idx] = sole if not remaining_names: stmt = self._stmt_for_import(import_from) if stmt is not None: _edits.remove_node(stmt) return
[docs] def names(self) -> list[tuple[str | None, str]]: """List of ``(from_, name)`` pairs in source order.""" out: list[tuple[str | None, str]] = [] for imp in self._source_tree.iter_imports(): if imp.type == "import_name": # `import_name` -> [keyword, target] # target ∈ {name, dotted_name, dotted_as_name, dotted_as_names} target = imp.children[1] if target.type == "name": out.append((None, target.value)) elif target.type == "dotted_as_name": # [name, 'as', alias]: record the original name. out.append((None, target.children[0].value)) elif target.type == "dotted_as_names": for sub in target.children: if sub.type == "name": out.append((None, sub.value)) elif sub.type == "dotted_as_name": out.append((None, sub.children[0].value)) elif target.type == "dotted_name": out.append((None, target.children[0].value)) elif imp.type == "import_from": from_module = self._import_from_module_name(imp) names_node = self._import_from_names_node(imp) if names_node is None: continue if names_node.type == "name": out.append((from_module, names_node.value)) elif names_node.type == "import_as_names": for sub in names_node.children: if sub.type == "name": out.append((from_module, sub.value)) elif sub.type == "import_as_name": out.append((from_module, sub.children[0].value)) return out
# -- internal helpers ---------------------------------------------------- def _stmt_for_import(self, import_node: NodeOrLeaf) -> NodeOrLeaf | None: """The wrapping ``simple_stmt`` for an import_name/import_from.""" return import_node.parent def _find_plain_import(self, name: str) -> NodeOrLeaf | None: for imp in self._source_tree.iter_imports(): if imp.type != "import_name": continue target = imp.children[1] if target.type == "name" and target.value == name: return self._stmt_for_import(imp) return None def _find_import_from(self, module: str) -> NodeOrLeaf | None: for imp in self._source_tree.iter_imports(): if imp.type != "import_from": continue if self._import_from_module_name(imp) == module: return imp return None @staticmethod def _import_from_module_name(import_from: NodeOrLeaf) -> str: # children: [keyword 'from', <leading-dots>?, <module-node>?, keyword 'import', names_node] # Relative imports prefix the module with one or more `.` operators, # and `from . import x` has no module node at all. children = import_from.children parts: list[str] = [] for c in children[1:]: if c.type == "keyword" and c.value == "import": break if c.type == "operator" and c.value == ".": parts.append(".") elif c.type == "name": parts.append(c.value) elif c.type == "dotted_name": parts.append("".join(sub.value for sub in c.children if hasattr(sub, "value"))) return "".join(parts) @staticmethod def _import_from_names_node(import_from: NodeOrLeaf) -> NodeOrLeaf | None: # Find the child after the 'import' keyword. children = import_from.children for i, c in enumerate(children): if c.type == "keyword" and c.value == "import": # Names node is next non-paren child. for nxt in children[i + 1 :]: if nxt.type == "operator" and nxt.value == "(": continue if nxt.type == "operator" and nxt.value == ")": continue return nxt return None def _merge_into_import_from(self, import_from: NodeOrLeaf, new_name: str) -> bool: """Merge ``new_name`` into ``import_from``'s names, sorted, and lay the line out. Everything after the ``import`` keyword is rewritten -- the brackets included -- so it is rewritten to the shape a formatter leaves: on one line while the names fit on one, and one name per line in brackets once they do not (:func:`~simvx.core.scene_io.layout.wrap_import`). A bracketed line the author left a trailing comma on keeps its line per name however short it gets, which is the answer the layout gives a hand-broken call too. Each existing name is carried across as the source it was written as, so ``sprite as spr`` survives the merge under its alias. ``False`` when there is nothing here to merge into -- ``from x import *`` names nothing this can add to -- which asks the caller for a line of its own instead. """ names_node = self._import_from_names_node(import_from) if names_node is None or names_node.type not in ("name", "import_as_name", "import_as_names"): return False existing = _import_element_texts(names_node) if new_name in [text.split(" as ", 1)[0] for text in existing]: return True elements = _children(names_node) if is_node(names_node) else [] had_trailing_comma = bool(elements) and elements[-1].type == "operator" and elements[-1].get_code() == "," module = self._import_from_module_name(import_from) new_src = wrap_import(module, sorted({*existing, new_name}), explode=had_trailing_comma) new_stmt = parse_snippet(new_src + "\n") old_cut = _index_after_import_keyword(import_from) new_import_from = _children(new_stmt)[0] if is_node(new_stmt) else new_stmt new_cut = _index_after_import_keyword(new_import_from) if old_cut is None or new_cut is None or not is_node(import_from) or not is_node(new_import_from): return False tail = _children(new_import_from)[new_cut:] for child in tail: child.parent = import_from _children(import_from)[old_cut:] = tail return True def _insert_import_line(self, new_stmt: NodeOrLeaf) -> None: """Insert a new import simple_stmt at the bottom of the existing import block (packed tightly), or at the top of the module if no imports exist.""" module = self._source_tree.module last_import_stmt: NodeOrLeaf | None = None for imp in self._source_tree.iter_imports(): stmt = imp.parent if stmt is not None and stmt.parent is module: last_import_stmt = stmt if last_import_stmt is not None: children = module.children idx = children.index(last_import_stmt) # Pack tightly: top-level statements have empty prefix and a # trailing newline in their content, so an empty prefix is the # correct place-on-the-next-line marker. _set_prefix(new_stmt, "") new_stmt.parent = module children.insert(idx + 1, new_stmt) return # No existing imports: place right at the top of the module, before # the first non-newline child. Preserve the first child's prefix on # itself (typical case: a docstring or class). top_children = module.children first_idx = 0 while first_idx < len(top_children) and top_children[first_idx].type == "newline": first_idx += 1 if first_idx >= len(top_children): return anchor = top_children[first_idx] original_prefix = _get_prefix(anchor) _set_prefix(new_stmt, original_prefix) _set_prefix(anchor, "") new_stmt.parent = module top_children.insert(first_idx, new_stmt)
# --------------------------------------------------------------------------- # Module-level helpers for ImportSet # --------------------------------------------------------------------------- def _import_element_texts(names_node: NodeOrLeaf) -> list[str]: """The imported names of an ``import`` clause, each as the source it was written as. ``a``, ``a, b`` and ``a as spr, b`` give one entry per name, the alias carried along with the name it renames: a merge that rebuilt the line from bare names alone would write the alias out of the file. """ if names_node.type in ("name", "import_as_name") or not is_node(names_node): code: str = names_node.get_code() return [code.strip()] return [child.get_code().strip() for child in _children(names_node) if child.type != "operator"] def _index_after_import_keyword(import_from: NodeOrLeaf) -> int | None: """Where an ``import_from``'s names begin: one past its ``import`` keyword. Everything from there on -- the brackets as much as the names -- is what a merge rewrites, and the ``from <module>`` in front of it is what it keeps. """ if not is_node(import_from): return None for index, child in enumerate(_children(import_from)): if child.type == "keyword" and child.get_code().strip() == "import": return index + 1 return None # --------------------------------------------------------------------------- # Module-level helpers for SceneClass # --------------------------------------------------------------------------- def _statement_line(stmt: NodeOrLeaf | None) -> NodeOrLeaf | None: """The line ``stmt`` is written on, which is what the suite holds. A statement is its own line whenever no semicolon joined it to another; ``None`` for a statement no longer in the tree, and for one that never sat in a suite. """ if stmt is None: return None return stmt if stmt.type == "simple_stmt" else enclosing_statement(stmt) def _first_line(stmt: NodeOrLeaf) -> str: """The first line ``stmt`` is written on, which is how a block is named to a reader. A block is its header (``for i in range(3):``) and a body that may run to any length, so the header is the whole of what a reader wants quoted. Its own text, not the comment or the blank line its prefix carries in front of it. A statement is quoted whole instead (:func:`_code_on_one_line`). """ code: str = stmt.get_code(include_prefix=False) for line in code.splitlines(): if line.strip(): return line.strip() return "" def _code_on_one_line(node: NodeOrLeaf) -> str: """``node`` as one line, however many the file wrote it over. A construction long enough to be wrapped is several lines in the file and one statement to a reader, so quoting it means running the code back together rather than naming its first fragment. The comments the author wrote inside it are left out, because a comment ends at its own line break and nothing else does: run onto one line beside the code, it would swallow every token that followed it, and a reader who took the quotation for the statement would be reading half of one. parso keeps a comment in the prefix of the leaf after it, so leaving it out is a matter of taking each prefix down to its whitespace. The leading prefix of ``node`` itself goes with them, as ``get_code(include_prefix=False)`` drops it. """ return " ".join( "".join( (_COMMENT.sub("", leaf.prefix) if index else "") + leaf.value for index, leaf in enumerate(_iter_leaves(node)) ).split() ) def _is_super_init_stmt(stmt: NodeOrLeaf) -> bool: """True iff ``stmt`` is ``super().__init__(...)``.""" if stmt.type != "atom_expr" or not stmt.children: return False head = stmt.children[0] if head.type != "name" or head.value != "super": return False # Expect: super, trailer(()), trailer(.__init__), trailer((...)). if len(stmt.children) < 4: return False return True def _assignment_target_name(stmt: NodeOrLeaf) -> str | None: """For an ``<name> = …`` statement return the name.""" if stmt.type != "expr_stmt" or len(stmt.children) < 3: return None target, eq = stmt.children[0], stmt.children[1] if target.type != "name": return None if getattr(eq, "type", None) != "operator" or eq.value != "=": return None return target.value def _self_attribute_name(target: NodeOrLeaf) -> str | None: """``hero`` for an assignment target written ``self.hero``, else ``None``.""" if target.type != "atom_expr" or len(target.children) != 2: return None head, trailer = target.children if head.type != "name" or head.value != "self": return None if trailer.type != "trailer" or len(trailer.children) != 2: return None if trailer.children[0].value != ".": return None return str(trailer.children[1].value) def _bound_names(stmt: NodeOrLeaf) -> list[str]: """The bindings a statement establishes, attributes spelled ``self.<name>``. An assignment binds its target, a local (``label_0 = Label()``) or an attribute (``self.label = Label()``). An ``add_child`` call binds nothing, but ``child_0.add_child(label_0)`` is the only statement in the file saying that ``label_0`` belongs to ``child_0``, so for the purpose of taking a child's statements out together it counts as one: removing that line is what makes ``label_0``'s own statements unreachable. """ out: list[str] = [] value: NodeOrLeaf | None = stmt if stmt.type == "expr_stmt": target = stmt.children[0] if target.type == "name": out.append(str(target.value)) else: attribute = _self_attribute_name(target) if attribute is not None: out.append(f"self.{attribute}") value = _assigned_value(stmt) if value is not None: out.extend(_added_child_names(value)) return out def _bound_locals_and_attributes(stmt: NodeOrLeaf) -> tuple[set[str], set[str]]: """What a statement binds, the locals and the attribute names apart. The two the removal sweep needs to start from, since a statement standing on a name is looked up by which of the two kinds it is (:func:`_stands_on`). """ bound = _bound_names(stmt) locals_ = {name for name in bound if not name.startswith("self.")} attributes = {name.removeprefix("self.") for name in bound if name.startswith("self.")} return locals_, attributes def _added_child_names(expr: NodeOrLeaf) -> list[str]: """The variable a ``<node>.add_child(<var>)`` expression adds, if it names one.""" if getattr(expr, "type", None) != "atom_expr" or len(expr.children) < 3: return [] trailers = expr.children[1:] for dot, call in zip(trailers, trailers[1:], strict=False): if dot.type != "trailer" or len(dot.children) != 2 or dot.children[0].value != ".": continue if dot.children[1].value != "add_child": continue if call.type != "trailer" or len(call.children) != 3 or call.children[0].value != "(": continue argument = call.children[1] return [str(argument.value)] if argument.type == "name" else [] return [] def _first_bindings(statements: list[NodeOrLeaf]) -> dict[tuple[str, str], int]: """Where each name these statements bind is first bound, by position. A name means nothing here until a line binds it: above that line the word belongs to whatever else is in scope, which in ``__init__`` is a parameter (``super().__init__(**kwargs)`` reads the parameter, whatever a later line may bind ``kwargs`` to). So a removal sweeping out a name has no claim on any line above the one that binds it, and this says which line that is. """ first: dict[tuple[str, str], int] = {} for index, stmt in enumerate(statements): for name in _bound_names(stmt): key = ("attribute", name.removeprefix("self.")) if name.startswith("self.") else ("local", name) first.setdefault(key, index) return first def _stands_on( stmt: NodeOrLeaf, variables: set[str], attributes: set[str], *, index: int | None = None, bound_at: dict[tuple[str, str], int] | None = None, ) -> bool: """Does this statement name a binding that is being removed? Both directions count, because both stop working the moment the binding goes: a statement reading the name (``child_0.add_child(label_0)``) and the one establishing it (``child_0 = Panel()``) alike. ``index`` is where the statement sits in ``__init__``'s body and ``bound_at`` where each name is first bound (:func:`_first_bindings`); given both, a statement above the line that binds a name is not standing on it, because up there the name is not that binding at all. A caller with no position to offer -- a block, which runs its lines in an order of its own -- asks the question without them, and every mention counts. """ for kind, name in _named_bindings(stmt): if name not in (variables if kind == "local" else attributes): continue if index is None or bound_at is None or index >= bound_at.get((kind, name), 0): return True return False def _named_bindings(stmt: NodeOrLeaf) -> Iterator[tuple[str, str]]: """Every binding a statement names, as ``("local", name)`` or ``("attribute", name)``.""" for kind, node in _binding_leaves(stmt): yield kind, (node.value if kind == "local" else str(node.children[1].value)) def _binding_leaves(stmt: NodeOrLeaf) -> Iterator[tuple[str, NodeOrLeaf]]: """Where each binding a statement names is written. ``("local", <name leaf>)`` for a name standing on its own, and ``("attribute", <the ``.name`` trailer>)`` for ``self.<name>``. Two names that look the same are neither, and are skipped: the one after a dot, which belongs to whatever precedes it rather than to any binding here, and the one before the ``=`` of a keyword argument, which is a parameter of the callee. """ if isinstance(stmt, Leaf): if stmt.type == "name": yield "local", stmt return if stmt.type == "trailer" and len(stmt.children) == 2 and stmt.children[0].value == ".": return if stmt.type == "argument" and _argument_name(stmt) is not None: yield from _binding_leaves(stmt.children[2]) return if stmt.type == "atom_expr" and len(stmt.children) >= 2: head, trailer = stmt.children[0], stmt.children[1] names_an_attribute = ( head.type == "name" and head.value == "self" and trailer.type == "trailer" and len(trailer.children) == 2 and trailer.children[0].value == "." ) if names_an_attribute: yield "attribute", trailer for child in stmt.children[2:]: yield from _binding_leaves(child) return for child in stmt.children: yield from _binding_leaves(child) def _assigned_value(expr: NodeOrLeaf) -> NodeOrLeaf | None: """The expression an ``expr_stmt`` assigns, or ``None`` when it assigns nothing. Both spellings of an assignment reach here. The plain one lays its targets and its value out side by side with ``=`` between them, so the value is the last child. The annotated one (``self.hero: Sprite2D = Sprite2D()``) folds the annotation and the value into a single ``annassign`` child, whose own last child is the value -- when there is one, since an annotation may stand alone (``hero: Sprite2D``) and then nothing is assigned. Anything else, including augmented assignment, gets ``None``. """ children = expr.children if len(children) > 1 and children[1].type == "annassign": annassign = children[1].children if len(annassign) < 4 or getattr(annassign[2], "value", None) != "=": return None return annassign[-1] if len(children) < 3 or getattr(children[1], "value", None) != "=": return None return children[-1] def _add_child_var_name(stmt: NodeOrLeaf, *, receiver: str | None = "self") -> str | None: """If ``stmt`` is ``<receiver>.add_child(<var>)`` return ``<var>`` else None. Only the bare call binds a var name this class can edit through: an assignment shape (``self.hero = self.add_child(hero)``) keeps a second reference to the child that a rename or a removal would have to follow, so it is not offered as one of :meth:`SceneClass.child_var_names`. ``receiver`` names the variable the call is written on, and ``None`` asks the question of any receiver (:func:`_statement_add_child_argument`). A whole line may be passed as well as a single statement, and a semicolon on it is read through, for the reason :func:`_add_child_argument` gives. """ for inner in line_statements(stmt): if inner.type != "atom_expr": continue argument = _statement_add_child_argument(inner, receiver=receiver) if argument is not None and argument.type == "name": return str(argument.value) return None def _add_child_receiver(stmt: NodeOrLeaf) -> str | None: """The local an ``add_child`` statement is written on, or ``None`` for anything else. The other half of :func:`_add_child_argument`'s ``receiver=None``: that says an ``add_child`` on some receiver was found, this says which. A reader that wants every child a file builds, grouped by the node each is added to, needs both and would otherwise ask the same question once per candidate receiver. """ for inner in line_statements(stmt): node = inner if node.type == "expr_stmt": assigned = _assigned_value(node) if assigned is None: continue node = assigned if _statement_add_child_argument(node, receiver=None) is None: continue return str(node.children[0].value) return None def _add_child_argument(stmt: NodeOrLeaf, *, receiver: str | None = "self") -> NodeOrLeaf | None: """The single expression a ``self.add_child(...)`` statement adds, else ``None``. All three shapes an author writes are recognised: the bare call, the call whose result is assigned to keep a reference to the child (``self.hero = self.add_child(Hero())``), and that same assignment carrying an annotation (``self.hero: Sprite2D = self.add_child(Hero())``), which is one statement to a reader and must be one child here too: a shape that goes unrecognised is a child the round trip writes out again, and the file then builds it twice. What comes back is whatever they put between the parentheses -- a name, a constructor call, a factory call -- and reading it is the caller's business. It is one parso node, which is not the same as one argument: a comma-separated list arrives folded into a single ``arglist``, so a caller that cares must look at what it was handed. ``None`` for a call passing nothing, and for anything that is not an ``add_child`` call on ``receiver``. ``receiver`` names the variable the call is written on. ``"self"``, the default, is the root's own children, which is the depth the editor writes; ``None`` asks the question of any receiver, so ``panel.add_child(label)`` is seen too. A reader restricted to ``self`` cannot see a grandchild at all, which is why the wider question exists. ``stmt`` may be a whole line or a single statement. A semicolon writes several statements on one line and parso folds them into one node, so a line is read through to whichever of its statements adds a child: one that stopped at the first would take an ``add_child`` written after a semicolon for no child at all, and the round trip would build that child a second time on every save. """ for inner in line_statements(stmt): argument = _statement_add_child_argument(inner, receiver=receiver) if argument is not None: return argument return None def _statement_add_child_argument(stmt: NodeOrLeaf, *, receiver: str | None = "self") -> NodeOrLeaf | None: """:func:`_add_child_argument` for one statement, with no line to read through. The receiver has to be a plain name. A call written on an attribute (``self.body.add_child(label)``) names a child the file keeps a second reference to, and nothing here can edit through it, so it is not an ``add_child`` this reader reports whatever ``receiver`` asks for. """ inner = stmt if inner.type == "expr_stmt": assigned = _assigned_value(inner) if assigned is None: return None inner = assigned if inner.type != "atom_expr" or len(inner.children) < 3: return None head = inner.children[0] if head.type != "name": return None if receiver is not None and head.value != receiver: return None dot_trailer = inner.children[1] if dot_trailer.type != "trailer" or len(dot_trailer.children) < 2: return None if dot_trailer.children[0].value != ".": return None method_name = dot_trailer.children[1] if getattr(method_name, "value", None) != "add_child": return None call_trailer = inner.children[2] if call_trailer.type != "trailer" or len(call_trailer.children) < 3: return None if call_trailer.children[0].value != "(" or call_trailer.children[-1].value != ")": return None inner_args = call_trailer.children[1:-1] if len(inner_args) != 1: return None return inner_args[0] def _arglist_arguments(trailer: NodeOrLeaf): """Yield ``argument`` nodes inside a call trailer. Mirrors :func:`edits._iter_arguments` but kept local to scene_file so SceneClass operations don't depend on private edits helpers. """ inner = trailer.children[1:-1] if not inner: return if len(inner) == 1: node = inner[0] if node.type == "argument": yield node return if node.type == "arglist": for c in node.children: if c.type == "argument": yield c return return for c in inner: if c.type == "argument": yield c def _argument_name(arg: NodeOrLeaf) -> str | None: if not hasattr(arg, "children") or len(arg.children) < 3: return None name_node, eq = arg.children[0], arg.children[1] if name_node.type != "name" or getattr(eq, "type", None) != "operator" or eq.value != "=": return None return name_node.value def _remove_argument(arg: NodeOrLeaf) -> None: """Remove an ``argument`` from its enclosing arglist or single-arg trailer. The statement is laid out again afterwards, so a call that no longer needs the lines it was written across comes back together (:func:`~simvx.core.scene_io.edits.relayout_statement`) -- unless its arguments still end in a comma, which is how a file says to keep them apart. """ parent = arg.parent if parent is None: raise ValueError("_remove_argument: argument has no parent") # Asked for while the argument is still in the tree: taking it out is what # cuts the path this answer is read off. statement = enclosing_statement(arg) if parent.type == "arglist": children = parent.children idx = children.index(arg) # Remove the argument and one neighbouring comma to keep arglist # well-formed. if idx + 1 < len(children) and children[idx + 1].type == "operator" and children[idx + 1].value == ",": del children[idx : idx + 2] elif idx > 0 and children[idx - 1].type == "operator" and children[idx - 1].value == ",": del children[idx - 1 : idx + 1] else: del children[idx] # If only one argument remains, collapse arglist back to that arg. remaining_args = [c for c in children if c.type == "argument"] if len(remaining_args) == 1 and len(children) == 1: sole = children[0] grandparent = parent.parent if grandparent is not None: gp_children = grandparent.children gp_idx = gp_children.index(parent) sole.parent = grandparent sole.get_first_leaf().prefix = parent.get_first_leaf().prefix gp_children[gp_idx] = sole if statement is not None: relayout_statement(statement) return # Single-argument trailer: parent is the trailer. if parent.type == "trailer": children = parent.children idx = children.index(arg) del children[idx] if statement is not None: relayout_statement(statement) return raise ValueError(f"_remove_argument: unexpected parent {parent.type}") def _first_positional_value(trailer: NodeOrLeaf) -> NodeOrLeaf | None: """First positional argument value inside a call trailer, else None.""" inner = trailer.children[1:-1] if not inner: return None if len(inner) == 1: node = inner[0] if node.type == "argument": # Could be keyword; treat name=value as not positional. if len(node.children) >= 3 and getattr(node.children[1], "value", None) == "=": return None return node if node.type == "arglist": for c in node.children: if c.type == "argument": if len(c.children) >= 3 and getattr(c.children[1], "value", None) == "=": return None return c if c.type == "operator" and c.value == ",": continue return c return None return node return None