From 1738e3a2049ac3d4b4047caa94dff5d9b0b0c32c Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 19 Jul 2026 15:19:48 +0300 Subject: [PATCH 1/2] gh-154115: Fix typing._tp_cache calling the wrapped function twice on TypeError _tp_cache falls back to calling the wrapped function directly when the arguments are unhashable, which it detected by catching TypeError from the lru_cache lookup. A TypeError raised by the wrapped function itself was caught the same way, so with hashable arguments the function ran once inside the cache, was caught, and then ran a second time through the fallback before the error propagated. This was reachable through the public API, for example typing.List[int, str] evaluated its whole __getitem__ twice. Only fall back when the arguments really are unhashable, by rebuilding the same cache key lru_cache uses, and re-raise otherwise so the function is not run a second time. --- Lib/test/test_typing.py | 86 +++++++++++++++++++ Lib/typing.py | 14 ++- Misc/ACKS | 1 + ...-07-19-12-06-30.gh-issue-154115.RpeFgs.rst | 3 + 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-19-12-06-30.gh-issue-154115.RpeFgs.rst diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index c1f340230159db6..2c2d04d6686e653 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -6554,6 +6554,92 @@ def test_lazy_import(self): "annotationlib", }) + def make_tp_cached(self, func, *, typed=False): + # Wrap *func* with typing._tp_cache, undoing the module-global + # registration afterwards so the test stays reference-leak clean. + before = len(typing._cleanups) + wrapped = typing._tp_cache(func, typed=typed) + self.addCleanup(typing._caches.__delitem__, func) + self.addCleanup(typing._cleanups.__delitem__, slice(before, None)) + return wrapped + + def test_tp_cache_does_not_swallow_type_error(self): + # gh-154115: A TypeError raised by the wrapped function itself used + # to be mistaken for an unhashable-argument error, causing the + # function to run a second time before the error propagated. + calls = [] + + @self.make_tp_cached + def func(*args, **kwds): + calls.append((args, kwds)) + raise TypeError("boom") + + with self.assertRaisesRegex(TypeError, "boom"): + func("hashable") + self.assertEqual(calls, [(("hashable",), {})]) + + calls.clear() + with self.assertRaisesRegex(TypeError, "boom"): + func(kwd="hashable") + self.assertEqual(calls, [((), {"kwd": "hashable"})]) + + def test_tp_cache_unhashable_fallback(self): + # Unhashable arguments cannot be cached, so the wrapped function is + # still used directly as a fallback and runs exactly once. + calls = [] + + @self.make_tp_cached + def func(*args, **kwds): + calls.append((args, kwds)) + return "result" + + unhashable = [1, 2, 3] + self.assertEqual(func(unhashable), "result") + self.assertEqual(func(kwd=unhashable), "result") + self.assertEqual( + calls, [((unhashable,), {}), ((), {"kwd": unhashable})] + ) + + def test_tp_cache_caches_repeated_calls(self): + # A successful call with hashable arguments is cached: calling again + # with the same arguments returns the same object without running + # the wrapped function a second time. + calls = [] + + @self.make_tp_cached + def func(arg): + calls.append(arg) + return object() + + first = func(42) + second = func(42) + self.assertIs(first, second) + self.assertEqual(calls, [42]) + + def test_tp_cache_typed_falls_back_for_unhashable_type(self): + # gh-154115: with typed=True the cache key also includes the type of + # each argument, so a hashable value whose type is unhashable cannot + # be cached. Such a call must fall back to the wrapped function + # rather than being mistaken for a function-raised TypeError. + class Meta(type): + __hash__ = None + + class C(metaclass=Meta): + def __hash__(self): + return 0 + + calls = [] + + def func(arg): + calls.append(arg) + return "result" + + func = self.make_tp_cached(func, typed=True) + obj = C() + self.assertEqual(func(obj), "result") + self.assertEqual(len(calls), 1) + self.assertIs(calls[0], obj) + @lru_cache() def cached_func(x, y): diff --git a/Lib/typing.py b/Lib/typing.py index 054420865d7fb50..2b831233b18b064 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -413,7 +413,19 @@ def inner(*args, **kwds): try: return _caches[func](*args, **kwds) except TypeError: - pass # All real errors (not unhashable args) are raised below. + # A TypeError here may mean the arguments are unhashable + # (so they cannot be cached and the original function is + # used as a fallback), or that the wrapped function itself + # raised TypeError. Rebuild the same key lru_cache uses to + # tell the two apart: only fall back when the key really is + # unhashable, otherwise re-raise so the function is not run + # a second time. + try: + hash(functools._make_key(args, kwds, typed)) + except TypeError: + pass # Unhashable arguments: fall back below. + else: + raise return func(*args, **kwds) return inner diff --git a/Misc/ACKS b/Misc/ACKS index fec00c1b272f4ea..5fe1d8cd1121ee4 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -2008,6 +2008,7 @@ Wm. Keith van der Meulen Eric N. Vander Weele Andrew Vant Atul Varma +Vyron Vasileiadis Dmitry Vasiliev Sebastian Ortiz Vasquez Alexandre Vassalotti diff --git a/Misc/NEWS.d/next/Library/2026-07-19-12-06-30.gh-issue-154115.RpeFgs.rst b/Misc/NEWS.d/next/Library/2026-07-19-12-06-30.gh-issue-154115.RpeFgs.rst new file mode 100644 index 000000000000000..05d4f448a5a791c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-19-12-06-30.gh-issue-154115.RpeFgs.rst @@ -0,0 +1,3 @@ +Subscripting a :mod:`typing` special form or generic alias with arguments that +are rejected with a :exc:`TypeError` (such as ``typing.List[int, str]``) no +longer evaluates the subscription twice before raising the error. From 0c256d0f8c19d97bdb512a8f7eb22f748d92a85d Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 19 Jul 2026 16:19:34 +0300 Subject: [PATCH 2/2] gh-154115: Use subtests for the _tp_cache positional/keyword variations Both tests exercised the same positional and keyword call variations but handled them inconsistently: one cleared the calls list between them, the other asserted the combined result. Loop over the variations with subTest and clear the list each iteration so each variation is asserted in isolation. --- Lib/test/test_typing.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 2c2d04d6686e653..bac3f26c6535fac 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -6574,14 +6574,12 @@ def func(*args, **kwds): calls.append((args, kwds)) raise TypeError("boom") - with self.assertRaisesRegex(TypeError, "boom"): - func("hashable") - self.assertEqual(calls, [(("hashable",), {})]) - - calls.clear() - with self.assertRaisesRegex(TypeError, "boom"): - func(kwd="hashable") - self.assertEqual(calls, [((), {"kwd": "hashable"})]) + for args, kwds in [(("hashable",), {}), ((), {"kwd": "hashable"})]: + with self.subTest(args=args, kwds=kwds): + calls.clear() + with self.assertRaisesRegex(TypeError, "boom"): + func(*args, **kwds) + self.assertEqual(calls, [(args, kwds)]) def test_tp_cache_unhashable_fallback(self): # Unhashable arguments cannot be cached, so the wrapped function is @@ -6594,11 +6592,11 @@ def func(*args, **kwds): return "result" unhashable = [1, 2, 3] - self.assertEqual(func(unhashable), "result") - self.assertEqual(func(kwd=unhashable), "result") - self.assertEqual( - calls, [((unhashable,), {}), ((), {"kwd": unhashable})] - ) + for args, kwds in [((unhashable,), {}), ((), {"kwd": unhashable})]: + with self.subTest(args=args, kwds=kwds): + calls.clear() + self.assertEqual(func(*args, **kwds), "result") + self.assertEqual(calls, [(args, kwds)]) def test_tp_cache_caches_repeated_calls(self): # A successful call with hashable arguments is cached: calling again