Skip to content

Commit d50f666

Browse files
committed
Preserve original names in external from-imports
1 parent e1f2cc2 commit d50f666

3 files changed

Lines changed: 152 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# **Upcoming release**
22

3+
- #831 Preserve external imported names when renaming their local bindings
34
- #850 Update and pin black version in pre-commit and Github Actions
45
- #851 Bump supported python version to up to Python 3.14
56
- #852 Implement patchedast handlers for TypeAlias

‎rope/refactor/rename.py‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from keyword import iskeyword
33

44
from rope.base import (
5+
ast,
56
codeanalyze,
67
evaluate,
78
exceptions,
@@ -120,7 +121,12 @@ def unsure_func(value=unsure):
120121
job_set = task_handle.create_jobset("Collecting Changes", len(resources))
121122
for file_ in resources:
122123
job_set.started_job(file_.path)
123-
new_content = rename_in_module(finder, new_name, resource=file_)
124+
new_content = rename_in_module(
125+
finder,
126+
new_name,
127+
resource=file_,
128+
preserve_imports=self._get_preserved_imports(file_),
129+
)
124130
if new_content is not None:
125131
changes.add_change(ChangeContents(file_, new_content))
126132
job_set.finished_job()
@@ -130,6 +136,33 @@ def unsure_func(value=unsure):
130136
self._rename_module(resource, new_name, changes)
131137
return changes
132138

139+
def _get_preserved_imports(self, resource):
140+
"""Find imported names whose definitions are outside the project."""
141+
pymodule = self.project.get_pymodule(resource)
142+
lines = codeanalyze.ASTLinesAdapter(pymodule.source_code)
143+
for node in ast.walk(pymodule.get_ast()):
144+
if not isinstance(node, ast.ImportFrom):
145+
continue
146+
names = [alias for alias in node.names if alias.name == self.old_name]
147+
if not names:
148+
continue
149+
imported_module = pynames.ImportedModule(
150+
pymodule, node.module or "", level=node.level
151+
).get_object()
152+
imported_resource = (
153+
imported_module.get_resource()
154+
if isinstance(imported_module, pyobjects.AbstractModule)
155+
else None
156+
)
157+
if imported_resource is not None and (
158+
imported_resource.project == self.project
159+
and not self.project.is_ignored(imported_resource)
160+
):
161+
continue
162+
for alias in names:
163+
start, _ = lines[alias]
164+
yield start, alias.asname is None
165+
133166
def validate_changes(
134167
self,
135168
new_name: str,
@@ -233,13 +266,15 @@ def rename_in_module(
233266
region=None,
234267
reads=True,
235268
writes=True,
269+
preserve_imports=None,
236270
):
237271
"""Returns the changed source or `None` if there is no changes"""
238272
if resource is not None:
239273
source_code = resource.read()
240274
else:
241275
source_code = pymodule.source_code
242276
change_collector = codeanalyze.ChangeCollector(source_code)
277+
preserved_imports = None
243278
for occurrence in occurrences_finder.find_occurrences(resource, pymodule):
244279
if replace_primary and occurrence.is_a_fixed_primary():
245280
continue
@@ -252,7 +287,16 @@ def rename_in_module(
252287
):
253288
continue
254289
if region is None or region[0] <= start < region[1]:
255-
change_collector.add_change(start, end, new_name)
290+
replacement = new_name
291+
if preserve_imports is not None and occurrence.is_in_import_statement():
292+
if preserved_imports is None:
293+
preserved_imports = dict(preserve_imports)
294+
if start in preserved_imports:
295+
if not preserved_imports[start]:
296+
continue
297+
if source_code[start:end] != new_name:
298+
replacement = source_code[start:end] + " as " + new_name
299+
change_collector.add_change(start, end, replacement)
256300
return change_collector.get_changed()
257301

258302

‎ropetest/refactor/renametest.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,111 @@ def new_func():
582582
mod2.read(),
583583
)
584584

585+
def test_renaming_external_import_preserves_imported_name(self):
586+
self.project.prefs.add("extension_modules", "builtins")
587+
code = "from builtins import zip\nresult = list(zip([1], [2]))\n"
588+
refactored = self._local_rename(code, code.rindex("zip"), "pairs")
589+
self.assertEqual(
590+
"from builtins import zip as pairs\nresult = list(pairs([1], [2]))\n",
591+
refactored,
592+
)
593+
namespace = {}
594+
exec(refactored, namespace)
595+
self.assertEqual([(1, 2)], namespace["result"])
596+
597+
def test_renaming_external_import_with_same_alias(self):
598+
code = "from builtins import zip as zip\nresult = zip([], [])\n"
599+
refactored = self._local_rename(code, code.rindex("zip"), "pairs")
600+
self.assertEqual(
601+
"from builtins import zip as pairs\nresult = pairs([], [])\n",
602+
refactored,
603+
)
604+
605+
def test_renaming_external_import_with_different_alias(self):
606+
code = "from builtins import zip as pairs\nresult = pairs([], [])\n"
607+
refactored = self._local_rename(code, code.rindex("pairs"), "new_pairs")
608+
self.assertEqual(
609+
"from builtins import zip as new_pairs\nresult = new_pairs([], [])\n",
610+
refactored,
611+
)
612+
613+
def test_renaming_multiline_external_import_after_unicode(self):
614+
code = dedent("""\
615+
café = 1; from builtins import (
616+
zip as # Keep the imported name.
617+
zip,
618+
)
619+
result = zip([], [])
620+
""")
621+
refactored = self._local_rename(code, code.rindex("zip"), "pairs")
622+
self.assertEqual(
623+
dedent("""\
624+
café = 1; from builtins import (
625+
zip as # Keep the imported name.
626+
pairs,
627+
)
628+
result = pairs([], [])
629+
"""),
630+
refactored,
631+
)
632+
633+
def test_renaming_conditional_external_import_from_another_module(self):
634+
compat = testutils.create_module(self.project, "compat")
635+
compat.write(dedent("""\
636+
import sys
637+
if sys.version_info[0] >= 3:
638+
from builtins import map, zip
639+
else:
640+
zip = lambda *args: None
641+
"""))
642+
mod = testutils.create_module(self.project, "mod")
643+
mod.write("from compat import zip\nresult = list(zip([1], [2]))\n")
644+
self._rename(mod, mod.read().rindex("zip"), "pairs")
645+
self.assertEqual(
646+
dedent("""\
647+
import sys
648+
if sys.version_info[0] >= 3:
649+
from builtins import map, zip as pairs
650+
else:
651+
pairs = lambda *args: None
652+
"""),
653+
compat.read(),
654+
)
655+
self.assertEqual(
656+
"from compat import pairs\nresult = list(pairs([1], [2]))\n",
657+
mod.read(),
658+
)
659+
660+
def test_renaming_import_from_external_python_module(self):
661+
external_project = testutils.sample_project()
662+
self.addCleanup(testutils.remove_project, external_project)
663+
external = testutils.create_module(external_project, "external")
664+
external.write("value = 42\n")
665+
self.project.prefs.add("python_path", external_project.address)
666+
code = "from external import value\nresult = value\n"
667+
refactored = self._local_rename(code, code.rindex("value"), "new_value")
668+
self.assertEqual(
669+
"from external import value as new_value\nresult = new_value\n",
670+
refactored,
671+
)
672+
self.assertEqual("value = 42\n", external.read())
673+
674+
def test_renaming_external_import_does_not_resolve_unrelated_import(self):
675+
invalid = testutils.create_module(self.project, "invalid")
676+
invalid.write("def broken(:\n")
677+
code = (
678+
"from invalid import other\n"
679+
"from builtins import zip\n"
680+
"result = zip([], [])\n"
681+
)
682+
refactored = self._local_rename(code, code.rindex("zip"), "pairs")
683+
self.assertEqual(
684+
"from invalid import other\n"
685+
"from builtins import zip as pairs\n"
686+
"result = pairs([], [])\n",
687+
refactored,
688+
)
689+
585690
def test_applying_all_changes_together(self):
586691
mod1 = testutils.create_module(self.project, "mod1")
587692
mod1.write(dedent("""\

0 commit comments

Comments
 (0)