Skip to content

Commit 1c296cf

Browse files
committed
gh-153236: Propagate lazy submodule import errors
1 parent ef4d3f3 commit 1c296cf

12 files changed

Lines changed: 154 additions & 96 deletions

Include/internal/pycore_global_objects_fini_generated.h

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_global_strings.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ struct _Py_global_strings {
267267
STRUCT_FOR_ID(_filters)
268268
STRUCT_FOR_ID(_finalizing)
269269
STRUCT_FOR_ID(_find_and_load)
270+
STRUCT_FOR_ID(_find_and_load_lazy_submodule)
270271
STRUCT_FOR_ID(_fix_up_module)
271272
STRUCT_FOR_ID(_flags_)
272273
STRUCT_FOR_ID(_get_sourcefile)

Include/internal/pycore_import.h

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,13 @@ 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);
42+
typedef enum {
43+
_Py_LAZY_SUBMODULE_ERROR = -1,
44+
_Py_LAZY_SUBMODULE_NOT_FOUND = 0,
45+
_Py_LAZY_SUBMODULE_LOADED = 1,
46+
} _PyLazySubmoduleImportResult;
47+
extern _PyLazySubmoduleImportResult _PyImport_TryLoadLazySubmodule(
48+
PyObject *mod_name, PyObject *attr_name, PyObject **result);
4449
extern PyObject * _PyImport_LazyImportModuleLevelObject(
4550
PyThreadState *tstate, PyObject *name, PyObject *builtins,
4651
PyObject *globals, PyObject *locals, PyObject *fromlist, int level);

Include/internal/pycore_runtime_init_generated.h

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Include/internal/pycore_unicodeobject_generated.h

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

Lib/importlib/_bootstrap.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,7 @@ def _sanity_check(name, package, level):
12541254

12551255
_ERR_MSG_PREFIX = 'No module named '
12561256

1257-
def _find_and_load_unlocked(name, import_):
1257+
def _find_and_load_unlocked(name, import_, *, lazy_submodule=False):
12581258
path = None
12591259
sys.audit(
12601260
"import",
@@ -1277,6 +1277,8 @@ def _find_and_load_unlocked(name, import_):
12771277
try:
12781278
path = parent_module.__path__
12791279
except AttributeError:
1280+
if lazy_submodule:
1281+
return None
12801282
msg = f'{_ERR_MSG_PREFIX}{name!r}; {parent!r} is not a package'
12811283
raise ModuleNotFoundError(msg, name=name) from None
12821284
parent_spec = parent_module.__spec__
@@ -1289,6 +1291,8 @@ def _find_and_load_unlocked(name, import_):
12891291
child = name.rpartition('.')[2]
12901292
spec = _find_spec(name, path)
12911293
if spec is None:
1294+
if lazy_submodule:
1295+
return None
12921296
raise ModuleNotFoundError(f'{_ERR_MSG_PREFIX}{name!r}', name=name)
12931297
else:
12941298
if parent_spec:
@@ -1320,7 +1324,7 @@ def _find_and_load_unlocked(name, import_):
13201324
_NEEDS_LOADING = object()
13211325

13221326

1323-
def _find_and_load(name, import_):
1327+
def _find_and_load(name, import_, *, lazy_submodule=False):
13241328
"""Find and load the module."""
13251329

13261330
# Optimization: we avoid unneeded module locking if the module
@@ -1337,7 +1341,8 @@ def _find_and_load(name, import_):
13371341
with lock_manager:
13381342
module = sys.modules.get(name, _NEEDS_LOADING)
13391343
if module is _NEEDS_LOADING:
1340-
return _find_and_load_unlocked(name, import_)
1344+
return _find_and_load_unlocked(
1345+
name, import_, lazy_submodule=lazy_submodule)
13411346

13421347
# Optimization: only call _bootstrap._lock_unlock_module() if
13431348
# module.__spec__._initializing is True.
@@ -1351,7 +1356,7 @@ def _find_and_load(name, import_):
13511356
# to preserve normal semantics: the caller gets the exception from
13521357
# the actual import failure rather than a synthetic error.
13531358
if sys.modules.get(name) is not module:
1354-
return _find_and_load(name, import_)
1359+
return _find_and_load(name, import_, lazy_submodule=lazy_submodule)
13551360

13561361
if module is None:
13571362
message = f'import of {name} halted; None in sys.modules'
@@ -1360,6 +1365,10 @@ def _find_and_load(name, import_):
13601365
return module
13611366

13621367

1368+
def _find_and_load_lazy_submodule(name, import_):
1369+
return _find_and_load(name, import_, lazy_submodule=True)
1370+
1371+
13631372
def _gcd_import(name, package=None, level=0):
13641373
"""Import and return the module based on its name, the package the call is
13651374
being made from, and the level adjustment.

Lib/test/test_lazy_import/__init__.py

Lines changed: 68 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -676,52 +676,35 @@ def test_lazy_modules_tracks_lazy_imports(self):
676676

677677
@support.requires_subprocess()
678678
class ErrorHandlingTests(LazyImportTestCase):
679-
"""Tests for error handling during lazy import reification.
679+
"""Tests for error handling during lazy import reification."""
680680

681-
PEP 810: Errors during reification should show exception chaining with
682-
both the lazy import definition location and the access location.
683-
"""
684-
685-
def test_import_error_shows_chained_traceback(self):
681+
def test_missing_lazy_submodule_raises_attribute_error(self):
686682
"""Accessing a nonexistent lazy submodule via parent attr raises AttributeError."""
687683
code = textwrap.dedent("""
688-
import sys
689684
lazy import test.test_lazy_import.data.nonexistent_module
690685
691686
try:
692-
x = test.test_lazy_import.data.nonexistent_module
693-
except AttributeError as e:
694-
print("OK")
687+
_ = test.test_lazy_import.data.nonexistent_module
688+
except AttributeError:
689+
pass
690+
else:
691+
raise AssertionError("AttributeError was not raised")
695692
""")
696-
result = subprocess.run(
697-
[sys.executable, "-c", code],
698-
capture_output=True,
699-
text=True
700-
)
701-
self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}")
702-
self.assertIn("OK", result.stdout)
693+
assert_python_ok("-c", code)
703694

704-
def test_attribute_error_on_from_import_shows_chained_traceback(self):
695+
def test_missing_lazy_from_import_shows_chained_traceback(self):
705696
"""Accessing missing attribute from lazy from-import should chain errors."""
706-
# Tests 'lazy from module import nonexistent' behavior
707697
code = textwrap.dedent("""
708-
import sys
709698
lazy from test.test_lazy_import.data.basic2 import nonexistent_name
710699
711700
try:
712-
x = nonexistent_name
701+
_ = nonexistent_name
713702
except ImportError as e:
714-
# PEP 810: Enhanced error reporting through exception chaining
715703
assert e.__cause__ is not None, "Expected chained exception"
716-
print("OK")
704+
else:
705+
raise AssertionError("ImportError was not raised")
717706
""")
718-
result = subprocess.run(
719-
[sys.executable, "-c", code],
720-
capture_output=True,
721-
text=True
722-
)
723-
self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}")
724-
self.assertIn("OK", result.stdout)
707+
assert_python_ok("-c", code)
725708

726709
def test_reification_retries_on_failure(self):
727710
"""Failed reification should allow retry on subsequent access.
@@ -731,53 +714,74 @@ def test_reification_retries_on_failure(self):
731714
"""
732715
code = textwrap.dedent("""
733716
import sys
734-
import types
735717
736718
lazy import test.test_lazy_import.data.broken_module
737719
738-
# First access - should fail
739720
try:
740-
x = test.test_lazy_import.data.broken_module
741-
except AttributeError:
742-
pass
721+
_ = test.test_lazy_import.data.broken_module
722+
except ValueError as exc:
723+
assert str(exc) == "This module always fails to import", exc
724+
else:
725+
raise AssertionError("ValueError was not raised")
726+
727+
assert "test.test_lazy_import.data.broken_module" not in sys.modules
743728
744-
# The lazy object should still be a lazy proxy (not reified)
745-
g = globals()
746-
lazy_obj = g['test']
747-
# The root 'test' binding should still allow retry
748-
# Second access - should also fail (retry the import)
749729
try:
750-
x = test.test_lazy_import.data.broken_module
751-
except AttributeError:
752-
print("OK - retry worked")
730+
_ = test.test_lazy_import.data.broken_module
731+
except ValueError as exc:
732+
assert str(exc) == "This module always fails to import", exc
733+
else:
734+
raise AssertionError("ValueError was not raised")
753735
""")
754-
result = subprocess.run(
755-
[sys.executable, "-c", code],
756-
capture_output=True,
757-
text=True
758-
)
759-
self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}")
760-
self.assertIn("OK", result.stdout)
736+
assert_python_ok("-c", code)
761737

762-
def test_error_during_module_execution_propagates(self):
763-
"""Errors in module code during reification should propagate correctly."""
738+
def test_module_not_found_during_module_execution_propagates(self):
739+
code = textwrap.dedent("""
740+
lazy import test.test_lazy_import.data.missing_dependency
741+
742+
try:
743+
_ = test.test_lazy_import.data.missing_dependency
744+
except ModuleNotFoundError as exc:
745+
assert exc.name == "missing_dependency_for_lazy_import_test", exc.name
746+
assert str(exc) == "No module named 'missing_dependency_for_lazy_import_test'", exc
747+
else:
748+
raise AssertionError("ModuleNotFoundError was not raised")
749+
""")
750+
assert_python_ok("-c", code)
751+
752+
def test_self_named_module_not_found_during_module_execution_propagates(self):
753+
code = textwrap.dedent("""
754+
lazy import test.test_lazy_import.data.self_named_module_not_found
755+
756+
try:
757+
_ = test.test_lazy_import.data.self_named_module_not_found
758+
except ModuleNotFoundError as exc:
759+
assert exc.name == "test.test_lazy_import.data.self_named_module_not_found", exc.name
760+
assert str(exc) == "boom", exc
761+
else:
762+
raise AssertionError("ModuleNotFoundError was not raised")
763+
""")
764+
assert_python_ok("-c", code)
765+
766+
def test_none_in_sys_modules_during_submodule_resolution_propagates(self):
764767
code = textwrap.dedent("""
765768
import sys
766-
lazy import test.test_lazy_import.data.broken_module
769+
770+
sys.modules["test.test_lazy_import.data.blocked_module"] = None
771+
lazy import test.test_lazy_import.data.blocked_module
767772
768773
try:
769-
_ = test.test_lazy_import.data.broken_module
770-
print("FAIL - should have raised")
771-
except AttributeError:
772-
print("OK")
774+
_ = test.test_lazy_import.data.blocked_module
775+
except ModuleNotFoundError as exc:
776+
assert exc.name == "test.test_lazy_import.data.blocked_module", exc.name
777+
assert str(exc) == (
778+
"import of test.test_lazy_import.data.blocked_module "
779+
"halted; None in sys.modules"
780+
), exc
781+
else:
782+
raise AssertionError("ModuleNotFoundError was not raised")
773783
""")
774-
result = subprocess.run(
775-
[sys.executable, "-c", code],
776-
capture_output=True,
777-
text=True
778-
)
779-
self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}, stderr: {result.stderr}")
780-
self.assertIn("OK", result.stdout)
784+
assert_python_ok("-c", code)
781785

782786
def test_circular_lazy_import_does_not_crash_for_gh_144727(self):
783787
with tempfile.TemporaryDirectory() as tmpdir:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import missing_dependency_for_lazy_import_test
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
raise ModuleNotFoundError("boom", name=__name__)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Propagate exceptions raised while importing lazy submodules instead of
2+
reporting them as missing attributes.

0 commit comments

Comments
 (0)