From 2fc680f408d672e292e4fd9abaf792b3563ac6b6 Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Fri, 12 Jun 2026 07:40:40 -0700 Subject: [PATCH 1/4] Bug fix for bug in trace module --- Lib/test/test_lazy_import/__init__.py | 30 ++++++++++++ Python/ceval.c | 70 ++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 1724beb8ce6951..7e62391fa9898c 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -545,6 +545,36 @@ def test_package_from_import_with_module_getattr(self): """) assert_python_ok("-c", code) + @support.requires_subprocess() + def test_lazy_import_reimports_submodule_evicted_from_sys_modules(self): + """A submodule evicted from sys.modules but still cached on its parent + must be re-imported, like eager 'import a.b.c as t' is.""" + code = textwrap.dedent(""" + import sys + modname = "test.test_lazy_import.data.pkg.bar" + import test.test_lazy_import.data.pkg.bar + del sys.modules[modname] + lazy import test.test_lazy_import.data.pkg.bar as t + t.f() + assert modname in sys.modules, modname + """) + assert_python_ok("-c", code) + + @support.requires_subprocess() + def test_lazy_from_import_reimports_submodule_evicted_from_sys_modules(self): + """Same as above for 'from a.b import c': the stale parent attribute + must not shadow a re-import after sys.modules eviction.""" + code = textwrap.dedent(""" + import sys + modname = "test.test_lazy_import.data.pkg.bar" + import test.test_lazy_import.data.pkg.bar + del sys.modules[modname] + lazy from test.test_lazy_import.data.pkg import bar + bar.f() + assert modname in sys.modules, modname + """) + assert_python_ok("-c", code) + class DunderLazyImportTests(LazyImportTestCase): """Tests for __lazy_import__ builtin function.""" diff --git a/Python/ceval.c b/Python/ceval.c index a9b31affca9890..5b84cf3c3c4b4c 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3123,6 +3123,60 @@ _PyEval_LazyImportName(PyThreadState *tstate, PyObject *builtins, return res; } +static PyObject * +import_from_resolve_module_attr(PyThreadState *tstate, PyObject *package_name, + PyObject *name, PyObject *attr) +{ + if (!PyModule_Check(attr) || package_name == NULL + || !PyUnicode_Check(package_name)) { + return Py_NewRef(attr); + } + + PyObject *fullmodname = PyUnicode_FromFormat("%U.%U", package_name, name); + if (fullmodname == NULL) { + return NULL; + } + + PyObject *attr_name; + if (PyObject_GetOptionalAttr(attr, &_Py_ID(__name__), &attr_name) < 0) { + Py_DECREF(fullmodname); + return NULL; + } + + int matches = (attr_name != NULL && PyUnicode_Check(attr_name)) + ? PyObject_RichCompareBool(attr_name, fullmodname, Py_EQ) + : 0; + Py_XDECREF(attr_name); + if (matches <= 0) { + /* Not the canonical submodule (matches == 0), or compare failed + (matches < 0, exception set). */ + Py_DECREF(fullmodname); + return matches < 0 ? NULL : Py_NewRef(attr); + } + + /* If the package still caches a submodule object after its entry was + removed from sys.modules, import the canonical submodule again. */ + PyObject *submod = PyImport_GetModule(fullmodname); + if (submod == NULL && !_PyErr_Occurred(tstate)) { + PyObject *imported = PyImport_ImportModuleLevelObject( + fullmodname, NULL, NULL, NULL, 0); + if (imported == NULL) { + Py_DECREF(fullmodname); + return NULL; + } + Py_DECREF(imported); + submod = PyImport_GetModule(fullmodname); + } + Py_DECREF(fullmodname); + if (submod != NULL) { + return submod; + } + if (_PyErr_Occurred(tstate)) { + return NULL; + } + return Py_NewRef(attr); +} + PyObject * _PyEval_ImportFrom(PyThreadState *tstate, PyObject *v, PyObject *name) { @@ -3130,6 +3184,17 @@ _PyEval_ImportFrom(PyThreadState *tstate, PyObject *v, PyObject *name) PyObject *fullmodname, *mod_name, *origin, *mod_name_or_unknown, *errmsg, *spec; if (PyObject_GetOptionalAttr(v, name, &x) != 0) { + if (x != NULL && PyModule_Check(x)) { + PyObject *resolved; + if (PyObject_GetOptionalAttr(v, &_Py_ID(__name__), &mod_name) < 0) { + Py_DECREF(x); + return NULL; + } + resolved = import_from_resolve_module_attr(tstate, mod_name, name, x); + Py_XDECREF(mod_name); + Py_DECREF(x); + return resolved; + } return x; } /* Issue #17636: in case this failed because of a circular relative @@ -3311,8 +3376,11 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje return NULL; } if (ret != NULL) { + PyObject *resolved = import_from_resolve_module_attr( + tstate, d->lz_from, name, ret); + Py_DECREF(ret); Py_DECREF(mod); - return ret; + return resolved; } } } From 1f219b12e1bd80d4f4fdaff94d046ac507915d32 Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Fri, 12 Jun 2026 10:56:46 -0700 Subject: [PATCH 2/4] Comments, pkg fix, new test --- Lib/test/test_import/__init__.py | 17 +++++++++++++++++ Python/ceval.c | 25 +++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_import/__init__.py b/Lib/test/test_import/__init__.py index c905c0da0a1232..b50640e2078240 100644 --- a/Lib/test/test_import/__init__.py +++ b/Lib/test/test_import/__init__.py @@ -416,6 +416,23 @@ def test_from_import_missing_attr_path_is_canonical(self): self.assertIn(cm.exception.name, {'posixpath', 'ntpath'}) self.assertIsNotNone(cm.exception) + def test_from_import_module_attr_from_non_package(self): + module_name = 'test_from_import_module_attr_from_non_package' + child_name = f'{module_name}.child' + module = types.ModuleType(module_name) + child = types.ModuleType(child_name) + module.child = child + self.addCleanup(unload, module_name) + self.addCleanup(unload, child_name) + sys.modules[module_name] = module + + # A plain module can expose a module-valued attribute without being a + # package, so from-import must return the attribute as-is. + ns = {} + exec(f'from {module_name} import child as imported', ns) + self.assertIs(ns['imported'], child) + self.assertNotIn(child_name, sys.modules) + def test_from_import_star_invalid_type(self): with ready_to_import() as (name, path): with open(path, 'w', encoding='utf-8') as f: diff --git a/Python/ceval.c b/Python/ceval.c index 5b84cf3c3c4b4c..2714603a7a2ba5 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3132,6 +3132,25 @@ import_from_resolve_module_attr(PyThreadState *tstate, PyObject *package_name, return Py_NewRef(attr); } + /* Only packages can legitimately republish importable submodules here. + Plain modules may expose arbitrary module-valued attributes. */ + PyObject *package = PyImport_GetModule(package_name); + if (package == NULL) { + if (_PyErr_Occurred(tstate)) { + return NULL; + } + return Py_NewRef(attr); + } + + int is_package = PyObject_HasAttrWithError(package, &_Py_ID(__path__)); + Py_DECREF(package); + if (is_package <= 0) { + if (is_package < 0) { + return NULL; + } + return Py_NewRef(attr); + } + PyObject *fullmodname = PyUnicode_FromFormat("%U.%U", package_name, name); if (fullmodname == NULL) { return NULL; @@ -3190,6 +3209,8 @@ _PyEval_ImportFrom(PyThreadState *tstate, PyObject *v, PyObject *name) Py_DECREF(x); return NULL; } + /* If this is a cached submodule, resolve it through sys.modules + so from-import repairs stale package entries. */ resolved = import_from_resolve_module_attr(tstate, mod_name, name, x); Py_XDECREF(mod_name); Py_DECREF(x); @@ -3366,8 +3387,8 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje PyLazyImportObject *d = (PyLazyImportObject *)v; PyObject *mod = PyImport_GetModule(d->lz_from); if (mod != NULL) { - // Check if the module already has the attribute, if so, resolve it - // eagerly. + /* If the parent is already imported, resolve any module-valued + attribute through the same stale-submodule check. */ if (PyModule_Check(mod)) { PyObject *mod_dict = PyModule_GetDict(mod); if (mod_dict != NULL) { From b57f4c9d777a0c332ff448e13550ed49aa4b5d3b Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Fri, 12 Jun 2026 11:03:23 -0700 Subject: [PATCH 3/4] Update test format to be more uniform --- Lib/test/test_import/__init__.py | 35 ++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/Lib/test/test_import/__init__.py b/Lib/test/test_import/__init__.py index b50640e2078240..12b3990db7d62b 100644 --- a/Lib/test/test_import/__init__.py +++ b/Lib/test/test_import/__init__.py @@ -29,6 +29,7 @@ from unittest import mock import _imp +from test import support from test.support import os_helper from test.support import ( STDLIB_DIR, @@ -416,22 +417,26 @@ def test_from_import_missing_attr_path_is_canonical(self): self.assertIn(cm.exception.name, {'posixpath', 'ntpath'}) self.assertIsNotNone(cm.exception) + @support.requires_subprocess() def test_from_import_module_attr_from_non_package(self): - module_name = 'test_from_import_module_attr_from_non_package' - child_name = f'{module_name}.child' - module = types.ModuleType(module_name) - child = types.ModuleType(child_name) - module.child = child - self.addCleanup(unload, module_name) - self.addCleanup(unload, child_name) - sys.modules[module_name] = module - - # A plain module can expose a module-valued attribute without being a - # package, so from-import must return the attribute as-is. - ns = {} - exec(f'from {module_name} import child as imported', ns) - self.assertIs(ns['imported'], child) - self.assertNotIn(child_name, sys.modules) + code = textwrap.dedent(""" + import sys + import types + + module_name = 'test_from_import_module_attr_from_non_package' + child_name = f'{module_name}.child' + module = types.ModuleType(module_name) + child = types.ModuleType(child_name) + module.child = child + sys.modules[module_name] = module + + # A plain module can expose a module-valued attribute without + # being a package, so from-import must return the attribute as-is. + from test_from_import_module_attr_from_non_package import child as imported + assert imported is child + assert child_name not in sys.modules, child_name + """) + script_helper.assert_python_ok("-c", code) def test_from_import_star_invalid_type(self): with ready_to_import() as (name, path): From cfa863b55cd8e8071ffad1139427a31d3d07bd9c Mon Sep 17 00:00:00 2001 From: Brittany Reynoso Date: Fri, 12 Jun 2026 11:14:59 -0700 Subject: [PATCH 4/4] Add a news entry --- .../2026-06-12-11-45-00.gh-issue-151408.4s9KpL.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-12-11-45-00.gh-issue-151408.4s9KpL.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-12-11-45-00.gh-issue-151408.4s9KpL.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-12-11-45-00.gh-issue-151408.4s9KpL.rst new file mode 100644 index 00000000000000..05dd32460c7c55 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-12-11-45-00.gh-issue-151408.4s9KpL.rst @@ -0,0 +1,2 @@ +Fix lazy submodule imports so package submodules removed from +:data:`sys.modules` are properly re-imported.