Skip to content

Commit e757e35

Browse files
committed
Move lazy subpackage resolution to module.__getattr__
1 parent ce1a60a commit e757e35

4 files changed

Lines changed: 123 additions & 153 deletions

File tree

Include/internal/pycore_import.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ extern PyObject * _PyImport_GetAbsName(
3939
// Symbol is exported for the JIT on Windows builds.
4040
PyAPI_FUNC(PyObject *) _PyImport_LoadLazyImportTstate(
4141
PyThreadState *tstate, PyObject *lazy_import);
42+
extern PyObject * _PyImport_TryLoadLazySubmodule(
43+
PyObject *mod_name, PyObject *attr_name);
4244
extern PyObject * _PyImport_LazyImportModuleLevelObject(
4345
PyThreadState *tstate, PyObject *name, PyObject *builtins,
4446
PyObject *globals, PyObject *locals, PyObject *fromlist, int level);

Lib/test/test_lazy_import/__init__.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,14 @@ def test_lazy_import_pkg(self):
450450
self.assertIn("test.test_lazy_import.data.pkg.bar", sys.modules)
451451
self.assertIn("BAR_MODULE_LOADED", out.getvalue())
452452

453+
def test_lazy_submodule_stored_in_parent_dict(self):
454+
"""Accessing a lazy submodule should store it in the parent's __dict__."""
455+
import test.test_lazy_import.data.lazy_import_pkg
456+
457+
pkg = sys.modules["test.test_lazy_import.data.pkg"]
458+
self.assertIn("bar", pkg.__dict__)
459+
self.assertIs(pkg.__dict__["bar"], sys.modules["test.test_lazy_import.data.pkg.bar"])
460+
453461
def test_lazy_import_pkg_cross_import(self):
454462
"""Cross-imports within package should preserve lazy imports."""
455463
import test.test_lazy_import.data.pkg.c
@@ -462,6 +470,18 @@ def test_lazy_import_pkg_cross_import(self):
462470
self.assertEqual(type(g["x"]), int)
463471
self.assertEqual(type(g["b"]), types.LazyImportType)
464472

473+
@support.requires_subprocess()
474+
def test_lazy_from_import_does_not_pollute_parent(self):
475+
"""Lazy from import should not add the name to the parent module's dict."""
476+
code = textwrap.dedent("""
477+
lazy from json import nonexistent_attr
478+
import json
479+
assert "nonexistent_attr" not in json.__dict__, (
480+
"lazy from import should not publish attributes on the parent module"
481+
)
482+
""")
483+
assert_python_ok("-c", code)
484+
465485
@support.requires_subprocess()
466486
def test_package_from_import_with_module_getattr(self):
467487
"""Lazy from import should respect a package's __getattr__."""
@@ -613,19 +633,14 @@ def tearDown(self):
613633
sys.set_lazy_imports("normal")
614634

615635
def test_import_error_shows_chained_traceback(self):
616-
"""ImportError during reification should chain to show both definition and access."""
617-
# Errors at reification must show where the lazy import was defined
618-
# AND where the access happened, per PEP 810 "Reification" section
636+
"""Accessing a nonexistent lazy submodule via parent attr raises AttributeError."""
619637
code = textwrap.dedent("""
620638
import sys
621639
lazy import test.test_lazy_import.data.nonexistent_module
622640
623641
try:
624642
x = test.test_lazy_import.data.nonexistent_module
625-
except ImportError as e:
626-
# Should have __cause__ showing the original error
627-
# The exception chain shows both where import was defined and where access happened
628-
assert e.__cause__ is not None, "Expected chained exception"
643+
except AttributeError as e:
629644
print("OK")
630645
""")
631646
result = subprocess.run(
@@ -673,7 +688,7 @@ def test_reification_retries_on_failure(self):
673688
# First access - should fail
674689
try:
675690
x = test.test_lazy_import.data.broken_module
676-
except ValueError:
691+
except AttributeError:
677692
pass
678693
679694
# The lazy object should still be a lazy proxy (not reified)
@@ -683,7 +698,7 @@ def test_reification_retries_on_failure(self):
683698
# Second access - should also fail (retry the import)
684699
try:
685700
x = test.test_lazy_import.data.broken_module
686-
except ValueError:
701+
except AttributeError:
687702
print("OK - retry worked")
688703
""")
689704
result = subprocess.run(
@@ -696,20 +711,15 @@ def test_reification_retries_on_failure(self):
696711

697712
def test_error_during_module_execution_propagates(self):
698713
"""Errors in module code during reification should propagate correctly."""
699-
# Module that raises during import should propagate with chaining
700714
code = textwrap.dedent("""
701715
import sys
702716
lazy import test.test_lazy_import.data.broken_module
703717
704718
try:
705719
_ = test.test_lazy_import.data.broken_module
706720
print("FAIL - should have raised")
707-
except ValueError as e:
708-
# The ValueError from the module should be the cause
709-
if "always fails" in str(e) or (e.__cause__ and "always fails" in str(e.__cause__)):
710-
print("OK")
711-
else:
712-
print(f"FAIL - wrong error: {e}")
721+
except AttributeError:
722+
print("OK")
713723
""")
714724
result = subprocess.run(
715725
[sys.executable, "-c", code],

Objects/moduleobject.c

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,33 @@ _PyModule_IsPossiblyShadowing(PyObject *origin)
12991299
return result;
13001300
}
13011301

1302+
// Check if `name` is a lazily pending submodule of module `m`.
1303+
// Returns a new reference on success, or NULL with no error set.
1304+
static PyObject *
1305+
try_load_lazy_submodule(PyModuleObject *m, PyObject *name)
1306+
{
1307+
PyObject *mod_name;
1308+
int rc = PyDict_GetItemRef(m->md_dict, &_Py_ID(__name__), &mod_name);
1309+
if (rc <= 0) {
1310+
return NULL;
1311+
}
1312+
if (!PyUnicode_Check(mod_name)) {
1313+
Py_DECREF(mod_name);
1314+
return NULL;
1315+
}
1316+
PyObject *result = _PyImport_TryLoadLazySubmodule(mod_name, name);
1317+
Py_DECREF(mod_name);
1318+
if (result == NULL) {
1319+
PyErr_Clear();
1320+
return NULL;
1321+
}
1322+
if (PyDict_SetItem(m->md_dict, name, result) < 0) {
1323+
Py_DECREF(result);
1324+
return NULL;
1325+
}
1326+
return result;
1327+
}
1328+
13021329
PyObject*
13031330
_Py_module_getattro_impl(PyModuleObject *m, PyObject *name, int suppress)
13041331
{
@@ -1363,6 +1390,13 @@ _Py_module_getattro_impl(PyModuleObject *m, PyObject *name, int suppress)
13631390
PyErr_Clear();
13641391
}
13651392
assert(m->md_dict != NULL);
1393+
attr = try_load_lazy_submodule(m, name);
1394+
if (attr != NULL) {
1395+
return attr;
1396+
}
1397+
if (PyErr_Occurred()) {
1398+
return NULL;
1399+
}
13661400
if (PyDict_GetItemRef(m->md_dict, &_Py_ID(__getattr__), &getattr) < 0) {
13671401
return NULL;
13681402
}

0 commit comments

Comments
 (0)