Skip to content

Commit 03bb26f

Browse files
miss-islingtonpablogsalencukouYhg1shugovk
authored
[3.15] gh-158140: Resolve lazily imported sibling submodules independently (GH-158521) (#158613)
Co-authored-by: Pablo Galindo Salgado <Pablogsal@gmail.com> Co-authored-by: Petr Viktorin <encukou@gmail.com> Co-authored-by: T. Wouters <thomas@python.org> Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
1 parent 3d9473f commit 03bb26f

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
@@ -352,9 +352,11 @@ struct _import_state {
352352
// lazily imported. This value is only for debugging/introspection
353353
// purposes and is not used by the runtime.
354354
PyObject *lazy_modules;
355-
// A dict mapping package names to a set of submodule names that
356-
// may need to be imported when an attribute is missing on the package.
355+
// Package names map to pending children: declarations for plain imports,
356+
// or None for from-import names that may be ordinary attributes.
357357
PyObject *lazy_pending_submodules;
358+
// Avoid pending-child work for ordinary cached imports.
359+
int has_lazy_submodules;
358360
#ifdef Py_GIL_DISABLED
359361
PyMutex lazy_mutex;
360362
#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
@@ -457,6 +457,121 @@ def test_lazy_with_from(self):
457457
self.assertNotIn("test.test_lazy_import.data.basic2", sys.modules)
458458

459459

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

@@ -794,7 +909,7 @@ def test_chained_exception_import_shows_notes(self, name):
794909
lazy import {name}
795910
796911
try:
797-
_ = test
912+
_ = {name}
798913
except ValueError as e:
799914
assert any(
800915
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)