From 6f385b9c254f9f062310055c56dffc030134d8c0 Mon Sep 17 00:00:00 2001 From: Tristan Manchester Date: Fri, 17 Apr 2026 09:39:44 +0200 Subject: [PATCH] Use full lines for Python relative import moves --- desloppify/languages/python/move.py | 30 +++++++++++---- .../languages/python/tests/test_py_move.py | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/desloppify/languages/python/move.py b/desloppify/languages/python/move.py index df250fe64..7bcb182b0 100644 --- a/desloppify/languages/python/move.py +++ b/desloppify/languages/python/move.py @@ -49,6 +49,19 @@ def _replace_exact_module(line: str, old_module: str, new_module: str) -> str: return re.sub(rf"(? str: + """Replace the module portion of a relative ``from`` import line.""" + return re.sub( + rf"^from\s+{re.escape(old_module)}(?=\s+import\b)", + f"from {new_module}", + line, + ) + + def _resolve_py_relative(source_dir: Path, dots: str, remainder: str) -> str | None: """Resolve a relative Python import to an absolute file path.""" dot_count = len(dots) @@ -154,9 +167,11 @@ def find_replacements( if resolved and str(Path(resolved).resolve()) == source_abs: new_rel = _compute_py_relative_import(importer, dest_abs) if new_rel: - old_from = f"from {dots}{remainder}" - new_from = f"from {new_rel}" - replacements.append((old_from, new_from)) + new_line = _replace_relative_from_module( + stripped, f"{dots}{remainder}", new_rel + ) + if new_line != stripped: + replacements.append((stripped, new_line)) if replacements: changes[importer] = _dedup(replacements) @@ -199,10 +214,11 @@ def find_self_replacements( if not new_rel: continue - old_from = f"from {dots}{remainder}" - new_from = f"from {new_rel}" - if old_from != new_from: - replacements.append((old_from, new_from)) + new_line = _replace_relative_from_module( + stripped, f"{dots}{remainder}", new_rel + ) + if new_line != stripped: + replacements.append((stripped, new_line)) return _dedup(replacements) diff --git a/desloppify/languages/python/tests/test_py_move.py b/desloppify/languages/python/tests/test_py_move.py index 749a751f9..d520184ea 100644 --- a/desloppify/languages/python/tests/test_py_move.py +++ b/desloppify/languages/python/tests/test_py_move.py @@ -5,6 +5,7 @@ from pathlib import Path import desloppify.languages.python.move as py_move +from desloppify.languages.python.detectors.deps import build_dep_graph def test_move_py_module_imports(): @@ -70,3 +71,40 @@ def test_resolve_py_relative_package(self, tmp_path): def test_resolve_py_relative_not_found(self, tmp_path): result = py_move._resolve_py_relative(tmp_path, ".", "nonexistent") assert result is None + + def test_relative_import_replacement_uses_full_line(self, tmp_path, monkeypatch): + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("SOME_CONSTANT = 42\n") + (pkg_dir / "utils.py").write_text("def helper():\n return 1\n") + + sub_dir = pkg_dir / "sub" + sub_dir.mkdir() + importer = sub_dir / "importer.py" + importer.write_text( + "from .. import SOME_CONSTANT\nfrom ..utils import helper\n" + ) + + monkeypatch.setenv("DESLOPPIFY_ROOT", str(tmp_path)) + + graph = build_dep_graph(tmp_path) + source_abs = str((pkg_dir / "__init__.py").resolve()) + dest_abs = str((tmp_path / "newpkg" / "__init__.py").resolve()) + + changes = py_move.find_replacements(source_abs, dest_abs, graph) + importer_abs = str(importer.resolve()) + + assert changes[importer_abs] == [ + ( + "from .. import SOME_CONSTANT", + "from ...newpkg import SOME_CONSTANT", + ) + ] + + content = importer.read_text() + for old_str, new_str in changes[importer_abs]: + content = content.replace(old_str, new_str) + + assert content == ( + "from ...newpkg import SOME_CONSTANT\nfrom ..utils import helper\n" + )