diff --git a/desloppify/languages/python/detectors/deps_resolution.py b/desloppify/languages/python/detectors/deps_resolution.py index ef770c22c..10eb357a4 100644 --- a/desloppify/languages/python/detectors/deps_resolution.py +++ b/desloppify/languages/python/detectors/deps_resolution.py @@ -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 (``/``) and the + ``src`` layout (``/src/``) 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: @@ -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", diff --git a/desloppify/languages/python/phases.py b/desloppify/languages/python/phases.py index daeb851fb..0f78cb780 100644 --- a/desloppify/languages/python/phases.py +++ b/desloppify/languages/python/phases.py @@ -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) diff --git a/desloppify/languages/python/tests/test_py_deps.py b/desloppify/languages/python/tests/test_py_deps.py index 7ee69466a..2ec0fe568 100644 --- a/desloppify/languages/python/tests/test_py_deps.py +++ b/desloppify/languages/python/tests/test_py_deps.py @@ -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 ``/src/mypkg/main.py`` resolves to + ``/src/mypkg/schema.py``. Before the fix, only ``/mypkg`` and + ``/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 ────────────────────────────────────── diff --git a/desloppify/tests/detectors/test_orphaned.py b/desloppify/tests/detectors/test_orphaned.py index dff923f1b..1830bdb65 100644 --- a/desloppify/tests/detectors/test_orphaned.py +++ b/desloppify/tests/detectors/test_orphaned.py @@ -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)