Skip to content

Commit e770e7d

Browse files
jaracoclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 7a04d49 commit e770e7d

7 files changed

Lines changed: 139 additions & 30 deletions

File tree

‎importlib_metadata/__init__.py‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@
4444
SimplePath,
4545
)
4646
from .compat import py311
47+
from .compat.py314 import lazy_importer
48+
49+
__lazy_modules__ = {
50+
'csv',
51+
'inspect',
52+
'json',
53+
# 'zipfile' in the stdlib, which needs no overlay
54+
'zipp.compat.overlay',
55+
f'{__name__}._adapters',
56+
}
57+
"""
58+
Imports to defer until first use (PEP 810).
59+
60+
Each of them costs more than the path that reads it is traveled, so
61+
deferring keeps that cost off every other caller. Stating it here rather
62+
than importing inside those functions leaves the dependencies visible.
63+
64+
Python 3.15 honors this declaration itself; earlier versions reach the
65+
same end through the context below.
66+
67+
Note that the adapters are imported by member and not as ``from .
68+
import _adapters``, which names this package as the module to defer
69+
and so would defer nothing, the package being mid-import.
70+
"""
71+
72+
with lazy_importer():
73+
import csv
74+
import inspect
75+
import json
76+
77+
from zipp.compat.overlay import zipfile
78+
79+
from ._adapters import Message
4780

4881
__all__ = [
4982
'Distribution',
@@ -598,10 +631,7 @@ def _has_metadata(self) -> bool:
598631

599632
@staticmethod
600633
def _assemble_message(text: str) -> PackageMetadata:
601-
# deferred for performance (python/cpython#109829)
602-
from . import _adapters
603-
604-
return _adapters.Message(email.message_from_string(text))
634+
return Message(email.message_from_string(text))
605635

606636
def _ensure_metadata_present(self, text: str | None) -> str:
607637
if text is not None:
@@ -662,10 +692,6 @@ def make_file(name, hash=None, size_str=None):
662692

663693
@pass_none
664694
def make_files(lines):
665-
# Delay csv import, since Distribution.files is not as widely used
666-
# as other parts of importlib.metadata
667-
import csv
668-
669695
return starmap(make_file, csv.reader(lines))
670696

671697
@pass_none
@@ -787,9 +813,6 @@ def origin(self):
787813
return self._load_json('direct_url.json')
788814

789815
def _load_json(self, filename):
790-
# Deferred for performance (python/importlib_metadata#503)
791-
import json
792-
793816
return pass_none(json.loads)(
794817
self.read_text(filename),
795818
object_hook=lambda data: types.SimpleNamespace(**data),
@@ -907,9 +930,6 @@ def children(self):
907930
return []
908931

909932
def zip_children(self):
910-
# deferred for performance (python/importlib_metadata#502)
911-
from zipp.compat.overlay import zipfile
912-
913933
zip_path = zipfile.Path(self.root)
914934
names = zip_path.root.namelist()
915935
self.joinpath = zip_path.joinpath
@@ -1296,9 +1316,6 @@ def _get_toplevel_name(name: IPackagePath) -> str:
12961316
>>> _get_toplevel_name(PackagePath('foo.dist-info'))
12971317
'foo.dist-info'
12981318
"""
1299-
# Defer import of inspect for performance (python/cpython#118761)
1300-
import inspect
1301-
13021319
return _topmost(name) or inspect.getmodulename(name) or str(name)
13031320

13041321

‎importlib_metadata/_compat/localize.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,27 @@
66
import importlib_metadata
77

88
from ..compat import py312
9+
from ..compat.py314 import lazy_importer
10+
11+
__lazy_modules__ = {'importlib.metadata', 'importlib_metadata._adapters'}
12+
"""
13+
Imports to defer until first use (PEP 810).
14+
15+
Only a foreign distribution reaches the code that reads these, so the
16+
cost belongs to that caller and not to every import of the package.
17+
18+
As in the parent module, the adapters are imported by member so that
19+
the module to defer is ``_adapters`` and not this package.
20+
"""
21+
22+
with lazy_importer():
23+
import importlib.metadata
24+
25+
from .._adapters import Message
926

1027
if TYPE_CHECKING:
1128
import email.message
12-
import importlib.metadata
1329

14-
from .. import _adapters
1530
from .._meta import SimplePath
1631

1732

@@ -55,10 +70,6 @@ def dist(
5570
if isinstance(dist, importlib_metadata.Distribution):
5671
return dist
5772

58-
# only a foreign dist gets this far, so defer the cost of the import
59-
# (python/cpython#109829)
60-
import importlib.metadata
61-
6273
if isinstance(dist, importlib.metadata.PathDistribution):
6374
return importlib_metadata.PathDistribution(cast('SimplePath', dist._path))
6475
# Warn on what the interface requires and the class omits, rather than
@@ -82,8 +93,8 @@ def unimplemented(dist) -> list[str]:
8293

8394

8495
def message(
85-
input: _adapters.Message | email.message.Message,
86-
) -> _adapters.Message:
96+
input: Message | email.message.Message,
97+
) -> Message:
8798
"""
8899
Ensure a message is adapted to an importlib_metadata.Message.
89100
@@ -93,12 +104,9 @@ def message(
93104
>>> type(local)
94105
<class 'importlib_metadata._adapters.Message'>
95106
"""
96-
# deferred for performance (python/cpython#109829)
97-
from .. import _adapters
98-
99-
if isinstance(input, _adapters.Message):
107+
if isinstance(input, Message):
100108
return input
101-
return _adapters.Message(input)
109+
return Message(input)
102110

103111

104112
def package_path(

‎importlib_metadata/compat/py314.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import sys
2+
3+
4+
class native:
5+
"""
6+
A context that does nothing, as the interpreter defers the imports itself.
7+
"""
8+
9+
def __enter__(self):
10+
return self
11+
12+
def __exit__(self, *exc_info):
13+
return False
14+
15+
16+
def backport(): # pragma: no cover
17+
"""
18+
Borrow lazy_importer's emulation of PEP 810.
19+
"""
20+
from lazy_importer import lazy_importer
21+
22+
return lazy_importer()
23+
24+
25+
# Python 3.15 honors __lazy_modules__ natively (PEP 810); only earlier
26+
# versions need the imports routed through the backport.
27+
lazy_importer = native if sys.version_info >= (3, 15) else backport

‎mypy.ini‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,9 @@ ignore_missing_imports = True
2222
[mypy-zipp.*]
2323
ignore_missing_imports = True
2424

25+
# Not a dependency on Python 3.15 and later, which needs no backport
26+
[mypy-lazy_importer.*]
27+
ignore_missing_imports = True
28+
2529
[mypy-test.support.*]
2630
ignore_missing_imports = True

‎newsfragments/554.feature.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Restored ``csv``, ``inspect``, ``json``, ``zipfile``, ``_adapters``, and
2+
``importlib.metadata`` to module scope, where a reader can see what the
3+
module depends on, instead of hiding them in the functions that use them
4+
for performance. They are declared in ``__lazy_modules__`` and so stay
5+
deferred: Python 3.15 honors the declaration natively (PEP 810) and
6+
earlier Pythons reach the same end through the ``lazy-importer``
7+
backport, which is now a dependency there.

‎pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ requires-python = ">=3.11"
2424
license = "Apache-2.0"
2525
dependencies = [
2626
"zipp>=3.20",
27+
# PEP 810 lazy imports, native from 3.15 (python/importlib_metadata#554)
28+
"lazy-importer>=0.1; python_version < '3.15'",
2729
]
2830
dynamic = ["version"]
2931

‎tests/test_main.py‎

Lines changed: 44 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,48 @@ def test_resolve_without_attr(self):
108110
assert ep.load() is importlib_metadata
109111

110112

113+
class LazyImportTests(unittest.TestCase):
114+
"""
115+
The imports at module scope cost no more than the deferred ones did.
116+
"""
117+
118+
def test_declared_modules_stay_unloaded(self):
119+
"""
120+
Importing the package loads none of the modules it declares lazy.
121+
122+
``importlib.abc`` goes first because the package imports it
123+
eagerly and, on some Pythons, it reaches ``inspect`` through
124+
``importlib.resources``. Taking the snapshot after it leaves only
125+
what the declaration itself governs.
126+
127+
Ref python/importlib_metadata#554.
128+
"""
129+
script = (
130+
'import sys, importlib.abc; before = frozenset(sys.modules); '
131+
'import importlib_metadata as md; '
132+
'print(*sorted(md.__lazy_modules__ & (sys.modules.keys() - before)))'
133+
)
134+
proc = subprocess.run(
135+
[sys.executable, '-c', script],
136+
capture_output=True,
137+
encoding='utf-8',
138+
check=True,
139+
)
140+
assert not proc.stdout.split(), f'imported eagerly: {proc.stdout.strip()}'
141+
142+
def test_declared_modules_resolve(self):
143+
"""
144+
Reading a lazy name yields the module itself, not a stand-in.
145+
146+
The backport binds a proxy that resolves on first attribute
147+
access, so a name read for anything else -- ``isinstance``, a
148+
``type()`` comparison -- could see the proxy instead.
149+
"""
150+
assert importlib_metadata.Message is importlib_metadata._adapters.Message
151+
assert importlib_metadata.csv.reader is importlib.import_module('csv').reader
152+
assert isinstance(importlib_metadata.json.loads('{}'), dict)
153+
154+
111155
class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
112156
@staticmethod
113157
def make_pkg(name):

0 commit comments

Comments
 (0)