diff --git a/Doc/library/types.rst b/Doc/library/types.rst index 9e928e6dd3637b0..6616bbc970d95f8 100644 --- a/Doc/library/types.rst +++ b/Doc/library/types.rst @@ -358,6 +358,12 @@ Standard names are defined for the following types: Reify the lazy import and return the "real" object being imported. + A lazy import is reified at most once. Repeated calls, and any later + access to a name still bound to the proxy, return the object from the + first reification, even if the module has since been removed from + :data:`sys.modules`. A reification that raises is not remembered, so + the next access retries the import. + .. class:: GetSetDescriptorType diff --git a/Include/internal/pycore_lazyimportobject.h b/Include/internal/pycore_lazyimportobject.h index b81e4211b08ff39..fab368303751409 100644 --- a/Include/internal/pycore_lazyimportobject.h +++ b/Include/internal/pycore_lazyimportobject.h @@ -19,6 +19,7 @@ typedef struct { PyObject *lz_builtins; PyObject *lz_from; PyObject *lz_attr; + PyObject *lz_resolved; // Result of the first reification, or NULL. // 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. diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 9147e788d7a81f2..f69c8e2f7a2263c 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -294,6 +294,98 @@ def test_lazy_import_type_attribute_error_message(self): """) assert_python_ok("-c", code) + # Setup for the two tests below. A repeat import is visible both in + # ``calls`` and as a different module object. They read the proxy out of + # the module dict because a plain global load would reify it first. + _COUNTING_IMPORT = """ + import builtins + import types + + real_import = builtins.__import__ + calls = [] + fail_import = False + + lazy import target_module as target + + def counting_import(name, *args, **kwargs): + if name != "target_module": + return real_import(name, *args, **kwargs) + calls.append(name) + if fail_import: + raise ImportError("no target_module for you") + return types.ModuleType(name) + + builtins.__import__ = counting_import + """ + + def _assert_counting_import_ok(self, body): + """Run *body* in a subprocess with the counting __import__ installed.""" + code = textwrap.dedent(self._COUNTING_IMPORT) + textwrap.dedent(body) + return assert_python_ok("-c", code) + + @support.requires_subprocess() + def test_proxy_reifies_once_whichever_path_reaches_it(self): + """Every reification of one proxy yields the first imported object.""" + self._assert_counting_import_ok(""" + def main(): + proxy = globals()["target"] + resolved = proxy.resolve() + assert proxy.resolve() is resolved + # The global is still bound to the proxy, so loading it reifies. + assert target is resolved, (target, resolved) + # So does loading a copy of the proxy from another namespace. + namespace = {"__builtins__": builtins, "alias": proxy} + exec("alias", namespace) + assert namespace["alias"] is resolved + assert calls == ["target_module"], calls + + main() + """) + + @support.requires_subprocess() + def test_failed_resolve_is_not_cached(self): + """A failed reification is retried rather than remembered.""" + self._assert_counting_import_ok(""" + def main(): + global fail_import + fail_import = True + proxy = globals()["target"] + try: + proxy.resolve() + except ImportError: + pass + else: + assert False, 'ImportError is not raised' + fail_import = False + resolved = proxy.resolve() + assert proxy.resolve() is resolved + assert calls == ["target_module"] * 2, calls + + main() + """) + + @support.requires_subprocess() + def test_from_import_proxy_remembers_the_attribute(self): + """A `lazy from` proxy binds the attribute, not the module.""" + code = textwrap.dedent(""" + import sys + + lazy from test.test_lazy_import.data.basic2 import f + + def main(): + proxy = globals()["f"] + resolved = proxy.resolve() + module = sys.modules["test.test_lazy_import.data.basic2"] + assert resolved is module.f, (resolved, module.f) + # Rebinding on the source module does not retarget the proxy, + # just as it does not for an eager ``from ... import``. + module.f = lambda: None + assert proxy.resolve() is resolved + + main() + """) + assert_python_ok("-c", code) + class SyntaxRestrictionTests(LazyImportTestCase): """Tests for syntax restrictions on lazy imports.""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-17-10-00-00.gh-issue-152298.Kq3vXt.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-17-10-00-00.gh-issue-152298.Kq3vXt.rst new file mode 100644 index 000000000000000..5cd91ee525d9f51 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-17-10-00-00.gh-issue-152298.Kq3vXt.rst @@ -0,0 +1,3 @@ +Reify a lazy import at most once. Previously +:meth:`!types.LazyImportType.resolve` followed by a normal access to the +still-lazy global imported twice and bound two different objects. diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 8f7f3f98c291289..4b2cd526a8fc057 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -33,6 +33,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_resolved = NULL; // Capture frame information for the original import location. m->lz_code = NULL; @@ -58,6 +59,7 @@ lazy_import_traverse(PyObject *op, visitproc visit, void *arg) Py_VISIT(m->lz_builtins); Py_VISIT(m->lz_from); Py_VISIT(m->lz_attr); + Py_VISIT(m->lz_resolved); Py_VISIT(m->lz_code); return 0; } @@ -69,6 +71,7 @@ lazy_import_clear(PyObject *op) Py_CLEAR(m->lz_builtins); Py_CLEAR(m->lz_from); Py_CLEAR(m->lz_attr); + Py_CLEAR(m->lz_resolved); Py_CLEAR(m->lz_code); return 0; } diff --git a/Python/import.c b/Python/import.c index 037f15d4ca2bafa..f430d05a77bb070 100644 --- a/Python/import.c +++ b/Python/import.c @@ -3913,6 +3913,13 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) // Acquire the global import lock to serialize reification _PyImport_AcquireLock(interp); + // Reify only once. lz_resolved is read and written under the import lock. + if (lz->lz_resolved != NULL) { + PyObject *resolved = Py_NewRef(lz->lz_resolved); + _PyImport_ReleaseLock(interp); + return resolved; + } + // Check if we are already importing this module, if so, then we want to // return an error that indicates we've hit a cycle which will indicate // the value isn't yet available. @@ -4000,6 +4007,10 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) assert(!PyLazyImport_CheckExact(obj)); + // Reentrancy on the same proxy is rejected above as a cycle. + assert(lz->lz_resolved == NULL); + Py_XSETREF(lz->lz_resolved, Py_NewRef(obj)); + goto ok; error: