FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Import of submodule shadowing parent module attribute fails under lazy_imports mode · Issue #151208 · python/cpython · GitHub

Repository navigation

Import of submodule shadowing parent module attribute fails under lazy_imports mode #151208

Description

Bug report

Bug description:

For this module structure:

└─ a/
   ├─ __init__.py    # Contains a function foo()
   └─ foo.py

This script passes on 3.14: (edited: removed unnecessary from ... line)

import a.foo
import types
assert isinstance(a.foo, types.ModuleType), type(a.foo)

And fails on 3.15 with lazy imports (python3.15 -X lazy_imports=all script.py):

Traceback (most recent call last):
  File "/Users/jwalls/script.py", line 3, in <module>
    assert isinstance(a.foo, types.ModuleType), type(a.foo)
           ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: <class 'function'>

CPython versions tested on:

3.15

Operating systems tested on:

macOS

Activity

  1. harjothkhara commented on Jun 10, 2026

    Contributor

    Confirmed still reproducing on main (ce916dc). Here's the root cause, in case it's useful.

    The two pieces that interact:

    1. 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"].

    2. 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:

      • the generic dict lookup finds the real foo function that a/__init__.py defined, and a found non-lazy attribute is returned immediately at L1377 (return attr;);
      • try_load_lazy_submodule(m, name) at L1393 is only reached when the attribute is absent, so the pending submodule is never resolved.

    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:

    • Should an explicit import a.foo always win over an __init__-defined a.foo attribute under lazy mode (matching eager)?
    • Or is the intent to preserve the __init__ attribute, in which case this is arguably the documented divergence and the eager/lazy mismatch is acceptable?

    Happy to put up a PR with a regression test in test_lazy_import once the desired precedence is confirmed.

  2. added a commit that references this issue on Jun 12, 2026
  3. harjothkhara commented on Jun 16, 2026

    Contributor

    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.

    1. from pkg import name and import pkg.name need opposite precedence, but share state

    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:

    • import pkg.name rebinds pkg.name to the submodule (submodule wins).
    • from pkg import name only imports the submodule if pkg lacks the attribute — so when pkg/__init__.py defines name, the attribute wins and the submodule is never imported.

    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).

    2. A getattro-level fix is bypassed by the LOAD_ATTR_MODULE specializer

    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.

    3. Resolving on access (rather than at the import statement) clobbers later reassignment

    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')

    Where I think this points

    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:

    1. Should an explicit lazy import pkg.sub override an __init__-defined pkg.sub by installing a submodule placeholder at reification time (matching eager's statement-time rebind), while from pkg import sub keeps the existing missing-attribute behavior?
    2. Is the LOAD_ATTR_MODULE interaction something you'd want handled by the placeholder/dict-version path above, or is there a preferred invalidation hook?

    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.

  4. jacobtylerwalls commented on Oct 1, 2026

    ContributorAuthor

    Working now, bisected the fix to dffac61. Have not checked if an additional regression test is needed.

  5. jacobtylerwalls commented on Oct 7, 2026

    ContributorAuthor

    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)
  6. hugovk commented on Oct 9, 2026

    Member

    (*) "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.

  7. pablogsal commented on Oct 9, 2026

    Member

    @brittanyrey can you take a look?

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions


      Back | FazBrowse Home | New Git URL