Repository navigation
fix(python): resolve names re-exported through package namespaces - #2704
Merged
Merged
Conversation
A package __init__.py re-exports its submodules' names (explicit, star, `__all__`-restricted, renamed, chained through a subpackage, cyclic stars, the django `from .fields import *` shape) and callers reach them as module attributes (`pkg.Explicit()`) or through `from pkg import Name`. Each call must resolve by an lsp_* strategy to the re-exported definition. Names a star does not export (`__all__`-unlisted, private), a re-export the module shadows later, and one before a whole-namespace barrier must not. Plain from-imports of the defining module are the control. Both venues. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A lazy-module package publishes its names only under `if TYPE_CHECKING:` (the else branch installs the lazy module): `lz.Lazy()` resolves to the re-exported class. The compat shape imports a name under TYPE_CHECKING and again in `else: try:`: the runtime binding comes from the else branch, so `cmp.Shim()` must not reach the TYPE_CHECKING-side class. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A package `__init__.py` re-exports its submodules' names (`from .fields import *`, `from .x import Orig as Renamed`), and callers reach them as module attributes (`models.CharField()`) or through `from pkg import Name`. The Python namespace facts already capture each module's top-level bindings in order; nothing consumed them, so those calls fell to name guesses that pick the first same-named definition under the package. - Records: cbm_pxc_collect_all_defs replays each module's COMPLETE fact stream into re-export, star-import and `__all__` records next to its defs, including for an import-only `__init__.py` (zero defs). Records carry the raw module spec; the Python registry resolves it (relative exactly, absolute by unique scope tail, else unknown). - Fold: the shared Python registry folds the records once, after finalize and before the seal, into a sorted per-module export table (binary search; no lookup in the registration loop). Order-deterministic; a cycle contributes what it has so far. - States (conservative): an incomplete fact stream publishes nothing; a shadow, delete or whole-namespace unknown (a top-level try/if/with) withdraws the names it covers; a star whose source cannot be enumerated publishes nothing; a literal `__all__` restricts a star, else public names only. - Consult: the Python LSP looks a module attribute up in the export table on a miss (calls, expression types, callable values) and when binding `from pkg import Y` where Y is no def of pkg. - Namespace facts read a module-level `if TYPE_CHECKING:` consequence as executed: lazy-module packages publish their names there. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…ords cbm_pxc_collect_all_defs sizes the def array by every file's def count and appends a Python file's namespace records behind its defs. The growth check only made room for the records themselves, so the records consumed the slack reserved for later files and their defs overflowed the array (ASan: heap-buffer-overflow in pipeline_python_package_reexports_resolve_by_lsp). The array now grows to keep every later file's defs reserved on top of the records placed so far, geometrically, so the copying stays linear. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…ts again A package namespace read in the type-checker view takes the consequence of `if TYPE_CHECKING:` as executed (lazy-module packages publish their names there). The compat shape imports a name there and again in `else: try: from runtime.place import name`: the module's runtime export comes from the else branch, so publishing the TYPE_CHECKING source as the re-export target was a guess. Every name an elif/else import binds now becomes a shadow fact (the re-export is withdrawn, as for a later assignment); a star import or an import form the builder does not read there makes the whole namespace unknown. The branch is walked iteratively. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The re-export test checks a table of calls in one loop; a failure only reported the assertion line. Each failing call is now named, with its venue. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A namespace fact stream is checked by cbm_py_namespace_facts_valid, which accepts a shadow only for the value, decorated, unproven-class and unsupported reasons. The withdrawn TYPE_CHECKING re-export carried the compound reason, so the whole stream was rejected and the package published no names at all (a lazy-module package lost its TYPE_CHECKING exports). It now carries the unsupported reason: the builder cannot tell which source the name has at runtime. python_namespace_facts_type_checking_alternatives pins both shapes as complete, valid streams. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The memory-core linter forbids a new raw allocator site. Growing the def array for a Python file's namespace records needed a realloc because a spilled result's facts were taken as not peekable; its header carries the fact stream's status and size, though. cbm_pxc_collect_all_defs now adds each Python module's record bound to the first count (one header peek per spilled result, as the def count already took), writes the records into that reserved space, and needs no growth: the realloc and its bookkeeping are gone. An import-only package (zero defs) still gets its records. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The re-export records of a package come from its namespace facts. An incremental re-index that touches only the caller must still find them for the unchanged packages (restored from their stored facts). Both drivers now also run as a full index followed by an incremental re-index of app/main.py alone, with the same expectations. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A small incremental closure resolves each changed file through a per-file selection of defs (its own and its imported modules') instead of the shared registry. A package that re-exports a name keeps only the record there; the definition lives in the module the record points at, possibly through more re-exports, so a call through the package found nothing after an incremental re-index of the caller alone. cbm_pxc_filter_defs_for_file now follows every selected re-export or star record of a Python module to the module it names (relative exactly, absolute under the longest matching prefix, a member as a submodule) and selects that module's defs too, once per record, so the per-file LSP folds the same chain the shared registry does. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
py_lsp.c: #2697 moved py_lookup_attribute below the C3 code (own members, C3 order, then the depth-first walk) and declares it up front; the trivial definition this branch still carried next to its module-member lookup is dropped, the module-member block is kept, and the C3 section takes main's header. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.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.
What
A Python package exposes names its submodules define. It re-exports them in its
__init__.py, either explicitly or through a star import:A caller writes
models.CharField(...),from tests.common import MockConfigEntryor@testing.combinations(...). The LSP looked for such a name among the package's own definitions only. The package defines none of them, so the call fell through to the name-based fallbacks. Those pick the first same-named QN under the package (import_map_suffix) or any same-named definition (suffix_match). For sqlalchemy,@testing.combinationsresolved to_NullFixtureFunctions.combinationsinstead oftesting.config.combinations.Extraction has already recorded each module's ordered namespace facts since de1819e: own definitions, imports, star imports,
__all__, shadowing assignments, deletions and unknown statements. This change consumes them:Records (
pass_lsp_cross.c).cbm_pxc_collect_all_defsturns each complete namespace-fact stream intoPyReexport/PyStarImport/PyAllrecords next to the module's definitions. An import-only__init__.pyhas no definitions, but still gets its records. The def array is sized for the records up front. Import targets come from the same import map the resolver binds imports with. An ambiguous or unresolved import gets no record, which leaves that name unknown.Fold (
py_lsp.c,type_registry.c). The shared Python registry folds the records once: after finalize, before the seal. The result is a sorted export table,module.nameto the definition QN, plus the sorted list of modules. Lookups are binary searches and never mutate it. There is no lookup in the registration loop and no tail scan. The fold is order-deterministic: modules are sorted, records stay in source order, and a cycle contributes what it has so far.Conservative states. A fact stream that is not complete publishes nothing for its module. Each of these removes the names it covers, so the old fallbacks keep handling them:
del, or a name aTYPE_CHECKINGelse-branch imports again;try:/with/ call), which removes the whole namespace.A star whose source cannot be enumerated publishes nothing. A literal
__all__restricts a star. Without one, a star exports the public names.Consult (
py_lsp.c). The export table is checked on a module-attribute miss and onfrom pkg import YwhenYis no definition ofpkg. That covers calls, expression types and callable values. A re-export under another name (from .x import Orig as Renamed) joins aslsp_import_alias, with the written name asreason.Incremental. An incremental re-index of a closure of at most 8 files (
CLOSURE_BUDGET_FLOOR_FILES) resolves with a per-file selection of definitions rather than the shared registry. That selection now follows the selected re-export records to the modules they name, transitively, so the re-exported definitions are in the per-file registry too (pxc_mark_reexport_targets). Both full-index drivers always build the shared Python registry and never reach this path.No index-format change. Other languages are unchanged.
Known limit:
sql.cast,sql.select,sql.and_(sqlalchemy)These names travel
sqlalchemy.sql→sql.expression→sql._elements_constructors/_selectable_constructors. Two namespace barriers break the chain:lib/sqlalchemy/sql/__init__.pyends with a top-level call,__go(locals())(line 156). A top-level call is an unknown EFFECT: it can rebind any name of the module. So the wholesqlalchemy.sqlnamespace is withdrawn, and its 108 re-exports are not published.lib/sqlalchemy/sql/expression.pyhas attribute docstrings after its imports (nullsfirst = nulls_firstfollowed by"""Synonym for ..."""). The namespace facts (de1819e) prove only plain one-line strings safe. A triple-quoted string statement is therefore an unknown EFFECT, and it withdraws the 149 re-exports before it.sql.cast,sql.selectandsql.and_therefore still resolve by the old fallback (ColumnElement.cast,FunctionElement.select,BooleanClauseList.and_). This is deliberate: proving which top-level calls and string statements are harmless is a separate, narrower change and a follow-up.@testing.combinationsis behind neither barrier and is fixed (12 edges).Evidence
Measured at 12b3b84, merged with #2680 on main ea5c1f7, against ea5c1f7 + #2680. The two trees differ by this change only. Both are clean production builds. Corpus: 26 Python repos (manifest v1), macOS arm64,
--repeat 2, every repeat byte-identical (noise 0). The later commits are the incremental test leg, the per-file selection above (not reached by a full index), and a merge of main.SEMANTICALLY_RELATED (derived) moves +558/-416. Every other edge type is identical.
CALLS.
Every added edge is
lsp_*:lsp_methodlsp_callable_aliaslsp_constructorlsp_operator_dunderlsp_module_attrlsp_import_alias44,050 existing edges keep their target and move from a name guess to an
lsp_*strategy.Of the 4,892 removed edges, 4,882 are same-line retargets away from weak guesses:
suffix_match4,720 andimport_map_suffix102.Judged precision: seeded samples, read with their source snippets.
MockConfigEntryis re-exported bytests.commonfrompytest-homeassistant-custom-component, which alone accounts for 9,801.add_to_hasscalls.reverse/Template/forms.*, sympy functions, scrapyLinkExtractortoLxmlLinkExtractor, pandasregister_matplotlib_converters.Other types:
array_api_compatmodules (arange = get_xp(cp)(_aliases.arange)). Their reference to_aliases.arangehad been bound to the module's own variable. It is now a CALL_REFERENCE tocommon._aliases.arange.AbstractBaseUser.clean.Tests
pipeline_python_package_reexports_resolve_by_lsp. Shapes:__all__-listed name, both as a module attribute and throughfrom pkg import;db.modelsstar package, a variable typed through a re-export, and aTYPE_CHECKINGre-export whose else-branch binds other names only.Controls that must not resolve by the LSP: a name
__all__leaves out, a private name under a star, a re-export the module shadows later, a call before a top-leveltry:barrier, and aTYPE_CHECKINGre-export whose else-branch binds the same name again (a compattryimport). Four modes: sequential driver, parallel driver, and each followed by an incremental re-index of the caller alone. RED without the change, by name for the call that fails.python_namespace_facts_type_checking_alternatives(extraction): a name the else-branch of aTYPE_CHECKINGblock imports again becomes a SHADOW fact with a valid reason, and a star or unreadable alternative makes the namespace unknown.Affected suites on the merged tree pass (4,099 tests): complexity, extraction, extraction_imports, extraction_inheritance, grammar_imports, registry, pipeline, pipeline_semantic_manifest_repro, cross_repo, py_lsp, py_lsp_stress, py_lsp_scale, py_lsp_bench, the go / c / php / cs / ts / java / kotlin / rust LSP suites, lsp_resolution_probe, edge_imports, edge_structural, edge_types_probe, convergence_probe, matrix_known_classes, parallel, incremental, integration, index_resilience.
Lint: clang-format clean, the memory-core linter shows no growth, and cppcheck 2.20 reports nothing new. py_lsp.c has the same findings as on main.