From 18ced5bb2096dea59c5d973b4d5119e0eab3fc31 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Sun, 28 Jun 2026 12:49:10 +0800 Subject: [PATCH] gh-152297: Reify dotted lazy imports with full name --- Lib/test/test_lazy_import/__init__.py | 119 ++++++++++++++++-- Lib/test/test_traceback.py | 41 ++++-- ...-06-26-23-40-24.gh-issue-152297.fhS3aw.rst | 2 + Python/import.c | 34 +---- 4 files changed, 145 insertions(+), 51 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-23-40-24.gh-issue-152297.fhS3aw.rst diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 4658882243d65ff..4b91b48abc00e13 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -167,6 +167,103 @@ def test_from_import_with_imported_module_getattr(self): """) assert_python_ok("-c", code) + @support.requires_subprocess() + def test_dotted_import_calls_full_name_import_hook(self): + """Dotted lazy imports should call __import__ with the full name.""" + code = textwrap.dedent(r""" + import atexit + import builtins + import shutil + import sys + import tempfile + import types + from pathlib import Path + + PKG = "hookpkg_gh_152297" + SUBMOD = f"{PKG}.sub" + + real_import = builtins.__import__ + full_name_calls = [] + calls = [] + + tmpdir = tempfile.mkdtemp() + atexit.register(shutil.rmtree, tmpdir, ignore_errors=True) + + package_dir = Path(tmpdir, PKG) + package_dir.mkdir() + (package_dir / "__init__.py").touch() + (package_dir / "sub.py").write_text( + "VALUE = 'real-submodule'\n", encoding="utf-8") + + sys.path.insert(0, tmpdir) + atexit.register(setattr, builtins, "__import__", real_import) + + def custom_import(name, globals=None, locals=None, fromlist=(), + level=0): + caller = ( + globals.get("__name__") + if isinstance(globals, dict) else None + ) + call = (name, caller, tuple(fromlist or ())) + calls.append(call) + + if name == SUBMOD: + full_name_calls.append(call) + package = real_import(PKG, globals, locals, (), level) + module = types.ModuleType(SUBMOD) + module.VALUE = "hooked-submodule" + package.sub = module + sys.modules[SUBMOD] = module + return package + + return real_import(name, globals, locals, fromlist, level) + + builtins.__import__ = custom_import + + lazy import hookpkg_gh_152297.sub + + assert not full_name_calls, calls + assert hookpkg_gh_152297.sub.VALUE == "hooked-submodule", calls + assert full_name_calls == [(SUBMOD, "__main__", ())], calls + """) + assert_python_ok("-c", code) + + @support.requires_subprocess() + def test_dotted_import_first_use_loads_full_target(self): + """First use of a dotted lazy import should load the submodule.""" + code = textwrap.dedent(r""" + import atexit + import shutil + import sys + import tempfile + from pathlib import Path + + PKG = "hookpkg_gh_152297_default" + SUBMOD = f"{PKG}.sub" + + tmpdir = tempfile.mkdtemp() + atexit.register(shutil.rmtree, tmpdir, ignore_errors=True) + + package_dir = Path(tmpdir, PKG) + package_dir.mkdir() + (package_dir / "__init__.py").touch() + (package_dir / "sub.py").write_text( + "VALUE = 42\n", encoding="utf-8") + + sys.path.insert(0, tmpdir) + + lazy import hookpkg_gh_152297_default.sub + + assert SUBMOD not in sys.modules + + _ = hookpkg_gh_152297_default + + assert PKG in sys.modules + assert SUBMOD in sys.modules + assert hookpkg_gh_152297_default.sub.VALUE == 42 + """) + assert_python_ok("-c", code) + class GlobalLazyImportModeTests(LazyImportTestCase): """Tests for sys.set_lazy_imports() global mode control.""" @@ -658,14 +755,15 @@ class ErrorHandlingTests(LazyImportTestCase): """ def test_import_error_shows_chained_traceback(self): - """Accessing a nonexistent lazy submodule via parent attr raises AttributeError.""" + """A failed dotted lazy import should preserve the import failure.""" code = textwrap.dedent(""" import sys lazy import test.test_lazy_import.data.nonexistent_module try: x = test.test_lazy_import.data.nonexistent_module - except AttributeError as e: + except ModuleNotFoundError as e: + assert e.__cause__ is not None, "Expected chained exception" print("OK") """) result = subprocess.run( @@ -710,20 +808,22 @@ def test_reification_retries_on_failure(self): lazy import test.test_lazy_import.data.broken_module + def lazy_proxy(): + return globals()['test'] + # First access - should fail try: x = test.test_lazy_import.data.broken_module - except AttributeError: + except ValueError: pass - # The lazy object should still be a lazy proxy (not reified) - g = globals() - lazy_obj = g['test'] - # The root 'test' binding should still allow retry + assert type(lazy_proxy()) is types.LazyImportType + # Second access - should also fail (retry the import) try: x = test.test_lazy_import.data.broken_module - except AttributeError: + except ValueError: + assert type(lazy_proxy()) is types.LazyImportType print("OK - retry worked") """) result = subprocess.run( @@ -743,7 +843,8 @@ def test_error_during_module_execution_propagates(self): try: _ = test.test_lazy_import.data.broken_module print("FAIL - should have raised") - except AttributeError: + except ValueError as e: + assert str(e) == "This module always fails to import" print("OK") """) result = subprocess.run( diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index e38d0942e463e9c..96213f219474589 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -5609,24 +5609,43 @@ def _make_syntax_error(text, offset, end_offset): class TestLazyImportSuggestions(unittest.TestCase): """Test that lazy imports are not reified when computing AttributeError suggestions.""" + @staticmethod + def _lazy_holder_script(body): + setup = textwrap.dedent(""" + import atexit + import os + import shutil + import sys + import tempfile + + tmpdir = tempfile.mkdtemp() + atexit.register(shutil.rmtree, tmpdir, ignore_errors=True) + with open(os.path.join(tmpdir, "lazy_traceback_bar.py"), + "w", encoding="utf-8") as f: + f.write('print("BAR_MODULE_LOADED")\\n') + with open(os.path.join(tmpdir, "lazy_holder.py"), + "w", encoding="utf-8") as f: + f.write("lazy import lazy_traceback_bar\\n") + + sys.path.insert(0, tmpdir) + import lazy_holder + """) + return setup + textwrap.dedent(body) + def test_attribute_error_does_not_reify_lazy_imports(self): """Printing an AttributeError should not trigger lazy import reification.""" - # pkg.bar prints "BAR_MODULE_LOADED" when imported. - # If lazy import is reified during suggestion computation, we'll see it. - code = textwrap.dedent(""" - lazy import test.test_lazy_import.data.pkg.bar - test.test_lazy_import.data.pkg.nonexistent + code = self._lazy_holder_script(""" + lazy_holder.nonexistent """) rc, stdout, stderr = assert_python_failure('-c', code) self.assertNotIn(b"BAR_MODULE_LOADED", stdout) def test_traceback_formatting_does_not_reify_lazy_imports(self): """Formatting a traceback should not trigger lazy import reification.""" - code = textwrap.dedent(""" + code = self._lazy_holder_script(""" import traceback - lazy import test.test_lazy_import.data.pkg.bar try: - test.test_lazy_import.data.pkg.nonexistent + lazy_holder.nonexistent except AttributeError: traceback.format_exc() print("OK") @@ -5637,10 +5656,8 @@ def test_traceback_formatting_does_not_reify_lazy_imports(self): def test_suggestion_still_works_for_non_lazy_attributes(self): """Suggestions should still work for non-lazy module attributes.""" - code = textwrap.dedent(""" - lazy import test.test_lazy_import.data.pkg.bar - # Typo for __name__ - test.test_lazy_import.data.pkg.__nme__ + code = self._lazy_holder_script(""" + lazy_holder.__nme__ """) rc, stdout, stderr = assert_python_failure('-c', code) self.assertIn(b"__name__", stderr) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-23-40-24.gh-issue-152297.fhS3aw.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-23-40-24.gh-issue-152297.fhS3aw.rst new file mode 100644 index 000000000000000..089f0678669d771 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-23-40-24.gh-issue-152297.fhS3aw.rst @@ -0,0 +1,2 @@ +Fix ``lazy import pkg.sub`` so reification calls ``__import__`` with +``pkg.sub``, matching eager imports. diff --git a/Python/import.c b/Python/import.c index 6da6faf5f28cc3b..c0c450f50c83b05 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3940,19 +3940,6 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) goto error; } - Py_ssize_t dot = -1; - int full = 0; - if (lz->lz_attr != NULL) { - full = 1; - } - if (!full) { - dot = PyUnicode_FindChar(lz->lz_from, '.', 0, - PyUnicode_GET_LENGTH(lz->lz_from), 1); - } - if (dot < 0) { - full = 1; - } - if (lz->lz_attr != NULL) { if (PyUnicode_Check(lz->lz_attr)) { fromlist = PyTuple_New(1); @@ -3978,23 +3965,10 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) PyErr_SetString(PyExc_ImportError, "__import__ not found"); goto error; } - if (full) { - obj = _PyEval_ImportNameWithImport( - tstate, import_func, globals, globals, - lz->lz_from, fromlist, _PyLong_GetZero() - ); - } - else { - PyObject *name = PyUnicode_Substring(lz->lz_from, 0, dot); - if (name == NULL) { - goto error; - } - obj = _PyEval_ImportNameWithImport( - tstate, import_func, globals, globals, - name, fromlist, _PyLong_GetZero() - ); - Py_DECREF(name); - } + obj = _PyEval_ImportNameWithImport( + tstate, import_func, globals, globals, + lz->lz_from, fromlist, _PyLong_GetZero() + ); if (obj == NULL) { goto error; }