From cfba96e4545ba990655a7ecb705b35118695502c Mon Sep 17 00:00:00 2001 From: Georgios Alexopoulos Date: Wed, 16 Sep 2026 16:52:48 +0300 Subject: [PATCH 1/2] gh-157614: Make the lazy import subprocess test helper reusable Move _run_subprocess_with_modules() and _assert_subprocess_ok() from FilterFunctionSignatureTests to LazyImportTestCase, so that any lazy import test can build a package tree in a temporary directory and run code against it in a subprocess. Signed-off-by: Georgios Alexopoulos --- Lib/test/test_lazy_import/__init__.py | 59 +++++++++++++-------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 9147e788d7a81f2..3daad78d77c5d8a 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -36,6 +36,35 @@ def tearDown(self): sys.set_lazy_imports(self.lazy_imports) sys.lazy_modules.clear() + def _run_subprocess_with_modules(self, code, files): + with tempfile.TemporaryDirectory() as tmpdir: + for relpath, contents in files.items(): + path = os.path.join(tmpdir, relpath) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as file: + file.write(textwrap.dedent(contents)) + + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + entry for entry in (tmpdir, env.get("PYTHONPATH")) if entry + ) + env["PYTHON_LAZY_IMPORTS"] = "normal" + + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + capture_output=True, + cwd=tmpdir, + env=env, + text=True, + ) + return result + + def _assert_subprocess_ok(self, code, files): + result = self._run_subprocess_with_modules(code, files) + self.assertEqual( + result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}" + ) + return result class LazyImportTests(LazyImportTestCase): """Tests for basic lazy import functionality.""" @@ -1407,36 +1436,6 @@ class FilterFunctionSignatureTests(LazyImportTestCase): PEP 810: func(importer: str, name: str, fromlist: tuple[str, ...] | None) -> bool """ - def _run_subprocess_with_modules(self, code, files): - with tempfile.TemporaryDirectory() as tmpdir: - for relpath, contents in files.items(): - path = os.path.join(tmpdir, relpath) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as file: - file.write(textwrap.dedent(contents)) - - env = os.environ.copy() - env["PYTHONPATH"] = os.pathsep.join( - entry for entry in (tmpdir, env.get("PYTHONPATH")) if entry - ) - env["PYTHON_LAZY_IMPORTS"] = "normal" - - result = subprocess.run( - [sys.executable, "-c", textwrap.dedent(code)], - capture_output=True, - cwd=tmpdir, - env=env, - text=True, - ) - return result - - def _assert_subprocess_ok(self, code, files): - result = self._run_subprocess_with_modules(code, files) - self.assertEqual( - result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}" - ) - return result - def test_filter_receives_correct_arguments_for_import(self): """Filter should receive (importer, name, fromlist=None) for 'import x'.""" code = textwrap.dedent(""" From 33988718f35c5c2bbe0c85516a221c61bd62c11a Mon Sep 17 00:00:00 2001 From: Georgios Alexopoulos Date: Wed, 16 Sep 2026 16:52:48 +0300 Subject: [PATCH 2/2] gh-157614: Fix lazy import a.b as c binding a.b.b instead of the module _PyEval_LazyImportFrom() ran its sys.modules fast path on lz_from, which for lazy import a.b as c is the whole dotted name a.b rather than the module a that b has to be taken from. Changes to _PyEval_LazyImportFrom(): 1. Compute the base module before the fast path instead of after it, and look the attribute up on the base. The base is lz_from up to the first dot when lz_attr is NULL, lz_from + "." + lz_attr when lz_attr is a str, and lz_from itself for a from-import. 2. Add last_step and skip the fast path when it is false. It is true for a from-import and for a placeholder without lz_attr whose name has a single dot, and false when lz_attr is a str, that is on the later steps of a name with three or more components. Resolving one of those eagerly would hand a real module to the next IMPORT_FROM, which then imports the rest of the name right away instead of deferring it. 3. Hold the base in one owned reference, from, released on every exit, so the three _PyLazyImport_New() call sites collapse into one. Add a test that imports a.b, where a/b.py defines an attribute b, and checks that lazy import a.b as x binds the module. Signed-off-by: Georgios Alexopoulos --- Lib/test/test_lazy_import/__init__.py | 17 +++++ ...-09-16-13-45-00.gh-issue-157614.IPhxHy.rst | 2 + Python/ceval.c | 69 +++++++++++-------- 3 files changed, 60 insertions(+), 28 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-16-13-45-00.gh-issue-157614.IPhxHy.rst diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 3daad78d77c5d8a..9e6d2666ec329b4 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -1740,6 +1740,23 @@ def test_lazy_import_before_eager_resolves_to_same_module(self): self.assertIn("OK", result.stdout) + def test_eager_dotted_import_before_lazy_resolves_to_same_module(self): + """Eager 'import a.b as c' before 'lazy import a.b as d' should bind the module.""" + # gh-157614: with a.b already imported, the lazy statement bound the + # attribute a.b.b instead of the module a.b. + files = { + "a/__init__.py": "", + "a/b.py": "b = 'attribute a.b.b, not the module a.b'\n", + } + code = textwrap.dedent(""" + import a.b as c + lazy import a.b as lazy_c + + assert lazy_c is c, lazy_c + """) + self._assert_subprocess_ok(code, files) + + class RelativeImportTests(LazyImportTestCase): """Tests for relative imports with lazy keyword.""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-16-13-45-00.gh-issue-157614.IPhxHy.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-16-13-45-00.gh-issue-157614.IPhxHy.rst new file mode 100644 index 000000000000000..f6869e56eac2494 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-16-13-45-00.gh-issue-157614.IPhxHy.rst @@ -0,0 +1,2 @@ +Fix lazy import of a dotted module name binding an attribute of the module +instead of the module itself when the module was already imported. diff --git a/Python/ceval.c b/Python/ceval.c index 8cf02651d9a408f..8d7ea45e0475681 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3333,7 +3333,43 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje assert(PyUnicode_Check(name)); PyObject *ret; PyLazyImportObject *d = (PyLazyImportObject *)v; - PyObject *mod = PyImport_GetModule(d->lz_from); + + PyObject *from; + int last_step; + if (d->lz_attr != NULL) { + if (PyUnicode_Check(d->lz_attr)) { + from = PyUnicode_FromFormat("%U.%U", d->lz_from, d->lz_attr); + if (from == NULL) { + return NULL; + } + last_step = 0; + } + else { + from = Py_NewRef(d->lz_from); + last_step = 1; + } + } + else { + Py_ssize_t len = PyUnicode_GET_LENGTH(d->lz_from); + Py_ssize_t dot = PyUnicode_FindChar(d->lz_from, '.', 0, len, 1); + if (dot >= 0) { + from = PyUnicode_Substring(d->lz_from, 0, dot); + if (from == NULL) { + return NULL; + } + last_step = PyUnicode_FindChar( + d->lz_from, '.', dot + 1, len, 1) == -1; + } + else { + from = Py_NewRef(d->lz_from); + last_step = 1; + } + } + + PyObject *mod = NULL; + if (last_step) { + mod = PyImport_GetModule(from); + } if (mod != NULL) { // Check if the module already has the attribute, if so, resolve it // eagerly. @@ -3342,10 +3378,12 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje if (mod_dict != NULL) { if (PyDict_GetItemRef(mod_dict, name, &ret) < 0) { Py_DECREF(mod); + Py_DECREF(from); return NULL; } if (ret != NULL) { Py_DECREF(mod); + Py_DECREF(from); return ret; } } @@ -3353,33 +3391,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); + ret = _PyLazyImport_New(frame, d->lz_builtins, from, name); + Py_DECREF(from); return ret; }