Repository navigation
Conversation
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>
Before 3.15 the backport binds a proxy, which passes attribute access and calls through but answers isinstance() as no match however the object relates to the class it stands for. localize.message asks exactly that, and so adapted an already adapted message a second time on every Python before 3.15. Route such uses through compat.py314.resolve, the backport's resolve() before 3.15 and the identity after, so the behavior matches across versions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed a fix for a behavior difference this change introduced, found while checking it really does behave alike on 3.14 and 3.15. Before 3.15 the backport binds a proxy. A proxy passes attribute access and calls through to the object it stands for, which covers almost every use of a lazy name — if isinstance(input, Message):
return input
return Message(input)So an already adapted message was adapted a second time on every Python before 3.15, while 3.15 passed it through: $ python3.14 isinst.py # before the fix
passed through unchanged: False
double-wrapped: True
$ python3.15 isinst.py
passed through unchanged: True
double-wrapped: FalseThe backport provides The regression test runs as a subprocess, because reading the name anywhere resolves it for good and any other test in the class would do that first. Without the fix it fails on 3.14 and passes on 3.15, which is what made the bug invisible until I went looking for divergence specifically. One residual difference is worth recording rather than fixing, since it is the backport's design: before 3.15 the bound name is not the module, so All five envs green, diff coverage 100%. |
Only the package's own __init__ has to import the member: naming the package as the module to defer is what no emulation can honor from inside the package itself. Elsewhere `from .. import _adapters` works, and reaching the class through the module resolves the lazy name, so isinstance sees the class and not the proxy without being told to. That leaves compat.py314.resolve with no callers, so it goes, and what it documented moves to lazy_importer, out of the shared module and into the shim the stdlib never sees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Simplified: The constraint I'd generalized turns out to be narrow. $ python3.14 -c 'import importlib_metadata' # from . import _adapters in __init__
RecursionError: maximum recursion depth exceeded
$ python3.14 pkgspell.py # from .. import _adapters in localize
_adapters deferred: True
passed through unchanged: TrueSo only What I also corrected the note in Verified: 267/268 passed on 3.11 through 3.15, mypy clean, docs and diffcov clean at 100% diff coverage. The regression test still earns its place — reinstating the member spelling without |
Closes #554.
Puts all six function-local imports back at module scope and keeps them deferred by declaring
__lazy_modules__(PEP 810).Shape
__lazy_modules__is only a module global, so it is inert before 3.15 and needs no guard — the declaration reads the same in both trees, and__name__keeps the entries for this package's own modules identical too. The one entry that has to differ iszipfile, next to the import line that already differs for thezippoverlay; there is a comment on it so the sync step is visible.Same behavior before 3.15 needs the backport, and it lives behind
compat/py314in the shapecompatalready uses, so the shared module carries onlyfrom .compat.py314 import lazy_importerandwith lazy_importer():. Both come out at sync time likefrom .compat import py311already does. The one real sync cost is that the imports inside the block are indented and have to be dedented._compat/localize.pygets the same treatment, not for its own sake but because it is imported eagerly — leaving_adaptersat module scope there would have undone the deferral in__init__.py.Two things worth knowing
from . import _adaptersdoesn't work. Per PEP 810 that form names the package as the module to defer, and the package is mid-import, so it defers nothing; on the backport it recurses until the stack is exhausted. Importing the member instead keys the declaration on_adaptersand works on both, at the cost of_adapters.MessagebecomingMessageat two call sites. Both declarations carry a note, since the next person to add an entry will hit this.inspectcan't be observed as deferred on 3.12 and 3.13, whereimportlib.abc— eagerly imported here — reaches it throughimportlib.resources._common. The declaration is still right; the module is just loaded for other reasons.test_declared_modules_stay_unloadedimportsimportlib.abcbefore taking its snapshot so that it measures the declaration and not the stdlib's own graph.Cost
Per the project's own
pytest-perfexercise:Ranges because repeated runs on one machine disagree by that much — the honest figure is 1 to 2 ms before 3.15, and nothing measurable on 3.15. The entire cost is importing
lazy_importeritself (its module body, pluscontextlib,threadingandimportlib.util); the deferrals it buys back are the ones already in place.lazy-importerbecomes a dependency below 3.15. It is pure Python with no dependencies of its own, but it is at 0.1.0, which is worth weighing for a package this far down the stack — say the word and I'll makecompat/py314fall back to eager imports when it is absent, at the price of uniformity.Verification
tox -e py,py311,py312,py313,py314— 266/267 passed on each, mypy clean, coverage 99%;tox -e docsandtox -e diffcovclean, diff coverage 100%. Beyond the test above,test_declared_modules_resolvechecks that reading a lazy name yields the real module and not one of the backport's proxies, which is whatisinstanceandtype()comparisons would otherwise trip over.🤖 Generated with Claude Code