Repository navigation
Conversation
A package that does `lazy from .sub import sub` binds `sub` to a lazy import. When something else imported `pkg.sub` first, the import system set `pkg.sub` to the submodule over that binding, and the binding was lost. Eagerly, the statement imported the submodule itself and kept its own binding. The import system now leaves a lazy binding that imports the submodule in place, and its resolution yields what the statement named. Assisted-By: Claude Opus 5.5
Lillecarl
requested review from
AA-Turner,
DinoV,
FFY00,
Yhg1s,
brettcannon,
emmatyping,
ericsnowcurrently,
erlend-aasland,
itamaro,
kumaraditya303,
ncoghlan,
pablogsal and
warsaw
as code owners
November 3, 2026 23:14
The fix makes it pass: unittest/__init__.py does "from .main import main", and an import of unittest.main elsewhere replaced the function with the submodule. Assisted-By: Claude Opus 5.5
Author
|
The "Lazy imports enabled" job found one more module this fixes:
|
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.
TL;DR
With lazy imports, a package's
lazy from .sub import subcan end up bound to the submodulepkg.subinstead of the object it names. This happens when other code importspkg.subbefore anything uses the name: the import system binds each newly loaded submodule on its parent, and thatsetattroverwrites the lazy placeholder. Withoutlazy, the package's own binding wins.The fix skips that
setattrwhen the name on the parent is a lazy import that would itself import the submodule. It costs one dict lookup per submodule import.Details
When
pkg/__init__.pydoeslazy from .sub import suband something else importspkg.subfirst,_find_and_load_unlockedsetspkg.subto the submodule over the package's placeholder, and the binding is lost. Eagerly, the statement imports the submodule itself and keeps its own binding._find_and_load_unlockednow skips thatsetattrwhen the parent binds the name to a lazy import of the child, or of a module inside it: run eagerly, such a statement would already have imported the child._imp._lazily_binds_child()makes that check in C, because Python code cannot read a placeholder's target without resolving it. A binding of any other module is still overwritten, as eagerly. The call is not wrapped intry, unlike_set_lazy_attributes()below it: it can only fail if comparing the name raises.Two behaviours to review:
sub = "override"), the outside import still overwrites it, where eagerly"override"stays. This is unchanged.lazy from pkg.sub import missing as sub,pkg.subgave the module and now raisesImportError. The eager package fails at import, so this follows PEP 810's error timing.Alternatives I tried:
lazy from .sub.deep import x as subthen importspkg.sub.deepas a side effect of someone else'simport pkg.sub, and a placeholder that cannot resolve makes that unrelated import raise.getattro, as Import of submodule shadowing parent module attribute fails under lazy_imports mode #151208 discusses. Not tried, because it cannot work here: thesetattrhas already replaced the placeholder in the dict, so a read finds the module and nothing is left to intercept.LOAD_ATTR_MODULEwould also bypass it, as noted on Import of submodule shadowing parent module attribute fails under lazy_imports mode #151208.lazy_import_replace_child(Implement PEP 810 – Explicit lazy imports #142349), since the placeholder now stays bound while it resolves. With its call disabled on top of this change, the same test suites pass, also under-X lazy_imports=all. I left it in to keep this change small, and can remove it here if you prefer one mechanism.Tests: six in
test_lazy_import.AttributeSideEffectTests, for thelazyand-X lazy_imports=allspellings, a module inside the child, afrom . import sub, and two bindings that must still be overwritten. The four that keep a binding fail without the fix.test_lazy_import,test_import,test_importlib,test_pkg,test_module,test_pkgutil,test_runpyandtest_zipimportpass. Under-X lazy_imports=allthis adds no failure and fixestest_import.RelativeImportTests.test_rebinding. The new tests pass-R 3:3.Cost: one C call and one dict lookup per submodule import, under 50 ns including the call from Python. On optimised builds, with and without the GIL, re-importing 2,000 submodules of a package takes 26 µs per import (33 µs free-threaded) with or without the change, within noise.
Free threading: a placeholder's fields do not change after creation, and
PyDict_GetItemRefreturns a strong reference, so the check takes no lock. The check and thesetattrrun while the import holds the child's module lock. Resolving the placeholder imports the child, so a resolution in another thread waits for both. On the free-threaded build the tests above pass. A stress test with 8 threads importing the child keeps the binding in 300 of 300 rounds, where stock loses it in all 300.Real case: cryptography's
hazmat/backends/openssl/__init__.pydoesfrom cryptography.hazmat.backends.openssl.backend import backend, so the namebackendis both a submodule and the object the package exports.cryptography.hazmat.backends.default_backend()imports that submodule inside a function. The SSH library asyncssh then doesfrom cryptography.hazmat.backends.openssl import backendand callsbackend.ed25519_supported(). With cryptography's imports made lazy through__lazy_modules__anddefault_backend()called first, stock 3.15.0 gives asyncssh the submodule, and the call raisesAttributeError. With this fix backported to 3.15.0, asyncssh gets the backend object and generates an ed25519 key.I used an AI assistant (Claude) to find the cause, write the patch and tests, and check the edge cases. I reviewed the change and can explain it.
from .sub import subin a package is overwritten when the submodule is imported elsewhere #161265