MoveModule creates new top-level imports
SomeoneSerge opened this issue · 5 comments
Describe the bug
When MoveModule
encounters an import statement inside a function (thus, evaluated at some unknown point later and maybe never), and the import statement concerns the module being moved, the calculated changes include both a modification of the original import statement and an extraneous import statement at the top of the file. This changes the order of imports and, in sufficiently cursed code-bases, may even result in a circular import.
Basically, the issue is that MoveModule
appears to consider imports to be side effect-free, which sometimes they aren't.
The extra import is being added here:
Lines 594 to 596 in 25586a6
Lines 769 to 773 in 25586a6
To Reproduce
Steps to reproduce the behavior:
- Code before refactoring:
# a.py
def use_b_later():
import b
# b.py
import a
- Describe the refactoring you want to do
Rename (move) b
into prefix.b
.
- Expected code after refactoring:
# a.py
def use_b_later():
import prefix.b
# b.py
import a
- Describe the error or unexpected result that you are getting
# a.py
import prefix.b
def use_b_later():
import prefix.b
# b.py
import a
Reproducible example
$ python << EOF
import shutil
import tempfile
from contextlib import contextmanager
from os import walk
from pathlib import Path
from rope.base.project import Project
from rope.refactor.move import MoveModule
@contextmanager
def temp_tree():
path = tempfile.mkdtemp()
try:
yield Path(path)
finally:
shutil.rmtree(path, ignore_errors=True)
with temp_tree() as root:
(root / "utils.py").write_text(
"""
def use_lazy():
import lazy"""
)
(root / "lazy.py").write_text(
"""
import utils"""
)
(root / "prefix").mkdir()
(root / "prefix" / "__init__.py").touch()
project = Project(root)
new_package = project.get_folder("prefix")
lazy = project.get_module("lazy").get_resource()
print(MoveModule(project, lazy).get_changes(new_package).get_description())
EOF
Moving module <lazy>:
--- a/lazy.py
+++ b/lazy.py
@@ -1,2 +1 @@
-
-import utils+import utils
--- a/utils.py
+++ b/utils.py
@@ -1,3 +1,3 @@
-
+import prefix.lazy
def use_lazy():
- import lazy+ import prefix.lazy
rename from lazy.py
rename to prefix/lazy.py
Editor information (please complete the following information):
- Project Python version: 3.11
- Rope Python version: 3.11
- Rope version: 1.11.0 (also checked 1.9.0)
- Text editor/IDE and version: ...
Additional context
Context: trying to use Rope for some automation in https://github.com/SomeoneSerge/pkgs/blob/master/python-packages/by-name/pr/prefix-python-modules/prefix_python_modules.py
My understanding is that in this particular case, create_finder(..., imports=False)
is supposed to skip import lazy
but it doesn't (do I misunderstand the meaning of imports=False
?).
This may be an additional issue overlapping with the original one actually: I can change use_lazy
to something like import lazy; lazy.do()
, and I would still expect should_import
to only scan the scope where lazy
was introduced (i.e. the use_lazy
function), and to only add normalized imports at the top of the function.
Currently occurs_in_module
finds:
(Pdb) b rope/refactor/move.py:741
...
(Pdb) occurrences = list(occurrences)
(Pdb) p [o.tools._source_code[o.get_primary_range()[0] - 7: o.get_primary_range()[1] + 5] for o in occurrences]
['import lazy\n ', 'zy\n lazy.do()']
(Pdb) p occurrences[0]._is_in_import_statement
False
I tried extending test_is_import_statement with:
def test_is_import_statement(self):
code, annotations = self._annotated_code(annotated_code=dedent("""\
import a.b.c.d
++++++++
from a.b import c
import a.b.c.d as d
+++++++++++++
from a.b import c as e
from a.b import (
abc
)
result = a.b.c.d.f()
def import_later():
import lazy
+++++
"""))
And the following output, which AFAIU confirms that NoImportsFilter
doesn't acknowledge imports nested in functions:
ropetest/codeanalyzetest.py:113: in assert_equal_annotation
self.fail("".join(msg))
E AssertionError: Annotation does not match:
E import a.b.c.d
E from a.b import c
E import a.b.c.d as d
E from a.b import c as e
E from a.b import (
E abc
E )
E result = a.b.c.d.f()
E def import_later():
E import lazy
E e ++++
E a
Did I get the annotation syntax right?
and self._find_word_start(line_start) == last_import
# ...
def _find_word_start(self, offset):
current_offset = offset
while current_offset >= 0 and self._is_id_char(current_offset):
current_offset -= 1
return current_offset + 1
H'm, is it intentional that you begin at the leftmost character of the line and then try to go further left?
Hi @SomeoneSerge, thanks for writing this issue and creating the PR. I'm currently travelling on vacation, so my availability to look into rope issues and review PRs will be limited until December. So don't worry, but I hadn't forgotten about this 🙂, but I won't be actioning the PR until I'm back.
I think I just encountered the import inside a function
issue with the Rename
. Will take some time to document though.
@lieryan any pointers as to where to begin?