diff --git a/Doc/library/importlib.rst b/Doc/library/importlib.rst index 0b76020eacc1da2..913e13c2ab20351 100644 --- a/Doc/library/importlib.rst +++ b/Doc/library/importlib.rst @@ -1371,6 +1371,16 @@ an :term:`importer`. compatibility warning for :class:`importlib.machinery.BuiltinImporter` and :class:`importlib.machinery.ExtensionFileLoader`. + .. versionchanged:: 3.16 + Reading a lazily-loaded module's :attr:`~module.__name__` and + :attr:`~module.__file__` attributes no longer triggers the load. Until + the module is loaded they act as aliases for the + :attr:`~importlib.machinery.ModuleSpec.name` and + :attr:`~importlib.machinery.ModuleSpec.origin` of its + :attr:`~module.__spec__`, and assigning to them updates the spec + instead. This avoids the unintentional loading caused by + introspection tools like :func:`inspect.getframeinfo` (:gh:`139669`). + .. classmethod:: factory(loader) A class method which returns a callable that creates a lazy loader. This @@ -1482,6 +1492,11 @@ The example below shows how to implement lazy imports:: >>> lazy_typing.TYPE_CHECKING False +Reading the module's :attr:`~module.__name__` or :attr:`~module.__file__` +does not trigger the load; until then they mirror the module's +:attr:`~module.__spec__`. Accessing any other attribute (such as +``TYPE_CHECKING`` above) loads the module. + Setting up an importer '''''''''''''''''''''' diff --git a/Lib/importlib/util.py b/Lib/importlib/util.py index 2b564e9b52e0cb2..88d5b5529a96113 100644 --- a/Lib/importlib/util.py +++ b/Lib/importlib/util.py @@ -171,6 +171,13 @@ class _LazyModule(types.ModuleType): def __getattribute__(self, attr): """Trigger the load of the module and return the attribute.""" __spec__ = object.__getattribute__(self, '__spec__') + + # gh-139669: avoid triggering lazy loading from these 2 attrs + if attr == "__name__": + return __spec__.name + if attr == "__file__": + return __spec__.origin + loader_state = __spec__.loader_state with loader_state['lock']: # Only the first thread to get the lock should trigger the load @@ -223,11 +230,20 @@ def __getattribute__(self, attr): return getattr(self, attr) + def __setattr__(self, attr, value): + """Keep __name__/__file__ in sync with __spec__ without loading.""" + __spec__ = object.__getattribute__(self, '__spec__') + if attr == "__name__": + __spec__.name = value + elif attr == "__file__": + __spec__.origin = value + else: + object.__setattr__(self, attr, value) + def __delattr__(self, attr): """Trigger the load and then perform the deletion.""" - # To trigger the load and raise an exception if the attribute - # doesn't exist. - self.__getattribute__(attr) + self.__getattribute__('__spec__') + # Goes into ModuleType.__delattr__ delattr(self, attr) diff --git a/Lib/test/test_importlib/test_lazy.py b/Lib/test/test_importlib/test_lazy.py index e48fad8898f0ef3..452f42c6aa046bc 100644 --- a/Lib/test/test_importlib/test_lazy.py +++ b/Lib/test/test_importlib/test_lazy.py @@ -1,6 +1,7 @@ import importlib from importlib import abc from importlib import util +import inspect import sys import time import threading @@ -99,6 +100,9 @@ def test_attr_unchanged(self): # An attribute only mutated as a side-effect of import should not be # changed needlessly. module = self.new_module() + # __name__ itself doesn't trigger the lazy loading, + # trigger via another attr + module.__package__ self.assertEqual(TestingImporter.mutated_name, module.__name__) def test_new_attr(self): @@ -138,14 +142,14 @@ def test_module_substitution_error(self): sys.modules[TestingImporter.module_name] = fresh_module module = self.new_module() with self.assertRaisesRegex(ValueError, "substituted"): - module.__name__ + module.__package__ def test_module_already_in_sys(self): with test_util.uncache(TestingImporter.module_name): module = self.new_module() sys.modules[TestingImporter.module_name] = module # Force the load; just care that no exception is raised. - module.__name__ + module.__package__ @threading_helper.requires_working_threading() def test_module_load_race(self): @@ -224,6 +228,20 @@ def __delattr__(self, name): with self.assertRaises(AttributeError): del module.CONSTANT + def test_inspect_does_not_trigger_lazy_load(self): + # gh-139669: introspecting an unrelated frame iterates over + # sys.modules and must not force lazy modules to be loaded. + loader = TestingImporter() + module = self.new_module(loader=loader) + with test_util.uncache(TestingImporter.module_name): + sys.modules[TestingImporter.module_name] = module + self.assertIsInstance(module, util._LazyModule) + + inspect.getframeinfo(inspect.currentframe()) + self.assertIsNone(loader.loaded) + self.assertEqual(0, loader.load_count) + self.assertIsInstance(module, util._LazyModule) + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-06-06-12-00-00.gh-issue-139669.La9Zk1.rst b/Misc/NEWS.d/next/Library/2026-06-06-12-00-00.gh-issue-139669.La9Zk1.rst new file mode 100644 index 000000000000000..a13647ddd5fcb2c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-06-12-00-00.gh-issue-139669.La9Zk1.rst @@ -0,0 +1,7 @@ +Reading the :attr:`~module.__name__` and :attr:`~module.__file__` attributes +of a module created by :class:`importlib.util.LazyLoader` no longer triggers +the load. While the module remains unloaded these attributes are aliases for +its :attr:`~module.__spec__`, and assigning to them updates the spec. This +prevents introspection tools such as :func:`inspect.getframeinfo`, via +:func:`inspect.getmodule`, from forcing every lazily-loaded module in +:data:`sys.modules` to be imported.