Skip to content

Commit 3ce4bd1

Browse files
committed
gh-152298: Clarify lazy resolve owner cleanup
1 parent 36f1821 commit 3ce4bd1

4 files changed

Lines changed: 197 additions & 10 deletions

File tree

Doc/library/types.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,16 @@ Standard names are defined for the following types:
350350
actually accessed. This type can be used to detect lazy imports
351351
programmatically.
352352

353+
.. method:: resolve()
354+
355+
Resolve the lazy import and return the imported object. Successful
356+
resolutions are cached.
357+
358+
For module-level lazy imports, ``resolve()`` replaces the original global
359+
only while it still refers to this proxy. If that binding was deleted or
360+
rebound, it is left unchanged and later ``resolve()`` calls on the same
361+
proxy do not update it.
362+
353363
.. versionadded:: 3.15
354364

355365
.. seealso:: :pep:`810`

Include/internal/pycore_lazyimportobject.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ typedef struct {
1919
PyObject *lz_builtins;
2020
PyObject *lz_from;
2121
PyObject *lz_attr;
22+
// Protected by the import lock. The owner fields remember the first
23+
// globals/key pair until resolution succeeds or the proxy is cleared.
2224
PyObject *lz_owner_globals;
2325
PyObject *lz_owner_key;
2426
PyObject *lz_resolved;

Lib/test/test_lazy_import/__init__.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,166 @@ def custom_import(name, globals=None, locals=None, fromlist=None,
574574
""")
575575
assert_python_ok("-c", code)
576576

577+
@support.requires_subprocess()
578+
def test_module_attr_dynamic_equal_name_clears_owner(self):
579+
code = textwrap.dedent("""
580+
import builtins
581+
import sys
582+
import types
583+
584+
real_import = builtins.__import__
585+
module = sys.modules[__name__]
586+
587+
lazy import target_module as target
588+
proxy = globals()["target"]
589+
stored_name = next(name for name in globals() if name == "target")
590+
dynamic_name = bytearray(b"target").decode()
591+
assert dynamic_name == stored_name
592+
assert dynamic_name is not stored_name
593+
594+
def custom_import(name, globals=None, locals=None, fromlist=None,
595+
level=0):
596+
if name == "target_module":
597+
resolved = types.ModuleType(name)
598+
resolved.VALUE = "resolved"
599+
return resolved
600+
return real_import(name, globals, locals, fromlist, level)
601+
602+
builtins.__import__ = custom_import
603+
try:
604+
resolved = getattr(module, dynamic_name)
605+
assert resolved.VALUE == "resolved"
606+
assert module.__dict__["target"] is resolved
607+
608+
module.__dict__["target"] = proxy
609+
again = proxy.resolve()
610+
assert again is resolved
611+
assert module.__dict__["target"] is proxy
612+
finally:
613+
builtins.__import__ = real_import
614+
""")
615+
assert_python_ok("-c", code)
616+
617+
@support.requires_subprocess()
618+
def test_rebound_back_to_same_proxy_after_stale_resolve_does_not_replace(self):
619+
code = textwrap.dedent("""
620+
import builtins
621+
import types
622+
623+
real_import = builtins.__import__
624+
calls = []
625+
626+
lazy import target_module as target
627+
proxy = globals()["target"]
628+
629+
def custom_import(name, globals=None, locals=None, fromlist=None,
630+
level=0):
631+
if name == "target_module":
632+
calls.append(name)
633+
resolved = types.ModuleType(name)
634+
resolved.VALUE = "resolved"
635+
return resolved
636+
return real_import(name, globals, locals, fromlist, level)
637+
638+
builtins.__import__ = custom_import
639+
try:
640+
sentinel = object()
641+
globals()["target"] = sentinel
642+
resolved = proxy.resolve()
643+
assert globals()["target"] is sentinel
644+
645+
globals()["target"] = proxy
646+
again = proxy.resolve()
647+
assert again is resolved
648+
assert globals()["target"] is proxy
649+
assert len(calls) == 1, calls
650+
finally:
651+
builtins.__import__ = real_import
652+
""")
653+
assert_python_ok("-c", code)
654+
655+
@support.requires_subprocess()
656+
def test_resolve_replaces_unaliased_import_global(self):
657+
code = textwrap.dedent("""
658+
import builtins
659+
import types
660+
661+
real_import = builtins.__import__
662+
663+
lazy import target_module
664+
proxy = globals()["target_module"]
665+
666+
def custom_import(name, globals=None, locals=None, fromlist=None,
667+
level=0):
668+
if name == "target_module":
669+
resolved = types.ModuleType(name)
670+
resolved.VALUE = "resolved"
671+
return resolved
672+
return real_import(name, globals, locals, fromlist, level)
673+
674+
builtins.__import__ = custom_import
675+
try:
676+
resolved = proxy.resolve()
677+
assert resolved.VALUE == "resolved"
678+
assert globals()["target_module"] is resolved
679+
finally:
680+
builtins.__import__ = real_import
681+
""")
682+
assert_python_ok("-c", code)
683+
684+
@support.requires_subprocess()
685+
def test_resolve_replaces_unaliased_dotted_import_binding(self):
686+
code = textwrap.dedent("""
687+
import types
688+
689+
lazy import test.test_lazy_import.data.pkg.bar
690+
proxy = globals()["test"]
691+
692+
resolved = proxy.resolve()
693+
assert isinstance(resolved, types.ModuleType)
694+
assert resolved.__name__ == "test"
695+
assert globals()["test"] is resolved
696+
assert "test.test_lazy_import.data.pkg" not in globals()
697+
""")
698+
assert_python_ok("-c", code)
699+
700+
@support.requires_subprocess()
701+
def test_copying_lazy_proxy_to_second_global_keeps_first_owner(self):
702+
code = textwrap.dedent("""
703+
import builtins
704+
import types
705+
706+
real_import = builtins.__import__
707+
calls = []
708+
709+
lazy import target_module as primary
710+
secondary = globals()["primary"]
711+
proxy = globals()["primary"]
712+
713+
def custom_import(name, globals=None, locals=None, fromlist=None,
714+
level=0):
715+
if name == "target_module":
716+
calls.append(name)
717+
resolved = types.ModuleType(name)
718+
resolved.VALUE = "resolved"
719+
return resolved
720+
return real_import(name, globals, locals, fromlist, level)
721+
722+
builtins.__import__ = custom_import
723+
try:
724+
resolved = proxy.resolve()
725+
assert globals()["primary"] is resolved
726+
assert globals()["secondary"] is proxy
727+
728+
again = globals()["secondary"].resolve()
729+
assert again is resolved
730+
assert globals()["secondary"] is proxy
731+
assert len(calls) == 1, calls
732+
finally:
733+
builtins.__import__ = real_import
734+
""")
735+
assert_python_ok("-c", code)
736+
577737

578738
class SyntaxRestrictionTests(LazyImportTestCase):
579739
"""Tests for syntax restrictions on lazy imports."""

Objects/lazyimportobject.c

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "pycore_interpframe.h"
1010
#include "pycore_lazyimportobject.h"
1111
#include "pycore_modsupport.h"
12+
#include "pycore_unicodeobject.h"
1213

1314
#define PyLazyImportObject_CAST(op) ((PyLazyImportObject *)(op))
1415

@@ -141,6 +142,8 @@ _PyLazyImport_BindGlobal(PyThreadState *tstate, PyObject *op,
141142
PyLazyImportObject *m = PyLazyImportObject_CAST(op);
142143
PyInterpreterState *interp = tstate->interp;
143144
_PyImport_AcquireLock(interp);
145+
// First owner wins: copies of the proxy do not change the original global
146+
// binding that resolve() may replace.
144147
if (m->lz_resolved == NULL &&
145148
m->lz_owner_globals == NULL &&
146149
m->lz_owner_key == NULL)
@@ -157,7 +160,10 @@ clear_owner_if_matches(PyThreadState *tstate, PyLazyImportObject *m,
157160
{
158161
PyInterpreterState *interp = tstate->interp;
159162
_PyImport_AcquireLock(interp);
160-
if (m->lz_owner_globals == globals && m->lz_owner_key == name) {
163+
if (m->lz_owner_globals == globals &&
164+
m->lz_owner_key != NULL &&
165+
_PyUnicode_Equal(m->lz_owner_key, name))
166+
{
161167
Py_CLEAR(m->lz_owner_globals);
162168
Py_CLEAR(m->lz_owner_key);
163169
}
@@ -178,20 +184,27 @@ _PyLazyImport_CommitIfCurrent(PyThreadState *tstate, PyObject *op,
178184

179185
PyObject *current = NULL;
180186
int err = 0;
181-
187+
int clear_owner = 0;
182188
Py_BEGIN_CRITICAL_SECTION(globals);
183189
int found = _PyDict_GetItemRef_Unicode_LockHeld(
184190
(PyDictObject *)globals, name, &current);
185191
if (found < 0) {
186192
err = -1;
187193
}
188-
else if (found > 0 && current == op) {
189-
err = _PyDict_SetItem_LockHeld((PyDictObject *)globals, name, value);
194+
else {
195+
// Stop tracking the owner after a successful reification, so a stale
196+
// proxy cannot replace a future rebinding.
197+
clear_owner = 1;
198+
if (found > 0 && current == op) {
199+
err = _PyDict_SetItem_LockHeld(
200+
(PyDictObject *)globals, name, value);
201+
clear_owner = (err == 0);
202+
}
190203
}
191-
Py_XDECREF(current);
192204
Py_END_CRITICAL_SECTION();
205+
Py_XDECREF(current);
193206

194-
if (err == 0) {
207+
if (clear_owner) {
195208
clear_owner_if_matches(tstate, PyLazyImportObject_CAST(op),
196209
globals, name);
197210
}
@@ -211,6 +224,8 @@ _PyLazyImport_CommitStoredIfCurrent(PyThreadState *tstate, PyObject *op,
211224
PyObject *name = NULL;
212225

213226
PyInterpreterState *interp = tstate->interp;
227+
// Copy the owner under the import lock, then release it before entering a
228+
// dict critical section in _PyLazyImport_CommitIfCurrent().
214229
_PyImport_AcquireLock(interp);
215230
if (m->lz_owner_globals != NULL && m->lz_owner_key != NULL) {
216231
globals = Py_NewRef(m->lz_owner_globals);
@@ -246,7 +261,7 @@ lazy_import_resolve(PyObject *self, PyObject *args)
246261
static PyMethodDef lazy_import_methods[] = {
247262
{
248263
"resolve", lazy_import_resolve, METH_NOARGS,
249-
PyDoc_STR("resolves the lazy import and returns the actual object")
264+
PyDoc_STR("resolve the lazy import and return the actual object")
250265
},
251266
{NULL, NULL}
252267
};
@@ -258,9 +273,9 @@ PyDoc_STRVAR(lazy_import_doc,
258273
"\n"
259274
"Represents a lazy import that will be resolved on first use.\n"
260275
"\n"
261-
"Instances of this object accessed from the global scope will be\n"
262-
"automatically imported based upon their name and then replaced with\n"
263-
"the imported value.");
276+
"A successful resolution is cached. Instances of this object accessed\n"
277+
"from the global scope will be automatically imported based upon their\n"
278+
"name and then replaced with the imported value.");
264279

265280
PyTypeObject PyLazyImport_Type = {
266281
PyVarObject_HEAD_INIT(&PyType_Type, 0)

0 commit comments

Comments
 (0)