diff --git a/Include/internal/pycore_lazyimportobject.h b/Include/internal/pycore_lazyimportobject.h index b81e4211b08ff39..0438e9373c92a4c 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_import_as; // Whether to reify using import-as semantics. } PyLazyImportObject; PyAPI_FUNC(PyObject *) _PyLazyImport_GetName(PyObject *lazy_import); PyAPI_FUNC(PyObject *) _PyLazyImport_New( struct _PyInterpreterFrame *frame, PyObject *import_func, PyObject *from, PyObject *attr); +PyAPI_FUNC(PyObject *) _PyLazyImport_NewImportAs( + struct _PyInterpreterFrame *frame, PyObject *import_func, PyObject *from); #ifdef __cplusplus } diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 4658882243d65ff..09c3435ebe306d7 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -40,6 +40,67 @@ def tearDown(self): class LazyImportTests(LazyImportTestCase): """Tests for basic lazy import functionality.""" + def check_dotted_import_calls_full_name_import_hook( + self, package, import_stmt, access_expr + ): + code = textwrap.dedent(f""" + import atexit + import builtins + import shutil + import sys + import tempfile + import types + from pathlib import Path + + PKG = {package!r} + SUBMOD = 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 + + {import_stmt} + + assert not full_name_calls, calls + assert {access_expr}.VALUE == "hooked-submodule", calls + assert full_name_calls == [(SUBMOD, "__main__", ())], calls + """) + assert_python_ok("-c", code) + def test_basic_unused(self): """Lazy imported module should not be loaded if never accessed.""" import test.test_lazy_import.data.basic_unused @@ -167,6 +228,60 @@ 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.""" + self.check_dotted_import_calls_full_name_import_hook( + "lazy_import_hook_pkg", + "lazy import lazy_import_hook_pkg.sub", + "lazy_import_hook_pkg.sub", + ) + + @support.requires_subprocess() + def test_dotted_import_as_calls_full_name_import_hook(self): + """Dotted lazy import-as should call __import__ with the full name.""" + self.check_dotted_import_calls_full_name_import_hook( + "lazy_import_as_hook_pkg", + "lazy import lazy_import_as_hook_pkg.sub as alias", + "alias", + ) + + @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 +773,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 +826,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 +861,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..25c8edad55d73f3 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-26-23-40-24.gh-issue-152297.fhS3aw.rst @@ -0,0 +1,3 @@ +Fix dotted lazy imports such as ``lazy import pkg.sub`` and ``lazy import +pkg.sub as alias`` so reification calls ``__import__`` with the full dotted +name, matching eager imports. diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index fa1eb25047d9617..b2fa213ae678d1a 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -10,8 +10,9 @@ #define PyLazyImportObject_CAST(op) ((PyLazyImportObject *)(op)) -PyObject * -_PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, PyObject *name, PyObject *fromlist) +static PyObject * +lazy_import_new(_PyInterpreterFrame *frame, PyObject *builtins, + PyObject *name, PyObject *fromlist, int import_as) { 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_import_as = import_as; // Capture frame information for the original import location. m->lz_code = NULL; @@ -51,6 +53,20 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, PyObject *name return (PyObject *)m; } +PyObject * +_PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, + PyObject *name, PyObject *fromlist) +{ + return lazy_import_new(frame, builtins, name, fromlist, 0); +} + +PyObject * +_PyLazyImport_NewImportAs(_PyInterpreterFrame *frame, PyObject *builtins, + PyObject *name) +{ + return lazy_import_new(frame, builtins, name, NULL, 1); +} + static int lazy_import_traverse(PyObject *op, visitproc visit, void *arg) { diff --git a/Python/ceval.c b/Python/ceval.c index fbea1f67a36f442..45db4ffb14c688b 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3290,6 +3290,19 @@ _PyEval_ImportFrom(PyThreadState *tstate, PyObject *v, PyObject *name) return NULL; } +static PyObject * +lazy_import_as_from(_PyInterpreterFrame *frame, PyObject *builtins, + PyObject *from, PyObject *name) +{ + PyObject *full = PyUnicode_FromFormat("%U.%U", from, name); + if (full == NULL) { + return NULL; + } + PyObject *ret = _PyLazyImport_NewImportAs(frame, builtins, full); + Py_DECREF(full); + return ret; +} + PyObject * _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObject *v, PyObject *name) { @@ -3298,6 +3311,25 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje assert(PyUnicode_Check(name)); PyObject *ret; PyLazyImportObject *d = (PyLazyImportObject *)v; + + if (d->lz_import_as) { + return lazy_import_as_from(frame, d->lz_builtins, d->lz_from, name); + } + else if (d->lz_attr == NULL) { + 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 = lazy_import_as_from(frame, d->lz_builtins, from, name); + Py_DECREF(from); + return ret; + } + } + PyObject *mod = PyImport_GetModule(d->lz_from); if (mod != NULL) { // Check if the module already has the attribute, if so, resolve it @@ -3330,20 +3362,6 @@ _PyEval_LazyImportFrom(PyThreadState *tstate, _PyInterpreterFrame *frame, PyObje 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; } diff --git a/Python/import.c b/Python/import.c index 6da6faf5f28cc3b..bfd05ae005a1465 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3940,20 +3940,13 @@ _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_import_as) { + fromlist = PyTuple_New(0); + if (fromlist == NULL) { + goto error; + } } - - if (lz->lz_attr != NULL) { + else if (lz->lz_attr != NULL) { if (PyUnicode_Check(lz->lz_attr)) { fromlist = PyTuple_New(1); if (fromlist == NULL) { @@ -3978,28 +3971,43 @@ _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; } - if (lz->lz_attr != NULL && PyUnicode_Check(lz->lz_attr)) { + if (lz->lz_import_as) { + Py_ssize_t len = PyUnicode_GET_LENGTH(lz->lz_from); + Py_ssize_t dot = PyUnicode_FindChar(lz->lz_from, '.', 0, len, 1); + if (dot == -2) { + goto error; + } + while (dot >= 0) { + Py_ssize_t start = dot + 1; + Py_ssize_t next_dot = PyUnicode_FindChar( + lz->lz_from, '.', start, len, 1); + if (next_dot == -2) { + goto error; + } + PyObject *attr = PyUnicode_Substring( + lz->lz_from, start, next_dot >= 0 ? next_dot : len); + if (attr == NULL) { + goto error; + } + PyObject *from = obj; + obj = _PyEval_ImportFrom(tstate, from, attr); + Py_DECREF(attr); + Py_DECREF(from); + if (obj == NULL) { + goto error; + } + dot = next_dot; + } + } + 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);