diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 7346882f15..bcaa6abb3a 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2036,6 +2036,49 @@ def _resolve_python_module_path(module_name: str, current_path: Path, root: Path return cand return None +def _resolve_python_namespace_dir(module_name: str, current_path: Path, root: Path, level: int) -> "Path | None": + """The directory a ``from import ...`` names when that module is a + PEP 420 namespace package: a directory under the scan root with no + ``__init__.py``. ``_resolve_python_module_path`` returns None for it (there is + no module file to probe), so a package that omits ``__init__.py`` -- which + ``python -m pkg.mod`` runs without complaint -- had every ``from . import + sibling`` dropped whole, and with it every ``sibling.func()`` call the #1883 + module arm would otherwise have resolved: the most-called functions in such a + repo carried in-degree 0. Mirrors that resolver's walk (relative base, then + scan root, then sys.path-root ancestors) and returns only a directory that + exists inside the root.""" + def _namespace(candidate: Path) -> "Path | None": + if not candidate.is_dir() or (candidate / "__init__.py").is_file(): + return None + try: + candidate.resolve().relative_to(root.resolve()) + except ValueError: + return None + return candidate + + if level > 0: + base = current_path.parent + for _ in range(level - 1): + base = base.parent + return _namespace(base / module_name.replace(".", "/") if module_name else base) + if not module_name: + return None + rel = module_name.replace(".", "/") + hit = _namespace(root / rel) + if hit is not None: + return hit + for anc in current_path.parents: + try: + anc.relative_to(root) + except ValueError: + break # left the scan root; stop walking up + if anc == root or (anc / "__init__.py").is_file(): + continue # root already probed; a package dir is not a sys.path root (#2072) + hit = _namespace(anc / rel) + if hit is not None: + return hit + return None + def _python_top_level_function_bodies(path: Path, root_node, source: bytes) -> list[tuple[str, object]]: bodies: list[tuple[str, object]] = [] stem = _file_stem(path) @@ -2081,13 +2124,21 @@ def _collect_python_symbol_resolution_facts( continue level, module_name = module target_path = _resolve_python_module_path(module_name, path, root, level) - if target_path is None: - continue - # #1146: `from pkg import submod` — if the target is a package - # (__init__.py) and an imported name matches a submodule file on - # disk, emit a file-level import edge to that submodule rather - # than only to the package. - pkg_dir = target_path.parent if target_path.name == "__init__.py" else None + if target_path is not None: + # #1146: `from pkg import submod` — if the target is a package + # (__init__.py) and an imported name matches a submodule file on + # disk, emit a file-level import edge to that submodule rather + # than only to the package. + pkg_dir = target_path.parent if target_path.name == "__init__.py" else None + else: + # A PEP 420 namespace package: the module names a directory with + # no __init__.py, so there is no module file to resolve to, but + # the names it imports can still be submodule files on disk. + # Without this branch `from . import brain` in such a package + # emitted nothing, and `brain.think()` never became an edge. + pkg_dir = _resolve_python_namespace_dir(module_name, path, root, level) + if pkg_dir is None: + continue for imported_name, local_name in _python_imported_names(node, source): line = node.start_point[0] + 1 if pkg_dir is not None: @@ -2097,6 +2148,8 @@ def _collect_python_symbol_resolution_facts( if submodule is not None: facts.module_imports.append((path, submodule, line, local_name)) continue + if target_path is None: + continue # a namespace package owns no symbols of its own to bind facts.imports.append( _SymbolImportFact(path, local_name, target_path, imported_name, line) ) diff --git a/tests/test_extract.py b/tests/test_extract.py index 5a41b297cc..5597c66889 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1769,6 +1769,95 @@ def test_python_relative_from_import_alias_module_call_resolves(tmp_path): assert edges[0]["confidence"] == "EXTRACTED" +def test_python_namespace_package_submodule_imports_resolve_member_calls(tmp_path): + """A PEP 420 namespace package -- a directory with no __init__.py, which + `python -m pkg.mod` runs without complaint -- must resolve `from . import + brain, ledger` to its sibling module files and then `brain.think()` / + `ledger.write()` through the #1883 module arm, exactly as a regular package + does. Before this fix the module path resolved to nothing (no __init__.py to + probe), the whole statement was skipped, and the most-called functions in + such a repo carried in-degree 0 in the graph.""" + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "brain.py").write_text("def think(q):\n return q\n") + (pkg / "ledger.py").write_text("def write(e):\n return e\n") + caller = pkg / "agent.py" + caller.write_text( + "from . import brain, ledger\n\n" + "def cycle(q):\n" + " ledger.write(q)\n" + " return brain.think(q)\n" + ) + result = extract( + [caller, pkg / "brain.py", pkg / "ledger.py"], cache_root=tmp_path, root=tmp_path, + ) + nodes = {n["id"]: n for n in result["nodes"]} + + def calls(callee: str, in_file: str) -> list[dict]: + return [ + e for e in result["edges"] + if e["relation"] == "calls" + and "cycle" in nodes[e["source"]]["label"] + and callee in nodes[e["target"]]["label"] + and in_file in (nodes[e["target"]].get("source_file") or "") + ] + + think, write = calls("think", "brain.py"), calls("write", "ledger.py") + assert len(think) == 1 and think[0]["confidence"] == "EXTRACTED", think + assert len(write) == 1 and write[0]["confidence"] == "EXTRACTED", write + imported = { + nodes[e["target"]]["label"] for e in result["edges"] + if e["relation"] == "imports_from" + and nodes.get(e["source"], {}).get("label") == "agent.py" + and e["target"] in nodes + } + assert {"brain.py", "ledger.py"} <= imported, imported + + +def test_python_namespace_package_absolute_and_parent_relative_forms(tmp_path): + """The same gap in its other spellings: `from pkg import brain` (absolute, + with the scan root as the namespace package's parent) and `from .. import + brain` from a nested namespace subpackage.""" + pkg = tmp_path / "pkg" + sub = pkg / "sub" + sub.mkdir(parents=True) + (pkg / "brain.py").write_text("def think(q):\n return q\n") + absolute = pkg / "abs_caller.py" + absolute.write_text("from pkg import brain\n\ndef use_abs(q):\n return brain.think(q)\n") + nested = sub / "deep_caller.py" + nested.write_text("from .. import brain\n\ndef use_deep(q):\n return brain.think(q)\n") + result = extract([absolute, nested, pkg / "brain.py"], cache_root=tmp_path, root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + callers = [ + nodes[e["source"]]["label"] for e in result["edges"] + if e["relation"] == "calls" and "think" in nodes[e["target"]]["label"] + and e["confidence"] == "EXTRACTED" + ] + assert any("use_abs" in c for c in callers), callers + assert any("use_deep" in c for c in callers), callers + + +def test_python_namespace_package_import_of_non_module_fabricates_nothing(tmp_path): + """A namespace-package import whose name is not a module file on disk -- a + data directory, or a name that does not exist -- must add no resolved edge + and must not raise. A namespace package owns no symbols of its own to bind, + so there is nothing to fall back to.""" + pkg = tmp_path / "pkg" + (pkg / "data").mkdir(parents=True) + (pkg / "data" / "rows.csv").write_text("a,b\n") + caller = pkg / "loader.py" + caller.write_text("from . import data, missing\n\ndef load():\n return data.read()\n") + result = extract([caller], cache_root=tmp_path, root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + fabricated = [ + e for e in result["edges"] + if e["relation"] in ("calls", "imports_from") + and nodes.get(e["source"], {}).get("label") in ("loader.py", "load()") + and e["target"] in nodes + ] + assert fabricated == [], fabricated + + def test_python_external_aliased_import_fabricates_no_call_edge(tmp_path): """#2082 must not over-resolve: an aliased import of an EXTERNAL/uncorpus module (`import numpy as np; np.array()`) has no in-corpus callee, so it must