Skip to content

Commit e2f3da0

Browse files
committed
gh-152297: Reify dotted lazy imports with full name
1 parent 6713576 commit e2f3da0

4 files changed

Lines changed: 145 additions & 51 deletions

File tree

Lib/test/test_lazy_import/__init__.py

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,103 @@ def test_from_import_with_imported_module_getattr(self):
167167
""")
168168
assert_python_ok("-c", code)
169169

170+
@support.requires_subprocess()
171+
def test_dotted_import_calls_full_name_import_hook(self):
172+
"""Dotted lazy imports should call __import__ with the full name."""
173+
code = textwrap.dedent(r"""
174+
import atexit
175+
import builtins
176+
import shutil
177+
import sys
178+
import tempfile
179+
import types
180+
from pathlib import Path
181+
182+
PKG = "hookpkg_gh_152297"
183+
SUBMOD = f"{PKG}.sub"
184+
185+
real_import = builtins.__import__
186+
full_name_calls = []
187+
calls = []
188+
189+
tmpdir = tempfile.mkdtemp()
190+
atexit.register(shutil.rmtree, tmpdir, ignore_errors=True)
191+
192+
package_dir = Path(tmpdir, PKG)
193+
package_dir.mkdir()
194+
(package_dir / "__init__.py").touch()
195+
(package_dir / "sub.py").write_text(
196+
"VALUE = 'real-submodule'\n", encoding="utf-8")
197+
198+
sys.path.insert(0, tmpdir)
199+
atexit.register(setattr, builtins, "__import__", real_import)
200+
201+
def custom_import(name, globals=None, locals=None, fromlist=(),
202+
level=0):
203+
caller = (
204+
globals.get("__name__")
205+
if isinstance(globals, dict) else None
206+
)
207+
call = (name, caller, tuple(fromlist or ()))
208+
calls.append(call)
209+
210+
if name == SUBMOD:
211+
full_name_calls.append(call)
212+
package = real_import(PKG, globals, locals, (), level)
213+
module = types.ModuleType(SUBMOD)
214+
module.VALUE = "hooked-submodule"
215+
package.sub = module
216+
sys.modules[SUBMOD] = module
217+
return package
218+
219+
return real_import(name, globals, locals, fromlist, level)
220+
221+
builtins.__import__ = custom_import
222+
223+
lazy import hookpkg_gh_152297.sub
224+
225+
assert not full_name_calls, calls
226+
assert hookpkg_gh_152297.sub.VALUE == "hooked-submodule", calls
227+
assert full_name_calls == [(SUBMOD, "__main__", ())], calls
228+
""")
229+
assert_python_ok("-c", code)
230+
231+
@support.requires_subprocess()
232+
def test_dotted_import_first_use_loads_full_target(self):
233+
"""First use of a dotted lazy import should load the submodule."""
234+
code = textwrap.dedent(r"""
235+
import atexit
236+
import shutil
237+
import sys
238+
import tempfile
239+
from pathlib import Path
240+
241+
PKG = "hookpkg_gh_152297_default"
242+
SUBMOD = f"{PKG}.sub"
243+
244+
tmpdir = tempfile.mkdtemp()
245+
atexit.register(shutil.rmtree, tmpdir, ignore_errors=True)
246+
247+
package_dir = Path(tmpdir, PKG)
248+
package_dir.mkdir()
249+
(package_dir / "__init__.py").touch()
250+
(package_dir / "sub.py").write_text(
251+
"VALUE = 42\n", encoding="utf-8")
252+
253+
sys.path.insert(0, tmpdir)
254+
255+
lazy import hookpkg_gh_152297_default.sub
256+
257+
assert SUBMOD not in sys.modules
258+
259+
_ = hookpkg_gh_152297_default
260+
261+
assert PKG in sys.modules
262+
assert SUBMOD in sys.modules
263+
assert hookpkg_gh_152297_default.sub.VALUE == 42
264+
""")
265+
assert_python_ok("-c", code)
266+
170267

171268
class GlobalLazyImportModeTests(LazyImportTestCase):
172269
"""Tests for sys.set_lazy_imports() global mode control."""
@@ -658,14 +755,15 @@ class ErrorHandlingTests(LazyImportTestCase):
658755
"""
659756

660757
def test_import_error_shows_chained_traceback(self):
661-
"""Accessing a nonexistent lazy submodule via parent attr raises AttributeError."""
758+
"""A failed dotted lazy import should preserve the import failure."""
662759
code = textwrap.dedent("""
663760
import sys
664761
lazy import test.test_lazy_import.data.nonexistent_module
665762
666763
try:
667764
x = test.test_lazy_import.data.nonexistent_module
668-
except AttributeError as e:
765+
except ModuleNotFoundError as e:
766+
assert e.__cause__ is not None, "Expected chained exception"
669767
print("OK")
670768
""")
671769
result = subprocess.run(
@@ -710,20 +808,22 @@ def test_reification_retries_on_failure(self):
710808
711809
lazy import test.test_lazy_import.data.broken_module
712810
811+
def lazy_proxy():
812+
return globals()['test']
813+
713814
# First access - should fail
714815
try:
715816
x = test.test_lazy_import.data.broken_module
716-
except AttributeError:
817+
except ValueError:
717818
pass
718819
719-
# The lazy object should still be a lazy proxy (not reified)
720-
g = globals()
721-
lazy_obj = g['test']
722-
# The root 'test' binding should still allow retry
820+
assert type(lazy_proxy()) is types.LazyImportType
821+
723822
# Second access - should also fail (retry the import)
724823
try:
725824
x = test.test_lazy_import.data.broken_module
726-
except AttributeError:
825+
except ValueError:
826+
assert type(lazy_proxy()) is types.LazyImportType
727827
print("OK - retry worked")
728828
""")
729829
result = subprocess.run(
@@ -743,7 +843,8 @@ def test_error_during_module_execution_propagates(self):
743843
try:
744844
_ = test.test_lazy_import.data.broken_module
745845
print("FAIL - should have raised")
746-
except AttributeError:
846+
except ValueError as e:
847+
assert str(e) == "This module always fails to import"
747848
print("OK")
748849
""")
749850
result = subprocess.run(

Lib/test/test_traceback.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5609,24 +5609,43 @@ def _make_syntax_error(text, offset, end_offset):
56095609
class TestLazyImportSuggestions(unittest.TestCase):
56105610
"""Test that lazy imports are not reified when computing AttributeError suggestions."""
56115611

5612+
@staticmethod
5613+
def _lazy_holder_script(body):
5614+
setup = textwrap.dedent("""
5615+
import atexit
5616+
import os
5617+
import shutil
5618+
import sys
5619+
import tempfile
5620+
5621+
tmpdir = tempfile.mkdtemp()
5622+
atexit.register(shutil.rmtree, tmpdir, ignore_errors=True)
5623+
with open(os.path.join(tmpdir, "lazy_traceback_bar.py"),
5624+
"w", encoding="utf-8") as f:
5625+
f.write('print("BAR_MODULE_LOADED")\\n')
5626+
with open(os.path.join(tmpdir, "lazy_holder.py"),
5627+
"w", encoding="utf-8") as f:
5628+
f.write("lazy import lazy_traceback_bar\\n")
5629+
5630+
sys.path.insert(0, tmpdir)
5631+
import lazy_holder
5632+
""")
5633+
return setup + textwrap.dedent(body)
5634+
56125635
def test_attribute_error_does_not_reify_lazy_imports(self):
56135636
"""Printing an AttributeError should not trigger lazy import reification."""
5614-
# pkg.bar prints "BAR_MODULE_LOADED" when imported.
5615-
# If lazy import is reified during suggestion computation, we'll see it.
5616-
code = textwrap.dedent("""
5617-
lazy import test.test_lazy_import.data.pkg.bar
5618-
test.test_lazy_import.data.pkg.nonexistent
5637+
code = self._lazy_holder_script("""
5638+
lazy_holder.nonexistent
56195639
""")
56205640
rc, stdout, stderr = assert_python_failure('-c', code)
56215641
self.assertNotIn(b"BAR_MODULE_LOADED", stdout)
56225642

56235643
def test_traceback_formatting_does_not_reify_lazy_imports(self):
56245644
"""Formatting a traceback should not trigger lazy import reification."""
5625-
code = textwrap.dedent("""
5645+
code = self._lazy_holder_script("""
56265646
import traceback
5627-
lazy import test.test_lazy_import.data.pkg.bar
56285647
try:
5629-
test.test_lazy_import.data.pkg.nonexistent
5648+
lazy_holder.nonexistent
56305649
except AttributeError:
56315650
traceback.format_exc()
56325651
print("OK")
@@ -5637,10 +5656,8 @@ def test_traceback_formatting_does_not_reify_lazy_imports(self):
56375656

56385657
def test_suggestion_still_works_for_non_lazy_attributes(self):
56395658
"""Suggestions should still work for non-lazy module attributes."""
5640-
code = textwrap.dedent("""
5641-
lazy import test.test_lazy_import.data.pkg.bar
5642-
# Typo for __name__
5643-
test.test_lazy_import.data.pkg.__nme__
5659+
code = self._lazy_holder_script("""
5660+
lazy_holder.__nme__
56445661
""")
56455662
rc, stdout, stderr = assert_python_failure('-c', code)
56465663
self.assertIn(b"__name__", stderr)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix ``lazy import pkg.sub`` so reification calls ``__import__`` with
2+
``pkg.sub``, matching eager imports.

Python/import.c

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3940,19 +3940,6 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import)
39403940
goto error;
39413941
}
39423942

3943-
Py_ssize_t dot = -1;
3944-
int full = 0;
3945-
if (lz->lz_attr != NULL) {
3946-
full = 1;
3947-
}
3948-
if (!full) {
3949-
dot = PyUnicode_FindChar(lz->lz_from, '.', 0,
3950-
PyUnicode_GET_LENGTH(lz->lz_from), 1);
3951-
}
3952-
if (dot < 0) {
3953-
full = 1;
3954-
}
3955-
39563943
if (lz->lz_attr != NULL) {
39573944
if (PyUnicode_Check(lz->lz_attr)) {
39583945
fromlist = PyTuple_New(1);
@@ -3978,23 +3965,10 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import)
39783965
PyErr_SetString(PyExc_ImportError, "__import__ not found");
39793966
goto error;
39803967
}
3981-
if (full) {
3982-
obj = _PyEval_ImportNameWithImport(
3983-
tstate, import_func, globals, globals,
3984-
lz->lz_from, fromlist, _PyLong_GetZero()
3985-
);
3986-
}
3987-
else {
3988-
PyObject *name = PyUnicode_Substring(lz->lz_from, 0, dot);
3989-
if (name == NULL) {
3990-
goto error;
3991-
}
3992-
obj = _PyEval_ImportNameWithImport(
3993-
tstate, import_func, globals, globals,
3994-
name, fromlist, _PyLong_GetZero()
3995-
);
3996-
Py_DECREF(name);
3997-
}
3968+
obj = _PyEval_ImportNameWithImport(
3969+
tstate, import_func, globals, globals,
3970+
lz->lz_from, fromlist, _PyLong_GetZero()
3971+
);
39983972
if (obj == NULL) {
39993973
goto error;
40003974
}

0 commit comments

Comments
 (0)