Skip to content

Commit 2cf6a68

Browse files
authored
gh-146556: Fix infinite loop in annotationlib.get_annotations() on circular __wrapped__ (#146557)
1 parent 4d0e8ee commit 2cf6a68

File tree

3 files changed

+45
-2
lines changed

3 files changed

+45
-2
lines changed

Lib/annotationlib.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,13 +1037,26 @@ def get_annotations(
10371037
obj_globals = obj_locals = unwrap = None
10381038

10391039
if unwrap is not None:
1040+
# Use an id-based visited set to detect cycles in the __wrapped__
1041+
# and functools.partial.func chain (e.g. f.__wrapped__ = f).
1042+
# On cycle detection we stop and use whatever __globals__ we have
1043+
# found so far, mirroring the approach of inspect.unwrap().
1044+
_seen_ids = {id(unwrap)}
10401045
while True:
10411046
if hasattr(unwrap, "__wrapped__"):
1042-
unwrap = unwrap.__wrapped__
1047+
candidate = unwrap.__wrapped__
1048+
if id(candidate) in _seen_ids:
1049+
break
1050+
_seen_ids.add(id(candidate))
1051+
unwrap = candidate
10431052
continue
10441053
if functools := sys.modules.get("functools"):
10451054
if isinstance(unwrap, functools.partial):
1046-
unwrap = unwrap.func
1055+
candidate = unwrap.func
1056+
if id(candidate) in _seen_ids:
1057+
break
1058+
_seen_ids.add(id(candidate))
1059+
unwrap = candidate
10471060
continue
10481061
break
10491062
if hasattr(unwrap, "__globals__"):

Lib/test/test_annotationlib.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,31 @@ def foo():
646646
get_annotations(foo, format=Format.FORWARDREF, eval_str=True)
647647
get_annotations(foo, format=Format.STRING, eval_str=True)
648648

649+
def test_eval_str_wrapped_cycle_self(self):
650+
# gh-146556: self-referential __wrapped__ cycle must not hang.
651+
def f(x: 'int') -> 'str': ...
652+
f.__wrapped__ = f
653+
# Cycle is detected and broken; globals from f itself are used.
654+
result = get_annotations(f, eval_str=True)
655+
self.assertEqual(result, {'x': int, 'return': str})
656+
657+
def test_eval_str_wrapped_cycle_mutual(self):
658+
# gh-146556: mutual __wrapped__ cycle (a -> b -> a) must not hang.
659+
def a(x: 'int'): ...
660+
def b(): ...
661+
a.__wrapped__ = b
662+
b.__wrapped__ = a
663+
result = get_annotations(a, eval_str=True)
664+
self.assertEqual(result, {'x': int})
665+
666+
def test_eval_str_wrapped_chain_no_cycle(self):
667+
# gh-146556: a valid (non-cyclic) __wrapped__ chain must still work.
668+
def inner(x: 'int'): ...
669+
def outer(x: 'int'): ...
670+
outer.__wrapped__ = inner
671+
result = get_annotations(outer, eval_str=True)
672+
self.assertEqual(result, {'x': int})
673+
649674
def test_stock_annotations(self):
650675
def foo(a: int, b: str):
651676
pass
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix :func:`annotationlib.get_annotations` hanging indefinitely when called
2+
with ``eval_str=True`` on a callable that has a circular ``__wrapped__``
3+
chain (e.g. ``f.__wrapped__ = f``). Cycle detection using an id-based
4+
visited set now stops the traversal and falls back to the globals found
5+
so far, mirroring the approach of :func:`inspect.unwrap`.

0 commit comments

Comments
 (0)