From 39b3d6dbe0edee6e4891fd8f86c92363549f39b2 Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Fri, 18 Sep 2026 13:43:29 -0700 Subject: [PATCH 1/5] Import the module a lazy `import a.b as c` names `import a.b as c` compiles to `IMPORT_NAME a.b` followed by `IMPORT_FROM b`. Lazily, IMPORT_NAME leaves a placeholder holding "a.b", and IMPORT_FROM rewrote it into the placeholder `lazy from a import b` produces. Reification then imported `a` alone and read `b` off it, so the module `a.b` was never imported under its own name: an attribute of the package shadowing it answered instead, and `math.pi`, which no module backs, bound the float where the eager statement raises ModuleNotFoundError. Mark the dotted import on the placeholder and keep the whole name on it. Reification imports that name and then walks its components with IMPORT_FROM, which is what the eager statement does. The test pinning `lazy import math.pi as pi` as working is inverted, since the eager statement raises. --- Include/internal/pycore_lazyimportobject.h | 5 ++- Lib/test/test_lazy_import/__init__.py | 31 ++++++++++++++- ...-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst | 3 ++ Objects/lazyimportobject.c | 4 +- Python/ceval.c | 39 ++++++------------- Python/import.c | 33 +++++++++++++++- 6 files changed, 81 insertions(+), 34 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst diff --git a/Include/internal/pycore_lazyimportobject.h b/Include/internal/pycore_lazyimportobject.h index b81e4211b08ff3..04302c54c48734 100644 --- a/Include/internal/pycore_lazyimportobject.h +++ b/Include/internal/pycore_lazyimportobject.h @@ -22,12 +22,15 @@ typedef struct { // Frame information for the original import location. PyCodeObject *lz_code; // Code object where the lazy import was created. int lz_instr_offset; // Instruction offset where the lazy import was created. + int lz_submodule; // True for `import a.b as c`, which binds `a.b` + // where `import a.b` binds `a`. } PyLazyImportObject; PyAPI_FUNC(PyObject *) _PyLazyImport_GetName(PyObject *lazy_import); PyAPI_FUNC(PyObject *) _PyLazyImport_New( - struct _PyInterpreterFrame *frame, PyObject *import_func, PyObject *from, PyObject *attr); + struct _PyInterpreterFrame *frame, PyObject *import_func, PyObject *from, + PyObject *attr, int submodule); #ifdef __cplusplus } diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 9147e788d7a81f..aae9104a169f5f 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -724,10 +724,18 @@ def test_non_package_lazily_imported(self): assert_python_ok("-c", code) def test_non_package_lazily_imported_as(self): - """Doing a dotted lazy import as still works""" + """A dotted lazy import as raises when the name is not a module.""" + # gh-157757: the eager statement raises ModuleNotFoundError, so the + # lazy one raises it at first use rather than binding math.pi. code = textwrap.dedent(""" lazy import math.pi as pi - pi + + try: + pi + except ModuleNotFoundError: + pass + else: + raise AssertionError("ModuleNotFoundError was not raised") """) assert_python_ok("-c", code) @@ -2209,6 +2217,25 @@ def test_import_after_variable_wins(self): ] self.assertIs(module_same_name_var_order2.bar, bar_mod) + def test_lazy_import_as_wins_over_variable(self): + """A dotted lazy import as imports the submodule the variable hides.""" + # gh-157757: the eager statement imports pkg.b, which rebinds pkg.b to + # the module, so the lazy one must import it too rather than read the + # variable off pkg. + code = textwrap.dedent(""" + import sys + import test.test_lazy_import.data.pkg as pkg + pkg.b = "hides the b submodule" + + lazy import test.test_lazy_import.data.pkg.b as b + lazy import test.test_lazy_import.data.metasyntactic.foo.bar as bar + + assert b is sys.modules["test.test_lazy_import.data.pkg.b"], b + assert bar is sys.modules[ + "test.test_lazy_import.data.metasyntactic.foo.bar"], bar + """) + assert_python_ok("-c", code) + class DeletedModuleReimportTests(unittest.TestCase): """Tests for reimporting after module deletion from sys.modules.""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst new file mode 100644 index 00000000000000..bb5377a53cab56 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst @@ -0,0 +1,3 @@ +Fix a lazy ``import a.b as c`` reading ``b`` off ``a`` instead of importing +the module ``a.b``. It now binds the submodule, and raises +:exc:`ModuleNotFoundError` when no module backs the name. diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 8f7f3f98c29128..1f8920af514289 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -11,7 +11,8 @@ #define PyLazyImportObject_CAST(op) ((PyLazyImportObject *)(op)) PyObject * -_PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, PyObject *name, PyObject *fromlist) +_PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, + PyObject *name, PyObject *fromlist, int submodule) { PyLazyImportObject *m; if (!name || !PyUnicode_Check(name)) { @@ -33,6 +34,7 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, PyObject *name m->lz_builtins = Py_XNewRef(builtins); m->lz_from = Py_NewRef(name); m->lz_attr = Py_XNewRef(fromlist); + m->lz_submodule = submodule; // Capture frame information for the original import location. m->lz_code = NULL; diff --git a/Python/ceval.c b/Python/ceval.c index 8cf02651d9a408..020bb7a7188b48 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3333,6 +3333,15 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje assert(PyUnicode_Check(name)); PyObject *ret; PyLazyImportObject *d = (PyLazyImportObject *)v; + + if (d->lz_attr == NULL) { + // `import a.b as x` binds `a.b`, not an attribute of `a` (gh-157757). + if (d->lz_submodule) { + return Py_NewRef(v); // a later component of the same name + } + return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, NULL, 1); + } + PyObject *mod = PyImport_GetModule(d->lz_from); if (mod != NULL) { // Check if the module already has the attribute, if so, resolve it @@ -3353,34 +3362,8 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje Py_DECREF(mod); } - if (d->lz_attr != NULL) { - if (PyUnicode_Check(d->lz_attr)) { - PyObject *from = PyUnicode_FromFormat( - "%U.%U", d->lz_from, d->lz_attr); - if (from == NULL) { - return NULL; - } - ret = _PyLazyImport_New(frame, d->lz_builtins, from, name); - Py_DECREF(from); - return ret; - } - } - else { - Py_ssize_t dot = PyUnicode_FindChar( - d->lz_from, '.', 0, PyUnicode_GET_LENGTH(d->lz_from), 1 - ); - if (dot >= 0) { - PyObject *from = PyUnicode_Substring(d->lz_from, 0, dot); - if (from == NULL) { - return NULL; - } - ret = _PyLazyImport_New(frame, d->lz_builtins, from, name); - Py_DECREF(from); - return ret; - } - } - ret = _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, name); - return ret; + assert(!PyUnicode_Check(d->lz_attr)); // a fromlist, not a taken name + return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, name, 0); } #define CANNOT_CATCH_MSG "catching classes that do not inherit from "\ diff --git a/Python/import.c b/Python/import.c index 037f15d4ca2baf..3821da385a8e73 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3898,6 +3898,27 @@ _PyImport_ResolveName(PyThreadState *tstate, PyObject *name, return resolve_name(tstate, name, globals, level); } +// Repeat IMPORT_FROM over the components of `name` after the first, taking +// `a.b.c` off the `a` that importing "a.b.c" returns. +static PyObject * +import_from_dotted_name(PyThreadState *tstate, PyObject *mod, PyObject *name) +{ + PyObject *parts = PyUnicode_Split(name, _Py_LATIN1_CHR('.'), -1); + if (parts == NULL) { + return NULL; + } + PyObject *obj = Py_NewRef(mod); + for (Py_ssize_t i = 1; i < PyList_GET_SIZE(parts); i++) { + Py_SETREF(obj, _PyEval_ImportFrom(tstate, obj, + PyList_GET_ITEM(parts, i))); + if (obj == NULL) { + break; + } + } + Py_DECREF(parts); + return obj; +} + PyObject * _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) { @@ -3989,7 +4010,15 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) goto error; } - if (lz->lz_attr != NULL && PyUnicode_Check(lz->lz_attr)) { + if (lz->lz_submodule) { + PyObject *top = obj; + obj = import_from_dotted_name(tstate, top, lz->lz_from); + Py_DECREF(top); + if (obj == NULL) { + goto error; + } + } + else if (lz->lz_attr != NULL && PyUnicode_Check(lz->lz_attr)) { PyObject *from = obj; obj = _PyEval_ImportFrom(tstate, from, lz->lz_attr); Py_DECREF(from); @@ -4600,7 +4629,7 @@ _PyImport_LazyImportModuleLevelObject(PyThreadState *tstate, else { Py_XINCREF(fromlist); } - PyObject *res = _PyLazyImport_New(frame, builtins, abs_name, fromlist); + PyObject *res = _PyLazyImport_New(frame, builtins, abs_name, fromlist, 0); if (res == NULL) { Py_XDECREF(fromlist); Py_DECREF(abs_name); From 9194e0bc4d601717600108f420e6802130d3b3d4 Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Thu, 24 Sep 2026 07:56:05 -0700 Subject: [PATCH 2/5] Stop excluding test_trace from the lazy-imports-all run It passes now that a lazy `import a.b as c` imports the module: the KeyError on 'test.tracedmodules.testmod' came from the submodule never being imported under its own name. --- Lib/test/lazy_imports_all_exclude.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/lazy_imports_all_exclude.txt b/Lib/test/lazy_imports_all_exclude.txt index 2680d3b1e4357b..45796d1db9f0a5 100644 --- a/Lib/test/lazy_imports_all_exclude.txt +++ b/Lib/test/lazy_imports_all_exclude.txt @@ -35,6 +35,5 @@ test_pyrepl test_subprocess test_symtable test_tools -test_trace test_type_annotations test_unittest From a8ebc876d9e9a8ff7de1877343a722175784493a Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Thu, 24 Sep 2026 08:28:52 -0700 Subject: [PATCH 3/5] Rename lz_submodule to lz_dotted_as and trim the comments The flag means "bind the whole dotted name, not the root", which the old name did not say, and import.c already has unrelated lazy_pending_submodules machinery to be confused with. --- Include/internal/pycore_lazyimportobject.h | 5 ++--- Lib/test/test_lazy_import/__init__.py | 8 +++----- Objects/lazyimportobject.c | 4 ++-- Python/ceval.c | 8 ++++---- Python/import.c | 5 ++--- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/Include/internal/pycore_lazyimportobject.h b/Include/internal/pycore_lazyimportobject.h index 04302c54c48734..6c1de571febfe1 100644 --- a/Include/internal/pycore_lazyimportobject.h +++ b/Include/internal/pycore_lazyimportobject.h @@ -22,15 +22,14 @@ typedef struct { // Frame information for the original import location. PyCodeObject *lz_code; // Code object where the lazy import was created. int lz_instr_offset; // Instruction offset where the lazy import was created. - int lz_submodule; // True for `import a.b as c`, which binds `a.b` - // where `import a.b` binds `a`. + int lz_dotted_as; // `import a.b as c`, which binds `a.b` not `a`. } PyLazyImportObject; PyAPI_FUNC(PyObject *) _PyLazyImport_GetName(PyObject *lazy_import); PyAPI_FUNC(PyObject *) _PyLazyImport_New( struct _PyInterpreterFrame *frame, PyObject *import_func, PyObject *from, - PyObject *attr, int submodule); + PyObject *attr, int dotted_as); #ifdef __cplusplus } diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index aae9104a169f5f..d4af4320ec67d1 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -725,8 +725,7 @@ def test_non_package_lazily_imported(self): def test_non_package_lazily_imported_as(self): """A dotted lazy import as raises when the name is not a module.""" - # gh-157757: the eager statement raises ModuleNotFoundError, so the - # lazy one raises it at first use rather than binding math.pi. + # gh-157757: the eager statement raises, so the lazy one raises too. code = textwrap.dedent(""" lazy import math.pi as pi @@ -2219,9 +2218,8 @@ def test_import_after_variable_wins(self): def test_lazy_import_as_wins_over_variable(self): """A dotted lazy import as imports the submodule the variable hides.""" - # gh-157757: the eager statement imports pkg.b, which rebinds pkg.b to - # the module, so the lazy one must import it too rather than read the - # variable off pkg. + # gh-157757: importing pkg.b rebinds pkg.b from the variable to the + # module, eagerly and lazily alike. code = textwrap.dedent(""" import sys import test.test_lazy_import.data.pkg as pkg diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 1f8920af514289..eb97dbdca11dff 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -12,7 +12,7 @@ PyObject * _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, - PyObject *name, PyObject *fromlist, int submodule) + PyObject *name, PyObject *fromlist, int dotted_as) { PyLazyImportObject *m; if (!name || !PyUnicode_Check(name)) { @@ -34,7 +34,7 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, m->lz_builtins = Py_XNewRef(builtins); m->lz_from = Py_NewRef(name); m->lz_attr = Py_XNewRef(fromlist); - m->lz_submodule = submodule; + m->lz_dotted_as = dotted_as; // Capture frame information for the original import location. m->lz_code = NULL; diff --git a/Python/ceval.c b/Python/ceval.c index 020bb7a7188b48..86ca1f867e17d0 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3335,9 +3335,9 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje PyLazyImportObject *d = (PyLazyImportObject *)v; if (d->lz_attr == NULL) { - // `import a.b as x` binds `a.b`, not an attribute of `a` (gh-157757). - if (d->lz_submodule) { - return Py_NewRef(v); // a later component of the same name + // `import a.b as x` binds the module `a.b`, not an attribute of `a`. + if (d->lz_dotted_as) { + return Py_NewRef(v); } return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, NULL, 1); } @@ -3362,7 +3362,7 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje Py_DECREF(mod); } - assert(!PyUnicode_Check(d->lz_attr)); // a fromlist, not a taken name + assert(!PyUnicode_Check(d->lz_attr)); // a fromlist return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, name, 0); } diff --git a/Python/import.c b/Python/import.c index 3821da385a8e73..283a3844d8b092 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3898,8 +3898,7 @@ _PyImport_ResolveName(PyThreadState *tstate, PyObject *name, return resolve_name(tstate, name, globals, level); } -// Repeat IMPORT_FROM over the components of `name` after the first, taking -// `a.b.c` off the `a` that importing "a.b.c" returns. +// Take `a.b.c` off the `a` that importing "a.b.c" returns, as IMPORT_FROM does. static PyObject * import_from_dotted_name(PyThreadState *tstate, PyObject *mod, PyObject *name) { @@ -4010,7 +4009,7 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) goto error; } - if (lz->lz_submodule) { + if (lz->lz_dotted_as) { PyObject *top = obj; obj = import_from_dotted_name(tstate, top, lz->lz_from); Py_DECREF(top); From 8341ca0fe9d0408bfd3df5864221c40112516a41 Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Thu, 24 Sep 2026 09:25:52 -0700 Subject: [PATCH 4/5] Chain lazy IMPORT_FROM placeholders instead of flagging dotted imports Each deferred IMPORT_FROM off a placeholder without a fromlist now keeps the previous placeholder in lz_from and the attribute name in lz_attr. Reification walks back to the placeholder IMPORT_NAME left, runs that import, and replays the lookups in order with _PyEval_ImportFrom, which is what the eager bytecode does. This drops the lz_dotted_as flag and also follows a custom __lazy_import__ that returns a placeholder for a different module name. --- Include/internal/pycore_lazyimportobject.h | 4 +- Lib/test/test_lazy_import/__init__.py | 30 ++++++++ Objects/lazyimportobject.c | 42 ++++++++++-- Python/ceval.c | 13 ++-- Python/import.c | 79 +++++++++++----------- 5 files changed, 113 insertions(+), 55 deletions(-) diff --git a/Include/internal/pycore_lazyimportobject.h b/Include/internal/pycore_lazyimportobject.h index 6c1de571febfe1..b81e4211b08ff3 100644 --- a/Include/internal/pycore_lazyimportobject.h +++ b/Include/internal/pycore_lazyimportobject.h @@ -22,14 +22,12 @@ typedef struct { // Frame information for the original import location. PyCodeObject *lz_code; // Code object where the lazy import was created. int lz_instr_offset; // Instruction offset where the lazy import was created. - int lz_dotted_as; // `import a.b as c`, which binds `a.b` not `a`. } PyLazyImportObject; PyAPI_FUNC(PyObject *) _PyLazyImport_GetName(PyObject *lazy_import); PyAPI_FUNC(PyObject *) _PyLazyImport_New( - struct _PyInterpreterFrame *frame, PyObject *import_func, PyObject *from, - PyObject *attr, int dotted_as); + struct _PyInterpreterFrame *frame, PyObject *import_func, PyObject *from, PyObject *attr); #ifdef __cplusplus } diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index d4af4320ec67d1..727836252efdba 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -2234,6 +2234,36 @@ def test_lazy_import_as_wins_over_variable(self): """) assert_python_ok("-c", code) + def test_dotted_as_of_loaded_module(self): + """A dotted lazy import as binds the module, not a same-named attribute.""" + # importlib.metadata is already loaded and has a `metadata` attribute. + code = textwrap.dedent(""" + import importlib.metadata + import importlib.metadata as eager + + lazy import importlib.metadata as lazily + + assert lazily is eager, lazily + """) + assert_python_ok("-c", code) + + def test_dotted_as_replays_lookups_on_custom_placeholder(self): + """A dotted lazy import as looks up its names on what the hook returned.""" + code = textwrap.dedent(""" + import builtins + import xml.dom + + # In a list, so the hook reading it does not resolve it. + placeholder = [__lazy_import__("xml")] + default = builtins.__lazy_import__ + builtins.__lazy_import__ = lambda *args: placeholder[0] + lazy import fake.dom as dom + builtins.__lazy_import__ = default + + assert dom is xml.dom, dom + """) + assert_python_ok("-c", code) + class DeletedModuleReimportTests(unittest.TestCase): """Tests for reimporting after module deletion from sys.modules.""" diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index eb97dbdca11dff..4c7f14886a8780 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -11,12 +11,11 @@ #define PyLazyImportObject_CAST(op) ((PyLazyImportObject *)(op)) PyObject * -_PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, - PyObject *name, PyObject *fromlist, int dotted_as) +_PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, PyObject *name, PyObject *fromlist) { PyLazyImportObject *m; - if (!name || !PyUnicode_Check(name)) { - PyErr_SetString(PyExc_TypeError, "expected str for name"); + if (!name || !(PyUnicode_Check(name) || PyLazyImport_CheckExact(name))) { + PyErr_SetString(PyExc_TypeError, "expected str or lazy_import for name"); return NULL; } if (fromlist == Py_None || fromlist == NULL) { @@ -34,7 +33,6 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, m->lz_builtins = Py_XNewRef(builtins); m->lz_from = Py_NewRef(name); m->lz_attr = Py_XNewRef(fromlist); - m->lz_dotted_as = dotted_as; // Capture frame information for the original import location. m->lz_code = NULL; @@ -106,9 +104,43 @@ lazy_import_getattro(PyObject *op, PyObject *name) return value; } +static PyObject *lazy_import_name(PyLazyImportObject *m); + +// The dotted name of the object that resolving the placeholder returns. +static PyObject * +lazy_import_path(PyLazyImportObject *m) +{ + if (PyLazyImport_CheckExact(m->lz_from)) { + PyObject *base = lazy_import_path((PyLazyImportObject *)m->lz_from); + if (base == NULL) { + return NULL; + } + PyObject *res = PyUnicode_FromFormat("%U.%U", base, m->lz_attr); + Py_DECREF(base); + return res; + } + if (m->lz_attr != NULL) { + return lazy_import_name(m); + } + // __import__("a.b") returns the top-level package `a`. + Py_ssize_t dot = PyUnicode_FindChar( + m->lz_from, '.', 0, PyUnicode_GET_LENGTH(m->lz_from), 1 + ); + if (dot == -2) { + return NULL; + } + if (dot < 0) { + return Py_NewRef(m->lz_from); + } + return PyUnicode_Substring(m->lz_from, 0, dot); +} + static PyObject * lazy_import_name(PyLazyImportObject *m) { + if (PyLazyImport_CheckExact(m->lz_from)) { + return lazy_import_path(m); + } if (m->lz_attr != NULL) { if (PyUnicode_Check(m->lz_attr)) { return PyUnicode_FromFormat("%U.%U", m->lz_from, m->lz_attr); diff --git a/Python/ceval.c b/Python/ceval.c index 86ca1f867e17d0..03193ac7606242 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3334,12 +3334,10 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje PyObject *ret; PyLazyImportObject *d = (PyLazyImportObject *)v; - if (d->lz_attr == NULL) { - // `import a.b as x` binds the module `a.b`, not an attribute of `a`. - if (d->lz_dotted_as) { - return Py_NewRef(v); - } - return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, NULL, 1); + if (d->lz_attr == NULL || PyUnicode_Check(d->lz_attr)) { + // `import a.b.c as d`: record the lookup, to replay once the + // import has run. + return _PyLazyImport_New(frame, d->lz_builtins, v, name); } PyObject *mod = PyImport_GetModule(d->lz_from); @@ -3362,8 +3360,7 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje Py_DECREF(mod); } - assert(!PyUnicode_Check(d->lz_attr)); // a fromlist - return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, name, 0); + return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, name); } #define CANNOT_CATCH_MSG "catching classes that do not inherit from "\ diff --git a/Python/import.c b/Python/import.c index 283a3844d8b092..e945180b192da2 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3898,23 +3898,28 @@ _PyImport_ResolveName(PyThreadState *tstate, PyObject *name, return resolve_name(tstate, name, globals, level); } -// Take `a.b.c` off the `a` that importing "a.b.c" returns, as IMPORT_FROM does. +// Look up, in order, the attributes recorded from the root placeholder to lz +// on the module the root's import returned. static PyObject * -import_from_dotted_name(PyThreadState *tstate, PyObject *mod, PyObject *name) -{ - PyObject *parts = PyUnicode_Split(name, _Py_LATIN1_CHR('.'), -1); - if (parts == NULL) { - return NULL; - } - PyObject *obj = Py_NewRef(mod); - for (Py_ssize_t i = 1; i < PyList_GET_SIZE(parts); i++) { - Py_SETREF(obj, _PyEval_ImportFrom(tstate, obj, - PyList_GET_ITEM(parts, i))); - if (obj == NULL) { - break; +lazy_import_replay_from(PyThreadState *tstate, PyObject *mod, + PyLazyImportObject *lz) +{ + PyObject *from; + if (PyLazyImport_CheckExact(lz->lz_from)) { + from = lazy_import_replay_from( + tstate, mod, (PyLazyImportObject *)lz->lz_from); + if (from == NULL) { + return NULL; } } - Py_DECREF(parts); + else if (lz->lz_attr != NULL && PyUnicode_Check(lz->lz_attr)) { + from = Py_NewRef(mod); + } + else { + return Py_NewRef(mod); + } + PyObject *obj = _PyEval_ImportFrom(tstate, from, lz->lz_attr); + Py_DECREF(from); return obj; } @@ -3930,6 +3935,12 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) PyLazyImportObject *lz = (PyLazyImportObject *)lazy_import; PyInterpreterState *interp = tstate->interp; + // Walk back to the placeholder IMPORT_NAME left. + PyLazyImportObject *root = lz; + while (PyLazyImport_CheckExact(root->lz_from)) { + root = (PyLazyImportObject *)root->lz_from; + } + // Acquire the global import lock to serialize reification _PyImport_AcquireLock(interp); @@ -3966,7 +3977,7 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) return NULL; } PyErr_SetImportErrorSubclass(PyExc_ImportCycleError, errmsg, - lz->lz_from, NULL); + root->lz_from, NULL); Py_DECREF(errmsg); Py_DECREF(name); _PyImport_ReleaseLock(interp); @@ -3976,24 +3987,24 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) goto error; } - if (lz->lz_attr != NULL) { - if (PyUnicode_Check(lz->lz_attr)) { + if (root->lz_attr != NULL) { + if (PyUnicode_Check(root->lz_attr)) { fromlist = PyTuple_New(1); if (fromlist == NULL) { goto error; } - Py_INCREF(lz->lz_attr); - PyTuple_SET_ITEM(fromlist, 0, lz->lz_attr); + Py_INCREF(root->lz_attr); + PyTuple_SET_ITEM(fromlist, 0, root->lz_attr); } else { - Py_INCREF(lz->lz_attr); - fromlist = lz->lz_attr; + Py_INCREF(root->lz_attr); + fromlist = root->lz_attr; } } PyObject *globals = PyEval_GetGlobals(); - if (PyMapping_GetOptionalItem(lz->lz_builtins, &_Py_ID(__import__), + if (PyMapping_GetOptionalItem(root->lz_builtins, &_Py_ID(__import__), &import_func) < 0) { goto error; } @@ -4003,27 +4014,17 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) } obj = _PyEval_ImportNameWithImport( tstate, import_func, globals, globals, - lz->lz_from, fromlist, _PyLong_GetZero() + root->lz_from, fromlist, _PyLong_GetZero() ); if (obj == NULL) { goto error; } - if (lz->lz_dotted_as) { - PyObject *top = obj; - obj = import_from_dotted_name(tstate, top, lz->lz_from); - Py_DECREF(top); - if (obj == NULL) { - goto error; - } - } - else if (lz->lz_attr != NULL && PyUnicode_Check(lz->lz_attr)) { - PyObject *from = obj; - obj = _PyEval_ImportFrom(tstate, from, lz->lz_attr); - Py_DECREF(from); - if (obj == NULL) { - goto error; - } + PyObject *from = obj; + obj = lazy_import_replay_from(tstate, from, lz); + Py_DECREF(from); + if (obj == NULL) { + goto error; } assert(!PyLazyImport_CheckExact(obj)); @@ -4628,7 +4629,7 @@ _PyImport_LazyImportModuleLevelObject(PyThreadState *tstate, else { Py_XINCREF(fromlist); } - PyObject *res = _PyLazyImport_New(frame, builtins, abs_name, fromlist, 0); + PyObject *res = _PyLazyImport_New(frame, builtins, abs_name, fromlist); if (res == NULL) { Py_XDECREF(fromlist); Py_DECREF(abs_name); From f977cd3a467d86a02a08c5f46817e75507ea6a6b Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Thu, 24 Sep 2026 12:01:18 -0700 Subject: [PATCH 5/5] Chain every deferred IMPORT_FROM onto the previous placeholder `lazy from a import b` now records its lookup the same way as `import a.b as c`, so a placeholder holds either the module name and fromlist or the previous placeholder and an attribute name, and reification has a single path. The import passes only the name being resolved as the fromlist, so accessing b still does not import the other names' submodules. --- Lib/test/test_lazy_import/__init__.py | 16 ++++++++ Lib/test/test_lazy_import/data/pkg/broken.py | 2 + Objects/lazyimportobject.c | 11 +----- Python/ceval.c | 14 +++---- Python/import.c | 39 +++++++------------- 5 files changed, 40 insertions(+), 42 deletions(-) create mode 100644 Lib/test/test_lazy_import/data/pkg/broken.py diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 727836252efdba..cd6846382a8628 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -1177,6 +1177,22 @@ def test_accessing_one_name_leaves_others_as_proxies(self): self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}") self.assertIn("OK", result.stdout) + def test_accessing_one_name_imports_only_its_submodule(self): + """Accessing one name should not import the other names' submodules.""" + code = textwrap.dedent(""" + import sys + + lazy from test.test_lazy_import.data.pkg import b, bar, broken + + # Importing bar prints, and importing broken raises. + b.foo() + + assert "test.test_lazy_import.data.pkg.bar" not in sys.modules + assert "test.test_lazy_import.data.pkg.broken" not in sys.modules + """) + rc, out, err = assert_python_ok("-c", code) + self.assertEqual(out, b"") + def test_all_names_reified_after_all_accessed(self): """All names should be reified after each is accessed.""" code = textwrap.dedent(""" diff --git a/Lib/test/test_lazy_import/data/pkg/broken.py b/Lib/test/test_lazy_import/data/pkg/broken.py new file mode 100644 index 00000000000000..6499aa9e15030a --- /dev/null +++ b/Lib/test/test_lazy_import/data/pkg/broken.py @@ -0,0 +1,2 @@ +# Submodule that raises an error during import +raise ValueError("This module always fails to import") diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 4c7f14886a8780..35efdea89cc58b 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -104,8 +104,6 @@ lazy_import_getattro(PyObject *op, PyObject *name) return value; } -static PyObject *lazy_import_name(PyLazyImportObject *m); - // The dotted name of the object that resolving the placeholder returns. static PyObject * lazy_import_path(PyLazyImportObject *m) @@ -120,7 +118,7 @@ lazy_import_path(PyLazyImportObject *m) return res; } if (m->lz_attr != NULL) { - return lazy_import_name(m); + return Py_NewRef(m->lz_from); } // __import__("a.b") returns the top-level package `a`. Py_ssize_t dot = PyUnicode_FindChar( @@ -142,12 +140,7 @@ lazy_import_name(PyLazyImportObject *m) return lazy_import_path(m); } if (m->lz_attr != NULL) { - if (PyUnicode_Check(m->lz_attr)) { - return PyUnicode_FromFormat("%U.%U", m->lz_from, m->lz_attr); - } - else { - return PyUnicode_FromFormat("%U...", m->lz_from); - } + return PyUnicode_FromFormat("%U...", m->lz_from); } return Py_NewRef(m->lz_from); } diff --git a/Python/ceval.c b/Python/ceval.c index 03193ac7606242..5589612e99d8f9 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3333,14 +3333,12 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje assert(PyUnicode_Check(name)); PyObject *ret; PyLazyImportObject *d = (PyLazyImportObject *)v; - - if (d->lz_attr == NULL || PyUnicode_Check(d->lz_attr)) { - // `import a.b.c as d`: record the lookup, to replay once the - // import has run. - return _PyLazyImport_New(frame, d->lz_builtins, v, name); + PyObject *mod = NULL; + // Only `from a import b` can take b off an already imported a; + // `import a.b as c` has to import a.b first. + if (d->lz_attr != NULL && PyTuple_Check(d->lz_attr)) { + mod = PyImport_GetModule(d->lz_from); } - - PyObject *mod = PyImport_GetModule(d->lz_from); if (mod != NULL) { // Check if the module already has the attribute, if so, resolve it // eagerly. @@ -3360,7 +3358,7 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje Py_DECREF(mod); } - return _PyLazyImport_New(frame, d->lz_builtins, d->lz_from, name); + return _PyLazyImport_New(frame, d->lz_builtins, v, name); } #define CANNOT_CATCH_MSG "catching classes that do not inherit from "\ diff --git a/Python/import.c b/Python/import.c index e945180b192da2..aee0ee0e2b514d 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3904,20 +3904,14 @@ static PyObject * lazy_import_replay_from(PyThreadState *tstate, PyObject *mod, PyLazyImportObject *lz) { - PyObject *from; - if (PyLazyImport_CheckExact(lz->lz_from)) { - from = lazy_import_replay_from( - tstate, mod, (PyLazyImportObject *)lz->lz_from); - if (from == NULL) { - return NULL; - } - } - else if (lz->lz_attr != NULL && PyUnicode_Check(lz->lz_attr)) { - from = Py_NewRef(mod); - } - else { + if (!PyLazyImport_CheckExact(lz->lz_from)) { return Py_NewRef(mod); } + PyObject *from = lazy_import_replay_from( + tstate, mod, (PyLazyImportObject *)lz->lz_from); + if (from == NULL) { + return NULL; + } PyObject *obj = _PyEval_ImportFrom(tstate, from, lz->lz_attr); Py_DECREF(from); return obj; @@ -3935,9 +3929,10 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) PyLazyImportObject *lz = (PyLazyImportObject *)lazy_import; PyInterpreterState *interp = tstate->interp; - // Walk back to the placeholder IMPORT_NAME left. - PyLazyImportObject *root = lz; + // Walk back to the placeholder IMPORT_NAME left, and the first lookup on it. + PyLazyImportObject *root = lz, *first = NULL; while (PyLazyImport_CheckExact(root->lz_from)) { + first = root; root = (PyLazyImportObject *)root->lz_from; } @@ -3988,17 +3983,11 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) } if (root->lz_attr != NULL) { - if (PyUnicode_Check(root->lz_attr)) { - fromlist = PyTuple_New(1); - if (fromlist == NULL) { - goto error; - } - Py_INCREF(root->lz_attr); - PyTuple_SET_ITEM(fromlist, 0, root->lz_attr); - } - else { - Py_INCREF(root->lz_attr); - fromlist = root->lz_attr; + // `from a import b, c`: import only the name being resolved. + fromlist = first ? PyTuple_Pack(1, first->lz_attr) + : Py_NewRef(root->lz_attr); + if (fromlist == NULL) { + goto error; } }