Skip to content

gh-158597: Resolve lazy submodules with the declaration in scope - #159476

Closed
xv1rcn wants to merge 1 commit into
python:mainfrom
xv1rcn:gh-158597-lazy-declaration-scope
Closed

xv1rcn wants to merge 1 commit into
python:mainfrom
xv1rcn:gh-158597-lazy-declaration-scope

Conversation

@xv1rcn

@xv1rcn xv1rcn commented Oct 15, 2026

Copy link
Copy Markdown

gh-158597: Resolve lazy submodules with the declaration in scope

lazy import xml.missing records a pending child in interp->imports.lazy_pending_submodules, keyed only by (parent, child):

pending["xml"]["missing"] =

register_lazy_on_parent() writes that slot with PyDict_SetItem(), so a later declaration of the same name by another module replaces the earlier one. When a module then evaluates xml.missing, _PyImport_TryLoadLazySubmodule() reads that one slot and _PyLazyImport_LoadChild() copies lz_code / lz_instr_offset out of it, so the chained ImportError reports whichever declaration was registered last, no matter who is accessing.

Measured before the change (main at 64018ac, debug build):

declarations, in order reported location
A only A.py:1
B, then A A.py:2
A, then B B.py:1
A, B, C C.py:1

This keys the declarations by the declaring module and selects one at access time from the frame's globals, so a name resolves with the declaration in scope where the access is written:

pending["xml"]["missing"] == {"A": , "B": } # declarations pending["xml"]["dom"] == None # pending, no declaration

A declaration made by another module still names the same child and imports the same thing, so it is still used when there is nothing in scope. It is not reported as a frame of the accessing module's traceback: lz_attributed on the placeholder records whether the location belongs to the module performing the access, and lazy_import_add_exception_cause() only adds a frame when it does. The chained ImportError then carries no frame rather than pointing into an unrelated file, and the exception type is unchanged.

One visible change beyond the reported case: _PyLazyImport_LoadChild() takes lz_builtins from the declaration it is given, so the selected declaration also decides which __import__ runs. An access with a declaration of its own in scope now runs that module's hook instead of the last-registered one's — the case Yhg1s described in the gh-158521 review ("module A may end up calling module B's __import__ when it does its reification"), and the principle test_namespace_import_hooks already covers for placeholders. It does not cover it completely: an access with no declaration in scope still resolves with the most recently registered one, as before, so that case is unchanged.

Remaining gaps:

  • Two modules sharing the same __name__ are still indistinguishable, as noted in the gh-158140: Resolve lazily imported sibling submodules independently #158521 review discussion.
  • add_lazy_declaration() reads and then writes the per-child dict. The surrounding code already reads lazy_pending_submodules without a lock in _PyImport_TryLoadLazySubmodule(), and the consequence here is only that concurrent registrations of the same child can drop one declaration (the child stays pending and is resolved by the remaining one). I did not add locking because it would be a separate change; happy to do it if you want it in this PR.

Testing

  • ./python -m test test_lazy_import test_import test_importlib test_module test_sys test_capi — 3267 tests pass (debug build); -R 3:3 clean.
  • The same on a --disable-gil --with-pydebug build.
  • Two regression tests in ErrorHandlingTests, both failing on unmodified main:
  • test_pending_child_reports_accessing_module_declaration
  • test_unattributed_access_is_not_given_another_declaration
  • NEWS entry added.

AI assistance was used to locate the cause and to draft the change and the tests. I have reviewed the analysis and the patch and can explain every part of the diff.

@python-cla-bot

python-cla-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

A pending child in `lazy_pending_submodules` is keyed only by
`(parent, child)`, so a later declaration of the same name by another module
replaces the earlier one. Resolving the child then copies the location out of
the surviving declaration, so the chained `ImportError` points into the file
of a module that merely declared the name.

Key the declarations by the declaring module, and select one from the frame's
globals at access time, so the child resolves with the declaration in scope
where the access is written. A declaration made by another module still names
the same child and imports the same thing, so it is still used when there is
nothing in scope; its location is then not reported as a frame of the
accessing module's traceback.

The selection also decides which `__import__` runs, since
`_PyLazyImport_LoadChild()` takes `lz_builtins` from the declaration it is
given. An access that has a declaration of its own in scope now uses that
module's hook instead of the last-registered one's — the case Yhg1s described
in the pythongh-158521 review. An access with no declaration in scope is unchanged.
@xv1rcn
xv1rcn force-pushed the gh-158597-lazy-declaration-scope branch from 3762e81 to 00db886 Compare October 15, 2026 16:11
@pablogsal pablogsal closed this Oct 15, 2026
@pablogsal

pablogsal commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

I'm closing this per our AI policy. Please don't open more PRs without achieving first consensus in the issue on the approach

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants