Skip to content

Commit 00a1c3a

Browse files
pablogsalencukouYhg1s
authored
gh-158140: Resolve lazily imported sibling submodules independently (#158521)
* gh-158140: Resolve lazy siblings through the pending-child registry Co-authored-by: Petr Viktorin <encukou@gmail.com> Co-authored-by: T. Wouters <thomas@python.org>
1 parent 9d3b0b8 commit 00a1c3a

8 files changed

Lines changed: 408 additions & 28 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: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,121 @@ def test_lazy_with_from(self):
449449
self.assertNotIn("test.test_lazy_import.data.basic2", sys.modules)
450450

451451

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

@@ -786,7 +901,7 @@ def test_chained_exception_import_shows_notes(self, name):
786901
lazy import {name}
787902
788903
try:
789-
_ = test
904+
_ = {name}
790905
except ValueError as e:
791906
assert any(
792907
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: 129 additions & 2 deletions
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.
@@ -453,7 +528,7 @@ lazy_import_resolve_impl(PyThreadState *tstate, PyObject *lazy_import,
453528
}
454529
assert(obj == NULL || !PyLazyImport_CheckExact(obj));
455530
if (obj != NULL) {
456-
PyObject *name = lazy_import_name(lz);
531+
PyObject *name = lazy_import_path(lz);
457532
if (name == NULL ||
458533
_PyImport_DiscardLazyModule(tstate->interp, name) < 0) {
459534
Py_CLEAR(obj);
@@ -484,6 +559,58 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import)
484559
return lazy_import_resolve_impl(tstate, lazy_import, NULL);
485560
}
486561

562+
// Resolve a pending child as an aliased import, preserving its declaration.
563+
PyObject *
564+
_PyLazyImport_LoadChild(PyThreadState *tstate, PyObject *declaration,
565+
PyObject *name)
566+
{
567+
assert(PyLazyImport_CheckExact(declaration));
568+
PyLazyImportObject *source = PyLazyImportObject_CAST(declaration);
569+
PyObject *lz = _PyLazyImport_New(NULL, source->lz_builtins, name, NULL);
570+
Py_ssize_t end = PyUnicode_GET_LENGTH(name);
571+
Py_ssize_t dot = PyUnicode_FindChar(name, '.', 0, end, 1);
572+
if (dot == -2) {
573+
Py_CLEAR(lz);
574+
}
575+
while (lz != NULL && dot >= 0) {
576+
Py_ssize_t start = dot + 1;
577+
dot = PyUnicode_FindChar(name, '.', start, end, 1);
578+
if (dot == -2) {
579+
Py_CLEAR(lz);
580+
break;
581+
}
582+
PyObject *attr = PyUnicode_Substring(name, start, dot < 0 ? end : dot);
583+
PyObject *next = attr == NULL ? NULL :
584+
_PyLazyImport_New(NULL, NULL, lz, attr);
585+
Py_XDECREF(attr);
586+
Py_SETREF(lz, next);
587+
}
588+
if (lz == NULL) {
589+
return NULL;
590+
}
591+
PyLazyImportObject *child = PyLazyImportObject_CAST(lz);
592+
child->lz_code = (PyCodeObject *)Py_XNewRef(source->lz_code);
593+
child->lz_instr_offset = source->lz_instr_offset;
594+
PyObject *result = _PyImport_LoadLazyImportTstate(tstate, lz);
595+
Py_DECREF(lz);
596+
if (result != NULL && PyModule_Check(result) &&
597+
PyUnicode_Compare(source->lz_from, name) != 0 &&
598+
!PyDict_Contains(_PyModule_GetDict(result), &_Py_ID(__path__))) {
599+
// Reaching a non-package must still reject an invalid dotted import.
600+
Py_DECREF(result);
601+
return _PyLazyImport_LoadChild(tstate, declaration, source->lz_from);
602+
}
603+
return result;
604+
}
605+
606+
int
607+
_PyLazyImport_IsActive(PyObject *declaration)
608+
{
609+
assert(PyLazyImport_CheckExact(declaration));
610+
return FT_ATOMIC_LOAD_INT_RELAXED(
611+
PyLazyImportObject_CAST(declaration)->lz_active);
612+
}
613+
487614
// Loading pkg.child can replace a placeholder in pkg.child with the module
488615
// before a from-import retrieves the value that belongs in that binding.
489616
// 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)