gh-146556: Fix infinite loop in annotationlib.get_annotations() on circular __wrapped__ (#146557)

This commit is contained in:
Ramin Farajpour Cami 2026-03-30 06:38:18 +03:30 committed by GitHub
parent 4d0e8ee649
commit 2cf6a68f02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 45 additions and 2 deletions

View file

@ -1037,13 +1037,26 @@ def get_annotations(
obj_globals = obj_locals = unwrap = None
if unwrap is not None:
# Use an id-based visited set to detect cycles in the __wrapped__
# and functools.partial.func chain (e.g. f.__wrapped__ = f).
# On cycle detection we stop and use whatever __globals__ we have
# found so far, mirroring the approach of inspect.unwrap().
_seen_ids = {id(unwrap)}
while True:
if hasattr(unwrap, "__wrapped__"):
unwrap = unwrap.__wrapped__
candidate = unwrap.__wrapped__
if id(candidate) in _seen_ids:
break
_seen_ids.add(id(candidate))
unwrap = candidate
continue
if functools := sys.modules.get("functools"):
if isinstance(unwrap, functools.partial):
unwrap = unwrap.func
candidate = unwrap.func
if id(candidate) in _seen_ids:
break
_seen_ids.add(id(candidate))
unwrap = candidate
continue
break
if hasattr(unwrap, "__globals__"):

View file

@ -646,6 +646,31 @@ def foo():
get_annotations(foo, format=Format.FORWARDREF, eval_str=True)
get_annotations(foo, format=Format.STRING, eval_str=True)
def test_eval_str_wrapped_cycle_self(self):
# gh-146556: self-referential __wrapped__ cycle must not hang.
def f(x: 'int') -> 'str': ...
f.__wrapped__ = f
# Cycle is detected and broken; globals from f itself are used.
result = get_annotations(f, eval_str=True)
self.assertEqual(result, {'x': int, 'return': str})
def test_eval_str_wrapped_cycle_mutual(self):
# gh-146556: mutual __wrapped__ cycle (a -> b -> a) must not hang.
def a(x: 'int'): ...
def b(): ...
a.__wrapped__ = b
b.__wrapped__ = a
result = get_annotations(a, eval_str=True)
self.assertEqual(result, {'x': int})
def test_eval_str_wrapped_chain_no_cycle(self):
# gh-146556: a valid (non-cyclic) __wrapped__ chain must still work.
def inner(x: 'int'): ...
def outer(x: 'int'): ...
outer.__wrapped__ = inner
result = get_annotations(outer, eval_str=True)
self.assertEqual(result, {'x': int})
def test_stock_annotations(self):
def foo(a: int, b: str):
pass

View file

@ -0,0 +1,5 @@
Fix :func:`annotationlib.get_annotations` hanging indefinitely when called
with ``eval_str=True`` on a callable that has a circular ``__wrapped__``
chain (e.g. ``f.__wrapped__ = f``). Cycle detection using an id-based
visited set now stops the traversal and falls back to the globals found
so far, mirroring the approach of :func:`inspect.unwrap`.