Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# **Upcoming release**

- Refuse variable inlining that moves an initializer into a deferred annotation or a lazy function/class type parameter.
- #895 patchedast Starred and keyword now consumes their syntactically expected and ** (@lieryan)
- #896 patchedast cleanup and refactoring (@lieryan)

Expand Down
207 changes: 207 additions & 0 deletions rope/refactor/inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
# but it should be 200.

import re
import sys
from typing import List

import rope.base.builtins # Use fully qualified names for clarity.
from rope.base import (
ast,
codeanalyze,
evaluate,
exceptions,
Expand Down Expand Up @@ -267,6 +269,14 @@ def get_changes(
resources = [self.original]
if remove and self.original != self.resource:
resources.append(self.resource)
resources = list(resources)
check_resources = resources
if remove and not rename._is_local(self.pyname):
check_resources = list(
dict.fromkeys(resources + list(self.project.get_python_files()))
)
for resource in check_resources:
self._check_deferred_annotation_references(resource, remove, only_current)
changes = ChangeSet("Inline variable <%s>" % self.name)
jobset = task_handle.create_jobset("Calculating changes", len(resources))

Expand All @@ -283,6 +293,203 @@ def get_changes(
jobset.finished_job()
return changes

def _check_deferred_annotation_references(self, resource, remove, only_current):
pymodule = self.project.get_pymodule(resource)
tree = pymodule.get_ast()
future_annotations = any(
isinstance(node, ast.ImportFrom)
and node.module == "__future__"
and any(alias.name == "annotations" for alias in node.names)
for node in tree.body
)
if not future_annotations and sys.version_info < (3, 12):
return
annotations = []
type_parameters = []

def collect(node, scope=None):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
parameters = getattr(node, "type_params", [])
if parameters:
namespace = (
pymodule.get_scope()
.get_inner_scope_for_line(node.lineno)
.parent
)
names = {parameter.name for parameter in parameters}
for parameter in parameters:
for attr in ("bound", "default_value"):
expression = getattr(parameter, attr, None)
if expression is not None:
type_parameters.append((expression, namespace, names))
if isinstance(node, ast.arg):
annotations.append(node.annotation)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
annotations.append(node.returns)
scope = "function"
elif isinstance(node, ast.Lambda):
scope = "function"
elif isinstance(node, ast.ClassDef):
scope = "class"
elif (
isinstance(node, ast.AnnAssign) and node.simple and scope != "function"
):
annotations.append(node.annotation)
for child in ast.iter_child_nodes(node):
collect(child, scope)

collect(tree)
if not future_annotations and sys.version_info < (3, 14):
annotations = []
lines = codeanalyze.ASTLinesAdapter(pymodule.source_code)
regions = [lines[node] for node in annotations if node is not None]
parameter_regions = [lines[item[0]] for item in type_parameters]
if not regions and not parameter_regions:
return

def parameter_reference(node, namespace, names):
inner_namespace = namespace
inner_names = set(names)
while inner_namespace.get_kind() == "Class":
inner_names.update(
parameter.name
for parameter in getattr(
inner_namespace.pyobject.get_ast(), "type_params", []
)
)
inner_namespace = inner_namespace.parent
if isinstance(node, ast.Lambda):
arguments = node.args
defaults = arguments.defaults + [
default for default in arguments.kw_defaults if default is not None
]
bound_names = inner_names | {
argument.arg
for argument in (
arguments.posonlyargs
+ arguments.args
+ arguments.kwonlyargs
+ [arguments.vararg, arguments.kwarg]
)
if argument is not None
}
return any(
parameter_reference(default, namespace, names)
for default in defaults
) or parameter_reference(node.body, inner_namespace, bound_names)
if isinstance(
node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
):
bound_names = set(names)
for index, generator in enumerate(node.generators):
iterable_namespace = namespace if index == 0 else inner_namespace
if parameter_reference(
generator.iter, iterable_namespace, bound_names
):
return True
bound_names.update(inner_names)
bound_names.update(
name.id
for name in ast.walk(generator.target)
if isinstance(name, ast.Name)
and isinstance(name.ctx, ast.Store)
)
if parameter_reference(
generator.target, inner_namespace, bound_names
):
return True
if any(
parameter_reference(condition, inner_namespace, bound_names)
for condition in generator.ifs
):
return True
values = (
[node.key, node.value]
if isinstance(node, ast.DictComp)
else [node.elt]
)
return any(
parameter_reference(value, inner_namespace, bound_names)
for value in values
)
if isinstance(node, (ast.Name, ast.Attribute)):
primary = node
while isinstance(primary, ast.Attribute):
primary = primary.value
if not (isinstance(primary, ast.Name) and primary.id in names):
if isinstance(primary, ast.Name):
outer_scope = namespace
while outer_scope is not None:
if (
outer_scope.get_kind() != "Class"
or outer_scope is namespace
) and primary.id in outer_scope.get_names():
break
if any(
parameter.name == primary.id
for parameter in getattr(
outer_scope.pyobject.get_ast(), "type_params", []
)
):
return False
outer_scope = outer_scope.parent
if occurrences.same_pyname(
self.pyname, evaluate.eval_node(namespace, node)
):
return True
return any(
parameter_reference(child, namespace, names)
for child in ast.iter_child_nodes(node)
)

if remove and any(
parameter_reference(expression, namespace, names)
for expression, namespace, names in type_parameters
):
# Type parameter expressions use the enclosing annotation scope,
# not the function's parameters/locals or the new class's body.
raise exceptions.RefactoringError(
"Cannot inline a variable referenced in a deferred annotation."
)
if (
future_annotations
and remove
and any(
isinstance(node, (ast.Name, ast.Attribute))
and occurrences.same_pyname(
self.pyname, evaluate.eval_node(pymodule.get_scope(), node)
)
for annotation in annotations
if annotation is not None
for node in ast.walk(annotation)
)
):
# Stringified annotations may resolve module globals even when
# static name lookup finds a class member or function parameter.
raise exceptions.RefactoringError(
"Cannot inline a variable referenced in a deferred annotation."
)
finder = occurrences.create_finder(
self.project, self.name, self.pyname, imports=False
)
for occurrence in finder.find_occurrences(pymodule=pymodule):
current = True
if only_current:
start, end = occurrence.get_primary_range()
current = resource == self.original and start <= self.offset <= end
if not remove and not current:
continue
start, _ = occurrence.get_word_range()
if any(begin <= start < end for begin, end in regions) or (
current
and any(begin <= start < end for begin, end in parameter_regions)
):
# Substituting the initializer can change when an annotation
# evaluates it and which bindings it observes.
raise exceptions.RefactoringError(
"Cannot inline a variable referenced in a deferred annotation."
)

def _change_main_module(self, remove, only_current, docs):
region = None
if only_current and self.original == self.resource:
Expand Down
Loading
Loading