From 61a821befe1236ee73e9a6de74c3507b935fe6a0 Mon Sep 17 00:00:00 2001 From: raminfp Date: Sat, 28 Mar 2026 11:46:26 +0330 Subject: [PATCH 1/4] 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/4] 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 From e20538435a7c843b91ab9d18d60e09bbb76d4316 Mon Sep 17 00:00:00 2001 From: raminfp Date: Mon, 30 Mar 2026 11:46:34 +0330 Subject: [PATCH 3/4] gh-146553: Use _lazy_load_unwrap() helper instead of local import in get_type_hints() --- Lib/typing.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Lib/typing.py b/Lib/typing.py index f8fbc2d159fa3c..b39fef23ae64a6 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -1973,6 +1973,17 @@ def _lazy_load_getattr_static(): _cleanups.append(_lazy_load_getattr_static.cache_clear) +@functools.cache +def _lazy_load_unwrap(): + # Import unwrap lazily so as not to slow down the import of typing.py + # Cache the result so we don't slow down get_type_hints() unnecessarily + from inspect import unwrap + return unwrap + + +_cleanups.append(_lazy_load_unwrap.cache_clear) + + def _pickle_psargs(psargs): return ParamSpecArgs, (psargs.__origin__,) @@ -2485,8 +2496,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, globalns = obj.__dict__ else: # Find globalns for the unwrapped object. - import inspect - nsobj = inspect.unwrap(obj) + nsobj = _lazy_load_unwrap()(obj) globalns = getattr(nsobj, '__globals__', {}) if localns is None: localns = globalns From 27c948db0fceb963d9ee16ad584a9dd1a01a650c Mon Sep 17 00:00:00 2001 From: raminfp Date: Mon, 30 Mar 2026 12:03:40 +0330 Subject: [PATCH 4/4] gh-146553: Replace _lazy_load_* helpers with _LazyInspect for lazy inspect import --- Lib/typing.py | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/Lib/typing.py b/Lib/typing.py index b39fef23ae64a6..7e97e89e4ed1de 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -172,6 +172,16 @@ def __getattr__(self, attr): _lazy_annotationlib = _LazyAnnotationLib() +class _LazyInspect: + def __getattr__(self, attr): + global _lazy_inspect + import inspect + _lazy_inspect = inspect + return getattr(inspect, attr) + +_lazy_inspect = _LazyInspect() + + def _type_convert(arg, module=None, *, allow_special_forms=False, owner=None): """For converting None to type(None), and strings to ForwardRef.""" if arg is None: @@ -1962,28 +1972,6 @@ def _allow_reckless_class_checks(depth=2): } -@functools.cache -def _lazy_load_getattr_static(): - # Import getattr_static lazily so as not to slow down the import of typing.py - # Cache the result so we don't slow down _ProtocolMeta.__instancecheck__ unnecessarily - from inspect import getattr_static - return getattr_static - - -_cleanups.append(_lazy_load_getattr_static.cache_clear) - - -@functools.cache -def _lazy_load_unwrap(): - # Import unwrap lazily so as not to slow down the import of typing.py - # Cache the result so we don't slow down get_type_hints() unnecessarily - from inspect import unwrap - return unwrap - - -_cleanups.append(_lazy_load_unwrap.cache_clear) - - def _pickle_psargs(psargs): return ParamSpecArgs, (psargs.__origin__,) @@ -2116,7 +2104,7 @@ def __instancecheck__(cls, instance): if _abc_instancecheck(cls, instance): return True - getattr_static = _lazy_load_getattr_static() + getattr_static = _lazy_inspect.getattr_static for attr in cls.__protocol_attrs__: try: val = getattr_static(instance, attr) @@ -2496,7 +2484,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, globalns = obj.__dict__ else: # Find globalns for the unwrapped object. - nsobj = _lazy_load_unwrap()(obj) + nsobj = _lazy_inspect.unwrap(obj) globalns = getattr(nsobj, '__globals__', {}) if localns is None: localns = globalns