Skip to content

fix(resolver): resolve calls through absolute Python imports - #825

Merged
zzet merged 1 commit into
zzet:mainfrom
protereus:fix/python-absolute-import-calls
Sep 23, 2026
Merged

zzet merged 1 commit into
zzet:mainfrom
protereus:fix/python-absolute-import-calls

Conversation

@protereus

Copy link
Copy Markdown
Contributor

Summary

Resolves calls made through absolute Python imports of first-party modules (from shop.pricing import apply_discount; apply_discount()) to the function or class they name, instead of a dep:: stub. Until now these calls were lost from the graph, so get_callers, get_test_targets and analyze kind=untested missed them. Pytest files import the code under test this way, so on Python repos most tested functions were reported untested.

Fixes #824.

Changes

  • internal/resolver/python_absolute_imports.go: resolvePythonModuleExtern, called at the top of resolveExtern. It runs only for .py/.pyi callers whose extern import path is dotted (no /), and tries the two readings the Python extractor produces:

    1. the symbol defined in the module the path names: shop.pricing::apply_discount, from import shop.pricing as p; p.apply_discount() or from shop import pricing; pricing.apply_discount();
    2. the path's last segment defined in its parent module: shop.pricing.apply_discount::apply_discount, from from shop.pricing import apply_discount. The local name is ignored, so import ... as alias resolves too.

    A module a.b matches a/b.py, a/b.pyi or a/b/__init__.py, at the root or under a source root / repo prefix (src/a/b.py). Only KindFunction / KindType candidates count, because an import binds module attributes and never class members. The step resolves only on a unique match: when the module exists in more than one repo, it prefers the caller's repo; two same-repo matches (a src/ and a build/lib/ copy, say) are left unresolved rather than guessed. Anything it doesn't resolve falls through to the existing directory match unchanged.

  • internal/resolver/resolver.go: the hook, plus callerRepo hoisted above it so the Python step and Pass 1 share one lookup.

  • internal/resolver/repo_language_lookup.go: warmRepoLanguageNameCache also warms the imported name (reading 2) for Python extern edges, so the step doesn't add a store round-trip per edge.

  • internal/resolver/python_absolute_imports_test.go: a table test for the module/file matcher, plus resolveExtern tests for from-import, alias, class, module-attribute call, package __init__.py, methods ignored, wrong module, third-party, ambiguous layout, caller-repo preference, and a Go caller left untouched.

  • internal/indexer/python_absolute_import_test.go: TestIndex_PythonAbsoluteImportCallsResolve, end to end through the real extractor: a src/-layout package and a pytest file using a plain import, an alias, a module attribute and a class. Each test function must get both calls and tests edges to the first-party definition.

Effect

On the two public src/-layout projects from #824 (Python extractor only, in-process indexing):

requests before requests after click before click after
first-party calls edges landing on dep:: stubs 171 33 416 133
src/ functions + methods with an EdgeTests edge 32 / 268 63 / 268 72 / 583 82 / 583
index time 0.6 s 0.6 s 0.9 s 1.0 s

What remains is mostly re-exports through a package __init__.py (click's tests call click.echo, which click/__init__.py re-imports from click.utils). As noted in the issue, that case is out of scope here.

Testing

  • All tests pass (go test -race ./...), with the scope noted below
  • New tests added for new functionality
  • Benchmarks run if performance-relevant: not run. The step adds one warmed name per Python extern edge and a path check per candidate; index time on the two projects above is unchanged within noise.

go test -race passes on ./internal/resolver/, ./internal/parser/languages/, ./internal/analysis/ and ./internal/entrypoints/. I ran the affected packages rather than the full suite. In ./internal/indexer/, everything passes except TestBuildCommitLayerNeverFetchesPromisedRenameBlobs, the promisor-fixture test that also fails on unmodified main on this machine (local git 2.43.0), as noted in #796. golangci-lint v2.13.1 reports 0 issues on ./internal/resolver/ and ./internal/indexer/. With the hook removed, the six positive resolver tests and the indexer test fail; the negative cases (methods, wrong module, third-party, ambiguous, Go caller) pass either way, as they should.

Checklist

  • Code follows existing patterns in the codebase: a language-specific pass in its own file, like relative_imports.go, and extractor-backed indexer tests like the Pydantic and Alembic ones
  • No unnecessary abstractions added
  • Language extractor includes Meta["methods"] for interfaces (if applicable): n/a
  • Methods have EdgeMemberOf edges to their containing type (if applicable): n/a

🤖 Generated with Claude Code

A call through an absolute import of a first-party module, e.g.
`from shop.pricing import apply_discount; apply_discount()`, reaches
resolveExtern as `shop.pricing.apply_discount::apply_discount`. The
extern match there is Go-shaped (the candidate's directory must end in
the import path's last `/` component), and a dotted module path never
matches, so the call fell through to a `dep::` stub. get_callers,
get_test_targets and `analyze kind=untested` all missed it; since pytest
files import the code under test this way, most tested Python functions
were reported untested.

resolvePythonModuleExtern runs first for .py/.pyi callers with a dotted
import path. It tries the symbol defined in the module the path names
(`import shop.pricing as p; p.apply_discount()`), then the path's last
segment defined in its parent module (`from shop.pricing import
apply_discount [as alias]`, which also covers aliases). A module `a.b`
matches `a/b.py`, `a/b.pyi` or `a/b/__init__.py`, at the root or under
a source root such as `src/`. Only module-level functions and classes
are candidates. It resolves only on a unique match, preferring the
caller's repo, and otherwise falls through to the existing logic, so
non-Python callers and third-party imports are unchanged. The cache
warm-up also warms the imported name, so the step adds no store
round-trip per edge.

On psf/requests, first-party calls landing on `dep::` stubs drop from
171 to 33 and functions with a direct test edge rise from 32 to 63 of
268. Re-exports through a package __init__.py are not chased; that is
left for a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zzet
zzet merged commit 08121ac into zzet:main Sep 23, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python calls through absolute imports of first-party modules resolve to dep:: stubs

2 participants