Source code for simvx.core.scene_io.symbols

"""Where a class is defined, where it is used, and how to rename it.

This module is the foundation for cross-file class rename. It is stateless and
does no I/O: every function takes the source text of one module and either
answers a question about it or returns the rewritten text. Nothing here holds a
parse tree, so a caller can hand it text straight off disk and compare what it
gets back byte for byte.

Discovery is a walk of the standard library's own parse tree, so the constructs
it understands are the ones the running Python understands. Rewriting never
regenerates source: the spans of the identifiers that move are spliced and every
other byte of the file is the author's, which is what makes an idempotent rename
byte-identical rather than merely equivalent.

Use-site classification is purely syntactic, with no name resolution: an aliased
import does not propagate to its alias, and a plain rebinding of the name in a
block suppresses the uses below it in that block.
"""

from __future__ import annotations

import ast
import re
from dataclasses import dataclass
from typing import Literal

from .source import SceneSyntaxError
from .source._source_ast.spans import (
    LineTable,
    Span,
    iter_child_statements,
    node_span,
    statement_span,
)

__all__ = [
    "ClassDefRef",
    "UseSiteRef",
    "find_class_definitions",
    "find_class_uses",
    "rename_class_in_source",
    "rename_module_in_imports",
]


UseKind = Literal[
    "import",
    "import_alias",
    "base_class",
    "instantiation",
    "isinstance_arg",
    "annotation",
    "bare_reference",
]


[docs] @dataclass(frozen=True) class ClassDefRef: """A top-level class definition discovered in a module's source. A caller moving the class to another file wants :attr:`leading_trivia` and :attr:`text` together: the comment an author wrote above a class describes that class and has to travel with it, and it is held separately only so that a caller listing classes is not handed text that is not the class. """ name: str bases: tuple[str, ...] line: int #: The class's own source, decorators included and indentation excluded. text: str #: Everything written between the statement above and the class: the blank #: lines and the whole-line comments, verbatim. A comment written after the #: code on the previous statement's own line belongs to that statement and #: is not part of this. Empty when the class follows directly. leading_trivia: str = ""
[docs] @dataclass(frozen=True) class UseSiteRef: """A site where a class name is referenced. :attr:`line` and :attr:`column` locate the identifier itself, in the editor's convention: the line is 1-indexed and the column counts characters from 0. For an aliased import (``from x import Foo as Bar``) they locate the source name ``Foo`` rather than the alias. """ kind: UseKind line: int column: int import_module: str | None = None import_alias: str | None = None
[docs] def find_class_definitions(source: str) -> list[ClassDefRef]: """Top-level class definitions of ``source``, in source order. Classes nested inside functions (or other classes) are not returned: they are not project-rename candidates. Raises :class:`SceneSyntaxError` when ``source`` does not parse. """ module, table = _parse(source) found: list[ClassDefRef] = [] previous: ast.stmt | None = None for node in module.body: if isinstance(node, ast.ClassDef): found.append(_class_ref(table, node, previous)) previous = node return found
[docs] def find_class_uses(source: str, class_name: str) -> list[UseSiteRef]: """Every site in ``source`` referencing ``class_name``, classified by kind. Ordered by position. Scope-aware: a plain ``<class_name> = …`` rebinding shadows the uses written below it in the same block, and a function or class body starts again unshadowed because a name bound outside it is a different binding from one bound inside. Conservative everywhere else: a site whose meaning depends on what a name resolves to at runtime is included. Raises :class:`SceneSyntaxError` when ``source`` does not parse. """ module, table = _parse(source) collector = _UseCollector(table, class_name) collector.visit_suite(module.body, shadowed=False) return collector.sorted_uses()
[docs] def rename_class_in_source(source: str, old_name: str, new_name: str) -> str: """Rename a class throughout ``source``, returning the rewritten text. Rewrites the name of every top-level ``class <old_name>`` definition and every site :func:`find_class_uses` reports, except that an aliased import keeps its alias: ``from x import Old as P`` becomes ``from x import New as P`` and the ``P`` uses below it are left alone, because they were never references to this class. Only whole identifiers move. A class name written inside a string or a comment is text rather than a reference and is left as the author wrote it. Idempotent: renaming a class to the name it already has returns ``source`` unchanged, byte for byte. """ if old_name == new_name: return source module, table = _parse(source) spans: list[Span] = [] for node in module.body: if isinstance(node, ast.ClassDef) and node.name == old_name: spans.append(_class_name_span(table, node)) collector = _UseCollector(table, old_name) collector.visit_suite(module.body, shadowed=False) for use in collector.sorted_uses(): start = table.char_offset(use.line, use.column) spans.append(Span(start, start + len(old_name))) edits = [(span, new_name) for span in spans if span.text(source) == old_name] return _splice(source, edits)
[docs] def rename_module_in_imports(source: str, old_module: str, new_module: str) -> str: """Rewrite ``from <old_module> import …`` lines to name ``new_module`` instead. Used when the user renames a class's defining file (or folder) and every importer's module path has to follow. ``old_module`` and ``new_module`` are dotted names, spelled as they would be in an absolute import (``"src.player"``, ``"src.hero"``). Matches both: * **Exact** matches: ``from src.player import X`` when ``old_module`` is ``"src.player"``. * **Trailing-segment** matches: ``from .player import X`` and ``from foo.bar.player import X`` when the import's last component is the last component of ``old_module``. This is what makes a relative import follow (``.player`` shares its leaf with ``src.player`` although the prefix differs), and anchoring on the trailing segment is what keeps an unrelated ``from os.path import X`` out of it. Idempotent, and returns ``source`` unchanged when nothing matches. A plain ``import x`` binds the module itself rather than a name from it, and rewriting it would need the uses of that binding to follow; it is left alone here and would warrant a primitive of its own. """ if old_module == new_module: return source module, table = _parse(source) old_leaf = old_module.rsplit(".", 1)[-1] new_leaf = new_module.rsplit(".", 1)[-1] edits: list[tuple[Span, str]] = [] for node in ast.walk(module): if not isinstance(node, ast.ImportFrom): continue existing = _imported_module_name(node) if existing is None: continue replacement = _module_replacement(existing, old_module, old_leaf, new_module, new_leaf) if replacement is None: continue edits.append((_imported_module_span(table, node), replacement)) return _splice(source, edits)
# --------------------------------------------------------------------------- # Parsing and splicing # --------------------------------------------------------------------------- def _parse(source: str) -> tuple[ast.Module, LineTable]: """Parse ``source``, presenting a refusal the way the parser reported it.""" try: module = ast.parse(source) except SyntaxError as error: raise SceneSyntaxError.from_syntax_error(error) from error return module, LineTable(source) def _splice(source: str, edits: list[tuple[Span, str]]) -> str: """Apply every ``(span, text)`` replacement, leaving the rest of ``source`` alone.""" if not edits: return source out = source for span, text in sorted(edits, key=lambda edit: edit[0], reverse=True): out = out[: span.start] + text + out[span.stop :] return out def _class_ref(table: LineTable, node: ast.ClassDef, previous: ast.stmt | None) -> ClassDefRef: """The record :func:`find_class_definitions` hands out for one class. ``previous`` is the statement written above this one, which is where the class's trivia starts: from the line after it, so that a comment sitting at the end of that statement's own line stays with the statement it was written on. A class opening the file takes the trivia from the file's start. """ span = statement_span(table, node) if previous is None: trivia_start = 0 else: trivia_start = table.line_span(previous.end_lineno or previous.lineno).stop return ClassDefRef( name=node.name, bases=tuple(base.id for base in node.bases if isinstance(base, ast.Name)), line=node.lineno, text=span.text(table.text), leading_trivia=Span(min(trivia_start, span.start), span.start).text(table.text), ) def _class_name_span(table: LineTable, node: ast.ClassDef) -> Span: """Where the class's own name is written, which ``ast`` does not report. The name follows the ``class`` keyword with nothing but whitespace and line continuations between, so the first occurrence of the name after the keyword is the declaration itself. """ start = node_span(table, node).start + len("class") found = table.text.index(node.name, start) return Span(found, found + len(node.name)) # --------------------------------------------------------------------------- # find_class_uses # --------------------------------------------------------------------------- class _UseCollector: """The walk behind :func:`find_class_uses`, carrying its scope state. Statements are visited in source order so that a rebinding of the name is seen before the uses it shadows. Expressions are visited by role, because the role is what the kind of a use is: the same identifier is an ``annotation`` after a colon, an ``instantiation`` before a parenthesis and a ``base_class`` in a class header. """ __slots__ = ("_table", "_name", "_uses") def __init__(self, table: LineTable, class_name: str) -> None: self._table = table self._name = class_name self._uses: list[UseSiteRef] = [] def sorted_uses(self) -> list[UseSiteRef]: """Every use found, in source order.""" return sorted(self._uses, key=lambda use: (use.line, use.column)) # -- statements -------------------------------------------------------- def visit_suite(self, body: list[ast.stmt], *, shadowed: bool) -> None: """Visit one block, tracking the rebinding that shadows the rest of it.""" for statement in body: shadowed = self._visit_statement(statement, shadowed=shadowed) def _visit_statement(self, node: ast.stmt, *, shadowed: bool) -> bool: """Visit one statement; answer whether the name is shadowed below it.""" if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): self._visit_function(node, shadowed=shadowed) return shadowed if isinstance(node, ast.ClassDef): self._visit_class(node, shadowed=shadowed) return shadowed if isinstance(node, (ast.Import, ast.ImportFrom)): if not shadowed: self._visit_import(node) return shadowed if isinstance(node, (ast.Global, ast.Nonlocal)): if not shadowed: self._visit_declaration(node) return shadowed if isinstance(node, ast.Assign) and self._is_rebinding(node): if not shadowed: # ``Foo = Foo.subclass()`` is legal and the right-hand side is # read before the name is rebound, so it is not shadowed yet. for target in node.targets[1:]: self._visit_expression(target, in_annotation=False) self._visit_expression(node.value, in_annotation=False) return True if isinstance(node, ast.AnnAssign): if not shadowed: self._visit_expression(node.annotation, in_annotation=True) self._visit_expression(node.target, in_annotation=False) if node.value is not None: self._visit_expression(node.value, in_annotation=False) return shadowed self._visit_clause(node, shadowed=shadowed) return shadowed def _visit_clause(self, node: ast.AST, *, shadowed: bool) -> None: """Visit a statement's own expressions here and its blocks nested inside it. A block belongs to the scope it is written in, so the shadow carries into it; the expressions of the header (a condition, an iterable, a context manager, a ``case`` guard) are evaluated in this scope too. Clause nodes that are neither statement nor expression (a ``with`` item, an ``except`` handler, a ``match`` case, a pattern) are recursed into so that the blocks they carry are reached. """ suites = [suite for _field, suite in iter_child_statements(node)] nested = {id(statement) for suite in suites for statement in suite} for child in ast.iter_child_nodes(node): if id(child) in nested or isinstance(child, (ast.stmt, ast.expr_context)): continue if isinstance(child, ast.expr): if not shadowed: self._visit_expression(child, in_annotation=False) else: self._visit_clause(child, shadowed=shadowed) for suite in suites: self.visit_suite(suite, shadowed=shadowed) def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef, *, shadowed: bool) -> None: """Signature in the enclosing scope, body in a scope of its own. A parameter's annotation and default are written in the enclosing scope and read there, so they follow its shadow; the body binds its own names and starts again unshadowed. """ if not shadowed: for decorator in node.decorator_list: self._visit_expression(decorator, in_annotation=False) self._visit_signature(node.args) if node.returns is not None: self._visit_expression(node.returns, in_annotation=True) self.visit_suite(node.body, shadowed=False) def _visit_signature(self, arguments: ast.arguments) -> None: """Every annotation and default a parameter list writes.""" for parameter in ( *arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs, arguments.vararg, arguments.kwarg, ): if parameter is not None and parameter.annotation is not None: self._visit_expression(parameter.annotation, in_annotation=True) for default in (*arguments.defaults, *arguments.kw_defaults): if default is not None: self._visit_expression(default, in_annotation=False) def _visit_class(self, node: ast.ClassDef, *, shadowed: bool) -> None: """Bases and keywords in the enclosing scope, body in a scope of its own. A base written as a bare name is the class itself and is reported as ``base_class``; anything else in the header (``Protocol[T]``, ``metaclass=Meta``) is an ordinary expression, since what it references is not the base slot but whatever the expression reads. """ if not shadowed: for decorator in node.decorator_list: self._visit_expression(decorator, in_annotation=False) for base in node.bases: if isinstance(base, ast.Name) and base.id == self._name: self._record(base, "base_class") else: self._visit_expression(base, in_annotation=False) for keyword in node.keywords: self._visit_expression(keyword.value, in_annotation=False) self.visit_suite(node.body, shadowed=False) def _visit_import(self, node: ast.Import | ast.ImportFrom) -> None: """Record the names an import binds that spell this class. ``from x import Foo`` and ``import Foo`` are the same site to a rename; what differs is the module the name came from, which a caller needs to decide whether the site is this class at all. A dotted ``import a.b.Foo`` binds ``a``, not ``Foo``, and is not a use of it. """ module = _imported_module_name(node) if isinstance(node, ast.ImportFrom) else None for alias in node.names: if alias.name != self._name: continue if alias.asname is None: self._record(alias, "import", import_module=module) else: self._record(alias, "import_alias", import_module=module, import_alias=alias.asname) def _visit_declaration(self, node: ast.Global | ast.Nonlocal) -> None: """Record the class name where a ``global`` or ``nonlocal`` lists it. ``ast`` keeps these names as plain strings, with no position of their own, so they are found in the statement's own text instead. Nothing but the keyword, identifiers and commas can be written there, which makes a whole-word match the identifier itself and never part of anything else. A rename that skipped these would leave the declaration naming a class that no longer exists. """ if self._name not in node.names: return span = node_span(self._table, node) for match in re.finditer(rf"\b{re.escape(self._name)}\b", span.text(self._table.text)): self._record_at(span.start + match.start(), "bare_reference") def _is_rebinding(self, node: ast.Assign) -> bool: """Is this ``<class_name> = …``, the assignment that shadows the name? Only a plain assignment counts. An annotated ``Foo: type[X] = …`` is read as a declaration about the class rather than as a replacement of it, and an augmented assignment binds what the name already holds. """ return bool(node.targets) and _is_name(node.targets[0], self._name) # -- expressions ------------------------------------------------------- def _visit_expression(self, node: ast.AST, *, in_annotation: bool) -> None: """Classify every mention of the name inside one expression. ``in_annotation`` carries down: everything written in an annotation is an annotation, including a call written inside one, because what a rename owes such a site is the same either way. No statement can be written inside an expression, so this walk never leaves the scope it started in and takes no shadow state. """ if isinstance(node, ast.Name): if node.id == self._name: self._record(node, "annotation" if in_annotation else "bare_reference") return if isinstance(node, ast.Call): self._visit_call(node, in_annotation=in_annotation) return for child in ast.iter_child_nodes(node): if not isinstance(child, ast.expr_context): self._visit_expression(child, in_annotation=in_annotation) def _visit_call(self, node: ast.Call, *, in_annotation: bool) -> None: """Classify one call: the callee, then what it is passed. A class called is an instantiation, and a class handed to ``isinstance`` past its first argument is the type it tests against. Both are the same identifier to a rename and different things to a reader, which is why the kinds are told apart here rather than at the call sites. """ if _is_name(node.func, self._name): self._record(node.func, "annotation" if in_annotation else "instantiation") else: self._visit_expression(node.func, in_annotation=in_annotation) tests_a_type = isinstance(node.func, ast.Name) and node.func.id == "isinstance" for index, argument in enumerate(node.args): if tests_a_type and index >= 1 and _is_name(argument, self._name): self._record(argument, "isinstance_arg") else: self._visit_expression(argument, in_annotation=in_annotation) for keyword in node.keywords: self._visit_expression(keyword.value, in_annotation=in_annotation) def _record( self, node: ast.AST, kind: UseKind, *, import_module: str | None = None, import_alias: str | None = None, ) -> None: """Note one use of the name, located where ``node`` starts.""" self._record_at( node_span(self._table, node).start, kind, import_module=import_module, import_alias=import_alias, ) def _record_at( self, offset: int, kind: UseKind, *, import_module: str | None = None, import_alias: str | None = None, ) -> None: """Note one use of the name, located at the offset its identifier starts at.""" line, column = self._table.char_position(offset) self._uses.append( UseSiteRef( kind=kind, line=line, column=column, import_module=import_module, import_alias=import_alias, ) ) def _is_name(node: ast.AST, name: str) -> bool: """Is ``node`` the bare identifier ``name``?""" return isinstance(node, ast.Name) and node.id == name # --------------------------------------------------------------------------- # Import module names # --------------------------------------------------------------------------- #: The ``import`` keyword closing the module clause of a ``from`` import. It #: cannot appear inside the module name (it is a keyword, so no identifier #: spells it) and no comment can precede it there (the clause takes no #: brackets, so a ``#`` would end the statement mid-way), which makes the first #: match after ``from`` the real one. _IMPORT_KEYWORD = re.compile(r"\bimport\b") def _imported_module_name(node: ast.ImportFrom) -> str | None: """The module a ``from`` import names, dots and all: ``".."``, ``".player"``, ``"os.path"``.""" name = "." * node.level + (node.module or "") return name or None def _imported_module_span(table: LineTable, node: ast.ImportFrom) -> Span: """Where the module clause is written, between the two keywords. ``ast`` reports the module as a string with the dots counted separately and no position for either, so the span is read off the text: it is what lies between the ``from`` that opens the statement and the ``import`` that closes the clause, with the whitespace on both sides left where the author put it. """ source = table.text after_from = node_span(table, node).start + len("from") keyword = _IMPORT_KEYWORD.search(source, after_from) if keyword is None: # pragma: no cover - a parsed `from` import has an `import` raise ValueError(f"no 'import' keyword closes the import at line {node.lineno}") clause = source[after_from : keyword.start()] start = after_from + (len(clause) - len(clause.lstrip())) return Span(start, after_from + len(clause.rstrip())) def _module_replacement( existing: str, old_module: str, old_leaf: str, new_module: str, new_leaf: str, ) -> str | None: """What ``existing`` should be rewritten to, or ``None`` to leave it alone.""" if existing == old_module: return new_module stripped = existing.lstrip(".") leading_dots = len(existing) - len(stripped) if leading_dots == 0 and "." not in stripped: # A bare absolute import like ``from player import X``: it matches only # when the whole module is the leaf being renamed, since anything else # is a different module that happens to share a suffix. return new_leaf if stripped == old_leaf else None head, _, tail = stripped.rpartition(".") if tail != old_leaf: return None prefix = "." * leading_dots return f"{prefix}{head}.{new_leaf}" if head else f"{prefix}{new_leaf}"