diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index ac0969d3..9adcda55 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -9,6 +9,31 @@ from __future__ import annotations +__lazy_modules__ = { + 'contextlib', + 'email', + f'{__name__}._collections', + f'{__name__}.compat', +} +""" +Imports to defer until something first reads the name they bind (PEP 810). + +Honored by the interpreter from Python 3.15 and simply ignored before it. + +Only a name that no module-level statement reads belongs here. A class +base, a decorator, or a class attribute is evaluated as this module's +body runs, so deferring its imports would save nothing: that is what +keeps ``pathlib``, ``re``, and ``textwrap`` off this list +(python/importlib_metadata#554). + +A module that something else imports eagerly is also left off, however +late this module reads it, so that the declaration promises only what it +delivers. ``collections`` is the case to watch: ``typing`` imports it. + +Naming the package through ``__name__`` keeps the declaration identical +in the stdlib, where this module is ``importlib.metadata``. +""" + import abc import collections import email diff --git a/importlib_metadata/_compat/__init__.py b/importlib_metadata/_compat/__init__.py index fe9a721e..8ca22668 100644 --- a/importlib_metadata/_compat/__init__.py +++ b/importlib_metadata/_compat/__init__.py @@ -1,4 +1,3 @@ -import platform import sys __all__ = ['NullFinder', 'install'] @@ -46,11 +45,15 @@ def find_spec(*args, **kwargs): return None -def pypy_partial(val): +def pypy_partial(val): # pragma: no cover """ Adjust for variable stacklevel on partial under PyPy. Workaround for #327. """ + # the only reader of platform, and no longer called from anywhere, so + # don't make every import pay for it (#554) + import platform + is_pypy = platform.python_implementation() == 'PyPy' return val + is_pypy diff --git a/importlib_metadata/_compat/localize.py b/importlib_metadata/_compat/localize.py index b08b4fbd..5f7a74da 100644 --- a/importlib_metadata/_compat/localize.py +++ b/importlib_metadata/_compat/localize.py @@ -5,8 +5,6 @@ import importlib_metadata -from ..compat import py312 - if TYPE_CHECKING: import email.message import importlib.metadata @@ -55,10 +53,12 @@ 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) + # only a foreign dist gets this far, so defer the cost of the imports + # (python/cpython#109829, #554) import importlib.metadata + from ..compat import py312 + 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 @@ -77,6 +77,9 @@ def unimplemented(dist) -> list[str]: """ The members IDistribution requires that dist's class doesn't supply. """ + # deferred for performance (#554) + from ..compat import py312 + required = py312.required(importlib_metadata.IDistribution) return sorted(attr for attr in required if not hasattr(type(dist), attr)) diff --git a/newsfragments/554.feature.rst b/newsfragments/554.feature.rst new file mode 100644 index 00000000..88c2a531 --- /dev/null +++ b/newsfragments/554.feature.rst @@ -0,0 +1,9 @@ +Deferred the imports that no module-level statement reads, cutting the +cost of ``import importlib_metadata`` by about 30% on Python 3.15 and 3 +to 8% on earlier Pythons. + +Python 3.15 honors the new ``__lazy_modules__`` declaration (PEP 810) and +so defers ``contextlib``, ``email``, and two of the package's own modules; +earlier Pythons ignore the declaration and keep importing them eagerly. +The gain they do see comes from ``platform`` and ``compat.py312``, which +moved into the functions that read them. diff --git a/tests/test_main.py b/tests/test_main.py index b03911a3..073c0b90 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,35 @@ def test_resolve_without_attr(self): assert ep.load() is importlib_metadata +class LazyImportTests(unittest.TestCase): + @unittest.skipIf( + sys.version_info < (3, 15), + 'lazy imports (PEP 810) are honored from Python 3.15', + ) + def test_declared_modules_stay_unloaded(self): + """ + Importing the package loads none of the modules it defers. + + Holds the rule that ``__lazy_modules__`` documents: a name some + module-level statement reads resolves as the body runs, so + declaring it would defer nothing. + + Ref python/importlib_metadata#554. + """ + script = ( + 'import sys; 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()}' + + class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod def make_pkg(name):