Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Include/internal/pycore_import.h
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ extern void _PyImport_ClearModulesByIndex(PyInterpreterState *interp);
extern PyObject * _PyImport_InitLazyModules(
PyInterpreterState *interp);
extern void _PyImport_ClearLazyModules(PyInterpreterState *interp);
extern int _PyImport_DiscardLazyModule(
PyInterpreterState *interp, PyObject *name);

extern int _PyImport_InitDefaultImportFunc(PyInterpreterState *interp);
extern int _PyImport_IsDefaultImportFunc(
Expand Down
259 changes: 258 additions & 1 deletion Lib/test/test_lazy_import/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ def test_sys_lazy_modules(self):
self.fail('lazy import failed')

self.assertFalse("test.test_lazy_import.data.basic2" in sys.modules)
self.assertIn("test.test_lazy_import.data", sys.lazy_modules)
# The package is already loaded, so it is not a pending import.
self.assertNotIn("test.test_lazy_import.data", sys.lazy_modules)
self.assertIn("test.test_lazy_import.data.basic2", sys.lazy_modules)
test.test_lazy_import.data.basic_from_unused.basic2
self.assertNotIn("test.test_import.data", sys.lazy_modules)
Expand Down Expand Up @@ -1305,6 +1306,262 @@ def test_lazy_module_without_children_is_tracked(self):
""")
assert_python_ok("-c", code)

def test_already_loaded_module_is_not_tracked(self):
"""A lazy import of an already loaded module should not be tracked."""
code = textwrap.dedent("""
import sys
# Loaded by a regular import.
import json
lazy import json as lazy_json
assert "json" not in sys.lazy_modules, (
f"expected 'json' not in sys.lazy_modules, got {sys.lazy_modules}"
)
# Loaded by reifying an earlier lazy import.
lazy import base64
_ = base64.b64encode
lazy import base64 as lazy_base64
assert "base64" not in sys.lazy_modules, (
f"expected 'base64' not in sys.lazy_modules, got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)

def test_already_loaded_submodule_is_not_tracked(self):
"""`lazy from` a loaded submodule should not be tracked either."""
code = textwrap.dedent("""
import sys
import test.test_lazy_import.data.pkg.b
lazy from test.test_lazy_import.data.pkg import b
assert "test.test_lazy_import.data.pkg.b" not in sys.lazy_modules, (
f"expected 'pkg.b' untracked, got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)

def test_attribute_entry_removed_on_reification(self):
"""`lazy from x import attr` should untrack "x.attr" once resolved."""
code = textwrap.dedent("""
import sys
lazy from test.test_lazy_import.data.basic2 import x
assert "test.test_lazy_import.data.basic2.x" in sys.lazy_modules, (
f"expected 'basic2.x' tracked, got {sys.lazy_modules}"
)
_ = x
assert "test.test_lazy_import.data.basic2.x" not in sys.lazy_modules, (
f"expected 'basic2.x' untracked, got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)

def test_already_loaded_attribute_is_not_tracked(self):
code = textwrap.dedent("""
import sys
import math
lazy from math import pi
assert "math.pi" not in sys.lazy_modules, sys.lazy_modules
assert pi == math.pi
""")
assert_python_ok("-c", code)

def test_cached_lazy_attribute_tracks_its_target(self):
code = textwrap.dedent("""
import sys
import test.test_lazy_import.data.basic_from_unused
holder = "test.test_lazy_import.data.basic_from_unused"
target = "test.test_lazy_import.data.basic2"
namespace = {"__name__": "cached_import_test"}
exec(f"lazy from {holder} import basic2", namespace)
assert holder + ".basic2" not in sys.lazy_modules, sys.lazy_modules
assert target in sys.lazy_modules, sys.lazy_modules
exec("assert basic2.x == 42", namespace)
assert target not in sys.lazy_modules, sys.lazy_modules
""")
assert_python_ok("-c", code)

def test_cached_attribute_keeps_pending_module_tracked(self):
code = textwrap.dedent("""
import sys
import test.test_lazy_import.data.pkg as pkg
pkg.b = 42
lazy import test.test_lazy_import.data.pkg.b as pending
lazy from test.test_lazy_import.data.pkg import b
name = "test.test_lazy_import.data.pkg.b"
assert b == 42, b
assert name not in sys.modules, sys.modules
assert name in sys.lazy_modules, sys.lazy_modules
assert pending.foo() == "foo"
assert name not in sys.lazy_modules, sys.lazy_modules
""")
assert_python_ok("-c", code)

def test_failed_reification_stays_tracked(self):
"""A lazy import that fails to resolve must stay tracked."""
code = textwrap.dedent("""
import sys
lazy import test.test_lazy_import.data.broken_module
try:
_ = test.test_lazy_import.data.broken_module
except ValueError:
pass
else:
raise AssertionError("ValueError was not raised")
assert "test.test_lazy_import.data.broken_module" in sys.lazy_modules, (
f"failed reification must stay tracked, got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)

def test_blocked_module_is_still_tracked(self):
"""A ``None`` entry in sys.modules must not count as loaded."""
code = textwrap.dedent("""
import sys
sys.modules['test.test_lazy_import.data.basic2'] = None
lazy import test.test_lazy_import.data.basic2
assert "test.test_lazy_import.data.basic2" in sys.lazy_modules, (
f"blocked module must stay tracked, got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)

def test_initializing_module_is_still_tracked(self):
"""A module that is still executing must not count as loaded."""
code = textwrap.dedent("""
import sys
name = "test.test_lazy_import.data.init_fails"
try:
import test.test_lazy_import.data.init_fails
except ValueError:
pass
else:
raise AssertionError("ValueError was not raised")
assert name not in sys.modules, "failed import left a module behind"
assert name in sys.lazy_modules, (
f"expected {name!r} tracked, got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)

def test_module_spec_descriptor_is_not_run(self):
"""Checking whether a module is loaded must not run its descriptors."""
code = textwrap.dedent("""
import sys
import types
class RaisingSpec(types.ModuleType):
@property
def __spec__(self):
raise RuntimeError("__spec__ descriptor was run")
sys.modules["raising_spec"] = RaisingSpec("raising_spec")
lazy import raising_spec
assert "raising_spec" not in sys.lazy_modules, (
f"expected 'raising_spec' untracked, got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)

def test_spec_initializing_descriptor_is_not_run(self):
code = textwrap.dedent("""
import sys
import types
class Spec:
@property
def _initializing(self):
raise RuntimeError("_initializing descriptor was run")
@property
def __dict__(self):
raise RuntimeError("__dict__ descriptor was run")
class SlottedSpec:
__slots__ = ()
@property
def _initializing(self):
raise RuntimeError("_initializing descriptor was run")
for spec in (Spec(), SlottedSpec()):
module = types.ModuleType("custom_spec")
module.__spec__ = spec
sys.modules["custom_spec"] = module
lazy import custom_spec
""")
assert_python_ok("-c", code)

def test_cached_attribute_does_not_check_spec_twice(self):
code = textwrap.dedent("""
import sys
import types
class Spec:
def __init__(self):
self.calls = 0
@property
def _initializing(self):
self.calls += 1
if self.calls == 2:
raise RuntimeError("_initializing was read twice")
return False
module = types.ModuleType("cached_spec")
module.__spec__ = Spec()
module.attr = 1
sys.modules["cached_spec"] = module
lazy from cached_spec import attr
assert attr == 1, attr
""")
assert_python_ok("-c", code)

def test_spec_initializing_truth_conversion_is_not_run(self):
code = textwrap.dedent("""
import sys
import types
from importlib.machinery import ModuleSpec
class Flag:
def __bool__(self):
raise RuntimeError("_initializing truth conversion was run")
released = memoryview(b"")
released.release()
for flag in (Flag(), released):
sys.lazy_modules.discard("custom_spec")
module = types.ModuleType("custom_spec")
module.__spec__ = ModuleSpec("custom_spec", None)
module.__spec__._initializing = flag
sys.modules["custom_spec"] = module
lazy import custom_spec
assert "custom_spec" in sys.lazy_modules, sys.lazy_modules
""")
assert_python_ok("-c", code)

def test_pending_submodule_is_still_tracked(self):
"""`lazy from` a submodule that is not loaded must stay tracked."""
code = textwrap.dedent("""
import sys
lazy from test.test_lazy_import.data.pkg import b
assert "test.test_lazy_import.data.pkg.b" in sys.lazy_modules, (
f"expected 'pkg.b' tracked, got {sys.lazy_modules}"
)
import test.test_lazy_import.data.pkg
assert "test.test_lazy_import.data.pkg.b" not in sys.modules, (
"loading the package must not load the submodule"
)
assert "test.test_lazy_import.data.pkg.b" in sys.lazy_modules, (
f"loading the package must not untrack the submodule, "
f"got {sys.lazy_modules}"
)
""")
assert_python_ok("-c", code)


@support.requires_subprocess()
class CommandLineAndEnvVarTests(unittest.TestCase):
Expand Down
4 changes: 4 additions & 0 deletions Lib/test/test_lazy_import/data/init_fails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Imported by test_initializing_module_is_still_tracked. The module it imports
# lazily imports this one back while this one is still initializing.
import test.test_lazy_import.data.lazy_on_init_fails
raise ValueError("initialization failed")
1 change: 1 addition & 0 deletions Lib/test/test_lazy_import/data/lazy_on_init_fails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lazy import test.test_lazy_import.data.init_fails
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Resolved names are now removed from :data:`sys.lazy_modules` more
consistently: a lazy import of an already loaded module is no longer recorded,
and reifying ``lazy from pkg import attr`` now discards the ``pkg.attr`` entry.
8 changes: 8 additions & 0 deletions Objects/lazyimportobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,14 @@ lazy_import_resolve_impl(PyThreadState *tstate, PyObject *lazy_import,
lazy_import_add_exception_cause(tstate, lz);
}
assert(obj == NULL || !PyLazyImport_CheckExact(obj));
if (obj != NULL) {
PyObject *name = lazy_import_name(lz);

@encukou encukou Oct 2, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If #158521 is merged first, this will change to lazy_import_path since only the root will be resolved here:

Suggested change
PyObject *name = lazy_import_name(lz);
PyObject *name = lazy_import_path(lz);

if (name == NULL ||
_PyImport_DiscardLazyModule(tstate->interp, name) < 0) {
Py_CLEAR(obj);
}
Py_XDECREF(name);
}
if (resolving != NULL) {
// A failed set resize can leave the placeholder inserted. Removing by
// identity also permits greenlets to finish in a different order.
Expand Down
Loading
Loading