From 07352a358f85855bb0c0cdd478e8aff36d02c78c Mon Sep 17 00:00:00 2001 From: Xuanteng Huang Date: Sat, 6 Jun 2026 16:50:27 +0800 Subject: [PATCH 1/2] make `__name__` and `__file__` as a proxy for `_LazyModule.__spec__` --- Doc/library/importlib.rst | 17 +++++++ Lib/importlib/util.py | 25 +++++++++-- Lib/test/test_importlib/test_lazy.py | 45 ++++++++++++++++++- ...-06-06-12-00-00.gh-issue-139669.La9Zk1.rst | 7 +++ 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-06-12-00-00.gh-issue-139669.La9Zk1.rst diff --git a/Doc/library/importlib.rst b/Doc/library/importlib.rst index 0b76020eacc1da2..bca3d396291dda2 100644 --- a/Doc/library/importlib.rst +++ b/Doc/library/importlib.rst @@ -1371,6 +1371,18 @@ 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 keeps introspection tools such as + :func:`inspect.getmodule` -- used indirectly by, for example, + :func:`inspect.getframeinfo` -- from forcing every lazily-loaded module + in :data:`sys.modules` to be imported (:gh:`139669`). + .. classmethod:: factory(loader) A class method which returns a callable that creates a lazy loader. This @@ -1482,6 +1494,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..62bf0fcf54f8266 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,23 @@ 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) + # Reading __name__/__file__ aliases the spec and no longer triggers + # the load, so access __spec__ to force it. This also resets + # __class__ to the real module type, avoiding infinite recursion in + # the delattr() below. + self.__getattribute__('__spec__') delattr(self, attr) diff --git a/Lib/test/test_importlib/test_lazy.py b/Lib/test/test_importlib/test_lazy.py index e48fad8898f0ef3..99e37e53d4668c2 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,43 @@ def __delattr__(self, name): with self.assertRaises(AttributeError): del module.CONSTANT + def test_name_file_alias_spec(self): + # gh-139669: __name__/__file__ are aliases for __spec__.name and + # __spec__.origin. Reading them returns the spec values and does not + # trigger the load; writing them flows through to the spec. + loader = TestingImporter() + module = self.new_module(loader=loader) + spec = object.__getattribute__(module, '__spec__') + spec.origin = '/some/where.py' + self.assertEqual(module.__name__, spec.name) + self.assertEqual(module.__file__, '/some/where.py') + # Neither read triggered the load. + self.assertIsNone(loader.loaded) + self.assertEqual(0, loader.load_count) + # Writes flow through to the spec, still without loading. + module.__name__ = 'renamed' + module.__file__ = '/other.py' + self.assertEqual(spec.name, 'renamed') + self.assertEqual(spec.origin, '/other.py') + self.assertEqual(module.__name__, 'renamed') + self.assertEqual(module.__file__, '/other.py') + self.assertIsNone(loader.loaded) + self.assertIsInstance(module, util._LazyModule) + + 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. From 46b17f73b788047e2b43af09f1fb9d3085493e0b Mon Sep 17 00:00:00 2001 From: Xuanteng Huang Date: Sat, 6 Jun 2026 16:59:51 +0800 Subject: [PATCH 2/2] rephrase --- Doc/library/importlib.rst | 8 +++----- Lib/importlib/util.py | 5 +---- Lib/test/test_importlib/test_lazy.py | 23 ----------------------- 3 files changed, 4 insertions(+), 32 deletions(-) diff --git a/Doc/library/importlib.rst b/Doc/library/importlib.rst index bca3d396291dda2..913e13c2ab20351 100644 --- a/Doc/library/importlib.rst +++ b/Doc/library/importlib.rst @@ -1373,15 +1373,13 @@ an :term:`importer`. .. versionchanged:: 3.16 Reading a lazily-loaded module's :attr:`~module.__name__` and - :attr:`~module.__file__` attributes no longer triggers the load. Until + :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 keeps introspection tools such as - :func:`inspect.getmodule` -- used indirectly by, for example, - :func:`inspect.getframeinfo` -- from forcing every lazily-loaded module - in :data:`sys.modules` to be imported (:gh:`139669`). + instead. This avoids the unintentional loading caused by + introspection tools like :func:`inspect.getframeinfo` (:gh:`139669`). .. classmethod:: factory(loader) diff --git a/Lib/importlib/util.py b/Lib/importlib/util.py index 62bf0fcf54f8266..88d5b5529a96113 100644 --- a/Lib/importlib/util.py +++ b/Lib/importlib/util.py @@ -242,11 +242,8 @@ def __setattr__(self, attr, value): def __delattr__(self, attr): """Trigger the load and then perform the deletion.""" - # Reading __name__/__file__ aliases the spec and no longer triggers - # the load, so access __spec__ to force it. This also resets - # __class__ to the real module type, avoiding infinite recursion in - # the delattr() below. 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 99e37e53d4668c2..452f42c6aa046bc 100644 --- a/Lib/test/test_importlib/test_lazy.py +++ b/Lib/test/test_importlib/test_lazy.py @@ -228,29 +228,6 @@ def __delattr__(self, name): with self.assertRaises(AttributeError): del module.CONSTANT - def test_name_file_alias_spec(self): - # gh-139669: __name__/__file__ are aliases for __spec__.name and - # __spec__.origin. Reading them returns the spec values and does not - # trigger the load; writing them flows through to the spec. - loader = TestingImporter() - module = self.new_module(loader=loader) - spec = object.__getattribute__(module, '__spec__') - spec.origin = '/some/where.py' - self.assertEqual(module.__name__, spec.name) - self.assertEqual(module.__file__, '/some/where.py') - # Neither read triggered the load. - self.assertIsNone(loader.loaded) - self.assertEqual(0, loader.load_count) - # Writes flow through to the spec, still without loading. - module.__name__ = 'renamed' - module.__file__ = '/other.py' - self.assertEqual(spec.name, 'renamed') - self.assertEqual(spec.origin, '/other.py') - self.assertEqual(module.__name__, 'renamed') - self.assertEqual(module.__file__, '/other.py') - self.assertIsNone(loader.loaded) - self.assertIsInstance(module, util._LazyModule) - 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.