Skip to content

fix(python): resolve names re-exported through package namespaces - #2704

Merged
DeusData merged 13 commits into
mainfrom
fix/py-namespace-reexports-v2
Oct 19, 2026
Merged

DeusData merged 13 commits into
mainfrom
fix/py-namespace-reexports-v2

Conversation

@DeusData

@DeusData DeusData commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

What

A Python package exposes names its submodules define. It re-exports them in its __init__.py, either explicitly or through a star import:

# django/db/models/__init__.py
from django.db.models.fields import *        # CharField, IntegerField, ...
# tests/common.py (home-assistant)
from pytest_homeassistant_custom_component.common import MockConfigEntry
# sqlalchemy/testing/__init__.py
from .config import combinations

A caller writes models.CharField(...), from tests.common import MockConfigEntry or @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.combinations resolved to _NullFixtureFunctions.combinations instead of testing.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_defs turns each complete namespace-fact stream into PyReexport / PyStarImport / PyAll records next to the module's definitions. An import-only __init__.py has 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.name to 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:

    • a shadowing assignment, a del, or a name a TYPE_CHECKING else-branch imports again;
    • an unknown statement (a top-level 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 on from pkg import Y when Y is no definition of pkg. That covers calls, expression types and callable values. A re-export under another name (from .x import Orig as Renamed) joins as lsp_import_alias, with the written name as reason.

  • 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__.py ends 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 whole sqlalchemy.sql namespace is withdrawn, and its 108 re-exports are not published.
  • lib/sqlalchemy/sql/expression.py has attribute docstrings after its imports (nullsfirst = nulls_first followed 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.select and sql.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.combinations is 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.

type base this PR +added -removed same edge, new attributes
CALLS 1,355,199 1,376,407 26,100 4,892 44,050
TESTS 510,618 530,988 22,909 2,539 0
CALL_REFERENCE 26,924 27,532 608 0 0
HTTP_CALLS 7,582 8,162 580 0 0
USAGE 1,672,624 1,672,077 0 547 0

SEMANTICALLY_RELATED (derived) moves +558/-416. Every other edge type is identical.

CALLS.

  • Every added edge is lsp_*:

    strategy added
    lsp_method 20,440
    lsp_callable_alias 3,248
    lsp_constructor 1,569
    lsp_operator_dunder 738
    lsp_module_attr 77
    lsp_import_alias 20
  • 44,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_match 4,720 and import_map_suffix 102.

Judged precision: seeded samples, read with their source snippets.

  • home-assistant CALLS, 20/20. MockConfigEntry is re-exported by tests.common from pytest-homeassistant-custom-component, which alone accounts for 9,801 .add_to_hass calls.
  • CALLS in the other repos, 20/20. Examples: django reverse / Template / forms.*, sympy functions, scrapy LinkExtractor to LxmlLinkExtractor, pandas register_matplotlib_converters.
  • TESTS, 10/10.

Other types:

  • TESTS -2,539: these follow the CALLS retargets. 2,538 of the affected tests gain a TESTS edge to the correct target.
  • USAGE -547:
    • 320 come back as CALL_REFERENCE with the same source and target.
    • 161 are scikit-learn array_api_compat modules (arange = get_xp(cp)(_aliases.arange)). Their reference to _aliases.arange had been bound to the module's own variable. It is now a CALL_REFERENCE to common._aliases.arange.
    • The other 66 were cross-module name guesses, for example a django forms test bound to AbstractBaseUser.clean.

Tests

  • pipeline_python_package_reexports_resolve_by_lsp. Shapes:

    • explicit re-export, star re-export and __all__-listed name, both as a module attribute and through from pkg import;
    • a re-export under another name, a chain over three modules, and an import cycle;
    • a django-shaped db.models star package, a variable typed through a re-export, and a TYPE_CHECKING re-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-level try: barrier, and a TYPE_CHECKING re-export whose else-branch binds the same name again (a compat try import). 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 a TYPE_CHECKING block 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.

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>
…2690, #2695, #2696)

py_lsp.c: the module-member lookup of this branch and the C3 super() lookup of #2696 were added at the same place; both are kept.

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>
@DeusData
DeusData merged commit 0f6cc06 into main Oct 19, 2026
28 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.

1 participant