From 7d42e2935b2b1a8fbc633ef178fe47f88bd0da13 Mon Sep 17 00:00:00 2001 From: Will Frey Date: Wed, 10 Jun 2026 22:31:48 -0400 Subject: [PATCH 1/2] fix(python): resolve absolute imports under the src/ layout The Python import resolver only tried / and / when resolving an absolute import such as `from pkg.sub import x`. In a src/ layout (the PyPA-recommended packaging layout) the package lives at /src/pkg, which was never tried, so the edge resolved to nothing: the imported module was recorded with zero importers and misreported as orphaned, and every other graph-based detector (coupling, single-use, facade) silently lost those edges too. Factor the candidate roots into candidate_source_roots() and add the src/ variants, tried after the existing flat roots. The change is strictly additive: any import that resolved before resolves to the same file; only previously-unresolved src/-layout imports gain an edge. --- .../python/detectors/deps_resolution.py | 48 ++++++++++++++----- .../languages/python/tests/test_py_deps.py | 30 ++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) 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/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 ────────────────────────────────────── From aa2b74908be24a23c8d5b452ffbc0346977b75f4 Mon Sep 17 00:00:00 2001 From: Will Frey Date: Wed, 10 Jun 2026 22:31:48 -0400 Subject: [PATCH 2/2] fix(python): treat Sphinx conf.py as an entry point docs/conf.py is loaded by sphinx-build and has zero importers by design, so it was misreported as an orphaned file. Add conf.py to PY_ENTRY_PATTERNS alongside the other framework entry points (manage.py, wsgi.py, settings.py). --- desloppify/languages/python/phases.py | 1 + desloppify/tests/detectors/test_orphaned.py | 32 +++++++++++++++++++++ 2 files changed, 33 insertions(+) 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/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)