From 51cf1481b69dffaaa2020c22860e14b47fed00d9 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 13 Aug 2026 13:30:24 +0100 Subject: [PATCH] fix(python): resolve relative subpackage imports to their __init__ (#2688) --- graphify/extract.py | 17 ++++++++++-- tests/test_python_import_resolution.py | 36 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index c8a4f0bb..a2e5fd34 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -124,6 +124,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401 _resolve_js_import_target, _resolve_js_module_path, _resolve_lua_import_target, + _probe_python_module_candidate, _resolve_python_module_path, _resolve_tsconfig_alias, _resolve_workspace_import, @@ -350,8 +351,20 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s base = Path(str_path).parent for _ in range(dots - 1): base = base.parent - rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" - target_path = base / rel + # A relative import can name a subpackage (a directory with an + # __init__.py), not a module file. Probing the candidate on disk + # (mirroring the companion `imports` edge's + # _resolve_python_module_path) resolves `graphs` -> graphs/__init__.py + # instead of a nonexistent graphs.py: without it the target keeps an + # absolute-path-derived slug that the target_file stamp below can't + # heal, so it dangles per-checkout (#2455). + candidate = base / module_name.replace(".", "/") if module_name else base + resolved = _probe_python_module_candidate(candidate) + if resolved is not None: + target_path = resolved + else: + rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" + target_path = base / rel tgt_nid = _make_id(str(target_path)) else: tgt_nid = _make_id(raw) diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index 754de21a..5de9e5a5 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -60,6 +60,42 @@ def test_ordinary_relative_import_still_resolves(tmp_path: Path): assert _has_edge(result, source_file, target_symbol, "imports") +def test_relative_subpackage_import_from_targets_package_init(tmp_path: Path): + # `from ...graphs import build_graph` where `graphs/` is a package (a dir + # with __init__.py, not a graphs.py module). The imports_from edge must + # target the package's __init__.py file node, matching the companion + # `imports` edge — not an absolute-scan-path slug for a nonexistent + # graphs.py that dangles per-checkout (#2455). + init = _write(tmp_path / "src/mypkg/__init__.py", "") + api_init = _write(tmp_path / "src/mypkg/api/__init__.py", "") + routes_init = _write(tmp_path / "src/mypkg/api/routes/__init__.py", "") + graphs_init = _write( + tmp_path / "src/mypkg/graphs/__init__.py", + "def build_graph():\n return {}\n", + ) + health = _write( + tmp_path / "src/mypkg/api/routes/health.py", + "from ...graphs import build_graph\n", + ) + + result = extract( + [init, api_init, routes_init, graphs_init, health], cache_root=tmp_path + ) + + health_file = _node_id(result, "health.py", "src/mypkg/api/routes/health.py") + graphs_pkg = _node_id(result, "__init__.py", "src/mypkg/graphs/__init__.py") + + assert _has_edge(result, health_file, graphs_pkg, "imports_from") + # No imports_from edge out of health may carry an unresolved absolute-path + # slug (the pre-fix `_src_mypkg_graphs_py` target). + health_targets = [ + e["target"] + for e in result["edges"] + if e["source"] == health_file and e["relation"] == "imports_from" + ] + assert all(t.endswith("graphs_init") for t in health_targets), health_targets + + def test_python_package_reexport_resolves_import_and_call_to_origin_symbol(tmp_path: Path): origin = _write(tmp_path / "pkg/foo.py", "def Foo():\n return 1\n") barrel = _write(tmp_path / "pkg/__init__.py", "from .foo import Foo as PublicFoo\n")