From 61a821befe1236ee73e9a6de74c3507b935fe6a0 Mon Sep 17 00:00:00 2001 From: raminfp Date: Sat, 28 Mar 2026 11:46:26 +0330 Subject: [PATCH 1/2] gh-146553: Fix infinite loop in typing.get_type_hints() on circular __wrapped__ https://github.com/python/cpython/issues/146553 --- Lib/test/test_typing.py | 28 +++++++++++++++++++ Lib/typing.py | 10 +++++++ ...-03-28-11-43-25.gh-issue-146553.mXaWza.rst | 4 +++ 3 files changed, 42 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-03-28-11-43-25.gh-issue-146553.mXaWza.rst diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index c6f08ff8a052ab..00bc26c5bc8381 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -6837,6 +6837,34 @@ def test_get_type_hints_wrapped_decoratored_func(self): self.assertEqual(gth(ForRefExample.func), expects) self.assertEqual(gth(ForRefExample.nested), expects) + def test_get_type_hints_wrapped_cycle_self(self): + # gh-146553: __wrapped__ self-reference must raise ValueError, + # not loop forever. + def f(x: int) -> str: ... + f.__wrapped__ = f + with self.assertRaises(ValueError): + get_type_hints(f) + + def test_get_type_hints_wrapped_cycle_mutual(self): + # gh-146553: mutual __wrapped__ cycle (a -> b -> a) must raise + # ValueError, not loop forever. + def a(): ... + def b(): ... + a.__wrapped__ = b + b.__wrapped__ = a + with self.assertRaises(ValueError): + get_type_hints(a) + + def test_get_type_hints_wrapped_chain_no_cycle(self): + # gh-146553: a valid (non-cyclic) __wrapped__ chain must still work. + def inner(x: int) -> str: ... + def middle(x: int) -> str: ... + middle.__wrapped__ = inner + def outer(x: int) -> str: ... + outer.__wrapped__ = middle + # No cycle — should return the annotations without raising. + self.assertEqual(get_type_hints(outer), {'x': int, 'return': str}) + def test_get_type_hints_annotated(self): def foobar(x: List['X']): ... X = Annotated[int, (1, 10)] diff --git a/Lib/typing.py b/Lib/typing.py index e78fb8b71a996c..17e023a785cbde 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -2485,8 +2485,18 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, else: nsobj = obj # Find globalns for the unwrapped object. + # Use an id-based visited set to detect and break on cycles in the + # __wrapped__ chain (e.g. f.__wrapped__ = f), matching the behavior + # of inspect.unwrap(). + _seen_ids = {id(nsobj)} while hasattr(nsobj, '__wrapped__'): nsobj = nsobj.__wrapped__ + _nsobj_id = id(nsobj) + if _nsobj_id in _seen_ids: + raise ValueError( + f'wrapper loop when unwrapping {obj!r}' + ) + _seen_ids.add(_nsobj_id) globalns = getattr(nsobj, '__globals__', {}) if localns is None: localns = globalns diff --git a/Misc/NEWS.d/next/Library/2026-03-28-11-43-25.gh-issue-146553.mXaWza.rst b/Misc/NEWS.d/next/Library/2026-03-28-11-43-25.gh-issue-146553.mXaWza.rst new file mode 100644 index 00000000000000..b75cdc14725a0c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-28-11-43-25.gh-issue-146553.mXaWza.rst @@ -0,0 +1,4 @@ +Fix :func:`typing.get_type_hints` hanging indefinitely when a callable has a +circular ``__wrapped__`` chain (e.g. ``f.__wrapped__ = f``). A +:exc:`ValueError` is now raised on cycle detection, matching the behavior of +:func:`inspect.unwrap`. From 4e9afee252693b009bb523eda601e35aa14d97bd Mon Sep 17 00:00:00 2001 From: raminfp Date: Sat, 28 Mar 2026 20:03:47 +0330 Subject: [PATCH 2/2] gh-146553: Use inspect.unwrap() for cycle detection instead of custom helper --- Lib/test/test_typing.py | 14 ++------------ Lib/typing.py | 16 +++------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 00bc26c5bc8381..a2b76ed1303a33 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -6842,7 +6842,7 @@ def test_get_type_hints_wrapped_cycle_self(self): # not loop forever. def f(x: int) -> str: ... f.__wrapped__ = f - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, 'wrapper loop'): get_type_hints(f) def test_get_type_hints_wrapped_cycle_mutual(self): @@ -6852,19 +6852,9 @@ def a(): ... def b(): ... a.__wrapped__ = b b.__wrapped__ = a - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, 'wrapper loop'): get_type_hints(a) - def test_get_type_hints_wrapped_chain_no_cycle(self): - # gh-146553: a valid (non-cyclic) __wrapped__ chain must still work. - def inner(x: int) -> str: ... - def middle(x: int) -> str: ... - middle.__wrapped__ = inner - def outer(x: int) -> str: ... - outer.__wrapped__ = middle - # No cycle — should return the annotations without raising. - self.assertEqual(get_type_hints(outer), {'x': int, 'return': str}) - def test_get_type_hints_annotated(self): def foobar(x: List['X']): ... X = Annotated[int, (1, 10)] diff --git a/Lib/typing.py b/Lib/typing.py index 17e023a785cbde..f8fbc2d159fa3c 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -1972,6 +1972,7 @@ def _lazy_load_getattr_static(): _cleanups.append(_lazy_load_getattr_static.cache_clear) + def _pickle_psargs(psargs): return ParamSpecArgs, (psargs.__origin__,) @@ -2483,20 +2484,9 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, if isinstance(obj, types.ModuleType): globalns = obj.__dict__ else: - nsobj = obj # Find globalns for the unwrapped object. - # Use an id-based visited set to detect and break on cycles in the - # __wrapped__ chain (e.g. f.__wrapped__ = f), matching the behavior - # of inspect.unwrap(). - _seen_ids = {id(nsobj)} - while hasattr(nsobj, '__wrapped__'): - nsobj = nsobj.__wrapped__ - _nsobj_id = id(nsobj) - if _nsobj_id in _seen_ids: - raise ValueError( - f'wrapper loop when unwrapping {obj!r}' - ) - _seen_ids.add(_nsobj_id) + import inspect + nsobj = inspect.unwrap(obj) globalns = getattr(nsobj, '__globals__', {}) if localns is None: localns = globalns