Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 110 additions & 9 deletions Lib/test/test_lazy_import/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,103 @@ def test_from_import_with_imported_module_getattr(self):
""")
assert_python_ok("-c", code)

@support.requires_subprocess()
def test_dotted_import_calls_full_name_import_hook(self):
"""Dotted lazy imports should call __import__ with the full name."""
code = textwrap.dedent(r"""
import atexit
import builtins
import shutil
import sys
import tempfile
import types
from pathlib import Path

PKG = "hookpkg_gh_152297"
SUBMOD = f"{PKG}.sub"

real_import = builtins.__import__
full_name_calls = []
calls = []

tmpdir = tempfile.mkdtemp()
atexit.register(shutil.rmtree, tmpdir, ignore_errors=True)

package_dir = Path(tmpdir, PKG)
package_dir.mkdir()
(package_dir / "__init__.py").touch()
(package_dir / "sub.py").write_text(
"VALUE = 'real-submodule'\n", encoding="utf-8")

sys.path.insert(0, tmpdir)
atexit.register(setattr, builtins, "__import__", real_import)

def custom_import(name, globals=None, locals=None, fromlist=(),
level=0):
caller = (
globals.get("__name__")
if isinstance(globals, dict) else None
)
call = (name, caller, tuple(fromlist or ()))
calls.append(call)

if name == SUBMOD:
full_name_calls.append(call)
package = real_import(PKG, globals, locals, (), level)
module = types.ModuleType(SUBMOD)
module.VALUE = "hooked-submodule"
package.sub = module
sys.modules[SUBMOD] = module
return package

return real_import(name, globals, locals, fromlist, level)

builtins.__import__ = custom_import

lazy import hookpkg_gh_152297.sub

assert not full_name_calls, calls
assert hookpkg_gh_152297.sub.VALUE == "hooked-submodule", calls
assert full_name_calls == [(SUBMOD, "__main__", ())], calls
""")
assert_python_ok("-c", code)

@support.requires_subprocess()
def test_dotted_import_first_use_loads_full_target(self):
"""First use of a dotted lazy import should load the submodule."""
code = textwrap.dedent(r"""
import atexit
import shutil
import sys
import tempfile
from pathlib import Path

PKG = "hookpkg_gh_152297_default"
SUBMOD = f"{PKG}.sub"

tmpdir = tempfile.mkdtemp()
atexit.register(shutil.rmtree, tmpdir, ignore_errors=True)

package_dir = Path(tmpdir, PKG)
package_dir.mkdir()
(package_dir / "__init__.py").touch()
(package_dir / "sub.py").write_text(
"VALUE = 42\n", encoding="utf-8")

sys.path.insert(0, tmpdir)

lazy import hookpkg_gh_152297_default.sub

assert SUBMOD not in sys.modules

_ = hookpkg_gh_152297_default

assert PKG in sys.modules
assert SUBMOD in sys.modules
assert hookpkg_gh_152297_default.sub.VALUE == 42
""")
assert_python_ok("-c", code)


class GlobalLazyImportModeTests(LazyImportTestCase):
"""Tests for sys.set_lazy_imports() global mode control."""
Expand Down Expand Up @@ -658,14 +755,15 @@ class ErrorHandlingTests(LazyImportTestCase):
"""

def test_import_error_shows_chained_traceback(self):
"""Accessing a nonexistent lazy submodule via parent attr raises AttributeError."""
"""A failed dotted lazy import should preserve the import failure."""
code = textwrap.dedent("""
import sys
lazy import test.test_lazy_import.data.nonexistent_module

try:
x = test.test_lazy_import.data.nonexistent_module
except AttributeError as e:
except ModuleNotFoundError as e:
assert e.__cause__ is not None, "Expected chained exception"
print("OK")
""")
result = subprocess.run(
Expand Down Expand Up @@ -710,20 +808,22 @@ def test_reification_retries_on_failure(self):

lazy import test.test_lazy_import.data.broken_module

def lazy_proxy():
return globals()['test']

# First access - should fail
try:
x = test.test_lazy_import.data.broken_module
except AttributeError:
except ValueError:
pass

# The lazy object should still be a lazy proxy (not reified)
g = globals()
lazy_obj = g['test']
# The root 'test' binding should still allow retry
assert type(lazy_proxy()) is types.LazyImportType

# Second access - should also fail (retry the import)
try:
x = test.test_lazy_import.data.broken_module
except AttributeError:
except ValueError:
assert type(lazy_proxy()) is types.LazyImportType
print("OK - retry worked")
""")
result = subprocess.run(
Expand All @@ -743,7 +843,8 @@ def test_error_during_module_execution_propagates(self):
try:
_ = test.test_lazy_import.data.broken_module
print("FAIL - should have raised")
except AttributeError:
except ValueError as e:
assert str(e) == "This module always fails to import"
print("OK")
""")
result = subprocess.run(
Expand Down
41 changes: 29 additions & 12 deletions Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -5609,24 +5609,43 @@ def _make_syntax_error(text, offset, end_offset):
class TestLazyImportSuggestions(unittest.TestCase):
"""Test that lazy imports are not reified when computing AttributeError suggestions."""

@staticmethod
def _lazy_holder_script(body):
setup = textwrap.dedent("""
import atexit
import os
import shutil
import sys
import tempfile

tmpdir = tempfile.mkdtemp()
atexit.register(shutil.rmtree, tmpdir, ignore_errors=True)
with open(os.path.join(tmpdir, "lazy_traceback_bar.py"),
"w", encoding="utf-8") as f:
f.write('print("BAR_MODULE_LOADED")\\n')
with open(os.path.join(tmpdir, "lazy_holder.py"),
"w", encoding="utf-8") as f:
f.write("lazy import lazy_traceback_bar\\n")

sys.path.insert(0, tmpdir)
import lazy_holder
""")
return setup + textwrap.dedent(body)

def test_attribute_error_does_not_reify_lazy_imports(self):
"""Printing an AttributeError should not trigger lazy import reification."""
# pkg.bar prints "BAR_MODULE_LOADED" when imported.
# If lazy import is reified during suggestion computation, we'll see it.
code = textwrap.dedent("""
lazy import test.test_lazy_import.data.pkg.bar
test.test_lazy_import.data.pkg.nonexistent
code = self._lazy_holder_script("""
lazy_holder.nonexistent
""")
rc, stdout, stderr = assert_python_failure('-c', code)
self.assertNotIn(b"BAR_MODULE_LOADED", stdout)

def test_traceback_formatting_does_not_reify_lazy_imports(self):
"""Formatting a traceback should not trigger lazy import reification."""
code = textwrap.dedent("""
code = self._lazy_holder_script("""
import traceback
lazy import test.test_lazy_import.data.pkg.bar
try:
test.test_lazy_import.data.pkg.nonexistent
lazy_holder.nonexistent
except AttributeError:
traceback.format_exc()
print("OK")
Expand All @@ -5637,10 +5656,8 @@ def test_traceback_formatting_does_not_reify_lazy_imports(self):

def test_suggestion_still_works_for_non_lazy_attributes(self):
"""Suggestions should still work for non-lazy module attributes."""
code = textwrap.dedent("""
lazy import test.test_lazy_import.data.pkg.bar
# Typo for __name__
test.test_lazy_import.data.pkg.__nme__
code = self._lazy_holder_script("""
lazy_holder.__nme__
""")
rc, stdout, stderr = assert_python_failure('-c', code)
self.assertIn(b"__name__", stderr)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix ``lazy import pkg.sub`` so reification calls ``__import__`` with
``pkg.sub``, matching eager imports.
34 changes: 4 additions & 30 deletions Python/import.c
Original file line number Diff line number Diff line change
Expand Up @@ -3940,19 +3940,6 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import)
goto error;
}

Py_ssize_t dot = -1;
int full = 0;
if (lz->lz_attr != NULL) {
full = 1;
}
if (!full) {
dot = PyUnicode_FindChar(lz->lz_from, '.', 0,
PyUnicode_GET_LENGTH(lz->lz_from), 1);
}
if (dot < 0) {
full = 1;
}

if (lz->lz_attr != NULL) {
if (PyUnicode_Check(lz->lz_attr)) {
fromlist = PyTuple_New(1);
Expand All @@ -3978,23 +3965,10 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import)
PyErr_SetString(PyExc_ImportError, "__import__ not found");
goto error;
}
if (full) {
obj = _PyEval_ImportNameWithImport(
tstate, import_func, globals, globals,
lz->lz_from, fromlist, _PyLong_GetZero()
);
}
else {
PyObject *name = PyUnicode_Substring(lz->lz_from, 0, dot);
if (name == NULL) {
goto error;
}
obj = _PyEval_ImportNameWithImport(
tstate, import_func, globals, globals,
name, fromlist, _PyLong_GetZero()
);
Py_DECREF(name);
}
obj = _PyEval_ImportNameWithImport(
tstate, import_func, globals, globals,
lz->lz_from, fromlist, _PyLong_GetZero()
);
if (obj == NULL) {
goto error;
}
Expand Down
Loading