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
67 changes: 60 additions & 7 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <module> 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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
)
Expand Down
89 changes: 89 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down