From 33359aa01e530bae8c7c49b03d05c869459759f7 Mon Sep 17 00:00:00 2001 From: lillecarl Date: Tue, 3 Nov 2026 20:32:06 +0100 Subject: [PATCH 1/2] Keep a package's lazy binding when its submodule is imported elsewhere 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 --- Include/internal/pycore_lazyimportobject.h | 1 + Lib/importlib/_bootstrap.py | 16 +++-- Lib/test/test_lazy_import/__init__.py | 62 +++++++++++++++++++ .../data/eager_from_own_submodule/__init__.py | 1 + .../data/eager_from_own_submodule/thing.py | 1 + .../data/lazy_from_other_module/__init__.py | 1 + .../data/lazy_from_other_module/letters.py | 1 + .../data/lazy_from_own_submodule/__init__.py | 1 + .../data/lazy_from_own_submodule/thing.py | 1 + .../__init__.py | 1 + .../thing.py | 0 .../lazy_from_prefixed_submodule/__init__.py | 1 + .../lazy_from_prefixed_submodule/thing.py | 0 .../lazy_from_prefixed_submodule/things.py | 1 + .../lazy_import_inside_submodule/__init__.py | 1 + .../thing/__init__.py | 0 .../thing/inner.py | 0 Makefile.pre.in | 7 +++ ...-11-03-19-44-27.gh-issue-161265.kQ3vLz.rst | 4 ++ Objects/lazyimportobject.c | 46 ++++++++++++++ Python/clinic/import.c.h | 51 ++++++++++++++- Python/import.c | 33 ++++++++++ 22 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 Lib/test/test_lazy_import/data/eager_from_own_submodule/__init__.py create mode 100644 Lib/test/test_lazy_import/data/eager_from_own_submodule/thing.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_other_module/__init__.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_other_module/letters.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_own_submodule/__init__.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_own_submodule/thing.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_package_import_submodule/__init__.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_package_import_submodule/thing.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/__init__.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/thing.py create mode 100644 Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/things.py create mode 100644 Lib/test/test_lazy_import/data/lazy_import_inside_submodule/__init__.py create mode 100644 Lib/test/test_lazy_import/data/lazy_import_inside_submodule/thing/__init__.py create mode 100644 Lib/test/test_lazy_import/data/lazy_import_inside_submodule/thing/inner.py create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-11-03-19-44-27.gh-issue-161265.kQ3vLz.rst diff --git a/Include/internal/pycore_lazyimportobject.h b/Include/internal/pycore_lazyimportobject.h index 2da037b0b6fb842..97e24ca99265003 100644 --- a/Include/internal/pycore_lazyimportobject.h +++ b/Include/internal/pycore_lazyimportobject.h @@ -21,6 +21,7 @@ PyAPI_FUNC(PyObject *) _PyLazyImport_New( extern PyObject *_PyLazyImport_LoadChild( PyThreadState *tstate, PyObject *declaration, PyObject *name); extern int _PyLazyImport_IsActive(PyObject *declaration); +extern int _PyLazyImport_ImportsModule(PyObject *placeholder, PyObject *name); extern int _PyLazyImport_IsResolving(PyThreadState *tstate, PyObject *op); diff --git a/Lib/importlib/_bootstrap.py b/Lib/importlib/_bootstrap.py index 9b2a77e883acf19..b4f62a764619caf 100644 --- a/Lib/importlib/_bootstrap.py +++ b/Lib/importlib/_bootstrap.py @@ -1250,13 +1250,17 @@ def _find_and_load_unlocked(name, import_, *, lazy_submodule=False): if parent_spec: parent_spec._uninitialized_submodules.pop() if parent: - # Set the module as an attribute on its parent. + # Set the module as an attribute on its parent, unless the parent binds + # that name to a lazy import that imports this module. Run eagerly, that + # statement would import this module first and then bind the name, so + # its binding wins. parent_module = sys.modules[parent] - try: - setattr(parent_module, child, module) - except AttributeError: - msg = f"Cannot set an attribute on {parent!r} for child module {child!r}" - _warnings.warn(msg, ImportWarning) + if not _imp._lazily_binds_child(parent_module, child, name): + try: + setattr(parent_module, child, module) + except AttributeError: + msg = f"Cannot set an attribute on {parent!r} for child module {child!r}" + _warnings.warn(msg, ImportWarning) # Set attributes to lazy submodules on the module. try: _imp._set_lazy_attributes(module, name) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index fd74ce6827fcca1..e29e812eaa328b1 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -2639,6 +2639,68 @@ def test_version_submodule_does_not_overwrite(self): "Copyright (c) 2001-2022 Python Software Foundation.", ) + def test_submodule_imported_elsewhere_keeps_lazy_from_binding(self): + """Importing a submodule must not overwrite the parent's lazy + from-import of it: eagerly, that statement imported the submodule + itself and kept its own binding.""" + import test.test_lazy_import.data.lazy_from_own_submodule as pkg + self.assertIsInstance(pkg.__dict__["thing"], types.LazyImportType) + import test.test_lazy_import.data.lazy_from_own_submodule.thing + self.assertEqual(pkg.thing, "the object") + + def test_submodule_imported_elsewhere_keeps_from_binding_in_all_mode(self): + """The same, with an eager-spelled from-import made lazy by the + 'all' mode.""" + import importlib + sys.set_lazy_imports("all") + import test.test_lazy_import.data.eager_from_own_submodule as pkg + importlib.import_module( + "test.test_lazy_import.data.eager_from_own_submodule.thing") + self.assertEqual(pkg.thing, "the object") + + def test_submodule_imported_elsewhere_keeps_lazy_import_of_its_child(self): + """A lazy import of a module inside the submodule imports the + submodule too, so its binding is kept as well.""" + import test.test_lazy_import.data.lazy_import_inside_submodule as pkg + import test.test_lazy_import.data.lazy_import_inside_submodule.thing + self.assertEqual( + pkg.thing.__name__, + "test.test_lazy_import.data.lazy_import_inside_submodule.thing.inner", + ) + + def test_submodule_imported_elsewhere_keeps_lazy_from_package_import(self): + """A lazy "from . import sub" imports the submodule too, so its + binding is kept, and resolves to the submodule.""" + import test.test_lazy_import.data.lazy_from_package_import_submodule as pkg + import test.test_lazy_import.data.lazy_from_package_import_submodule.thing + self.assertIsInstance(pkg.__dict__["thing"], types.LazyImportType) + self.assertIs( + pkg.thing, + sys.modules[ + "test.test_lazy_import.data.lazy_from_package_import_submodule.thing"], + ) + + def test_submodule_overwrites_lazy_binding_from_elsewhere(self): + """A lazy binding of another module's name does not import the + submodule, so the submodule's import overwrites it, as it would + overwrite the eager binding.""" + import test.test_lazy_import.data.lazy_from_other_module as pkg + import test.test_lazy_import.data.lazy_from_other_module.letters + self.assertIs( + pkg.letters, + sys.modules["test.test_lazy_import.data.lazy_from_other_module.letters"], + ) + + def test_submodule_overwrites_lazy_binding_from_a_sibling(self): + """A sibling whose name starts with the submodule's name is another + module: pkg.things is not inside pkg.thing.""" + import test.test_lazy_import.data.lazy_from_prefixed_submodule as pkg + import test.test_lazy_import.data.lazy_from_prefixed_submodule.thing + self.assertIs( + pkg.thing, + sys.modules["test.test_lazy_import.data.lazy_from_prefixed_submodule.thing"], + ) + class ModuleVariableNameCollisionTests(unittest.TestCase): """Tests for name collision between a submodule and a variable.""" diff --git a/Lib/test/test_lazy_import/data/eager_from_own_submodule/__init__.py b/Lib/test/test_lazy_import/data/eager_from_own_submodule/__init__.py new file mode 100644 index 000000000000000..76619c61d1eec71 --- /dev/null +++ b/Lib/test/test_lazy_import/data/eager_from_own_submodule/__init__.py @@ -0,0 +1 @@ +from .thing import thing diff --git a/Lib/test/test_lazy_import/data/eager_from_own_submodule/thing.py b/Lib/test/test_lazy_import/data/eager_from_own_submodule/thing.py new file mode 100644 index 000000000000000..438e901e94fabac --- /dev/null +++ b/Lib/test/test_lazy_import/data/eager_from_own_submodule/thing.py @@ -0,0 +1 @@ +thing = "the object" diff --git a/Lib/test/test_lazy_import/data/lazy_from_other_module/__init__.py b/Lib/test/test_lazy_import/data/lazy_from_other_module/__init__.py new file mode 100644 index 000000000000000..db4337f25c35a01 --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_from_other_module/__init__.py @@ -0,0 +1 @@ +lazy from string import ascii_letters as letters diff --git a/Lib/test/test_lazy_import/data/lazy_from_other_module/letters.py b/Lib/test/test_lazy_import/data/lazy_from_other_module/letters.py new file mode 100644 index 000000000000000..2f77a33cdbe9bd6 --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_from_other_module/letters.py @@ -0,0 +1 @@ +letters = "the submodule's own" diff --git a/Lib/test/test_lazy_import/data/lazy_from_own_submodule/__init__.py b/Lib/test/test_lazy_import/data/lazy_from_own_submodule/__init__.py new file mode 100644 index 000000000000000..51ef0b096104d5f --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_from_own_submodule/__init__.py @@ -0,0 +1 @@ +lazy from .thing import thing diff --git a/Lib/test/test_lazy_import/data/lazy_from_own_submodule/thing.py b/Lib/test/test_lazy_import/data/lazy_from_own_submodule/thing.py new file mode 100644 index 000000000000000..438e901e94fabac --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_from_own_submodule/thing.py @@ -0,0 +1 @@ +thing = "the object" diff --git a/Lib/test/test_lazy_import/data/lazy_from_package_import_submodule/__init__.py b/Lib/test/test_lazy_import/data/lazy_from_package_import_submodule/__init__.py new file mode 100644 index 000000000000000..78d5f5688e56009 --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_from_package_import_submodule/__init__.py @@ -0,0 +1 @@ +lazy from . import thing diff --git a/Lib/test/test_lazy_import/data/lazy_from_package_import_submodule/thing.py b/Lib/test/test_lazy_import/data/lazy_from_package_import_submodule/thing.py new file mode 100644 index 000000000000000..e69de29bb2d1d64 diff --git a/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/__init__.py b/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/__init__.py new file mode 100644 index 000000000000000..75e9d8282403590 --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/__init__.py @@ -0,0 +1 @@ +lazy from .things import value as thing diff --git a/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/thing.py b/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/thing.py new file mode 100644 index 000000000000000..e69de29bb2d1d64 diff --git a/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/things.py b/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/things.py new file mode 100644 index 000000000000000..c4b6f87ccb13b3c --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_from_prefixed_submodule/things.py @@ -0,0 +1 @@ +value = "from things" diff --git a/Lib/test/test_lazy_import/data/lazy_import_inside_submodule/__init__.py b/Lib/test/test_lazy_import/data/lazy_import_inside_submodule/__init__.py new file mode 100644 index 000000000000000..606e164175ef329 --- /dev/null +++ b/Lib/test/test_lazy_import/data/lazy_import_inside_submodule/__init__.py @@ -0,0 +1 @@ +lazy import test.test_lazy_import.data.lazy_import_inside_submodule.thing.inner as thing diff --git a/Lib/test/test_lazy_import/data/lazy_import_inside_submodule/thing/__init__.py b/Lib/test/test_lazy_import/data/lazy_import_inside_submodule/thing/__init__.py new file mode 100644 index 000000000000000..e69de29bb2d1d64 diff --git a/Lib/test/test_lazy_import/data/lazy_import_inside_submodule/thing/inner.py b/Lib/test/test_lazy_import/data/lazy_import_inside_submodule/thing/inner.py new file mode 100644 index 000000000000000..e69de29bb2d1d64 diff --git a/Makefile.pre.in b/Makefile.pre.in index e74deee353488fe..062b5d7a2dcde38 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -2752,6 +2752,13 @@ TESTSUBDIRS= idlelib/idle_test \ test/test_lazy_import/data/pkg \ test/test_lazy_import/data/badsyntax \ test/test_lazy_import/data/circular_import_pkg \ + test/test_lazy_import/data/eager_from_own_submodule \ + test/test_lazy_import/data/lazy_from_other_module \ + test/test_lazy_import/data/lazy_from_own_submodule \ + test/test_lazy_import/data/lazy_from_package_import_submodule \ + test/test_lazy_import/data/lazy_from_prefixed_submodule \ + test/test_lazy_import/data/lazy_import_inside_submodule \ + test/test_lazy_import/data/lazy_import_inside_submodule/thing \ test/test_lazy_import/data/lazypkg \ test/test_lazy_import/data/metasyntactic \ test/test_lazy_import/data/metasyntactic/foo \ diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-11-03-19-44-27.gh-issue-161265.kQ3vLz.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-03-19-44-27.gh-issue-161265.kQ3vLz.rst new file mode 100644 index 000000000000000..e704e80b308702e --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-03-19-44-27.gh-issue-161265.kQ3vLz.rst @@ -0,0 +1,4 @@ +Fix a :ref:`lazy ` ``from .sub import sub`` in a package being +replaced by the submodule when ``pkg.sub`` was first imported elsewhere. The +name now resolves to the object the statement imports, as it does without +``lazy``. diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index aafd2ce764a1cbc..85749503bff8a86 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -611,6 +611,52 @@ _PyLazyImport_LoadChild(PyThreadState *tstate, PyObject *declaration, return result; } +// Return 1 if the module target is name or one of its submodules, 0 if not, +// -1 with an exception set on error. +static int +lazy_import_is_within(PyObject *target, PyObject *name) +{ + Py_ssize_t length = PyUnicode_GET_LENGTH(name); + Py_ssize_t target_length = PyUnicode_GET_LENGTH(target); + if (target_length < length) { + return 0; + } + Py_ssize_t matches = PyUnicode_Tailmatch(target, name, 0, length, -1); + if (matches <= 0) { + return (int)matches; + } + return target_length == length || + PyUnicode_READ_CHAR(target, length) == '.'; +} + +// Return 1 if resolving placeholder imports the module name or one of its +// submodules, 0 if not, -1 with an exception set on error. Run eagerly, +// such a statement would already have imported name. +int +_PyLazyImport_ImportsModule(PyObject *placeholder, PyObject *name) +{ + assert(PyLazyImport_CheckExact(placeholder)); + PyLazyImportObject *root = PyLazyImportObject_CAST(placeholder); + PyLazyImportObject *projection = NULL; + while (PyLazyImport_CheckExact(root->lz_from)) { + projection = root; + root = PyLazyImportObject_CAST(root->lz_from); + } + int rc = lazy_import_is_within(root->lz_from, name); + if (rc != 0 || projection == NULL) { + return rc; + } + // "from pkg import child" imports pkg.child when it is a submodule. + PyObject *child = PyUnicode_FromFormat("%U.%U", root->lz_from, + projection->lz_attr); + if (child == NULL) { + return -1; + } + rc = lazy_import_is_within(child, name); + Py_DECREF(child); + return rc; +} + int _PyLazyImport_IsActive(PyObject *declaration) { diff --git a/Python/clinic/import.c.h b/Python/clinic/import.c.h index 749eaebca19dc53..31facf3a3d15487 100644 --- a/Python/clinic/import.c.h +++ b/Python/clinic/import.c.h @@ -658,6 +658,55 @@ _imp__set_lazy_attributes(PyObject *module, PyObject *const *args, Py_ssize_t na return return_value; } +PyDoc_STRVAR(_imp__lazily_binds_child__doc__, +"_lazily_binds_child($module, parent, child, name, /)\n" +"--\n" +"\n" +"Return whether the module parent binds child to a lazy import of name.\n" +"\n" +"A lazy import of a module inside name counts too: resolving either one\n" +"imports name."); + +#define _IMP__LAZILY_BINDS_CHILD_METHODDEF \ + {"_lazily_binds_child", _PyCFunction_CAST(_imp__lazily_binds_child), METH_FASTCALL, _imp__lazily_binds_child__doc__}, + +static int +_imp__lazily_binds_child_impl(PyObject *module, PyObject *parent, + PyObject *child, PyObject *name); + +static PyObject * +_imp__lazily_binds_child(PyObject *module, PyObject *const *args, Py_ssize_t nargs) +{ + PyObject *return_value = NULL; + PyObject *parent; + PyObject *child; + PyObject *name; + int _return_value; + + if (!_PyArg_CheckPositional("_lazily_binds_child", nargs, 3, 3)) { + goto exit; + } + parent = args[0]; + if (!PyUnicode_Check(args[1])) { + _PyArg_BadArgument("_lazily_binds_child", "argument 2", "str", args[1]); + goto exit; + } + child = args[1]; + if (!PyUnicode_Check(args[2])) { + _PyArg_BadArgument("_lazily_binds_child", "argument 3", "str", args[2]); + goto exit; + } + name = args[2]; + _return_value = _imp__lazily_binds_child_impl(module, parent, child, name); + if ((_return_value == -1) && PyErr_Occurred()) { + goto exit; + } + return_value = PyBool_FromLong((long)_return_value); + +exit: + return return_value; +} + #ifndef _IMP_CREATE_DYNAMIC_METHODDEF #define _IMP_CREATE_DYNAMIC_METHODDEF #endif /* !defined(_IMP_CREATE_DYNAMIC_METHODDEF) */ @@ -665,4 +714,4 @@ _imp__set_lazy_attributes(PyObject *module, PyObject *const *args, Py_ssize_t na #ifndef _IMP_EXEC_DYNAMIC_METHODDEF #define _IMP_EXEC_DYNAMIC_METHODDEF #endif /* !defined(_IMP_EXEC_DYNAMIC_METHODDEF) */ -/*[clinic end generated code: output=ae9cf67e39955555 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=d0053acd7ab6241c input=a9049054013a1b77]*/ diff --git a/Python/import.c b/Python/import.c index 0f01815c71ed369..98b2714f7694011 100644 --- a/Python/import.c +++ b/Python/import.c @@ -5570,6 +5570,38 @@ _imp__set_lazy_attributes_impl(PyObject *module, PyObject *modobj, Py_RETURN_NONE; } +/*[clinic input] +_imp._lazily_binds_child -> bool + parent: object + child: unicode + name: unicode + / +Return whether the module parent binds child to a lazy import of name. + +A lazy import of a module inside name counts too: resolving either one +imports name. +[clinic start generated code]*/ + +static int +_imp__lazily_binds_child_impl(PyObject *module, PyObject *parent, + PyObject *child, PyObject *name) +/*[clinic end generated code: output=f5db314652b57fca input=c9da3c4d4d883490]*/ +{ + if (!PyModule_Check(parent)) { + return 0; + } + PyObject *bound; + if (PyDict_GetItemRef(_PyModule_GetDict(parent), child, &bound) < 0) { + return -1; + } + int result = 0; + if (bound != NULL && PyLazyImport_CheckExact(bound)) { + result = _PyLazyImport_ImportsModule(bound, name); + } + Py_XDECREF(bound); + return result; +} + PyDoc_STRVAR(doc_imp, "(Extremely) low-level import machinery bits as used by importlib."); @@ -5594,6 +5626,7 @@ static PyMethodDef imp_methods[] = { _IMP__FIX_CO_FILENAME_METHODDEF _IMP_SOURCE_HASH_METHODDEF _IMP__SET_LAZY_ATTRIBUTES_METHODDEF + _IMP__LAZILY_BINDS_CHILD_METHODDEF {NULL, NULL} /* sentinel */ }; From 2319f59fe8409c687af000eafbee609263a6e23a Mon Sep 17 00:00:00 2001 From: lillecarl Date: Wed, 4 Nov 2026 00:35:02 +0100 Subject: [PATCH 2/2] Run test_unittest in the lazy_imports=all job 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 --- 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 45796d1db9f0a58..ae993e439f6a654 100644 --- a/Lib/test/lazy_imports_all_exclude.txt +++ b/Lib/test/lazy_imports_all_exclude.txt @@ -36,4 +36,3 @@ test_subprocess test_symtable test_tools test_type_annotations -test_unittest