Skip to content

Commit 263a7b2

Browse files
jaracoclaude
andcommitted
Defer the imports no module-level statement reads.
Python 3.15 honors __lazy_modules__ (PEP 810), so declaring it defers contextlib, email, and two of the package's own modules; earlier Pythons ignore the declaration. platform and compat.py312 move into the functions that read them, which helps on every Python. Cuts import time ~30% on 3.15 and 3-8% on 3.11 through 3.14. Closes #554 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7a04d49 commit 263a7b2

5 files changed

Lines changed: 77 additions & 6 deletions

File tree

‎importlib_metadata/__init__.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@
99

1010
from __future__ import annotations
1111

12+
__lazy_modules__ = {
13+
'contextlib',
14+
'email',
15+
f'{__name__}._collections',
16+
f'{__name__}.compat',
17+
}
18+
"""
19+
Imports to defer until something first reads the name they bind (PEP 810).
20+
21+
Honored by the interpreter from Python 3.15 and simply ignored before it.
22+
23+
Only a name that no module-level statement reads belongs here. A class
24+
base, a decorator, or a class attribute is evaluated as this module's
25+
body runs, so deferring its imports would save nothing: that is what
26+
keeps ``pathlib``, ``re``, and ``textwrap`` off this list
27+
(python/importlib_metadata#554).
28+
29+
A module that something else imports eagerly is also left off, however
30+
late this module reads it, so that the declaration promises only what it
31+
delivers. ``collections`` is the case to watch: ``typing`` imports it.
32+
33+
Naming the package through ``__name__`` keeps the declaration identical
34+
in the stdlib, where this module is ``importlib.metadata``.
35+
"""
36+
1237
import abc
1338
import collections
1439
import email

‎importlib_metadata/_compat/__init__.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import platform
21
import sys
32

43
__all__ = ['NullFinder', 'install']
@@ -46,11 +45,15 @@ def find_spec(*args, **kwargs):
4645
return None
4746

4847

49-
def pypy_partial(val):
48+
def pypy_partial(val): # pragma: no cover
5049
"""
5150
Adjust for variable stacklevel on partial under PyPy.
5251
5352
Workaround for #327.
5453
"""
54+
# the only reader of platform, and no longer called from anywhere, so
55+
# don't make every import pay for it (#554)
56+
import platform
57+
5558
is_pypy = platform.python_implementation() == 'PyPy'
5659
return val + is_pypy

‎importlib_metadata/_compat/localize.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55

66
import importlib_metadata
77

8-
from ..compat import py312
9-
108
if TYPE_CHECKING:
119
import email.message
1210
import importlib.metadata
@@ -55,10 +53,12 @@ def dist(
5553
if isinstance(dist, importlib_metadata.Distribution):
5654
return dist
5755

58-
# only a foreign dist gets this far, so defer the cost of the import
59-
# (python/cpython#109829)
56+
# only a foreign dist gets this far, so defer the cost of the imports
57+
# (python/cpython#109829, #554)
6058
import importlib.metadata
6159

60+
from ..compat import py312
61+
6262
if isinstance(dist, importlib.metadata.PathDistribution):
6363
return importlib_metadata.PathDistribution(cast('SimplePath', dist._path))
6464
# Warn on what the interface requires and the class omits, rather than
@@ -77,6 +77,9 @@ def unimplemented(dist) -> list[str]:
7777
"""
7878
The members IDistribution requires that dist's class doesn't supply.
7979
"""
80+
# deferred for performance (#554)
81+
from ..compat import py312
82+
8083
required = py312.required(importlib_metadata.IDistribution)
8184
return sorted(attr for attr in required if not hasattr(type(dist), attr))
8285

‎newsfragments/554.feature.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Deferred the imports that no module-level statement reads, cutting the
2+
cost of ``import importlib_metadata`` by about 30% on Python 3.15 and 3
3+
to 8% on earlier Pythons.
4+
5+
Python 3.15 honors the new ``__lazy_modules__`` declaration (PEP 810) and
6+
so defers ``contextlib``, ``email``, and two of the package's own modules;
7+
earlier Pythons ignore the declaration and keep importing them eagerly.
8+
The gain they do see comes from ``platform`` and ``compat.py312``, which
9+
moved into the functions that read them.

‎tests/test_main.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import io
44
import pickle
55
import re
6+
import subprocess
7+
import sys
68
import unittest
79

810
import pyfakefs.fake_filesystem_unittest as ffs
@@ -108,6 +110,35 @@ def test_resolve_without_attr(self):
108110
assert ep.load() is importlib_metadata
109111

110112

113+
class LazyImportTests(unittest.TestCase):
114+
@unittest.skipIf(
115+
sys.version_info < (3, 15),
116+
'lazy imports (PEP 810) are honored from Python 3.15',
117+
)
118+
def test_declared_modules_stay_unloaded(self):
119+
"""
120+
Importing the package loads none of the modules it defers.
121+
122+
Holds the rule that ``__lazy_modules__`` documents: a name some
123+
module-level statement reads resolves as the body runs, so
124+
declaring it would defer nothing.
125+
126+
Ref python/importlib_metadata#554.
127+
"""
128+
script = (
129+
'import sys; before = frozenset(sys.modules); '
130+
'import importlib_metadata as md; '
131+
'print(*sorted(md.__lazy_modules__ & (sys.modules.keys() - before)))'
132+
)
133+
proc = subprocess.run(
134+
[sys.executable, '-c', script],
135+
capture_output=True,
136+
encoding='utf-8',
137+
check=True,
138+
)
139+
assert not proc.stdout.split(), f'imported eagerly: {proc.stdout.strip()}'
140+
141+
111142
class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
112143
@staticmethod
113144
def make_pkg(name):

0 commit comments

Comments
 (0)