diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 106ffdede6fd4ef..c85a2a96ccea09e 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -6567,6 +6567,90 @@ 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") + + 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 + # 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] + 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 + # 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.