Source code for simvx.core.scene_io.scene_file

"""High-level scene-shaped editing surface over a parsed source file.

This is Tier 3b of the scene I/O layer. It composes the statement-level
substrate (:mod:`~simvx.core.scene_io.source`) 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 io
import re
import tokenize
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple

from ..io import atomic_write_text
from .detection import primary_node_class_from_source
from .emitter import emit_scene
from .layout import wrap_statement
from .source import (
    Anchor,
    Argument,
    CallView,
    ClassDecl,
    SceneSyntaxError,
    SourceDocument,
    Statement,
    StatementKind,
    parse,
    rename_name,
)
from .syntax import refusal

if TYPE_CHECKING:
    from ..node import Node


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


def _parse_or_refuse(text: str, *, subject: str) -> SourceDocument:
    """Parse ``text``, refusing what CPython refuses and nothing else.

    Any Python the interpreter runs opens here, because the source layer reads
    through the interpreter's own parser. Source it will not compile raises
    :class:`~simvx.core.scene_io.syntax.UnsupportedSceneSyntaxError` carrying
    CPython's message, line and caret column: there is nothing to show a reader
    of a file that does not run, and the parser's own words are what tells them
    how to fix it.
    """
    try:
        return parse(text)
    except SceneSyntaxError as error:
        raise refusal(error, subject=subject) from error


[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 source document plus the original on-disk text. All edits go through the document (:mod:`~simvx.core.scene_io.source`) and :meth:`save` writes what it dumps. Round-trip identity is guaranteed when no edits were made. """ __slots__ = ("_document", "_path", "_imports", "_original_text") def __init__(self, document: SourceDocument, *, path: Path | None) -> None: self._document = document self._path = path self._original_text = document.original_text self._imports = ImportSet(document) # -- constructors --------------------------------------------------------
[docs] @classmethod def load(cls, path: str | Path) -> SceneFile: """Read ``path`` and parse it. Any Python the interpreter runs opens here. Source it will not compile raises :class:`~simvx.core.scene_io.syntax.UnsupportedSceneSyntaxError` carrying the parser's own message and position. Raises :class:`FileNotFoundError` when the file is absent. """ p = Path(path) text = p.read_text(encoding="utf-8") return cls(_parse_or_refuse(text, subject=str(p)), 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. """ subject = str(path) if path is not None else "this source" return cls(_parse_or_refuse(text, subject=subject), 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 document(self) -> SourceDocument: """The source document every edit goes through.""" return self._document
[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.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}") decl = self._document.find_class(name) if decl is None: # Detection found a class via ast walk (which descends into # nested scopes); the document's 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, decl)
[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.dump()) except SyntaxError: return [] names = [ node.name for node in ast.iter_child_nodes(ast_tree) if isinstance(node, ast.ClassDef) and _is_scene_class(node) ] return [SceneClass(self, decl) for name in names if (decl := self._document.find_class(name)) is not None]
# -- 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._document.find_class(name) is not None: raise ValueError(f"top-level class {name!r} already exists") self._imports.ensure(base, from_="simvx.core") written = f"class {name}({base}):\n {body}\n" statements = self._document.top_level_statements() anchor = self._anchor_statement(before) if anchor is not None: # Above the anchor, which means below whatever the module writes in # front of it; the two blank lines a class is owed are written here # because only a caller inserting a class knows it is owed them. above = statements.index(anchor) - 1 if above < 0: self._document.insert_top_level(f"{written}\n\n", after=None) else: self._document.insert_top_level(f"\n\n{written}", after=statements[above]) elif statements: self._document.insert_top_level(f"\n\n{written}", after=statements[-1]) else: self._document.insert_top_level(written, after=None) decl = self._document.find_class(name) if decl is None: # pragma: no cover - the class was just written into the module raise RuntimeError(f"insert_top_level_class: failed to locate {name!r} after insertion") return SceneClass(self, decl)
def _anchor_statement(self, before: str | None) -> Statement | None: """The statement a new top-level class goes above, or ``None`` for none. The class named by ``before`` when one is named, and otherwise the first class the file declares, which is where a class the scene uses has to be written. ``None`` when the file declares no class at all. """ classes = self._document.top_level_classes() if before is None: return None if not classes else classes[0].statement anchor = self._document.find_class(before) if anchor is None: raise ValueError(f"anchor class {before!r} not found") return anchor.statement # -- serialisation -------------------------------------------------------
[docs] def dump(self) -> str: """Current source as a string.""" return self._document.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", "_name") def __init__(self, file: SceneFile, decl: ClassDecl) -> None: self._file = file self._name = decl.name # -- identity ------------------------------------------------------------
[docs] @property def name(self) -> str: """Class name (e.g. ``"Arena"``).""" return self._name
@property def _decl(self) -> ClassDecl: """This class as the source seam sees it. Resolved on each use rather than held, because it is a view and costs nothing: what carries state across an edit is the document, and that is the file's. """ decl = self._file._document.find_class(self.name) if decl is None: # pragma: no cover - the node this view was built on is in the tree raise ValueError(f"{self.name} is no longer declared at the top level of this file") return decl
[docs] def set_base(self, old: str, new: str) -> None: """Write ``new`` where the class derives from ``old``. ``old`` is the base as the header spells it and ``new`` replaces exactly that: the rest of the header, and the rest of the file, are left as the author wrote them. What a caller owes besides this is the import ``new`` needs and the removal of the one ``old`` no longer does (:class:`ImportSet`). Raises :class:`ValueError` when the class does not derive from ``old``. """ self._decl.set_base(old, new)
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.line_group()[0].column() for stmt in self._decl.init_statements()] return min(columns) if columns else None # -- 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. """ default = self._property_default(name) return None if default is None else default.value.strip()
[docs] def add_property(self, name: str, default_expr: str) -> None: """Insert ``name = Property(default_expr)`` into the class body. Inserted after the class's existing Property declarations, or at the top of its body when it has none. Auto-imports ``Property`` via the file's :class:`ImportSet`. The declaration is written at the indentation of whatever it is anchored on -- the last Property, or the class's first statement -- so the first Property a class is given lands in the body rather than at column zero (:meth:`~simvx.core.scene_io.source.ClassDecl.insert`). """ if self.has_property(name): raise ValueError(f"property {name!r} already declared on {self.name}") self._file.imports.ensure("Property", from_="simvx.core") anchor: Statement | Anchor = Anchor.BODY_HEAD for stmt in self._decl.body_statements(): if _declares_a_property(stmt): anchor = stmt self._decl.insert(f"{name} = Property({default_expr})", after=anchor)
[docs] def remove_property(self, name: str) -> None: stmt = self._find_class_property(name) if stmt is None: raise ValueError(f"property {name!r} not declared on {self.name}") self._decl.remove(stmt)
[docs] def set_property_default(self, name: str, default_expr: str) -> None: """Write ``default_expr`` where the Property's default is. A ``Property()`` declared without one gets the default written in front of whatever else it passes, since a positional argument cannot follow a keyword and that is where the default belongs anyway. """ stmt = self._find_class_property(name) if stmt is None: raise ValueError(f"property {name!r} not declared on {self.name}") call = stmt.call() if call is None: # pragma: no cover - `_find_class_property` matched a call raise ValueError(f"property {name!r} on {self.name} is not declared as a call") if self._property_default(name) is not None: call.set_positional(0, default_expr) return rest = ", ".join(_argument_source(argument) for argument in call.arguments()) stmt.set_value(f"{call.callee()}({default_expr}{', ' + rest if rest else ''})")
# -- root super().__init__ kwargs ---------------------------------------
[docs] def get_root_kwarg(self, name: str) -> str | None: call = self._super_init_call() return None if call is None else call.kwarg(name)
[docs] def set_root_kwarg(self, name: str, value_expr: str) -> None: """Update or insert a kwarg in the root ``super().__init__(...)`` call.""" call = self._super_init_call() if call is None: raise ValueError(f"{self.name}.__init__ has no super().__init__() call to edit") call.set_kwarg(name, value_expr)
[docs] def remove_root_kwarg(self, name: str) -> None: call = self._super_init_call() if call is None: raise ValueError(f"{self.name}.__init__ has no super().__init__() call") if not _remove_call_kwarg(call, name): 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. anchor: Statement | Anchor | None above = False if before is not None: anchor = self._find_child_assignment(before) if anchor is None: raise ValueError(f"child {before!r} not found") above = True 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 = Anchor.INIT_HEAD 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 = Anchor.INIT_HEAD 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. indent = self._anchor_indent(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) add_child_src = wrap_statement(f"{receiver}.add_child({var_name})", indent=indent) if above: assignment = self._decl.insert_before(assignment_src, before=anchor) # type: ignore[arg-type] else: assignment = self._decl.insert(assignment_src, after=anchor) self._decl.insert(add_child_src, after=assignment)
def _anchor_indent(self, anchor: Statement | Anchor) -> int: """The column an insertion on ``anchor`` lands at, which is its line's. A statement a semicolon put in the middle of a line carries a space for its leading trivia, which says nothing about where the line starts, so the question is asked of whatever leads the line. An insertion with no statement to anchor on lands at the column ``__init__``'s own body is written at. """ if isinstance(anchor, Anchor): return self._body_indent() or 0 return len(_indent_run(anchor.line_group()[0].leading_trivia()))
[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: Statement) -> 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: Statement) -> 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[Statement]: """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: Statement) -> list[Statement]: """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 stmt in doomed else [stmt, *doomed] def _sweep_standing_on( self, variables: set[str], attributes: set[str] ) -> tuple[list[Statement], 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[Statement] = [] 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[Statement], also: Statement | 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) 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 = set(own) statements = [_code_on_one_line(stmt) for stmt in doomed if stmt not in spoken_for] decl = self._decl if also is not None and also not in doomed: decl.remove(also) for stmt in doomed: decl.remove(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__") rename_name(self._decl.init_statements(), old, new)
[docs] def get_child_kwarg(self, var_name: str, kwarg: str) -> str | None: call = self._child_ctor_call(var_name) return None if call is None else call.kwarg(kwarg)
[docs] def set_child_kwarg(self, var_name: str, kwarg: str, value_expr: str) -> None: call = self._child_ctor_call(var_name) if call is None: raise ValueError(f"child {var_name!r} not found") call.set_kwarg(kwarg, value_expr)
[docs] def remove_child_kwarg(self, var_name: str, kwarg: str) -> None: call = self._child_ctor_call(var_name) if call is None: raise ValueError(f"child {var_name!r} not found") if not _remove_call_kwarg(call, kwarg): 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 groups = self._child_groups(receiver=receiver) self.reorder_children_by_statement([groups[var] for var in order], receiver=receiver)
[docs] def reorder_children_by_statement(self, order: list[list[Statement]], *, receiver: str = "self") -> None: """Reorder ``receiver``'s children so their ``add_child`` statements run in ``order``. ``order`` holds one group per child, in the order the file should add them. A group is the statements that move together -- the construction the file binds to a variable, where there is one, and the ``add_child`` line itself -- and between them the groups must hold every ``add_child`` statement written on ``receiver`` exactly once. **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. Every moved line carries the comments and blank lines written above it, except that whatever stood above the block stays at the top of it. A statement of the author's written *between* two children still 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. """ in_source_order = [ stmt for stmt in self._init_statements() if _add_child_argument(stmt, receiver=receiver) is not None ] groups = [list(group) for group in order] requested = [_the_add_child_in(group, in_source_order) for group in groups] if len(requested) != len(in_source_order) or set(requested) != set(in_source_order): raise ValueError("reorder_children_by_statement: order does not match the statements written on receiver") if requested == in_source_order: return _refuse_shared_lines(groups) self._decl.reorder(groups)
def _child_group_of(self, add_child: Statement, *, receiver: str) -> list[Statement]: """The statements that move with one ``add_child`` line, as seam handles. The construction the file binds the child to, where it binds one, and the ``add_child`` line itself. A child the file constructs inside the call has only the one line, and moving it is as safe as moving a pair. """ group: list[Statement] = [] var = _add_child_var_name(add_child, receiver=receiver) if var is not None: assignment = self._find_child_assignment(var) if assignment is None: raise ValueError(f"child {var!r} structurally inconsistent during reorder") group.append(assignment) group.append(add_child) return group def _child_groups(self, *, receiver: str) -> dict[str, list[Statement]]: """Each of ``receiver``'s named children, mapped to the statements that move with it.""" out: dict[str, list[Statement]] = {} for var in self.child_var_names(receiver=receiver): 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") out[var] = self._child_group_of(add_child, receiver=receiver) return out # -- internal helpers ---------------------------------------------------- def _init_statements(self) -> list[Statement]: """The statements of ``__init__``'s body, a semicolon-joined line counting as several. What a line *does* is asked of this rather than of the lines themselves, 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. A statement carrying a block of its own is not one of these: what a block does is read but never rewritten, so it is asked for separately (:meth:`_init_blocks`). """ return [stmt for stmt in self._decl.init_statements() if stmt.kind() is not StatementKind.COMPOUND] def _init_blocks(self) -> list[Statement]: """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 the body 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`). """ return [stmt for stmt in self._decl.init_statements() if stmt.kind() is StatementKind.COMPOUND] 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 stmt in self._init_statements(): if stmt.shares_line() and _add_child_argument(stmt, receiver=receiver) is not None: return True for var in self.child_var_names(receiver=receiver): assignment = self._find_child_assignment(var) if assignment is not None and assignment.shares_line(): return True return False def _find_class_property(self, name: str) -> Statement | None: """The class-body statement declaring ``name = Property(...)``, or ``None``. One plain target and a ``Property`` construction: an annotated declaration and an unpacking are neither the shape the editor writes nor one it edits through. """ for stmt in self._decl.body_statements(): if _declares_a_property(stmt) and _assignment_target_name(stmt) == name: return stmt return None def _property_default(self, name: str) -> Argument | None: """The default a declared Property passes, or ``None`` when it passes none. The first thing the construction passes, and only when it is passed by position: a ``Property(kind="int")`` declares no default, and reading its keyword as one would report a value the runtime never sees. """ stmt = self._find_class_property(name) call = None if stmt is None else stmt.call() if call is None: return None arguments = call.arguments() if not arguments or arguments[0].name is not None or arguments[0].is_unpacking: return None return arguments[0] def _super_init_stmt(self) -> Statement | None: for stmt in self._init_statements(): if _is_super_init_stmt(stmt): return stmt return None def _super_init_call(self) -> CallView | None: """The root's own ``super().__init__(...)`` call, or ``None`` when it has none.""" stmt = self._super_init_stmt() return None if stmt is None else stmt.call() def _find_child_assignment(self, var_name: str) -> Statement | 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) -> Statement | 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") -> Statement | 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 _child_ctor_call(self, var_name: str) -> CallView | None: """The construction ``<var_name>`` is bound to, when the file binds it to a call. ``None`` when the file binds no such variable, and when what it binds it to is not a call at all (``panel = POOL[0]``): there is nothing there to pass an argument to. """ assignment = self._find_child_assignment(var_name) return None if assignment is None else assignment.call()
# --------------------------------------------------------------------------- # ImportSet # ---------------------------------------------------------------------------
[docs] class ImportSet: """Editable view of the file's top-level imports. The names a scene file imports, read and written as names rather than as text: what a line looks like once a name has been merged into it or taken out of it is the source layer's business (:meth:`~simvx.core.scene_io.source.SourceDocument.ensure_import`). """ __slots__ = ("_document",) def __init__(self, document: SourceDocument) -> None: self._document = document
[docs] def has(self, name: str, *, from_: str | None = None) -> bool: return any(imported == name and module == from_ for module, imported in self.names())
[docs] def has_any_alias(self, name: str) -> bool: """True iff ``name`` is imported from anywhere (any module).""" return any(imported == name for _module, imported in self.names())
[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 self._document.ensure_import(name, from_=from_)
[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_``). """ self._document.remove_import(name, from_=from_)
[docs] def names(self) -> list[tuple[str | None, str]]: """List of ``(from_, name)`` pairs in source order. A plain ``import a.b`` is reported under ``a``, which is the name it binds and the one a caller asking whether the file imports something already has. """ return [ (decl.module, imported.name if decl.module is not None else imported.name.partition(".")[0]) for decl in self._document.imports() for imported in decl.names ]
# --------------------------------------------------------------------------- # Module-level helpers for SceneClass # --------------------------------------------------------------------------- def _refuse_shared_lines(groups: list[list[Statement]]) -> None: """Refuse a move whose lines are not the moving children's alone. A semicolon can put a statement of the author's on a child's line, and that line cannot follow the child without taking the statement along. The mover works in whole lines, so this is what it cannot do rather than something it could do carefully (:meth:`SceneClass._children_share_lines`). """ for group in groups: for stmt in group: if any(other not in group for other in stmt.line_group()): raise ValueError(f"reorder_children: child `{stmt.text()}` shares a line with another statement") def _the_add_child_in(group: list[Statement], adds: list[Statement]) -> Statement: """The one statement of ``group`` that adds a child to the receiver being reordered. A group is a child's own lines, so exactly one of them is the line that parents it; a group holding none names no child to place, and one holding two asks for a single position for two of them. """ found = [stmt for stmt in group if stmt in adds] if len(found) != 1: raise ValueError( f"reorder_children_by_statement: a group holds {len(found)} of the add_child statements " "written on the receiver, and each group is one child's lines" ) return found[0] def _declares_a_property(stmt: Statement) -> bool: """Is this class-body statement a ``<name> = Property(...)`` declaration? What :meth:`SceneClass.add_property` anchors the next declaration on, so the file keeps its Properties together. Read through the seam rather than off the class body's text, which is why an annotated declaration counts here too. """ if stmt.kind() is not StatementKind.ASSIGNMENT: return False call = stmt.call() return call is not None and call.callee() == "Property" def _indent_run(trivia: str) -> str: """The whitespace a statement's leading trivia ends in, which is its indent. Trivia runs from the previous line's terminator to the statement's first character, so what stands after its last newline is the line the statement opens; the indent is that line's opening whitespace. A comment on that line would have ended it, so only the final whitespace run counts. """ tail = trivia.rsplit("\n", 1)[-1] return tail[len(tail.rstrip(" \t")) :] def _first_line(stmt: Statement) -> 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`). """ for line in stmt.text().splitlines(): if line.strip(): return line.strip() return "" def _code_on_one_line(source: Statement | str) -> str: """``source`` 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. A statement is quoted from its own source, and an expression written inside one -- the construction a file passes to ``add_child`` -- from the source of that expression. 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. """ text = source if isinstance(source, str) else source.text() return " ".join((_without_comments(text) if "#" in text else text).split()) def _without_comments(source: str) -> str: """``source`` with each comment cut back to where it opened. Which ``#`` opens a comment and which sits inside a string is a question for the tokeniser rather than for a search, so the answer is read off the tokens. A comment runs to the end of its line and no further, which is why cutting the line at the ``#`` is the whole of the work. Source the tokeniser cannot finish keeps whatever was established before it stopped: this is quoting a statement for a message, and a message is not worth an exception. """ lines = source.splitlines() try: for token in tokenize.generate_tokens(io.StringIO(source).readline): if token.type == tokenize.COMMENT: row, column = token.start lines[row - 1] = lines[row - 1][:column] except tokenize.TokenError, IndentationError, SyntaxError: pass return "\n".join(lines) def _is_super_init_stmt(stmt: Statement) -> bool: """True iff ``stmt`` is ``super().__init__(...)``. The call itself, written for its effect. An assignment whose value happens to be that call is a statement binding something, and taking it for the root's own construction would leave the binding behind. """ if stmt.kind() is not StatementKind.EXPRESSION: return False call = stmt.call() if call is None: return False callee = _without_spaces(call.callee()) return callee.startswith("super(") and callee.endswith(").__init__") #: An assignment written to one plain target, which is the only shape a child #: variable is bound in. An annotation puts a ``:`` before the ``=`` and an #: unpacking puts a comma or a bracket there, so neither matches, and neither is #: a child this layer edits through. _PLAIN_TARGET = re.compile(r"([^\W\d]\w*)\s*=(?!=)") def _assignment_target_name(stmt: Statement) -> str | None: """For an ``<name> = …`` statement return the name.""" if stmt.kind() is not StatementKind.ASSIGNMENT: return None match = _PLAIN_TARGET.match(stmt.text()) return None if match is None else match.group(1) def _bound_names(stmt: Statement) -> 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. """ return sorted(stmt.bound_names()) + _added_child_names(stmt) def _bound_locals_and_attributes(stmt: Statement) -> 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(stmt: Statement) -> list[str]: """The variable a ``<node>.add_child(<var>)`` statement adds, if it names one. Asked of any receiver and of any depth of one (``self.body.add_child(x)`` counts), because the question is what the statement establishes rather than what this layer can edit through. """ call = stmt.call() if call is None or not _without_spaces(call.callee()).endswith(".add_child"): return [] arguments = call.arguments() if len(arguments) != 1 or arguments[0].name is not None: return [] return [arguments[0].value] if arguments[0].value.isidentifier() else [] def _first_bindings(statements: list[Statement]) -> 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: Statement, 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 mentioned in stmt.mentioned_names(): attribute = mentioned.startswith("self.") kind, name = ("attribute", mentioned.removeprefix("self.")) if attribute else ("local", mentioned) 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 _add_child_var_name(stmt: Statement, *, 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:`_add_child_call`). """ if stmt.kind() is not StatementKind.EXPRESSION: return None argument = _add_child_argument(stmt, receiver=receiver) if argument is None or argument.name is not None: return None return argument.value if argument.value.isidentifier() else None def _add_child_receiver(stmt: Statement) -> 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. """ if _add_child_argument(stmt, receiver=None) is None: return None call = stmt.call() return None if call is None else _without_spaces(call.callee()).rpartition(".")[0] def _add_child_argument(stmt: Statement, *, receiver: str | None = "self") -> Argument | None: """The single argument a ``<receiver>.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. ``None`` for a call passing nothing or passing several things, neither of which adds one child, 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. """ call = _add_child_call(stmt, receiver=receiver) if call is None: return None arguments = call.arguments() return arguments[0] if len(arguments) == 1 else None def _add_child_call(stmt: Statement, *, receiver: str | None = "self") -> CallView | None: """The ``<receiver>.add_child(...)`` call ``stmt`` writes, or ``None`` for anything else. 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. """ call = stmt.call() if call is None: return None head, dot, method = _without_spaces(call.callee()).rpartition(".") if not dot or method != "add_child" or not head.isidentifier(): return None if receiver is not None and head != receiver: return None return call def _remove_call_kwarg(call: CallView, name: str) -> bool: """Stop ``call`` passing ``name``; ``False`` when it passes no such argument. The refusal is the caller's to word: which child, or which call, is missing the argument is what a reader needs told, and only the caller knows. """ if not any(argument.name == name for argument in call.arguments()): return False call.remove_kwarg(name) return True def _without_spaces(text: str) -> str: """``text`` with every run of whitespace taken out. What a callee is compared against: ``self.add_child`` and ``self . add_child`` call the same method, and the file is entitled to write either. """ return "".join(text.split()) def _argument_source(argument: Argument) -> str: """One argument as a call would write it, keyword and all.""" return argument.value if argument.name is None else f"{argument.name}={argument.value}"