Skip to content

Commit 57db4ad

Browse files
authored
Merge pull request #557 from python/feature/eager-imports
Restore the deferred imports to module scope
2 parents 1d5a2ce + 43c451e commit 57db4ad

8 files changed

Lines changed: 174 additions & 26 deletions

File tree

‎importlib_metadata/__init__.py‎

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

4982
__all__ = [
5083
'Distribution',
@@ -599,10 +632,7 @@ def _has_metadata(self) -> bool:
599632

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

607637
def _ensure_metadata_present(self, text: str | None) -> str:
608638
if text is not None:
@@ -663,10 +693,6 @@ def make_file(name, hash=None, size_str=None):
663693

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

672698
@pass_none
@@ -788,9 +814,6 @@ def origin(self):
788814
return self._load_json('direct_url.json')
789815

790816
def _load_json(self, filename):
791-
# Deferred for performance (python/importlib_metadata#503)
792-
import json
793-
794817
return pass_none(json.loads)(
795818
self.read_text(filename),
796819
object_hook=lambda data: types.SimpleNamespace(**data),
@@ -908,9 +931,6 @@ def children(self):
908931
return []
909932

910933
def zip_children(self):
911-
# deferred for performance (python/importlib_metadata#502)
912-
from zipp.compat.overlay import zipfile
913-
914934
zip_path = zipfile.Path(self.root)
915935
names = zip_path.root.namelist()
916936
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: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,24 @@
66
import importlib_metadata
77

88
from ..compat import py312
9+
from ..compat.py314 import lazy_importer
910

10-
if TYPE_CHECKING:
11-
import email.message
11+
__lazy_modules__ = {'importlib.metadata', 'importlib_metadata'}
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+
19+
with lazy_importer():
1220
import importlib.metadata
1321

1422
from .. import _adapters
23+
24+
if TYPE_CHECKING:
25+
import email.message
26+
1527
from .._meta import SimplePath
1628

1729

@@ -55,10 +67,6 @@ def dist(
5567
if isinstance(dist, importlib_metadata.Distribution):
5668
return dist
5769

58-
# only a foreign dist gets this far, so defer the cost of the import
59-
# (python/cpython#109829)
60-
import importlib.metadata
61-
6270
if isinstance(dist, importlib.metadata.PathDistribution):
6371
return importlib_metadata.PathDistribution(cast('SimplePath', dist._path))
6472
# Warn on what the interface requires and the class omits, rather than
@@ -93,9 +101,6 @@ def message(
93101
>>> type(local)
94102
<class 'importlib_metadata._adapters.Message'>
95103
"""
96-
# deferred for performance (python/cpython#109829)
97-
from .. import _adapters
98-
99104
if isinstance(input, _adapters.Message):
100105
return input
101106
return _adapters.Message(input)

‎importlib_metadata/compat/py314.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import sys
2+
3+
4+
class nothing:
5+
"""
6+
A context that does nothing, the interpreter deferring 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+
lazy_importer = nothing if sys.version_info >= (3, 15) else backport
26+
"""
27+
Defer the imports in the block that follows, before 3.15 as after.
28+
29+
Two properties of the emulation shape how a module may declare its lazy
30+
imports. Neither holds from 3.15, which defers them natively.
31+
32+
A lazy name is bound to a proxy. Attribute access and calls reach the
33+
object it stands for, so ``csv.reader`` and ``_adapters.Message`` behave,
34+
but ``isinstance`` and anything else handed the object itself see the
35+
proxy and get the wrong answer in silence. Reach such an object through
36+
a module (``_adapters.Message``), or resolve the name with
37+
``lazy_importer.resolve`` before testing it.
38+
39+
``from . import <submodule>`` names the package as the module to defer.
40+
That is fine from another module, but in the package's own ``__init__``
41+
the proxy stands for the module being executed and resolves itself
42+
through the globals entry that is the proxy, until the stack is
43+
exhausted. Import the member there instead.
44+
"""

‎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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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, and deferred alike on every supported Python: 3.15 honors the
6+
declaration natively (PEP 810) and earlier Pythons reach the same
7+
behavior through the ``lazy-importer`` backport, which is now a
8+
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/compat/test_localize.py‎

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

66
import importlib.metadata as stdlib
77
import pathlib
8+
import subprocess
89
import sys
910
import unittest
1011
import warnings
@@ -177,3 +178,38 @@ def metadata(self):
177178
warnings.simplefilter('always')
178179
assert localize.dist(hostile) is hostile
179180
assert not [str(warning.message) for warning in caught]
181+
182+
183+
class LazyAdaptersTests(unittest.TestCase):
184+
"""
185+
Adapting a message withstands the adapters being imported lazily.
186+
"""
187+
188+
def test_adapted_message_passes_through(self):
189+
"""
190+
An already adapted message survives localization unchanged.
191+
192+
``localize.message`` tests the message with ``isinstance``,
193+
reaching the class through a lazily imported module so that the
194+
name resolves first. Were the class the lazy name itself, before
195+
3.15 it would be a proxy, which answers as no match however the
196+
object relates to it, and the message would be adapted a second
197+
time. Run as a subprocess because reading a lazy name resolves it
198+
for good, which any other test would do first.
199+
200+
Ref python/importlib_metadata#554.
201+
"""
202+
script = (
203+
'import email.message, importlib; '
204+
'from importlib_metadata._compat import localize; '
205+
'adapters = importlib.import_module("importlib_metadata._adapters"); '
206+
'msg = adapters.Message(email.message.Message()); '
207+
'print(localize.message(msg) is msg)'
208+
)
209+
proc = subprocess.run(
210+
[sys.executable, '-c', script],
211+
capture_output=True,
212+
encoding='utf-8',
213+
check=True,
214+
)
215+
assert proc.stdout.strip() == 'True', 'the message was adapted twice'

‎tests/test_main.py‎

Lines changed: 32 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,36 @@ 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_lazy_modules_stay_unloaded(self):
119+
"""
120+
Importing the package loads none of its ``__lazy_modules__``.
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+
111143
class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
112144
@staticmethod
113145
def make_pkg(name):

0 commit comments

Comments
 (0)