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
48 changes: 34 additions & 14 deletions desloppify/languages/python/detectors/deps_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def resolve_python_import(
scan_root_path = Path(scan_root) if not isinstance(scan_root, Path) else scan_root
if module_path.startswith("."):
return resolve_relative_import(module_path, source_dir)
return resolve_absolute_import(module_path, scan_root_path)
return resolve_absolute_import(module_path, scan_root_path, source_dir=source_dir)


def resolve_relative_import(module_path: str, source_dir: Path) -> str | None:
Expand All @@ -106,20 +106,40 @@ def resolve_relative_import(module_path: str, source_dir: Path) -> str | None:
return try_resolve_path(target_base)


def resolve_absolute_import(module_path: str, scan_root: Path) -> str | None:
"""Resolve an absolute import within scan root first, then project root."""
def resolve_absolute_import(
module_path: str,
scan_root: Path,
source_dir: Path | None = None,
) -> str | None:
"""Resolve an absolute import within scan root, source-file package roots, then project root.

``source_dir`` (when given) enables multi-root repositories: each ancestor
of the importing file up to ``scan_root`` is tried as a package root, so
service-rooted absolute imports (Django apps under ``backend/service/``,
``src/`` layouts, monorepo subprojects) resolve correctly.
"""
parts = module_path.split(".")
target_base = scan_root.resolve()
for part in parts:
target_base = target_base / part
resolved = try_resolve_path(target_base)
if resolved:
return resolved

target_base = get_project_root()
for part in parts:
target_base = target_base / part
return try_resolve_path(target_base)
scan_base = scan_root.resolve()
candidates = [scan_base]
if source_dir is not None:
anchor = source_dir.resolve()
while True:
if anchor not in candidates:
candidates.append(anchor)
if anchor == scan_base or anchor.parent == anchor:
break
anchor = anchor.parent
project_root = get_project_root()
if project_root not in candidates:
candidates.append(project_root)
for base in candidates:
target_base = base
for part in parts:
target_base = target_base / part
resolved = try_resolve_path(target_base)
if resolved:
return resolved
return None


def try_resolve_path(target_base: Path) -> str | None:
Expand Down
22 changes: 22 additions & 0 deletions desloppify/languages/python/tests/test_py_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ def test_absolute_import_within_project(self, tmp_path):
# but cli.py should exist in the graph
assert "imports" in graph[cli_key]

def test_absolute_import_resolves_from_service_root(self, tmp_path):
"""Service-rooted absolute imports resolve in multi-root repositories.

A Django-style layout nests app packages under a service directory
(`backend/accounting/...`). Absolute imports such as
`from accounting.services import X` are rooted at the service
directory, not the scan root, and must still produce graph edges.
"""
service = tmp_path / "backend"
app = service / "accounting"
app.mkdir(parents=True)
(app / "__init__.py").write_text("")
(app / "services.py").write_text("CONST = 1\n")
(app / "tasks.py").write_text("from accounting.services import CONST\n")

graph = build_dep_graph(tmp_path)

services_key = next(k for k in graph if k.endswith("services.py"))
tasks_key = next(k for k in graph if k.endswith("tasks.py"))
assert any(t.endswith("services.py") for t in graph[tasks_key]["imports"])
assert graph[services_key]["importer_count"] >= 1

def test_multi_file_graph(self, tmp_path):
pkg = _make_pkg(
tmp_path,
Expand Down