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: 36 additions & 12 deletions desloppify/languages/python/detectors/deps_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,19 +107,42 @@ def resolve_relative_import(module_path: str, source_dir: Path) -> str | None:


def resolve_absolute_import(module_path: str, scan_root: Path) -> str | None:
"""Resolve an absolute import within scan root first, then project root."""
"""Resolve an absolute import to a project file.

Each candidate source root (see :func:`candidate_source_roots`) is tried in
priority order, covering both the flat layout (``<root>/<pkg>``) and the
``src`` layout (``<root>/src/<pkg>``) recommended by the Python Packaging
Authority. Without the ``src`` candidates, a ``from pkg.sub import x``
statement in a ``src``-layout project resolves to nothing, so ``pkg/sub.py``
is recorded with zero importers and misreported as orphaned/uncoupled.
"""
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)
for root in candidate_source_roots(scan_root):
target_base = root
for part in parts:
target_base = target_base / part
resolved = try_resolve_path(target_base)
if resolved:
return resolved
return None


def candidate_source_roots(scan_root: Path) -> list[Path]:
"""Return the roots an absolute import may resolve against, in priority order.

The scan root and the project root are each tried with and without a ``src``
prefix, so absolute imports resolve under both the flat and ``src`` layouts.
The flat roots are tried before the ``src`` roots, so this is strictly
additive: any import that resolved before resolves to the same file, and only
previously-unresolved ``src``-layout imports gain an edge. Duplicate roots
(common when the scan root is the project root) are collapsed.
"""
flat_roots = [scan_root.resolve(), get_project_root()]
roots: list[Path] = []
for candidate in (*flat_roots, *(root / "src" for root in flat_roots)):
if candidate not in roots:
roots.append(candidate)
return roots


def try_resolve_path(target_base: Path) -> str | None:
Expand All @@ -141,6 +164,7 @@ def try_resolve_path(target_base: Path) -> str | None:


__all__ = [
"candidate_source_roots",
"resolve_absolute_import",
"resolve_python_from_import",
"resolve_python_import",
Expand Down
1 change: 1 addition & 0 deletions desloppify/languages/python/phases.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"/migrations/",
"settings.py",
"config.py",
"conf.py", # Sphinx documentation config (loaded by sphinx-build)
"wsgi.py",
"asgi.py",
"cli.py", # CLI entry points (loaded via framework/importlib)
Expand Down
30 changes: 30 additions & 0 deletions desloppify/languages/python/tests/test_py_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,36 @@ def test_importer_count(self, tmp_path):
assert graph[shared_key]["importer_count"] >= 2


# ── src/ layout (PyPA recommended) ────────────────────────


class TestSrcLayout:
"""Absolute imports must resolve when the package lives under ``src/``.

Regression for the ``src``-layout import resolver: ``from mypkg.schema
import VAL`` in ``<root>/src/mypkg/main.py`` resolves to
``<root>/src/mypkg/schema.py``. Before the fix, only ``<root>/mypkg`` and
``<project_root>/mypkg`` were tried, so the edge was dropped and
``schema.py`` was misreported as orphaned with zero importers.
"""

def test_absolute_import_resolves_under_src(self, tmp_path):
root = tmp_path / "proj"
src_pkg = root / "src" / "mypkg"
src_pkg.mkdir(parents=True)
(src_pkg / "__init__.py").write_text("")
(src_pkg / "schema.py").write_text("VAL = 1\n")
(src_pkg / "main.py").write_text("from mypkg.schema import VAL\n")

graph = build_dep_graph(root)

schema_key = next((k for k in graph if k.endswith("schema.py")), None)
assert schema_key is not None, "schema.py should be in graph"
assert graph[schema_key]["importer_count"] >= 1, (
"schema.py is imported via `from mypkg.schema import VAL` under src/ layout"
)


# ── Deferred imports ──────────────────────────────────────


Expand Down
32 changes: 32 additions & 0 deletions desloppify/tests/detectors/test_orphaned.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,38 @@ def test_entry_pattern_match_excluded(self, tmp_path):
assert len(entries) == 1
assert entries[0]["file"] == str(f3)

def test_sphinx_conf_py_excluded_by_python_entry_patterns(self, tmp_path):
"""A Sphinx ``docs/conf.py`` is an entry point, not an orphan.

Regression for PY_ENTRY_PATTERNS: ``conf.py`` is loaded by ``sphinx-build``
and has zero importers by design, so it must be recognized as an entry
point rather than flagged as a dead file.
"""
from desloppify.languages.python.phases import PY_ENTRY_PATTERNS

conf = _write_file(tmp_path / "docs" / "conf.py", lines=30)
orphan = _write_file(tmp_path / "dead.py", lines=30)
graph = {
str(conf): _graph_entry(importer_count=0),
str(orphan): _graph_entry(importer_count=0),
}

with patch(
"desloppify.engine.detectors.orphaned.rel",
side_effect=lambda p: str(Path(p).relative_to(tmp_path)),
):
entries, total = detect_orphaned_files(
tmp_path,
graph,
[".py"],
options=OrphanedDetectionOptions(
extra_entry_patterns=PY_ENTRY_PATTERNS
),
)

assert total == 2
assert [e["file"] for e in entries] == [str(orphan)]

def test_barrel_names_excluded(self, tmp_path):
"""Files matching barrel_names are not reported as orphaned."""
f1 = _write_file(tmp_path / "index.ts", lines=30)
Expand Down