From e770e7dee6274b9fea8b60e62f3cfa2f66a0fadf Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 31 Oct 2026 15:45:32 -0400 Subject: [PATCH] Restore the deferred imports to module scope. Six imports sat inside the functions that used them, each with a comment explaining why, and none where a reader looks for a module's dependencies. PEP 810 lets the language express that instead: declared in __lazy_modules__, they stay deferred while reading as ordinary imports. Python 3.15 honors the declaration natively. Earlier versions reach the same behavior through the lazy_importer backport, kept behind compat/py314 so the shared module carries only one import and one with statement, both removed when syncing to the stdlib. Costs about 1-2 ms of import time before 3.15, the price of importing lazy_importer, and nothing measurable on 3.15. Closes #554 Co-Authored-By: Claude Opus 5 --- importlib_metadata/__init__.py | 51 +++++++++++++++++--------- importlib_metadata/_compat/localize.py | 34 ++++++++++------- importlib_metadata/compat/py314.py | 27 ++++++++++++++ mypy.ini | 4 ++ newsfragments/554.feature.rst | 7 ++++ pyproject.toml | 2 + tests/test_main.py | 44 ++++++++++++++++++++++ 7 files changed, 139 insertions(+), 30 deletions(-) create mode 100644 importlib_metadata/compat/py314.py create mode 100644 newsfragments/554.feature.rst diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index ac0969d3..564a6867 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -44,6 +44,39 @@ SimplePath, ) from .compat import py311 +from .compat.py314 import lazy_importer + +__lazy_modules__ = { + 'csv', + 'inspect', + 'json', + # 'zipfile' in the stdlib, which needs no overlay + 'zipp.compat.overlay', + f'{__name__}._adapters', +} +""" +Imports to defer until first use (PEP 810). + +Each of them costs more than the path that reads it is traveled, so +deferring keeps that cost off every other caller. Stating it here rather +than importing inside those functions leaves the dependencies visible. + +Python 3.15 honors this declaration itself; earlier versions reach the +same end through the context below. + +Note that the adapters are imported by member and not as ``from . +import _adapters``, which names this package as the module to defer +and so would defer nothing, the package being mid-import. +""" + +with lazy_importer(): + import csv + import inspect + import json + + from zipp.compat.overlay import zipfile + + from ._adapters import Message __all__ = [ 'Distribution', @@ -598,10 +631,7 @@ def _has_metadata(self) -> bool: @staticmethod def _assemble_message(text: str) -> PackageMetadata: - # deferred for performance (python/cpython#109829) - from . import _adapters - - return _adapters.Message(email.message_from_string(text)) + return Message(email.message_from_string(text)) def _ensure_metadata_present(self, text: str | None) -> str: if text is not None: @@ -662,10 +692,6 @@ def make_file(name, hash=None, size_str=None): @pass_none def make_files(lines): - # Delay csv import, since Distribution.files is not as widely used - # as other parts of importlib.metadata - import csv - return starmap(make_file, csv.reader(lines)) @pass_none @@ -787,9 +813,6 @@ def origin(self): return self._load_json('direct_url.json') def _load_json(self, filename): - # Deferred for performance (python/importlib_metadata#503) - import json - return pass_none(json.loads)( self.read_text(filename), object_hook=lambda data: types.SimpleNamespace(**data), @@ -907,9 +930,6 @@ def children(self): return [] def zip_children(self): - # deferred for performance (python/importlib_metadata#502) - from zipp.compat.overlay import zipfile - zip_path = zipfile.Path(self.root) names = zip_path.root.namelist() self.joinpath = zip_path.joinpath @@ -1296,9 +1316,6 @@ def _get_toplevel_name(name: IPackagePath) -> str: >>> _get_toplevel_name(PackagePath('foo.dist-info')) 'foo.dist-info' """ - # Defer import of inspect for performance (python/cpython#118761) - import inspect - return _topmost(name) or inspect.getmodulename(name) or str(name) diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py index b08b4fbd..03027d8b 100644 --- a/importlib_metadata/_compat/localize.py +++ b/importlib_metadata/_compat/localize.py @@ -6,12 +6,27 @@ import importlib_metadata from ..compat import py312 +from ..compat.py314 import lazy_importer + +__lazy_modules__ = {'importlib.metadata', 'importlib_metadata._adapters'} +""" +Imports to defer until first use (PEP 810). + +Only a foreign distribution reaches the code that reads these, so the +cost belongs to that caller and not to every import of the package. + +As in the parent module, the adapters are imported by member so that +the module to defer is ``_adapters`` and not this package. +""" + +with lazy_importer(): + import importlib.metadata + + from .._adapters import Message if TYPE_CHECKING: import email.message - import importlib.metadata - from .. import _adapters from .._meta import SimplePath @@ -55,10 +70,6 @@ def dist( if isinstance(dist, importlib_metadata.Distribution): return dist - # only a foreign dist gets this far, so defer the cost of the import - # (python/cpython#109829) - import importlib.metadata - if isinstance(dist, importlib.metadata.PathDistribution): return importlib_metadata.PathDistribution(cast('SimplePath', dist._path)) # Warn on what the interface requires and the class omits, rather than @@ -82,8 +93,8 @@ def unimplemented(dist) -> list[str]: def message( - input: _adapters.Message | email.message.Message, -) -> _adapters.Message: + input: Message | email.message.Message, +) -> Message: """ Ensure a message is adapted to an importlib_metadata.Message. @@ -93,12 +104,9 @@ def message( >>> type(local) """ - # deferred for performance (python/cpython#109829) - from .. import _adapters - - if isinstance(input, _adapters.Message): + if isinstance(input, Message): return input - return _adapters.Message(input) + return Message(input) def package_path( diff --git a/importlib_metadata/compat/py314.py b/importlib_metadata/compat/py314.py new file mode 100644 index 00000000..99af5fad --- /dev/null +++ b/importlib_metadata/compat/py314.py @@ -0,0 +1,27 @@ +import sys + + +class native: + """ + A context that does nothing, as the interpreter defers the imports itself. + """ + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + +def backport(): # pragma: no cover + """ + Borrow lazy_importer's emulation of PEP 810. + """ + from lazy_importer import lazy_importer + + return lazy_importer() + + +# Python 3.15 honors __lazy_modules__ natively (PEP 810); only earlier +# versions need the imports routed through the backport. +lazy_importer = native if sys.version_info >= (3, 15) else backport diff --git a/mypy.ini b/mypy.ini index 533fe73d..b7bdaa71 100644 --- a/mypy.ini +++ b/mypy.ini @@ -22,5 +22,9 @@ ignore_missing_imports = True [mypy-zipp.*] ignore_missing_imports = True +# Not a dependency on Python 3.15 and later, which needs no backport +[mypy-lazy_importer.*] +ignore_missing_imports = True + [mypy-test.support.*] ignore_missing_imports = True diff --git a/newsfragments/554.feature.rst b/newsfragments/554.feature.rst new file mode 100644 index 00000000..dbf7ff89 --- /dev/null +++ b/newsfragments/554.feature.rst @@ -0,0 +1,7 @@ +Restored ``csv``, ``inspect``, ``json``, ``zipfile``, ``_adapters``, and +``importlib.metadata`` to module scope, where a reader can see what the +module depends on, instead of hiding them in the functions that use them +for performance. They are declared in ``__lazy_modules__`` and so stay +deferred: Python 3.15 honors the declaration natively (PEP 810) and +earlier Pythons reach the same end through the ``lazy-importer`` +backport, which is now a dependency there. diff --git a/pyproject.toml b/pyproject.toml index ad5eeb5b..cb19b2f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ requires-python = ">=3.11" license = "Apache-2.0" dependencies = [ "zipp>=3.20", + # PEP 810 lazy imports, native from 3.15 (python/importlib_metadata#554) + "lazy-importer>=0.1; python_version < '3.15'", ] dynamic = ["version"] diff --git a/tests/test_main.py b/tests/test_main.py index b03911a3..b41da89b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,6 +3,8 @@ import io import pickle import re +import subprocess +import sys import unittest import pyfakefs.fake_filesystem_unittest as ffs @@ -108,6 +110,48 @@ def test_resolve_without_attr(self): assert ep.load() is importlib_metadata +class LazyImportTests(unittest.TestCase): + """ + The imports at module scope cost no more than the deferred ones did. + """ + + def test_declared_modules_stay_unloaded(self): + """ + Importing the package loads none of the modules it declares lazy. + + ``importlib.abc`` goes first because the package imports it + eagerly and, on some Pythons, it reaches ``inspect`` through + ``importlib.resources``. Taking the snapshot after it leaves only + what the declaration itself governs. + + Ref python/importlib_metadata#554. + """ + script = ( + 'import sys, importlib.abc; before = frozenset(sys.modules); ' + 'import importlib_metadata as md; ' + 'print(*sorted(md.__lazy_modules__ & (sys.modules.keys() - before)))' + ) + proc = subprocess.run( + [sys.executable, '-c', script], + capture_output=True, + encoding='utf-8', + check=True, + ) + assert not proc.stdout.split(), f'imported eagerly: {proc.stdout.strip()}' + + def test_declared_modules_resolve(self): + """ + Reading a lazy name yields the module itself, not a stand-in. + + The backport binds a proxy that resolves on first attribute + access, so a name read for anything else -- ``isinstance``, a + ``type()`` comparison -- could see the proxy instead. + """ + assert importlib_metadata.Message is importlib_metadata._adapters.Message + assert importlib_metadata.csv.reader is importlib.import_module('csv').reader + assert isinstance(importlib_metadata.json.loads('{}'), dict) + + class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod def make_pkg(name):