Skip to content

Commit 5a2d273

Browse files
authored
gh-150052: Resolve un-loaded lazily loaded submodules via module.__getattr__ instead of publishing lazy values (#150055) (#150744)
1 parent 35c314d commit 5a2d273

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
@@ -503,6 +503,14 @@ def test_lazy_import_pkg(self):
503503
self.assertIn("test.test_lazy_import.data.pkg.bar", sys.modules)
504504
self.assertIn("BAR_MODULE_LOADED", out.getvalue())
505505

506+
def test_lazy_submodule_stored_in_parent_dict(self):
507+
"""Accessing a lazy submodule should store it in the parent's __dict__."""
508+
import test.test_lazy_import.data.lazy_import_pkg
509+
510+
pkg = sys.modules["test.test_lazy_import.data.pkg"]
511+
self.assertIn("bar", pkg.__dict__)
512+
self.assertIs(pkg.__dict__["bar"], sys.modules["test.test_lazy_import.data.pkg.bar"])
513+
506514
def test_lazy_import_pkg_cross_import(self):
507515
"""Cross-imports within package should preserve lazy imports."""
508516
import test.test_lazy_import.data.pkg.c
@@ -515,6 +523,18 @@ def test_lazy_import_pkg_cross_import(self):
515523
self.assertEqual(type(g["x"]), int)
516524
self.assertEqual(type(g["b"]), types.LazyImportType)
517525

526+
@support.requires_subprocess()
527+
def test_lazy_from_import_does_not_pollute_parent(self):
528+
"""Lazy from import should not add the name to the parent module's dict."""
529+
code = textwrap.dedent("""
530+
lazy from json import nonexistent_attr
531+
import json
532+
assert "nonexistent_attr" not in json.__dict__, (
533+
"lazy from import should not publish attributes on the parent module"
534+
)
535+
""")
536+
assert_python_ok("-c", code)
537+
518538
@support.requires_subprocess()
519539
def test_package_from_import_with_module_getattr_raising(self):
520540
"""Lazy from import should respect a package's __getattr__."""
@@ -716,19 +736,14 @@ def tearDown(self):
716736
sys.set_lazy_imports("normal")
717737

718738
def test_import_error_shows_chained_traceback(self):
719-
"""ImportError during reification should chain to show both definition and access."""
720-
# Errors at reification must show where the lazy import was defined
721-
# AND where the access happened, per PEP 810 "Reification" section
739+
"""Accessing a nonexistent lazy submodule via parent attr raises AttributeError."""
722740
code = textwrap.dedent("""
723741
import sys
724742
lazy import test.test_lazy_import.data.nonexistent_module
725743
726744
try:
727745
x = test.test_lazy_import.data.nonexistent_module
728-
except ImportError as e:
729-
# Should have __cause__ showing the original error
730-
# The exception chain shows both where import was defined and where access happened
731-
assert e.__cause__ is not None, "Expected chained exception"
746+
except AttributeError as e:
732747
print("OK")
733748
""")
734749
result = subprocess.run(
@@ -776,7 +791,7 @@ def test_reification_retries_on_failure(self):
776791
# First access - should fail
777792
try:
778793
x = test.test_lazy_import.data.broken_module
779-
except ValueError:
794+
except AttributeError:
780795
pass
781796
782797
# The lazy object should still be a lazy proxy (not reified)
@@ -786,7 +801,7 @@ def test_reification_retries_on_failure(self):
786801
# Second access - should also fail (retry the import)
787802
try:
788803
x = test.test_lazy_import.data.broken_module
789-
except ValueError:
804+
except AttributeError:
790805
print("OK - retry worked")
791806
""")
792807
result = subprocess.run(
@@ -799,20 +814,15 @@ def test_reification_retries_on_failure(self):
799814

800815
def test_error_during_module_execution_propagates(self):
801816
"""Errors in module code during reification should propagate correctly."""
802-
# Module that raises during import should propagate with chaining
803817
code = textwrap.dedent("""
804818
import sys
805819
lazy import test.test_lazy_import.data.broken_module
806820
807821
try:
808822
_ = test.test_lazy_import.data.broken_module
809823
print("FAIL - should have raised")
810-
except ValueError as e:
811-
# The ValueError from the module should be the cause
812-
if "always fails" in str(e) or (e.__cause__ and "always fails" in str(e.__cause__)):
813-
print("OK")
814-
else:
815-
print(f"FAIL - wrong error: {e}")
824+
except AttributeError:
825+
print("OK")
816826
""")
817827
result = subprocess.run(
818828
[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)