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