Repository navigation
fix(python): resolve imports from the package roots the project has - #1921
Merged
colbymchenry merged 8 commits intoOct 20, 2026
Merged
Conversation
This was referenced Sep 23, 2026
inth3shadows
added a commit
to inth3shadows/codegraph
that referenced
this pull request
Sep 24, 2026
…bymchenry#1704) Resolve against colbymchenry#1921: keep both sync resurrect paths, route the dotted module descent through resolveImportPath (package roots replace the removed findPythonModuleFile fallback), and keep findExportedSymbol for the single-member lookup.
inth3shadows
added a commit
to inth3shadows/codegraph
that referenced
this pull request
Sep 29, 2026
Tree = upstream main + the open PRs that still merge cleanly: colbymchenry#1925, colbymchenry#1957, colbymchenry#1962, colbymchenry#1955, colbymchenry#1965 and the current heads of colbymchenry#1842 and colbymchenry#1913. Upstream has already carried colbymchenry#1969-colbymchenry#1971, colbymchenry#1082 and colbymchenry#1791. Dropped until they are rebased onto upstream's resolver rewrite: colbymchenry#1704, colbymchenry#1921, colbymchenry#1926 and colbymchenry#1935.
inth3shadows
force-pushed
the
fix/python-module-roots
branch
2 times, most recently
from
October 3, 2026 16:23
09a7988 to
9324b0f
Compare
This was referenced Oct 11, 2026
inth3shadows
force-pushed
the
fix/python-module-roots
branch
from
October 14, 2026 18:05
9324b0f to
b9840f6
Compare
This was referenced Oct 14, 2026
…olbymchenry#1704) Python absolute specifiers never went through resolveImportPath. Every caller bolted on a suffix match against any `…/a/b/c.py`, which bound `import json` to a project's `app/utils/json.py` (and `yaml`, `requests`, `openai` to any same-named nested file) and picked an arbitrary twin when two services both carry `core/models.py`. Python named imports couldn't resolve through the import at all: Python nodes are never isExported, and re-exports were JS-only. One resolver now answers every Python specifier, relative and absolute, from two kinds of evidence-derived root: - project roots: the repo root, and each service with its own pyproject.toml/setup.cfg/setup.py, plus its `src/` and the package dirs its config declares (read from the right tables, nested configs included). These may answer any importer, but only unambiguously. A service inside a package, or under a directory named as non-project code (tests, fixtures, examples, vendor, ...), only answers within that directory. - local roots: the parent of each top-level regular package, a script's own directory, and a namespace package seen from inside it. These answer only importers inside them, so a fixture's `tests/fixtures/proj/requests/` never becomes the application's `requests`. A containing root answers first. Among other roots, a regular package or module shadows a namespace portion. In one directory `pkg/__init__.py` beats `pkg.py`, and a dotted path is walked level by level, so a plain module ends it. A relative import can't climb above its top-level package. `from P import N` looks N up on the package first, as CPython's `_handle_fromlist` does: a def or re-export of N in `P/__init__.py` beats a submodule `P/N`, which answers only when the package doesn't bind N. Module-level defs are importable without an export flag, and a module's `from X import …` lines are its re-exports, chased with the existing cycle guard and depth cap. `from m import *` honours a literal `__all__`, and otherwise binds only public names. Import parsing reads parenthesised lists, continuations, `import a, b` and indented imports over comment- and docstring-blanked source. An attribute of an imported function (`task.delay()`) keeps the unresolved answer it had before, rather than landing on the function. Sync: the Python root set (each root with its tier and reach) is fingerprinted in project_metadata. When it changes, every Python resolution edge is re-opened (reusing the CG-33 resurrect helper) and failed Python refs are requeued. When only modules or packages below the roots appear or vanish, sync re-opens just the edges into those module names, the edges out of a changed package, and the parked failures naming them. When a package's `__init__.py` is edited, it re-opens its importers' references to the names whose binding moved (a def or re-export added, removed or repointed). The narrow sets are resolved directly; either way sync converges to what a fresh index produces. Trade-off: a package nested under a directory named as non-project code (third_party/, vendor/, examples/, tests/, ...) doesn't answer imports from outside that directory, so a `third_party/foo` used through an editable install is unresolved: a missed edge rather than a fixture capturing the application's imports.
…inds to a nested copy of it
`from requests import get; get("x")` still linked the call and the import to
`get` in a copy of `requests` nested in a test fixture. The bare-call gate
decided whether the project provides `requests` by path suffix, so any file
ending in `requests/__init__.py` counted. It now asks the import resolver,
which finds modules from the package roots, through a new optional
`resolveModuleFile` on the resolution context. A python `imports` reference for
a name from a module the project does not provide is not name-matched either.
inth3shadows
added a commit
to inth3shadows/codegraph
that referenced
this pull request
Oct 15, 2026
inth3shadows
force-pushed
the
fix/python-module-roots
branch
from
October 15, 2026 14:50
b9840f6 to
8a63b2a
Compare
…ever binds a same-named project symbol `from scipy import stats` bound `stats` by exact name to a top-level `stats` in another project file, and reported an import cycle when that file imports this one. The stdlib form and the aliased form (`from scipy import stats as st`) did the same. Package-root resolution already removes the edge; these cases fail on main and pass here.
danusha2345
pushed a commit
to danusha2345/codegraph
that referenced
this pull request
Oct 19, 2026
…Go field/interface types, workspace globs, baseUrl imports) Conflicts: name-matcher's receiver typing takes main's TS array/indexed-property block, then our Java branch (colbymchenry#1942) and Go outside-type check (colbymchenry#1954); `tsFieldCall` (main's new home of the this-field tie-break) keeps colbymchenry#1983's code-unit ordering; types.ts / index.ts keep colbymchenry#1921's `resolveModuleFile` beside main's `isDeclaredPackage` / `isIndexedFile`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep both CHANGELOG bullets. Both sides added resolver-context methods: keep resolveModuleFile next to main's isDeclaredPackage and isIndexedFile (types and coordinator), with main's newer wording of the path-aliases doc, and import the union of both lists from the import resolver.
Keep both CHANGELOG bullets.
Two pieces of main reached Python modules the old way, which this branch replaces with package roots: - A function-local `import a.b as X` (PR 2607) found its module with the suffix match and its member with pythonModuleSymbol, both gone here. It now resolves the module like any other import and reads the member with findExportedSymbol, as the module-level path below it. - The outside-module receiver check (PR 2606) split out isPythonProjectModule without the importer, which the root-aware lookup needs to key its memo and to ask resolveModuleFile; it now takes the importing file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The import mappings apply to every function of the file, so reading `import X as Y` at any indentation bound a function's own alias in the others: a parameter named like it, or a module-level import of the same name in another function, took that function's module (the two scoping tests from PR 2607). Plain imports are read at module level again, keeping this branch's comma lists and backslash continuations; a function-local `import a.b as X` binds X in its own function only, through pythonFunctionModuleAlias. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 20, 2026
colbymchenry
added a commit
that referenced
this pull request
Oct 21, 2026
… indexing (#2734) python-module-resolution.test.ts removed a test's temp dir only when the test never opened a CodeGraph (`if (cg) cg.destroy(); else if ...`), and destroy() closes the database without deleting anything. So every test that indexed left its codegraph-pymod-* dir behind, 47 per full run, passing or not, since PR #1921 added the file. The dev box's %TEMP% held 1,739 of them by 2026-10-21. The dir is now removed after destroy(), as the other test files do. Checked with a private TEMP: main's file leaves 47 codegraph-pymod-* dirs, this one none, and its 50 tests pass both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danusha2345
pushed a commit
to danusha2345/codegraph
that referenced
this pull request
Oct 21, 2026
…ymchenry#1921, colbymchenry#1935, colbymchenry#1955, colbymchenry#1957, colbymchenry#1965, colbymchenry#2575, colbymchenry#2595, colbymchenry#2597, colbymchenry#2603–colbymchenry#2608, colbymchenry#2611 landed upstream) Conflicts: files owned by the foreign PRs now in main (colbymchenry#1921's Python import resolver and tests, colbymchenry#1935's class-strategy method) take main's version; our colbymchenry#1954/colbymchenry#1942/colbymchenry#1945/colbymchenry#1939/colbymchenry#1715/colbymchenry#1951/colbymchenry#2308 hunks sit beside main's new Rust and Python blocks as in the rebased PR branches; java.rs keeps the integration's `constant_declaration` fields under main's initializer walk; three CHANGELOG lines the merged PRs had already contributed are deduplicated, and the method blocks the merge duplicated in queries.ts and extraction/index.ts (the resolution-edge queries and the sync resurrection helpers, present once on main) are kept once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Oct 30, 2026
colbymchenry
added a commit
that referenced
this pull request
Oct 31, 2026
… again (#3141) PR #1921 made Python imports resolve from the package roots a project has, and stopped binding by any file whose path ends in the module's name. A folder that is neither a root nor the importer's own is skipped on purpose, so this common script layout lost its links: redis's modules/vector-sets/tests/*.py do from test import TestCase, and modules/vector-sets/test.py is the runner whose folder is first on sys.path. No root says so. On redis, main lost 137 true edges (69 imports, 68 calls) that 1.6.2 had. As a last step, resolvePythonModule now walks the importer's ancestors for the module, nearest first and passing over packages. A name like test or utils may just as well be the stdlib's, so what proves the module is what the importer takes from it: every name must be one the file defines at module level. A plain import or a star import takes no name, so it proves nothing and stays unresolved, as on main. A module a root provides still answers first. Reading the importer's own from-imports keeps this inside the resolver, so every caller gets one answer: context.resolveModuleFile finds the module too, and the outside-the-project check from PR #3109 does not count it as outside. A sync follows it. Such a module binds only while it defines those names, so an edit can link or unlink importers though no file appeared or vanished. An edited module in a folder that is neither a package nor a root, with Python folders below it, now re-opens the edges into it and the failures naming it. Any other edit re-opens nothing. Validation: - 9 new tests in python-module-resolution.test.ts, 2 of them on sync. Without the fallback the 4 that expect a link fail, and the 5 that expect none pass, as they should. Here all 9 pass. Taking out each guard (every name, plain imports, packages passed over, the nearest module with the names, the re-open on edit) fails exactly its own test. - A/B against main on 14 Python repos plus express and gin, 741,560 edges: 173 added, 0 removed, 0 moved, 44 extends upgraded from exact-match to import, all in redis. All 137 edges 1.6.2 had and main lost are back. The other 36 added are the file-level imports of test.py, as a from-import from the same folder gets. The controls' full dumps are byte-identical. - A trace of the fallback found every candidate it could see on those repos and on whole django, polar's server, ansible and cpython: redis's test.py from its 36 test files, all proven, and nothing else, so no wrong edge and no rejected candidate. No plain or star import had a candidate, so leaving those unresolved costs nothing on these repos. - Index cost on redis, median of 3 interleaved: 12.0 s on main, 10.7 s here, which is noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry
added a commit
that referenced
this pull request
Oct 31, 2026
…der links again (#3155) PR #1921 made Python imports resolve from the package roots a project has. A file's own folder counts as a root only when it holds no __init__.py, because in a real package a bare `from utils import x` must not take a sibling over the true top-level module. That also lost this layout: rocksdb's tools/block_cache_analyzer/block_cache_pysim_test.py does `from block_cache_pysim import (...)` beside block_cache_pysim.py, in a folder that holds an __init__.py. A script run from there has that folder first on sys.path all the same. The fallback from PR #3141 now starts at the importer's own folder, still after every root has answered, so a top-level module a root provides keeps winning. A package above the importer stays passed over. Proving the names is not enough beside a file. A trace of every candidate found that most bare names with a module beside them are the stdlib's, and the proof passes on django/utils/text.py, whose `from html import escape` would have taken django/utils/html.py. So two checks now come with it. A name in sys.stdlib_module_names stays the stdlib's, above a script as well as beside it; CPython's `test` package isn't on that list, so redis's `from test import TestCase` is unaffected. And a module that imports its own name wraps the real one, which is what that name then means. That covers a wrapper's neighbours and the wrapper itself, so a file is never its own answer. A sync follows it. An edit re-opens a module's edges only if an import node spells that module from a folder that can reach it: a file in a package folder, for the path from that folder, or a file anywhere below a plain folder that isn't a root. Those spellings are looked up by name, one per folder above the module, so an edit nobody imports that way re-opens nothing, even for a hub like mypy/types.py, and costs nothing measurable. It also closes the gap PR #3141 left open, a dotted module above a script, and a module that is a package. The [Unreleased] bullet from PR #1921 gains a clause. No new bullet, because the regression never shipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Split from #1704, as the second of its three focused pieces. It is independent of the reworked #1704 and based directly on
main. Whichever lands second needs a small rebase.Problem: Python imports were resolved in two places. Relative specifiers went through the import resolver. Absolute ones (
pkg.sub.mod) fell back to a filename-suffix match, which could bindimport jsonto a projectserializers/json.py, andimport openaitollm/openai.py.src/layouts, monorepo services,__init__.pyre-exports and parenthesised imports were missed or guessed.Change: a single resolver now serves every Python import.
pyproject.toml/setup.cfg/setup.py, itssrc/, the package dirs its config declares, and a script's own folder. A root inside the importer wins, nearest first. Otherwise exactly one project root must provide the module; if two do, no edge is drawn.requests/can't capture the app'simport requests.pkg/__init__.py) beatspkg.py, which beats a namespace directory.from pkg import N, a name thatpkg/__init__.pydefines or re-exports beats a submodulepkg/N.py.__all__.import a, b, and ignores docstrings and comments.__init__.py's bindings, or editing the build config (viacodegraph sync; the file watcher doesn't track build configs yet) now converges to the same graph a fresh index would build. Only affected importers are re-resolved, so a no-op sync re-opens nothing.Overlap: this also fixes the case in #1518 (thanks @JacquesBLR):
from mod import fn as aliasfollowed by a barealias()call now gets itscallsedge. It also fixes #1517's case (thanks @JacquesBLR): a relative import list wrapped after its first name,from .services import (a,\n b). #1517's own test fails on currentmainand passes here. #1453's case (aliased module imports) was already fixed onmainby #1785, and still passes here.It also fixes #2566 (thanks @kogriv):
from scipy import stats(orfrom os import path, orfrom scipy import stats as st) no longer binds the name by exact match to a same-named variable or function in another project file, so the import cycle that edge reported is gone. The issue's cases are pinned inpython-module-resolution.test.ts; they fail onmainand pass here.Validation:
python-module-resolution.test.ts, many of them sync-vs-fresh differential tests. Full engine suite: 4563 passed.import json→serializers/json.pyand stdlibhtml.escape→django/utils/html.py.db/models/__init__.pyin 2.2 s (upstream 1.2 s, but upstream's result doesn't match a fresh index).examples/fixed, and the fakeopenailink dropped.python3runtime behaviour.Trade-offs:
third_party/foo(with its own pyproject, installed viapip install -e) and imported asimport foois left unresolved. That's a missed edge, chosen over letting fixtures capture the app's imports.if TYPE_CHECKING:in__init__.pyis treated as bound. That follows mypy/pyright rather than the runtime, where the submodule would win.Not verified: native Windows, and
sys.path/.pthmanipulation.