| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Confirmed still reproducing on main (ce916dc). Here's the root cause, in case it's useful.
The two pieces that interact:
import a.foo under -X lazy_imports=all defers the submodule import and only records "foo" as a pending submodule of "a" — Python/import.c#L4601 → register_lazy_on_parent(), which adds "foo" to lazy_pending_submodules["a"].
The deferred binding is supposed to happen in _Py_module_getattro_impl when a.foo is accessed, but the lookup order shadows it. In Objects/moduleobject.c:
So a same-named attribute set by the package __init__ permanently shadows a pending submodule, and the deferred import a.foo never runs its binding.
Why eager mode (3.14) doesn't have this: eager import a.foo goes through importlib._bootstrap._find_and_load, which does setattr(parent, child, child_module) unconditionally after importing the child, overwriting the function. Lazy mode moves that bind into getattro, but getattro resolves precedence the other way around.
The fix looks like it belongs in _Py_module_getattro_impl: when a found attribute is not a lazy placeholder but name is a registered pending submodule, the pending submodule should take precedence (mirroring eager import a.foo, where the explicit import statement rebinds the parent attribute). Before committing to that, though, I wanted to check the intended semantics, since this overlaps the precedence work in gh-144957 and gh-150052:
Happy to put up a PR with a regression test in test_lazy_import once the desired precedence is confirmed.
Following up on my root-cause note above: I prototyped a fix and it turned out to be subtler than the getattro shadowing alone suggests. Sharing the findings so whoever lands this (or I, with a steer on direction) accounts for them, since two of them are easy to miss.
register_from_lazy_on_parent (Python/import.c:4422) feeds "pkg.name" into the same lazy_pending_submodules structure as an explicit import pkg.name (register_lazy_on_parent, Python/import.c:4364). But eager semantics differ:
So a naive "a pending submodule outranks an existing __init__ attribute on access" fix regresses the from case: under -X lazy_imports=all, from pkg import collision (with pkg.__init__ defining collision and a collision.py) starts resolving to <module pkg.collision> and force-loads it into sys.modules, where eager yields the __init__ function. Any fix has to distinguish the two import forms (they're indistinguishable in lazy_pending_submodules today).
This is the structural one. _Py_module_getattro_impl is where the shadowing happens (Objects/moduleobject.c:1377), but LOAD_ATTR_MODULE (Python/bytecodes.c:2951) reads the module dict directly and never calls getattro. Registering an explicit lazy import pkg.sub mutates the pending tables, not pkg's dict, so the specialization is never invalidated:
# -X lazy_imports=all
import pkg as pkg
def get(): return pkg.collision
for _ in range(1000): get() # warm LOAD_ATTR_MODULE -> __init__ function
import pkg.collision
print(type(get())) # <class 'function'> (stale; want module)Any fix that lives only in getattro returns the stale __init__ attribute from a warmed call site.
Eager import pkg.sub rebinds pkg.sub once, at the statement; later pkg.sub = x wins. A fix that overrides whenever the name is accessed stays "armed" until the submodule loads, so it also reverts user assignments — and this hits non-colliding submodules too, diverging from both eager and pre-PEP-810 lazy:
# -X lazy_imports=all
import pkg.plain # plain.py exists; no `plain` attr in __init__
import pkg as pkg
pkg.plain = "sentinel"
print(pkg.plain) # <module ...pkg.plain> (want 'sentinel')The rebind seems to need to happen by mutating pkg's dict (so it's visible to LOAD_ATTR_MODULE and overridable by later assignment), not by intercepting getattro reads — most naturally by replacing the colliding attribute with a lazy placeholder that the existing PyLazyImport_CheckExact path already resolves, with the dict-version bump invalidating the specializer. Because pkg may itself be imported lazily when import pkg.sub is registered, that insertion likely has to hook the reification path (_imp__set_lazy_attributes_impl, Python/import.c:5618) for pkg's pending explicit submodules.
That's enough of a design choice inside PEP 810 that I'd rather not guess at it. Two questions for the feature owners:
Happy to implement whichever direction you prefer — I have the reproducers and a branch with the from/import split already worked out; just want to build on the right foundation rather than a getattro-only patch that the specializer defeats.
Working now, bisected the fix to dffac61. Have not checked if an additional regression test is needed.
The original reproducer was partially(*) fixed by dffac61 but is now failing again in 3.15.0rc3 since the backport of 00a1c3a.
cc/ @hugovk is this interesting for 3.15 final in any way?
(*) "Partially" means this variation of the original script using as ... still failed even on dffac61:
import a as pkg
import a.foo
import types
assert isinstance(pkg.foo, types.ModuleType), type(pkg.foo)(*) "Partially" means this variation of the original script using as ... still failed even on dffac61:
A very quick test of this passes on latest main and 3.15, cc @pablogsal @brittanyrey.
I think this can wait until 3.15.1.
@brittanyrey can you take a look?
| Back | FazBrowse Home | New Git URL |
Bug report
Bug description:
For this module structure:
This script passes on 3.14: (edited: removed unnecessary from ... line)
And fails on 3.15 with lazy imports (python3.15 -X lazy_imports=all script.py):
CPython versions tested on:
3.15
Operating systems tested on:
macOS