Skip to content

Commit 63a9bca

Browse files
committed
gh-158140: Resolve lazy submodules independently with custom import hooks
1 parent 587b7a5 commit 63a9bca

8 files changed

Lines changed: 928 additions & 72 deletions

File tree

‎Doc/reference/simple_stmts.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,13 @@ If an error occurs during module loading (such as :exc:`ImportError` or
917917
:exc:`SyntaxError`), it is raised at the point where the lazy import is first
918918
used, not at the import statement itself.
919919

920+
For ordinary packages, plain dotted lazy imports resolve the root first and
921+
then import each requested submodule through its declaring namespace's current
922+
:func:`__import__` hook. A shared package selects the declaration with matching
923+
builtins, or the first declaring namespace if none matches. Hooks that return
924+
objects outside :data:`sys.modules` retain full-path import behavior.
925+
Unresolved plain-import placeholders retain their declaring namespace.
926+
920927
See :pep:`810` for the full specification of lazy imports.
921928

922929
.. versionadded:: 3.15

‎Include/internal/pycore_import.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ extern PyObject * _PyImport_TryLoadLazySubmodule(
4444
PyObject *module, PyObject *attr_name, int suppress, int *recheck_dict);
4545
extern int _PyImport_ClearLazySubmodule(
4646
PyThreadState *tstate, PyObject *name, int bind);
47+
extern int _PyImport_RegisterLazySubmodules(
48+
PyThreadState *tstate, PyObject *name, PyObject *source);
49+
extern PyObject *_PyImport_GetLazyGroup(
50+
PyThreadState *tstate, PyObject *root, PyObject *globals, PyObject *candidate);
4751
extern PyObject * _PyImport_LazyImportModuleLevelObject(
4852
PyThreadState *tstate, PyObject *name, PyObject *builtins,
4953
PyObject *globals, PyObject *locals, PyObject *fromlist, int level);

‎Include/internal/pycore_interp_structs.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ struct _import_state {
359359
// Package names map to pending children: declarations for plain imports,
360360
// or None for from-import names that may be ordinary attributes.
361361
PyObject *lazy_pending_submodules;
362+
// Weakly indexed declaration groups, owned by unresolved placeholders.
363+
PyObject *lazy_import_groups;
362364
// Avoid pending-child work for ordinary cached imports.
363365
int has_lazy_submodules;
364366
#ifdef Py_GIL_DISABLED

‎Include/internal/pycore_lazyimportobject.h‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ PyAPI_FUNC(PyObject *) _PyLazyImport_New(
1919
PyObject *name, PyObject *fromlist);
2020

2121
extern PyObject *_PyLazyImport_LoadChild(
22-
PyThreadState *tstate, PyObject *declaration, PyObject *name);
22+
PyThreadState *tstate, PyObject *declaration, PyObject *name,
23+
PyObject *import_func);
24+
// Borrowed references from the original import declaration.
25+
extern PyObject *_PyLazyImport_GetBuiltins(PyObject *declaration);
26+
// Share declaration ownership with plain imports in the same namespace.
27+
extern PyObject *_PyLazyImport_Group(
28+
PyThreadState *tstate, PyObject *source, PyObject *globals);
2329
extern int _PyLazyImport_IsActive(PyObject *declaration);
2430

2531
extern int _PyLazyImport_IsResolving(PyThreadState *tstate, PyObject *op);

‎Lib/test/test_lazy_import/__init__.py‎

Lines changed: 268 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,272 @@ def test_siblings(self):
470470
assert {package}.{second} is sys.modules['{package}.{second}']
471471
""")
472472

473+
def test_siblings_with_import_hook(self):
474+
accesses = ('xml.dom', 'getattr(xml, "dom")',
475+
'getattr(xml, "dom", None)', 'hasattr(xml, "dom")',
476+
'helper(xml)', 'xml')
477+
for private in (False, True):
478+
for sibling in ('xml.etree', 'xml.missing'):
479+
for reverse in (False, True):
480+
statements = ['lazy import xml.dom',
481+
f'lazy import {sibling}']
482+
if reverse:
483+
statements.reverse()
484+
for access in accesses:
485+
code = '\n'.join(statements + [f'result = {access}'])
486+
with self.subTest(private=private, sibling=sibling,
487+
reverse=reverse, access=access):
488+
self.check(f"""
489+
import builtins, sys
490+
original = builtins.__import__
491+
calls = []
492+
def hook(name, *args):
493+
if name.startswith('xml'):
494+
calls.append(name)
495+
return original(name, *args)
496+
def helper(package):
497+
return package.dom
498+
namespace = {{'helper': helper}}
499+
if {private}:
500+
namespace['__builtins__'] = dict(
501+
vars(builtins), __import__=hook)
502+
else:
503+
builtins.__import__ = hook
504+
exec({code!r}, namespace)
505+
assert {sibling!r} not in calls, calls
506+
assert {sibling!r} not in sys.modules
507+
if {access!r} == 'xml':
508+
assert namespace['result'] is sys.modules['xml']
509+
assert 'xml.dom' not in sys.modules
510+
assert calls == ['xml'], calls
511+
else:
512+
result = namespace['result']
513+
if {access!r} == 'hasattr(xml, "dom")':
514+
assert result is True
515+
else:
516+
assert result is sys.modules['xml.dom']
517+
assert calls == ['xml', 'xml.dom'], calls
518+
""")
519+
520+
def test_preloaded_sibling_with_import_hook(self):
521+
for private in (False, True):
522+
with self.subTest(private=private):
523+
self.check(f"""
524+
import builtins, sys, xml.etree
525+
original = builtins.__import__
526+
calls = []
527+
def hook(name, *args):
528+
if name.startswith('xml'):
529+
calls.append(name)
530+
if name == 'xml.etree':
531+
raise AssertionError('requested an unused sibling')
532+
return original(name, *args)
533+
namespace = {{}}
534+
if {private}:
535+
namespace['__builtins__'] = dict(
536+
vars(builtins), __import__=hook)
537+
else:
538+
builtins.__import__ = hook
539+
exec('lazy import xml.dom\\nlazy import xml.etree', namespace)
540+
exec('root = xml', namespace)
541+
assert namespace['root'] is sys.modules['xml']
542+
assert 'xml.dom' not in sys.modules
543+
assert calls == ['xml'], calls
544+
exec('result = xml.dom', namespace)
545+
assert namespace['result'] is sys.modules['xml.dom']
546+
assert calls == ['xml', 'xml.dom'], calls
547+
""")
548+
549+
def test_pending_child_preserves_fromlist(self):
550+
for previous, fromlist in ((None, ()), ((), None)):
551+
with self.subTest(fromlist=fromlist):
552+
self.check(f"""
553+
import builtins
554+
original = builtins.__import__
555+
calls = []
556+
def hook(name, globals, locals, fromlist, level):
557+
calls.append((name, fromlist))
558+
assert fromlist == {fromlist!r}
559+
return original(name, globals, locals, fromlist, level)
560+
namespace = {{'__builtins__': dict(
561+
vars(builtins), __import__=hook)}}
562+
exec("xml = __lazy_import__('xml.dom.minidom', fromlist={previous!r})\\n"
563+
"xml = __lazy_import__('xml.dom.pulldom', fromlist={fromlist!r})",
564+
namespace)
565+
exec("assert xml.dom.__name__ == 'xml.dom'", namespace)
566+
assert calls == [('xml', {fromlist!r}),
567+
('xml.dom', {fromlist!r})], calls
568+
""")
569+
570+
def test_cached_child_with_import_hook(self):
571+
for access in ('xml.dom', 'getattr(xml, "dom", None)', 'helper(xml)'):
572+
with self.subTest(access=access):
573+
self.check(f"""
574+
import builtins, sys, xml.dom
575+
del xml.dom
576+
original = builtins.__import__
577+
calls = []
578+
denied = True
579+
def hook(name, *args):
580+
calls.append(name)
581+
if denied and name == 'xml.dom':
582+
raise PermissionError('denied child')
583+
return original(name, *args)
584+
def helper(package):
585+
return package.dom
586+
namespace = {{'helper': helper, '__builtins__': dict(
587+
vars(builtins), __import__=hook)}}
588+
exec('lazy import xml.dom\\nlazy import xml.missing', namespace)
589+
try:
590+
exec({access!r}, namespace)
591+
except PermissionError:
592+
pass
593+
else:
594+
raise AssertionError('import hook was bypassed')
595+
assert calls == ['xml', 'xml.dom'], calls
596+
denied = False
597+
exec('result = ' + {access!r}, namespace)
598+
assert namespace['result'] is sys.modules['xml.dom']
599+
assert calls == ['xml', 'xml.dom', 'xml.dom'], calls
600+
""")
601+
602+
def test_failed_hook_preserves_namespace_declarations(self):
603+
self.check("""
604+
import builtins, sys
605+
original = builtins.__import__
606+
calls = []
607+
def second_import(name, *args, **kwargs):
608+
if name == 'xml.dom':
609+
calls.append('second')
610+
raise RuntimeError('second')
611+
return original(name, *args, **kwargs)
612+
second = {'__builtins__': dict(vars(builtins), __import__=second_import)}
613+
def first_import(name, *args, **kwargs):
614+
module = original(name, *args, **kwargs)
615+
if name == 'xml.dom':
616+
calls.append('first')
617+
del module.dom
618+
del sys.modules[name]
619+
exec('lazy import xml.dom', second)
620+
raise RuntimeError('first')
621+
return module
622+
first = {'__builtins__': dict(vars(builtins), __import__=first_import)}
623+
exec('lazy import xml.dom', first)
624+
for namespace, message in ((first, 'first'), (first, 'first'),
625+
(second, 'second')):
626+
try:
627+
exec('xml.dom', namespace)
628+
except RuntimeError as exc:
629+
assert str(exc) == message, exc
630+
else:
631+
raise AssertionError('hook did not run')
632+
assert calls == ['first', 'first', 'second'], calls
633+
""")
634+
635+
def test_siblings_after_builtins_replacement(self):
636+
self.check("""
637+
import builtins, types, xml.dom
638+
calls = []
639+
def hook(name, *args):
640+
calls.append(name)
641+
assert name == 'xml.dom', name
642+
return types.SimpleNamespace(dom=42)
643+
def helper(package):
644+
return package.dom
645+
namespace = {'__builtins__': dict(vars(builtins), __import__=hook),
646+
'helper': helper}
647+
exec('lazy import xml.dom', namespace)
648+
namespace['__builtins__'] = vars(builtins)
649+
exec('lazy import xml.missing', namespace)
650+
del xml.dom
651+
exec('result = helper(xml)', namespace)
652+
assert namespace['result'] == 42
653+
assert calls == ['xml.dom'], calls
654+
assert 'dom' not in vars(xml)
655+
""")
656+
657+
def test_live_namespace_keeps_overwritten_sibling_source(self):
658+
for statement in ('lazy import {name}',
659+
'live_owners = __lazy_import__({name!r})',
660+
"dict.__setitem__(globals(), 'live_owners', "
661+
"__lazy_import__({name!r}))"):
662+
code = '\n'.join(statement.format(name='live_owners.' + child)
663+
for child in ('child', 'missing'))
664+
with self.subTest(statement=statement):
665+
self.check(f"""
666+
import builtins, gc, sys, types
667+
root = types.ModuleType('live_owners')
668+
root.__path__ = []
669+
sys.modules['live_owners'] = root
670+
def first_import(name, *args):
671+
raise AssertionError('used the first namespace hook')
672+
first = {{'__builtins__': dict(
673+
vars(builtins), __import__=first_import)}}
674+
calls = []
675+
def second_import(name, *args):
676+
calls.append(name)
677+
if name == 'live_owners':
678+
return root
679+
assert name == 'live_owners.child', name
680+
return types.SimpleNamespace(child=42)
681+
second = {{'__builtins__': dict(
682+
vars(builtins), __import__=second_import)}}
683+
exec('lazy import live_owners.child', first)
684+
exec({code!r}, second)
685+
gc.collect()
686+
exec('root = live_owners', second)
687+
gc.collect()
688+
exec('result = live_owners.child', second)
689+
assert second['result'] == 42
690+
assert calls == ['live_owners', 'live_owners.child'], calls
691+
""")
692+
693+
def test_discarded_namespaces_do_not_retain_all_import_hooks(self):
694+
self.check("""
695+
import builtins, gc, weakref
696+
class Payload:
697+
pass
698+
references = []
699+
for _ in range(32):
700+
payload = Payload()
701+
references.append(weakref.ref(payload))
702+
namespace = {'__builtins__': dict(vars(builtins), payload=payload)}
703+
exec('lazy import dormant_owners.child', namespace)
704+
del namespace, payload
705+
gc.collect()
706+
# Keep one fallback for accesses outside the declaring namespace.
707+
assert sum(ref() is not None for ref in references) == 1
708+
""")
709+
710+
def test_cached_declaration_does_not_run_spec_callbacks(self):
711+
self.check("""
712+
import xml.dom
713+
class Spec:
714+
@property
715+
def _initializing(self):
716+
raise AssertionError('spec read during declaration')
717+
xml.__spec__ = Spec()
718+
lazy import xml.dom
719+
""")
720+
721+
def test_hook_creates_aliased_package(self):
722+
self.check("""
723+
import builtins, sys, types
724+
root = types.ModuleType('alias')
725+
root.__path__ = []
726+
calls = []
727+
def hook(name, *args):
728+
calls.append(name)
729+
sys.modules['pkg'] = root
730+
if name == 'pkg.child':
731+
root.child = 42
732+
return root
733+
namespace = {'__builtins__': dict(vars(builtins), __import__=hook)}
734+
exec('lazy import pkg.child\\nresult = pkg.child', namespace)
735+
assert namespace['result'] == 42
736+
assert calls == ['pkg', 'pkg.child'], calls
737+
""")
738+
473739
def test_star_import(self):
474740
self.check("""
475741
lazy import urllib.nonexistent
@@ -2708,9 +2974,8 @@ def test_empty_fromlist_preserved_for_custom_import(self):
27082974
calls = []
27092975
27102976
def import_hook(name, globals, locals, fromlist, level):
2711-
assert name == "xml.dom", name
27122977
assert fromlist == (), fromlist
2713-
calls.append(fromlist)
2978+
calls.append((name, fromlist))
27142979
return module
27152980
27162981
builtins.__import__ = import_hook
@@ -2721,7 +2986,7 @@ def import_hook(name, globals, locals, fromlist, level):
27212986
builtins.__import__ = default_import
27222987
builtins.__lazy_import__ = default_lazy_import
27232988
2724-
assert calls == [(), ()], calls
2989+
assert calls == [('xml', ()), ('xml.dom', ()), ('xml.dom', ())], calls
27252990
""")
27262991
assert_python_ok("-c", code)
27272992

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Resolve lazy submodules of ordinary packages independently with custom
2+
:func:`__import__` hooks and private builtins. Select pending declarations
3+
from the appropriate builtins namespace.

0 commit comments

Comments
 (0)