diff --git a/Lib/test/lazy_imports_all_exclude.txt b/Lib/test/lazy_imports_all_exclude.txt index 2680d3b1e4357b7..45796d1db9f0a58 100644 --- a/Lib/test/lazy_imports_all_exclude.txt +++ b/Lib/test/lazy_imports_all_exclude.txt @@ -35,6 +35,5 @@ test_pyrepl test_subprocess test_symtable test_tools -test_trace test_type_annotations test_unittest diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 9147e788d7a81f2..5026c9670d81d23 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -724,10 +724,17 @@ def test_non_package_lazily_imported(self): assert_python_ok("-c", code) def test_non_package_lazily_imported_as(self): - """Doing a dotted lazy import as still works""" + """A dotted lazy import as raises when the name is not a module.""" + # gh-157757: the eager statement raises, so the lazy one raises too. code = textwrap.dedent(""" lazy import math.pi as pi - pi + + try: + pi + except ModuleNotFoundError: + pass + else: + raise AssertionError("ModuleNotFoundError was not raised") """) assert_python_ok("-c", code) @@ -1170,6 +1177,22 @@ def test_accessing_one_name_leaves_others_as_proxies(self): self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}") self.assertIn("OK", result.stdout) + def test_accessing_one_name_imports_only_its_submodule(self): + """Accessing one name should not import the other names' submodules.""" + code = textwrap.dedent(""" + import sys + + lazy from test.test_lazy_import.data.pkg import b, bar, broken + + # Importing bar prints, and importing broken raises. + b.foo() + + assert "test.test_lazy_import.data.pkg.bar" not in sys.modules + assert "test.test_lazy_import.data.pkg.broken" not in sys.modules + """) + rc, out, err = assert_python_ok("-c", code) + self.assertEqual(out, b"") + def test_all_names_reified_after_all_accessed(self): """All names should be reified after each is accessed.""" code = textwrap.dedent(""" @@ -2209,6 +2232,122 @@ def test_import_after_variable_wins(self): ] self.assertIs(module_same_name_var_order2.bar, bar_mod) + def test_lazy_import_as_wins_over_variable(self): + """A dotted lazy import as imports the submodule the variable hides.""" + # gh-157757: importing pkg.b rebinds pkg.b from the variable to the + # module, eagerly and lazily alike. + code = textwrap.dedent(""" + import sys + import test.test_lazy_import.data.pkg as pkg + pkg.b = "hides the b submodule" + + lazy import test.test_lazy_import.data.pkg.b as b + lazy import test.test_lazy_import.data.metasyntactic.foo.bar as bar + + assert b is sys.modules["test.test_lazy_import.data.pkg.b"], b + assert bar is sys.modules[ + "test.test_lazy_import.data.metasyntactic.foo.bar"], bar + """) + assert_python_ok("-c", code) + + def test_dotted_as_of_loaded_module(self): + """A dotted lazy import as binds the module, not a same-named attribute.""" + # importlib.metadata is already loaded and has a `metadata` attribute. + code = textwrap.dedent(""" + import importlib.metadata + import importlib.metadata as eager + + lazy import importlib.metadata as lazily + + assert lazily is eager, lazily + """) + assert_python_ok("-c", code) + + def test_dotted_as_replays_lookups_on_custom_placeholder(self): + """A dotted lazy import as looks up its names on what the hook returned.""" + code = textwrap.dedent(""" + import builtins + import xml.dom + + # In a list, so the hook reading it does not resolve it. + placeholder = [__lazy_import__("xml")] + default = builtins.__lazy_import__ + builtins.__lazy_import__ = lambda *args: placeholder[0] + lazy import fake.dom as dom + builtins.__lazy_import__ = default + + assert dom is xml.dom, dom + """) + assert_python_ok("-c", code) + + def test_empty_fromlist_placeholder_matches_no_fromlist(self): + """An empty fromlist behaves like None.""" + code = textwrap.dedent(""" + expected = "" + # In lists, so reading them does not resolve them. + for fromlist in (None, ()): + same = [__lazy_import__("xml.dom", fromlist=fromlist)] + assert repr(same[0]) == expected, (fromlist, repr(same[0])) + bare = [__lazy_import__("xml.dom")] + assert repr(bare[0]) == expected, repr(bare[0]) + """) + assert_python_ok("-c", code) + + def test_empty_fromlist_preserved_for_custom_import(self): + code = textwrap.dedent(""" + import builtins + import types + + value = object() + module = types.SimpleNamespace(dom=value) + placeholder = [__lazy_import__("xml.dom", fromlist=())] + default_import = builtins.__import__ + default_lazy_import = builtins.__lazy_import__ + calls = [] + + def import_hook(name, globals, locals, fromlist, level): + assert name == "xml.dom", name + assert fromlist == (), fromlist + calls.append(fromlist) + return module + + builtins.__import__ = import_hook + assert placeholder[0].resolve() is module + builtins.__lazy_import__ = lambda *args: placeholder[0] + lazy import fake.dom as dom + assert dom is value + builtins.__import__ = default_import + builtins.__lazy_import__ = default_lazy_import + + assert calls == [(), ()], calls + """) + assert_python_ok("-c", code) + + def test_dotted_as_replays_lookups_on_dotted_placeholder(self): + """A dotted lazy import as replays its names on the hook's package.""" + # importlib.metadata has a `metadata` attribute of its own, which the + # placeholder for importlib must not answer with. + for target in ("xml.dom", "importlib.metadata"): + with self.subTest(target=target): + leaf = target.rpartition(".")[2] + code = textwrap.dedent(f""" + import builtins + import sys + import {target} + + # In a list, so the hook reading it does not resolve it. + placeholder = [__lazy_import__("{target}", fromlist=())] + default = builtins.__lazy_import__ + builtins.__lazy_import__ = lambda *args: placeholder[0] + lazy import fake.{leaf} as {leaf} + builtins.__lazy_import__ = default + + name = repr(globals()["{leaf}"]) + assert name == "", name + assert {leaf} is sys.modules["{target}"], {leaf} + """) + assert_python_ok("-c", code) + class DeletedModuleReimportTests(unittest.TestCase): """Tests for reimporting after module deletion from sys.modules.""" diff --git a/Lib/test/test_lazy_import/data/pkg/broken.py b/Lib/test/test_lazy_import/data/pkg/broken.py new file mode 100644 index 000000000000000..6499aa9e15030a6 --- /dev/null +++ b/Lib/test/test_lazy_import/data/pkg/broken.py @@ -0,0 +1,2 @@ +# Submodule that raises an error during import +raise ValueError("This module always fails to import") diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst new file mode 100644 index 000000000000000..bb5377a53cab566 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-18-15-20-00.gh-issue-157757.Kq3Lm7.rst @@ -0,0 +1,3 @@ +Fix a lazy ``import a.b as c`` reading ``b`` off ``a`` instead of importing +the module ``a.b``. It now binds the submodule, and raises +:exc:`ModuleNotFoundError` when no module backs the name. diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 8f7f3f98c291289..e008c076352ebbe 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -14,8 +14,8 @@ PyObject * _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, PyObject *name, PyObject *fromlist) { PyLazyImportObject *m; - if (!name || !PyUnicode_Check(name)) { - PyErr_SetString(PyExc_TypeError, "expected str for name"); + if (!name || !(PyUnicode_Check(name) || PyLazyImport_CheckExact(name))) { + PyErr_SetString(PyExc_TypeError, "expected str or lazy_import for name"); return NULL; } if (fromlist == Py_None || fromlist == NULL) { @@ -104,16 +104,45 @@ lazy_import_getattro(PyObject *op, PyObject *name) return value; } +// The dotted name of the object that resolving the placeholder returns. static PyObject * -lazy_import_name(PyLazyImportObject *m) +lazy_import_path(PyLazyImportObject *m) { - if (m->lz_attr != NULL) { - if (PyUnicode_Check(m->lz_attr)) { - return PyUnicode_FromFormat("%U.%U", m->lz_from, m->lz_attr); - } - else { - return PyUnicode_FromFormat("%U...", m->lz_from); + if (PyLazyImport_CheckExact(m->lz_from)) { + PyObject *base = lazy_import_path((PyLazyImportObject *)m->lz_from); + if (base == NULL) { + return NULL; } + PyObject *res = PyUnicode_FromFormat("%U.%U", base, m->lz_attr); + Py_DECREF(base); + return res; + } + if (m->lz_attr != NULL && + (!PyTuple_Check(m->lz_attr) || PyTuple_GET_SIZE(m->lz_attr) > 0)) { + return Py_NewRef(m->lz_from); + } + // __import__("a.b") returns the top-level package `a`. + Py_ssize_t dot = PyUnicode_FindChar( + m->lz_from, '.', 0, PyUnicode_GET_LENGTH(m->lz_from), 1 + ); + if (dot == -2) { + return NULL; + } + if (dot < 0) { + return Py_NewRef(m->lz_from); + } + return PyUnicode_Substring(m->lz_from, 0, dot); +} + +static PyObject * +lazy_import_name(PyLazyImportObject *m) +{ + if (PyLazyImport_CheckExact(m->lz_from)) { + return lazy_import_path(m); + } + if (m->lz_attr != NULL && + (!PyTuple_Check(m->lz_attr) || PyTuple_GET_SIZE(m->lz_attr) > 0)) { + return PyUnicode_FromFormat("%U...", m->lz_from); } return Py_NewRef(m->lz_from); } diff --git a/Python/ceval.c b/Python/ceval.c index 8cf02651d9a408f..c1fbcb0611384c5 100644 --- a/Python/ceval.c +++ b/Python/ceval.c @@ -3333,7 +3333,13 @@ _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 *mod = NULL; + // Only `from a import b` can take b off an already imported a; + // `import a.b as c` has to import a.b first. + if (d->lz_attr != NULL && PyTuple_Check(d->lz_attr) && + PyTuple_GET_SIZE(d->lz_attr) > 0) { + mod = PyImport_GetModule(d->lz_from); + } if (mod != NULL) { // Check if the module already has the attribute, if so, resolve it // eagerly. @@ -3353,34 +3359,7 @@ _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); - return ret; + return _PyLazyImport_New(frame, d->lz_builtins, v, name); } #define CANNOT_CATCH_MSG "catching classes that do not inherit from "\ diff --git a/Python/import.c b/Python/import.c index 037f15d4ca2bafa..2fa63dd01833a64 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3898,6 +3898,25 @@ _PyImport_ResolveName(PyThreadState *tstate, PyObject *name, return resolve_name(tstate, name, globals, level); } +// Look up, in order, the attributes recorded from the root placeholder to lz +// on the module the root's import returned. +static PyObject * +lazy_import_replay_from(PyThreadState *tstate, PyObject *mod, + PyLazyImportObject *lz) +{ + if (!PyLazyImport_CheckExact(lz->lz_from)) { + return Py_NewRef(mod); + } + PyObject *from = lazy_import_replay_from( + tstate, mod, (PyLazyImportObject *)lz->lz_from); + if (from == NULL) { + return NULL; + } + PyObject *obj = _PyEval_ImportFrom(tstate, from, lz->lz_attr); + Py_DECREF(from); + return obj; +} + PyObject * _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) { @@ -3910,6 +3929,13 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) PyLazyImportObject *lz = (PyLazyImportObject *)lazy_import; PyInterpreterState *interp = tstate->interp; + // Walk back to the placeholder IMPORT_NAME left, and the first lookup on it. + PyLazyImportObject *root = lz, *first = NULL; + while (PyLazyImport_CheckExact(root->lz_from)) { + first = root; + root = (PyLazyImportObject *)root->lz_from; + } + // Acquire the global import lock to serialize reification _PyImport_AcquireLock(interp); @@ -3946,7 +3972,7 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) return NULL; } PyErr_SetImportErrorSubclass(PyExc_ImportCycleError, errmsg, - lz->lz_from, NULL); + root->lz_from, NULL); Py_DECREF(errmsg); Py_DECREF(name); _PyImport_ReleaseLock(interp); @@ -3956,24 +3982,20 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) goto error; } - if (lz->lz_attr != NULL) { - if (PyUnicode_Check(lz->lz_attr)) { - fromlist = PyTuple_New(1); - if (fromlist == NULL) { - goto error; - } - Py_INCREF(lz->lz_attr); - PyTuple_SET_ITEM(fromlist, 0, lz->lz_attr); - } - else { - Py_INCREF(lz->lz_attr); - fromlist = lz->lz_attr; + if (root->lz_attr != NULL) { + // `from a import b, c`: import only the name being resolved. + // Keep an empty tuple intact for custom __import__ hooks. + fromlist = first && PyTuple_GET_SIZE(root->lz_attr) > 0 + ? PyTuple_Pack(1, first->lz_attr) + : Py_NewRef(root->lz_attr); + if (fromlist == NULL) { + goto error; } } PyObject *globals = PyEval_GetGlobals(); - if (PyMapping_GetOptionalItem(lz->lz_builtins, &_Py_ID(__import__), + if (PyMapping_GetOptionalItem(root->lz_builtins, &_Py_ID(__import__), &import_func) < 0) { goto error; } @@ -3983,19 +4005,17 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) } obj = _PyEval_ImportNameWithImport( tstate, import_func, globals, globals, - lz->lz_from, fromlist, _PyLong_GetZero() + root->lz_from, fromlist, _PyLong_GetZero() ); if (obj == NULL) { goto error; } - if (lz->lz_attr != NULL && PyUnicode_Check(lz->lz_attr)) { - PyObject *from = obj; - obj = _PyEval_ImportFrom(tstate, from, lz->lz_attr); - Py_DECREF(from); - if (obj == NULL) { - goto error; - } + PyObject *from = obj; + obj = lazy_import_replay_from(tstate, from, lz); + Py_DECREF(from); + if (obj == NULL) { + goto error; } assert(!PyLazyImport_CheckExact(obj));