Skip to content

Commit 979f879

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

12 files changed

Lines changed: 864 additions & 75 deletions

File tree

‎Doc/reference/simple_stmts.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,12 @@ 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+
920926
See :pep:`810` for the full specification of lazy imports.
921927

922928
.. versionadded:: 3.15

‎Include/internal/pycore_import.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ 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);
4749
extern PyObject * _PyImport_LazyImportModuleLevelObject(
4850
PyThreadState *tstate, PyObject *name, PyObject *builtins,
4951
PyObject *globals, PyObject *locals, PyObject *fromlist, int level);

‎Include/internal/pycore_lazyimportobject.h‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ 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+
// Return a new binding that retains earlier plain imports for the same root.
27+
extern PyObject *_PyLazyImport_MergeBindings(
28+
PyObject *value, PyObject *previous, PyObject *name);
29+
// Store a lazy binding in a dictionary, retaining concurrent declarations.
30+
PyAPI_FUNC(int) _PyLazyImport_Store(
31+
PyObject *ns, PyObject *name, PyObject *value);
2332
extern int _PyLazyImport_IsActive(PyObject *declaration);
2433

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

‎Lib/test/test_lazy_import/__init__.py‎

Lines changed: 259 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,263 @@ 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 fromlist in (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', fromlist={fromlist!r})",
563+
namespace)
564+
exec("assert xml.dom.__name__ == 'xml.dom'", namespace)
565+
assert calls == [('xml', {fromlist!r}),
566+
('xml.dom', {fromlist!r})], calls
567+
""")
568+
569+
def test_cached_child_with_import_hook(self):
570+
for access in ('xml.dom', 'getattr(xml, "dom", None)', 'helper(xml)'):
571+
with self.subTest(access=access):
572+
self.check(f"""
573+
import builtins, sys, xml.dom
574+
del xml.dom
575+
original = builtins.__import__
576+
calls = []
577+
denied = True
578+
def hook(name, *args):
579+
calls.append(name)
580+
if denied and name == 'xml.dom':
581+
raise PermissionError('denied child')
582+
return original(name, *args)
583+
def helper(package):
584+
return package.dom
585+
namespace = {{'helper': helper, '__builtins__': dict(
586+
vars(builtins), __import__=hook)}}
587+
exec('lazy import xml.dom\\nlazy import xml.missing', namespace)
588+
try:
589+
exec({access!r}, namespace)
590+
except PermissionError:
591+
pass
592+
else:
593+
raise AssertionError('import hook was bypassed')
594+
assert calls == ['xml', 'xml.dom'], calls
595+
denied = False
596+
exec('result = ' + {access!r}, namespace)
597+
assert namespace['result'] is sys.modules['xml.dom']
598+
assert calls == ['xml', 'xml.dom', 'xml.dom'], calls
599+
""")
600+
601+
def test_failed_hook_preserves_namespace_declarations(self):
602+
self.check("""
603+
import builtins, sys
604+
original = builtins.__import__
605+
calls = []
606+
def second_import(name, *args, **kwargs):
607+
if name == 'xml.dom':
608+
calls.append('second')
609+
raise RuntimeError('second')
610+
return original(name, *args, **kwargs)
611+
second = {'__builtins__': dict(vars(builtins), __import__=second_import)}
612+
def first_import(name, *args, **kwargs):
613+
module = original(name, *args, **kwargs)
614+
if name == 'xml.dom':
615+
calls.append('first')
616+
del module.dom
617+
del sys.modules[name]
618+
exec('lazy import xml.dom', second)
619+
raise RuntimeError('first')
620+
return module
621+
first = {'__builtins__': dict(vars(builtins), __import__=first_import)}
622+
exec('lazy import xml.dom', first)
623+
for namespace, message in ((first, 'first'), (first, 'first'),
624+
(second, 'second')):
625+
try:
626+
exec('xml.dom', namespace)
627+
except RuntimeError as exc:
628+
assert str(exc) == message, exc
629+
else:
630+
raise AssertionError('hook did not run')
631+
assert calls == ['first', 'first', 'second'], calls
632+
""")
633+
634+
def test_siblings_after_builtins_replacement(self):
635+
self.check("""
636+
import builtins, types, xml.dom
637+
calls = []
638+
def hook(name, *args):
639+
calls.append(name)
640+
assert name == 'xml.dom', name
641+
return types.SimpleNamespace(dom=42)
642+
def helper(package):
643+
return package.dom
644+
namespace = {'__builtins__': dict(vars(builtins), __import__=hook),
645+
'helper': helper}
646+
exec('lazy import xml.dom', namespace)
647+
namespace['__builtins__'] = vars(builtins)
648+
exec('lazy import xml.missing', namespace)
649+
del xml.dom
650+
exec('result = helper(xml)', namespace)
651+
assert namespace['result'] == 42
652+
assert calls == ['xml.dom'], calls
653+
assert 'dom' not in vars(xml)
654+
""")
655+
656+
def test_live_namespace_keeps_overwritten_sibling_source(self):
657+
self.check("""
658+
import builtins, gc, sys, types
659+
root = types.ModuleType('live_owners')
660+
root.__path__ = []
661+
sys.modules['live_owners'] = root
662+
def first_import(name, *args):
663+
raise AssertionError('used the first namespace hook')
664+
first = {'__builtins__': dict(vars(builtins), __import__=first_import)}
665+
calls = []
666+
def second_import(name, *args):
667+
calls.append(name)
668+
if name == 'live_owners':
669+
return root
670+
assert name == 'live_owners.child', name
671+
return types.SimpleNamespace(child=42)
672+
second = {'__builtins__': dict(vars(builtins), __import__=second_import)}
673+
exec('lazy import live_owners.child', first)
674+
exec('lazy import live_owners.child\\nlazy import live_owners.missing',
675+
second)
676+
gc.collect()
677+
exec('root = live_owners', second)
678+
gc.collect()
679+
exec('result = live_owners.child', second)
680+
assert second['result'] == 42
681+
assert calls == ['live_owners', 'live_owners.child'], calls
682+
""")
683+
684+
def test_discarded_namespaces_do_not_retain_all_import_hooks(self):
685+
self.check("""
686+
import builtins, gc, weakref
687+
class Payload:
688+
pass
689+
references = []
690+
for _ in range(32):
691+
payload = Payload()
692+
references.append(weakref.ref(payload))
693+
namespace = {'__builtins__': dict(vars(builtins), payload=payload)}
694+
exec('lazy import dormant_owners.child', namespace)
695+
del namespace, payload
696+
gc.collect()
697+
# Keep one fallback for accesses outside the declaring namespace.
698+
assert sum(ref() is not None for ref in references) == 1
699+
""")
700+
701+
def test_cached_declaration_does_not_run_spec_callbacks(self):
702+
self.check("""
703+
import xml.dom
704+
class Spec:
705+
@property
706+
def _initializing(self):
707+
raise AssertionError('spec read during declaration')
708+
xml.__spec__ = Spec()
709+
lazy import xml.dom
710+
""")
711+
712+
def test_hook_creates_aliased_package(self):
713+
self.check("""
714+
import builtins, sys, types
715+
root = types.ModuleType('alias')
716+
root.__path__ = []
717+
calls = []
718+
def hook(name, *args):
719+
calls.append(name)
720+
sys.modules['pkg'] = root
721+
if name == 'pkg.child':
722+
root.child = 42
723+
return root
724+
namespace = {'__builtins__': dict(vars(builtins), __import__=hook)}
725+
exec('lazy import pkg.child\\nresult = pkg.child', namespace)
726+
assert namespace['result'] == 42
727+
assert calls == ['pkg', 'pkg.child'], calls
728+
""")
729+
473730
def test_star_import(self):
474731
self.check("""
475732
lazy import urllib.nonexistent
@@ -2708,9 +2965,8 @@ def test_empty_fromlist_preserved_for_custom_import(self):
27082965
calls = []
27092966
27102967
def import_hook(name, globals, locals, fromlist, level):
2711-
assert name == "xml.dom", name
27122968
assert fromlist == (), fromlist
2713-
calls.append(fromlist)
2969+
calls.append((name, fromlist))
27142970
return module
27152971
27162972
builtins.__import__ = import_hook
@@ -2721,7 +2977,7 @@ def import_hook(name, globals, locals, fromlist, level):
27212977
builtins.__import__ = default_import
27222978
builtins.__lazy_import__ = default_lazy_import
27232979
2724-
assert calls == [(), ()], calls
2980+
assert calls == [('xml', ()), ('xml.dom', ()), ('xml.dom', ())], calls
27252981
""")
27262982
assert_python_ok("-c", code)
27272983

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.

‎Modules/_testinternalcapi/test_cases.c.h‎

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)