diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index df8fb5e4c62079..9fee2564114339 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -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__"): diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index e89d6c0b1613ba..50cf8fcb6b4ed6 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -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 diff --git a/Misc/NEWS.d/next/Library/2026-03-28-12-20-19.gh-issue-146556.Y8Eson.rst b/Misc/NEWS.d/next/Library/2026-03-28-12-20-19.gh-issue-146556.Y8Eson.rst new file mode 100644 index 00000000000000..71f84593edb522 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-28-12-20-19.gh-issue-146556.Y8Eson.rst @@ -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`.