Skip to content

Commit 4e83bfa

Browse files
committed
gh-158140: Resolve lazy siblings through the pending-child registry
1 parent a6a63b6 commit 4e83bfa

8 files changed

Lines changed: 403 additions & 27 deletions

File tree

‎Include/internal/pycore_import.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ PyAPI_FUNC(PyObject *) _PyImport_LoadLazyImportTstate(
4141
// new reference, NULL without an exception if absent, or NULL on error.
4242
// Set recheck_dict if a concurrent lookup may have already bound the child.
4343
extern PyObject * _PyImport_TryLoadLazySubmodule(
44-
PyObject *module, PyObject *attr_name, int *recheck_dict);
44+
PyObject *module, PyObject *attr_name, int suppress, int *recheck_dict);
45+
extern int _PyImport_ClearLazySubmodule(
46+
PyThreadState *tstate, PyObject *name, int bind);
4547
extern PyObject * _PyImport_LazyImportModuleLevelObject(
4648
PyThreadState *tstate, PyObject *name, PyObject *builtins,
4749
PyObject *globals, PyObject *locals, PyObject *fromlist, int level);

‎Include/internal/pycore_interp_structs.h‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,11 @@ struct _import_state {
356356
// lazily imported. This value is only for debugging/introspection
357357
// purposes and is not used by the runtime.
358358
PyObject *lazy_modules;
359-
// A dict mapping package names to a set of submodule names that
360-
// may need to be imported when an attribute is missing on the package.
359+
// Package names map to pending children: declarations for plain imports,
360+
// or None for from-import names that may be ordinary attributes.
361361
PyObject *lazy_pending_submodules;
362+
// Avoid pending-child work for ordinary cached imports.
363+
int has_lazy_submodules;
362364
#ifdef Py_GIL_DISABLED
363365
PyMutex lazy_mutex;
364366
#endif

‎Include/internal/pycore_lazyimportobject.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ PyAPI_FUNC(PyObject *) _PyLazyImport_New(
1818
struct _PyInterpreterFrame *frame, PyObject *builtins,
1919
PyObject *name, PyObject *fromlist);
2020

21+
extern PyObject *_PyLazyImport_LoadChild(
22+
PyThreadState *tstate, PyObject *declaration, PyObject *name);
23+
extern int _PyLazyImport_IsActive(PyObject *declaration);
24+
2125
extern int _PyLazyImport_IsResolving(PyThreadState *tstate, PyObject *op);
2226

2327
// Resolve a placeholder and replace its binding if it is unchanged or holds

‎Lib/test/test_lazy_import/__init__.py‎

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,119 @@ def test_lazy_with_from(self):
448448
self.assertNotIn("test.test_lazy_import.data.basic2", sys.modules)
449449

450450

451+
@support.requires_subprocess()
452+
class IndependentSubmoduleTests(LazyImportTestCase):
453+
def check(self, code):
454+
assert_python_ok('-c', textwrap.dedent(code))
455+
456+
def test_siblings(self):
457+
for package, children in (
458+
('test.test_lazy_import.data.pkg', ('b', 'bar')),
459+
('test.test_lazy_import.data.metasyntactic.foo', ('ack', 'bar')),
460+
):
461+
for first, second in (children, children[::-1]):
462+
with self.subTest(package=package, first=first):
463+
self.check(f"""
464+
import sys
465+
lazy import {package}.{first}
466+
lazy import {package}.{second}
467+
assert {package}.{first} is sys.modules['{package}.{first}']
468+
assert '{package}.{second}' not in sys.modules
469+
assert {package}.{second} is sys.modules['{package}.{second}']
470+
""")
471+
472+
def test_star_import(self):
473+
self.check("""
474+
lazy import urllib.nonexistent
475+
lazy import urllib
476+
assert urllib.__name__ == 'urllib'
477+
from urllib import *
478+
""")
479+
480+
def test_deleted_child(self):
481+
self.check("""
482+
lazy from xml import dom
483+
assert dom.__name__ == 'xml.dom'
484+
import xml
485+
del xml.dom
486+
assert not hasattr(xml, 'dom')
487+
""")
488+
489+
def test_cached_import_releases_builtins(self):
490+
for statement in ('lazy from xml.dom import Node',
491+
'lazy import xml.dom.minidom'):
492+
with self.subTest(statement=statement):
493+
self.check(f"""
494+
import builtins, gc, weakref
495+
import xml.dom.minidom
496+
class Payload:
497+
pass
498+
payload = Payload()
499+
reference = weakref.ref(payload)
500+
namespace = {{'__builtins__': dict(vars(builtins), payload=payload)}}
501+
exec({statement!r}, namespace)
502+
exec('Node' if 'from' in {statement!r} else 'xml.dom.minidom', namespace)
503+
del namespace, payload
504+
gc.collect()
505+
assert reference() is None
506+
""")
507+
508+
def test_invalid_descendant_retries(self):
509+
self.check("""
510+
lazy import test.test_lazy_import.data.pkg.b.foo
511+
for _ in range(2):
512+
try:
513+
test.test_lazy_import.data.pkg.b.foo
514+
except ModuleNotFoundError:
515+
pass
516+
else:
517+
raise AssertionError('imported an attribute as a module')
518+
import test.test_lazy_import.data.pkg.b
519+
assert callable(test.test_lazy_import.data.pkg.b.foo)
520+
""")
521+
522+
def test_namespace_import_hooks(self):
523+
self.check("""
524+
import builtins
525+
first = {'__builtins__': vars(builtins).copy()}
526+
second = {'__builtins__': vars(builtins).copy()}
527+
exec('lazy import xml.dom', first)
528+
exec('lazy import xml.dom', second)
529+
def denied(*args):
530+
raise AssertionError('used another namespace import hook')
531+
second['__builtins__']['__import__'] = denied
532+
exec('assert xml.dom.__name__ == "xml.dom"', first)
533+
""")
534+
535+
def test_retry_after_recursive_access(self):
536+
self.check("""
537+
import sys, xml
538+
from importlib.machinery import ModuleSpec
539+
class Loader:
540+
calls = 0
541+
def find_spec(self, name, path=None, target=None):
542+
if name == 'xml.broken':
543+
return ModuleSpec(name, self)
544+
def create_module(self, spec):
545+
return None
546+
def exec_module(self, module):
547+
self.calls += 1
548+
xml.broken
549+
raise RuntimeError('failed initialization')
550+
loader = Loader()
551+
sys.meta_path.insert(0, loader)
552+
lazy import xml.broken
553+
for _ in range(2):
554+
try:
555+
xml.broken
556+
except RuntimeError as exc:
557+
assert str(exc) == 'failed initialization'
558+
else:
559+
raise AssertionError('returned a failed partial module')
560+
assert loader.calls == 2
561+
""")
562+
563+
451564
class PackageTests(LazyImportTestCase):
452565
"""Tests for lazy imports with packages."""
453566

@@ -785,7 +898,7 @@ def test_chained_exception_import_shows_notes(self, name):
785898
lazy import {name}
786899
787900
try:
788-
_ = test
901+
_ = {name}
789902
except ValueError as e:
790903
assert any(
791904
note.startswith("lazy import of '{name}' declared in ")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Resolve lazily imported sibling submodules independently, so accessing one
2+
does not import another merely because its declaration came last.

‎Objects/lazyimportobject.c‎

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "pycore_lazyimportobject.h"
1010
#include "pycore_long.h"
1111
#include "pycore_moduleobject.h"
12+
#include "pycore_pyatomic_ft_wrappers.h"
1213
#include "pycore_pyerrors.h"
1314
#include "pycore_traceback.h"
1415
#include "pycore_tstate.h"
@@ -25,6 +26,8 @@ typedef struct {
2526
// Declaration location.
2627
PyCodeObject *lz_code;
2728
int lz_instr_offset;
29+
// The root was accessed, so its declared path requires import semantics.
30+
int lz_active;
2831
} PyLazyImportObject;
2932

3033
#define PyLazyImportObject_CAST(op) ((PyLazyImportObject *)(op))
@@ -70,6 +73,7 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins,
7073

7174
m->lz_code = NULL;
7275
m->lz_instr_offset = -1;
76+
m->lz_active = 0;
7377

7478
if (frame != NULL) {
7579
m->lz_code = (PyCodeObject *)Py_NewRef(_PyFrame_GetCode(frame));
@@ -429,10 +433,81 @@ lazy_import_resolve_impl(PyThreadState *tstate, PyObject *lazy_import,
429433
PyErr_SetString(PyExc_ImportError, "__import__ not found");
430434
goto done;
431435
}
436+
PyObject *name = Py_NewRef(root->lz_from);
437+
if (first == NULL &&
438+
(fromlist == Py_None ||
439+
(PyTuple_Check(fromlist) && PyTuple_GET_SIZE(fromlist) == 0)) &&
440+
_PyImport_IsDefaultImportFunc(tstate->interp, import_func) &&
441+
root->lz_builtins == tstate->interp->builtins) {
442+
// A plain import binds the root package. Its pending children are
443+
// imported separately when their attributes are accessed.
444+
Py_ssize_t dot = PyUnicode_FindChar(
445+
name, '.', 0, PyUnicode_GET_LENGTH(name), 1);
446+
if (dot >= 0) {
447+
// Preserve eager traversal through aliased or non-package parents.
448+
int regular = 1;
449+
int complete = 1;
450+
Py_ssize_t end = PyUnicode_GET_LENGTH(name);
451+
for (Py_ssize_t i = dot; i >= 0 && regular;
452+
i = PyUnicode_FindChar(name, '.', i + 1, end, 1)) {
453+
PyObject *prefix = PyUnicode_Substring(name, 0, i);
454+
PyObject *cached = prefix == NULL ? NULL :
455+
lazy_import_get_loaded_attr(tstate, prefix, &_Py_ID(__name__));
456+
if (cached == NULL) {
457+
complete = 0;
458+
Py_XDECREF(prefix);
459+
break;
460+
}
461+
PyObject *path = lazy_import_get_loaded_attr(
462+
tstate, prefix, &_Py_ID(__path__));
463+
regular = path != NULL && PyUnicode_Check(cached) &&
464+
PyUnicode_Compare(cached, prefix) == 0;
465+
Py_XDECREF(path);
466+
Py_DECREF(cached);
467+
Py_DECREF(prefix);
468+
}
469+
PyObject *loaded = lazy_import_get_loaded_attr(
470+
tstate, name, &_Py_ID(__name__));
471+
if (regular && (!complete || loaded == NULL) && !PyErr_Occurred()) {
472+
Py_SETREF(name, PyUnicode_Substring(name, 0, dot));
473+
}
474+
Py_XDECREF(loaded);
475+
if (PyErr_Occurred()) {
476+
Py_CLEAR(name);
477+
}
478+
}
479+
else if (dot == -2) {
480+
Py_CLEAR(name);
481+
}
482+
if (name == NULL) {
483+
goto done;
484+
}
485+
}
432486
obj = _PyEval_ImportNameWithImport(
433487
tstate, import_func, globals, globals,
434-
root->lz_from, fromlist, _PyLong_GetZero()
488+
name, fromlist, _PyLong_GetZero()
435489
);
490+
if (obj != NULL && name != root->lz_from) {
491+
FT_ATOMIC_STORE_INT_RELAXED(root->lz_active, 1);
492+
FT_ATOMIC_STORE_INT_RELAXED(tstate->interp->imports.has_lazy_submodules, 1);
493+
int package = PyModule_CheckExact(obj) ?
494+
PyDict_Contains(_PyModule_GetDict(obj), &_Py_ID(__path__)) : 0;
495+
if (package < 0) {
496+
Py_CLEAR(obj);
497+
}
498+
else if (!package) {
499+
Py_DECREF(obj);
500+
obj = _PyEval_ImportNameWithImport(
501+
tstate, import_func, globals, globals,
502+
root->lz_from, fromlist, _PyLong_GetZero());
503+
}
504+
}
505+
if (obj != NULL && name == root->lz_from &&
506+
_PyImport_IsDefaultImportFunc(tstate->interp, import_func) &&
507+
_PyImport_ClearLazySubmodule(tstate, name, 0) < 0) {
508+
Py_CLEAR(obj);
509+
}
510+
Py_DECREF(name);
436511
obj = lazy_import_resolve_result(tstate, obj);
437512
// The normal importer may publish this module on its parent. Custom
438513
// hooks retain control of their own assignments to the parent.
@@ -476,6 +551,56 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import)
476551
return lazy_import_resolve_impl(tstate, lazy_import, NULL);
477552
}
478553

554+
// Resolve a pending child as an aliased import, preserving its declaration.
555+
PyObject *
556+
_PyLazyImport_LoadChild(PyThreadState *tstate, PyObject *declaration,
557+
PyObject *name)
558+
{
559+
PyLazyImportObject *source = (PyLazyImportObject *)declaration;
560+
PyObject *lz = _PyLazyImport_New(NULL, source->lz_builtins, name, NULL);
561+
Py_ssize_t end = PyUnicode_GET_LENGTH(name);
562+
Py_ssize_t dot = PyUnicode_FindChar(name, '.', 0, end, 1);
563+
if (dot == -2) {
564+
Py_CLEAR(lz);
565+
}
566+
while (lz != NULL && dot >= 0) {
567+
Py_ssize_t start = dot + 1;
568+
dot = PyUnicode_FindChar(name, '.', start, end, 1);
569+
if (dot == -2) {
570+
Py_CLEAR(lz);
571+
break;
572+
}
573+
PyObject *attr = PyUnicode_Substring(name, start, dot < 0 ? end : dot);
574+
PyObject *next = attr == NULL ? NULL :
575+
_PyLazyImport_New(NULL, NULL, lz, attr);
576+
Py_XDECREF(attr);
577+
Py_SETREF(lz, next);
578+
}
579+
if (lz == NULL) {
580+
return NULL;
581+
}
582+
PyLazyImportObject *child = (PyLazyImportObject *)lz;
583+
child->lz_code = (PyCodeObject *)Py_XNewRef(source->lz_code);
584+
child->lz_instr_offset = source->lz_instr_offset;
585+
PyObject *result = _PyImport_LoadLazyImportTstate(tstate, lz);
586+
Py_DECREF(lz);
587+
if (result != NULL && PyModule_Check(result) &&
588+
PyUnicode_Compare(source->lz_from, name) != 0 &&
589+
!PyDict_Contains(_PyModule_GetDict(result), &_Py_ID(__path__))) {
590+
// Reaching a non-package must still reject an invalid dotted import.
591+
Py_DECREF(result);
592+
return _PyLazyImport_LoadChild(tstate, declaration, source->lz_from);
593+
}
594+
return result;
595+
}
596+
597+
int
598+
_PyLazyImport_IsActive(PyObject *declaration)
599+
{
600+
return FT_ATOMIC_LOAD_INT_RELAXED(
601+
((PyLazyImportObject *)declaration)->lz_active);
602+
}
603+
479604
// Loading pkg.child can replace a placeholder in pkg.child with the module
480605
// before a from-import retrieves the value that belongs in that binding.
481606
// This is an optimization that can be safely skipped.

‎Objects/moduleobject.c‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1338,7 +1338,8 @@ _Py_module_getattro_impl(PyModuleObject *m, PyObject *name, int suppress)
13381338
}
13391339
assert(m->md_dict != NULL);
13401340
int recheck_dict;
1341-
attr = _PyImport_TryLoadLazySubmodule((PyObject *)m, name, &recheck_dict);
1341+
attr = _PyImport_TryLoadLazySubmodule((PyObject *)m, name, suppress,
1342+
&recheck_dict);
13421343
if (attr != NULL || PyErr_Occurred()) {
13431344
return attr;
13441345
}

0 commit comments

Comments
 (0)