Repository navigation
Restore the deferred imports to module scope with PEP 810 lazy imports #554
Description
Six imports in this package sit inside the functions that use them, purely so that importing the package doesn't pay for them:
__init__.py |
from . import _adapters (python/cpython#109829) |
__init__.py |
import csv |
__init__.py |
import json (#503) |
__init__.py |
from zipp.compat.overlay import zipfile (#502) |
__init__.py |
import inspect (python/cpython#118761) |
_compat/localize.py |
import importlib.metadata (python/cpython#109829) |
Each one needs a comment explaining why it is where it is, and none of them appears where a reader looks to see what the module depends on. The performance reason is real, but it is the kind of thing the language should express rather than the author.
PEP 810 now does. In Python 3.15 a module that declares __lazy_modules__ gets lazy semantics for plain import statements, with no new syntax:
$ python3.15 -c 'import sys; print(sorted(sys.lazy_modules)[:4])'
['copy', 'copy.copy', 'glob', 'glob._StringGlobber']So these imports can go back where they belong and stay deferred.
Aim
Restore all six to module scope, declared in __lazy_modules__, behaving the same way on every supported Python. Not a performance change: hold roughly the cost we have now, and accept a modest regression before 3.15 as the price of the readability.
Keeping the two trees close
This package is kept in sync with importlib.metadata, so the shape of the declaration matters as much as the behavior.
__lazy_modules__ is only a module global, so it is inert on 3.11–3.14 and needs no guard — the declaration itself is identical in both trees. Spelling the package through __name__ keeps the entries for this package's own modules identical too. Only the zipfile entry has to differ, alongside the import line that already differs for the zipp overlay.
Behaving the same before 3.15 does need @pganssle's lazy-importer, which emulates PEP 810 on 3.11+ through a context manager and defers to the native mechanism on 3.15+. That reference belongs behind compat/py314, in the shape the compat package already uses for version-conditional behavior, so the only traces in the shared module are one import line and one with statement — both removed at sync time, the way from .compat import py311 already is. The imports inside the block are re-indented, which is the one cost worth naming: each sync has to dedent them.
Cost
With all six restored, per the project's own pytest-perf exercise:
| import time | |
|---|---|
| 3.11 | +0.6 ms |
| 3.12 | +1.6 to +2.6 ms |
| 3.13 | +0.9 to +1.7 ms |
| 3.14 | +0.8 to +1.1 ms |
| 3.15 | +0.06 ms (+1%) |
3.15 is free, as it should be — nothing but one extra compat/py314 module in the graph, and +1% across repeated runs. Before it, the whole cost is importing lazy_importer itself: its own module body, plus contextlib, threading and importlib.util. The deferrals it buys back are the ones we already had, so that import is the entire difference. Ranges are given because repeated runs on one machine disagree by that much; the figure is around 1 to 2 ms, not a tighter number than that.
Notes
from . import _adapterscannot be used: per PEP 810 that form names the package as the module to defer, and the package is mid-import, so it defers nothing. Worse, on the backport it recurses until the stack is exhausted. Importing the member instead (from ._adapters import Message) keys the declaration on_adaptersand works on both.inspectcannot be observed as deferred on 3.12 and 3.13, whereimportlib.abc— which this module imports eagerly — reaches it throughimportlib.resources._common. The declaration is still correct; the module is simply loaded for other reasons.
Activity
jaraco commentedon Oct 31, 2026
#555 implements step 1. It lands better than the −6.1% projected above, because deferring
compat(once_compat.localizestopped importingpy312at module scope) andplatformturned out to matter more than the audit credited them for. Perpytest-perf: −30% on 3.15, and −8%/−3%/−4%/−8% on 3.11/3.12/3.13/3.14, since those two are ordinary deferred imports rather than lazy ones.One note for step 2:
exercises.py:entrypoint_regexp_perfalready callsre.match(EntryPoint.pattern, input), so the only in-repo consumer works whetherpatternis a compiled pattern or a string. That narrows what a first-access-compiling descriptor would have to preserve.[-]Defer performance-sensitive imports with PEP 810 lazy imports[/-][+]Restore the deferred imports to module scope with PEP 810 lazy imports[/+]jaraco commentedon Oct 31, 2026
Rewrote the description above: the original framing had this backwards, chasing import time rather than readability. The earlier measurements are superseded, and #555 is closed in favor of the scope now described.