]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-146556: Fix infinite loop in annotationlib.get_annotations() on circular __wrapped...
authorRamin Farajpour Cami <ramin.blackhat@gmail.com>
Mon, 30 Mar 2026 03:08:18 +0000 (06:38 +0330)
committerGitHub <noreply@github.com>
Mon, 30 Mar 2026 03:08:18 +0000 (03:08 +0000)
Lib/annotationlib.py
Lib/test/test_annotationlib.py
Misc/NEWS.d/next/Library/2026-03-28-12-20-19.gh-issue-146556.Y8Eson.rst [new file with mode: 0644]

index df8fb5e4c6207984d95bfd45852f55f2b6322f6f..9fee2564114339017c0b77829cea9e0d255270e5 100644 (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__"):
index e89d6c0b1613baec7f0d2ee4a3f90062e9a22664..50cf8fcb6b4ed60f29cac7a0349f72c6bc539c75 100644 (file)
@@ -646,6 +646,31 @@ class TestGetAnnotations(unittest.TestCase):
             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 (file)
index 0000000..71f8459
--- /dev/null
@@ -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`.