Repository navigation
LazyImportType.resolve() does not replace the module global and can import twice #152298
Description
Activity
I'm working on a fix for this.
- addedtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
on Jun 26, 2026 - added 12 commits that reference this issue
on Jun 26, 2026 17 remaining items
- added 9 commits that reference this issue
on Jul 4, 2026 - added a commit that references this issue
on Sep 18, 2026 Putting up a new PR to try to address this. #157730
Hmm, is this a bug? I thought that since we have a cache in
sys.modules, repeated imports are (intended to be) fast, and soresolve()doing a re-import is not a problem.
In the same way, it's not a problem that withlazy from module import a, b, cwe can eventually importmodulethree times (unlike the non-lazy variant). Is it?@encukou I did a little digging to try and quantify if addressing this change actually provides value and it looks like the case where it helps the most is if
resolve()is called repeatedly (which may be a scenario that is most likely hit during a stress test).benchmark numbers
Benchmark Base Average PR Average Verdict reify_import— first access of 2,000lazy importbindings0.795 ms 0.773 ms within noise (+0.1–0.3% instructions) reify_from— first access of 2,000lazy frombindings0.270 ms 0.271 ms within noise (+0.1–0.2% instructions) steady_global— 1M reads of a resolved lazy global17.654 ms 17.374 ms within noise resolve_repeat— 100kproxy.resolve()calls on one proxy26.390 ms 7.502 ms 3.5× faster startup_argparse—import argparse+ parse (subprocess)23.902 ms 23.846 ms within noise plausible-ish scenario where the improvement could come into play
import annotationlib lazy import json # A handler signature where one annotation names a type that only exists for type checkers. def handler(request: TypeCheckingOnly, decoder: json.JSONDecoder) -> None: ... # e.g. a web framework, CLI library or DI container inspecting handlers repeatedly for _ in range(1000): annotationlib.get_annotations(handler, format=annotationlib.Format.FORWARDREF)
Without the PR, we materialize
jsonviaresolve1000x and with the change, it happens once and is cached.
Granted for this codepath to even trigger, we need a function with mix of annotations that can and cannot be accessed at runtime to trigger theForwardRefcodepath, we need json to not be loaded prior to being used as an annotation, and we need the codepath (handler) in this case to be hit again and again...and even in that case, the raw time savings isn't huge...it just feels "more correct" to load it once than on every single hit.With all that said, I'm glad to drop the PR + we can close the issue if it seems mostly moot and just added complexity for not enough benefit. Just wanted to take a holistic pass on making sense around if this is worth addressing
Thank you!
I read that as “it's possible to deliberately bypass caches”. Adding a third one on top of
sys.modules&_PyLazyImport_Reify/_PyDict_ReplaceItemIfstill seems unnecessary to me.In your scenario, the
get_annotationscaller should add a cache. The function's docs describe an expensive uncached operation, even if they aren't explicit about it.Reacted by Brittany ReynosoThat makes sense, we can handle this corner case at the application level.
Want to close the issue + PR?
Bug report
Bug description:
Calling
resolve()on a lazy import proxy forces the import and returns the resolved object, but it does not replace the original lazy object in the module globals. A later normal global access reifies the same lazy object again.Expected: one import, with the module global replaced after
resolve().Actual on current
main:I confirmed this across tier1, tier2, and tier2 without uop optimization:
https://github.com/Kuhai9801/cpython/actions/runs/28243825007
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Linked PRs