From eb1cbd42e6589a5c8e2ba89847f8e5e5f8397924 Mon Sep 17 00:00:00 2001 From: WakaTaira <50697207+WakaTaira@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:12:36 +0900 Subject: [PATCH] gh-149313: Keep hash error details for dict keys and set elements The TypeError raised when hashing a dict key or a set element is replaced with a TypeError describing the container, which discards the traceback, the cause and the notes of the original exception. Chain the original exception as the cause of the replacement if it has a traceback or a cause. Otherwise, chaining would only repeat its message, so copy its notes instead. __context__ is intentionally not checked: a TypeError raised from C code inside an except block gets the handled exception as its context, and the replacement gets the same context anyway. An error raised when formatting the new message is left unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YSkzESiXGfCM1jUTyLpxHo --- Include/internal/pycore_pyerrors.h | 2 + Lib/test/test_dict.py | 94 ++++++++++++++++++ Lib/test/test_set.py | 96 ++++++++++++++++++- ...-11-07-12-00-00.gh-issue-149313.xHn7Qe.rst | 4 + Objects/dictobject.c | 2 +- Objects/setobject.c | 6 +- Python/errors.c | 58 +++++++++++ 7 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-11-07-12-00-00.gh-issue-149313.xHn7Qe.rst diff --git a/Include/internal/pycore_pyerrors.h b/Include/internal/pycore_pyerrors.h index 8f772a045e27a5d..a0b10a6db1e9483 100644 --- a/Include/internal/pycore_pyerrors.h +++ b/Include/internal/pycore_pyerrors.h @@ -198,6 +198,8 @@ PyAPI_FUNC(void) _PyErr_FormatNote(const char *format, ...); Py_DEPRECATED(3.12) extern void _PyErr_ChainExceptions(PyObject *, PyObject *, PyObject *); +extern void _PyErr_ReplaceTypeError(PyObject *exc, const char *format, ...); + // implementation detail for the codeop module. // Exported for test.test_peg_generator.test_c_parser PyAPI_DATA(PyTypeObject) _PyExc_IncompleteInputError; diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index ea697a5a920f777..6c6983a1947e0f4 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1,5 +1,6 @@ import collections import collections.abc +import functools import gc import pickle import random @@ -1661,6 +1662,99 @@ class Shenanigans: self.assertEqual(holds_reference.ref['data'], 42) self.assertEqual(holds_reference.attr, "whatever") + def test_unhashable_key_chained(self): + # gh-149313: A traceback or a cause is preserved by chaining. + class Key: + def __hash__(self): + exc = TypeError('error') + exc.add_note('note') + raise exc + + with self.assertRaisesRegex(TypeError, "as a dict key") as cm: + {Key(): 1} + cause = cm.exception.__cause__ + self.assertEqual(str(cause), 'error') + self.assertIsNotNone(cause.__traceback__) + self.assertEqual(cause.__notes__, ['note']) + self.assertNotHasAttr(cm.exception, '__notes__') + + def test_unhashable_key_format_error(self): + # gh-149313: An error raised when formatting the message is unchanged. + class BadStr: + def __str__(self): + raise RuntimeError('format') from ValueError('root') + + class Key: + def __hash__(self): + raise TypeError(BadStr()) + + with self.assertRaisesRegex(RuntimeError, 'format') as cm: + {Key(): 1} + self.assertIsInstance(cm.exception.__cause__, ValueError) + + class Reraise: + def __str__(self): + raise exc + + exc = TypeError(Reraise()) + + class Key2: + def __hash__(self): + raise exc + + with self.assertRaises(TypeError) as cm: + {Key2(): 1} + self.assertIs(cm.exception, exc) + self.assertIsNone(exc.__cause__) + + def test_unhashable_key_not_chained(self): + # gh-149313: Chaining would only repeat the message. + with self.assertRaisesRegex(TypeError, "cannot use 'list'") as cm: + {[]: 1} + self.assertIsNone(cm.exception.__cause__) + self.assertNotHasAttr(cm.exception, '__notes__') + + try: + 1/0 + except ZeroDivisionError as exc: + handled = exc + with self.assertRaisesRegex(TypeError, "cannot use 'list'") as cm: + {[]: 1} + self.assertIsNone(cm.exception.__cause__) + self.assertIs(cm.exception.__context__, handled) + + def test_unhashable_key_notes_copied(self): + # gh-149313: Without a traceback or a cause, only notes are preserved. + _testcapi = import_helper.import_module('_testcapi') + exc = TypeError('error') + exc.add_note('note') + + class Key: + __hash__ = staticmethod( + functools.partial(_testcapi.err_set_raised, exc)) + + with self.assertRaisesRegex(TypeError, "as a dict key") as cm: + {Key(): 1} + self.assertIsNone(cm.exception.__cause__) + self.assertEqual(cm.exception.__notes__, ['note']) + self.assertIsNot(cm.exception.__notes__, exc.__notes__) + + # A list subclass is copied without calling its methods. + class Notes(list): + def __iter__(self): + raise RuntimeError + + exc.__notes__ = Notes(['note2']) + with self.assertRaisesRegex(TypeError, "as a dict key") as cm: + {Key(): 1} + self.assertEqual(cm.exception.__notes__, ['note2']) + + # A cause alone is preserved by chaining. + exc.__cause__ = ValueError('cause') + with self.assertRaisesRegex(TypeError, "as a dict key") as cm: + {Key(): 1} + self.assertIs(cm.exception.__cause__, exc) + def test_unhashable_key(self): d = {'a': 1} key = [1, 2, 3] diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index 9bfd4bc7d636699..ee4dc239731d49e 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -1,5 +1,6 @@ import collections.abc import copy +import functools import gc import itertools import operator @@ -10,7 +11,7 @@ import weakref from random import randrange, shuffle from test import support -from test.support import warnings_helper +from test.support import import_helper, warnings_helper class PassThru(Exception): @@ -686,6 +687,99 @@ def __hash__(self): with self.assertRaises(KeyError): myset.discard(elem2) + def test_unhashable_element_chained(self): + # gh-149313: A traceback or a cause is preserved by chaining. + class Elem: + def __hash__(self): + exc = TypeError('error') + exc.add_note('note') + raise exc + + with self.assertRaisesRegex(TypeError, "as a set element") as cm: + {Elem()} + cause = cm.exception.__cause__ + self.assertEqual(str(cause), 'error') + self.assertIsNotNone(cause.__traceback__) + self.assertEqual(cause.__notes__, ['note']) + self.assertNotHasAttr(cm.exception, '__notes__') + + def test_unhashable_element_format_error(self): + # gh-149313: An error raised when formatting the message is unchanged. + class BadStr: + def __str__(self): + raise RuntimeError('format') from ValueError('root') + + class Elem: + def __hash__(self): + raise TypeError(BadStr()) + + with self.assertRaisesRegex(RuntimeError, 'format') as cm: + {Elem()} + self.assertIsInstance(cm.exception.__cause__, ValueError) + + class Reraise: + def __str__(self): + raise exc + + exc = TypeError(Reraise()) + + class Elem2: + def __hash__(self): + raise exc + + with self.assertRaises(TypeError) as cm: + {Elem2()} + self.assertIs(cm.exception, exc) + self.assertIsNone(exc.__cause__) + + def test_unhashable_element_not_chained(self): + # gh-149313: Chaining would only repeat the message. + with self.assertRaisesRegex(TypeError, "cannot use 'list'") as cm: + {[]} + self.assertIsNone(cm.exception.__cause__) + self.assertNotHasAttr(cm.exception, '__notes__') + + try: + 1/0 + except ZeroDivisionError as exc: + handled = exc + with self.assertRaisesRegex(TypeError, "cannot use 'list'") as cm: + {[]} + self.assertIsNone(cm.exception.__cause__) + self.assertIs(cm.exception.__context__, handled) + + def test_unhashable_element_notes_copied(self): + # gh-149313: Without a traceback or a cause, only notes are preserved. + _testcapi = import_helper.import_module('_testcapi') + exc = TypeError('error') + exc.add_note('note') + + class Elem: + __hash__ = staticmethod( + functools.partial(_testcapi.err_set_raised, exc)) + + with self.assertRaisesRegex(TypeError, "as a set element") as cm: + {Elem()} + self.assertIsNone(cm.exception.__cause__) + self.assertEqual(cm.exception.__notes__, ['note']) + self.assertIsNot(cm.exception.__notes__, exc.__notes__) + + # A list subclass is copied without calling its methods. + class Notes(list): + def __iter__(self): + raise RuntimeError + + exc.__notes__ = Notes(['note2']) + with self.assertRaisesRegex(TypeError, "as a set element") as cm: + {Elem()} + self.assertEqual(cm.exception.__notes__, ['note2']) + + # A cause alone is preserved by chaining. + exc.__cause__ = ValueError('cause') + with self.assertRaisesRegex(TypeError, "as a set element") as cm: + {Elem()} + self.assertIs(cm.exception.__cause__, exc) + def test_hash_collision_remove_add(self): self.maxDiff = None # There should be enough space, so all elements with unique hash diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-11-07-12-00-00.gh-issue-149313.xHn7Qe.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-07-12-00-00.gh-issue-149313.xHn7Qe.rst new file mode 100644 index 000000000000000..53ea1c5805c7a6e --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-07-12-00-00.gh-issue-149313.xHn7Qe.rst @@ -0,0 +1,4 @@ +Preserve the traceback, cause and notes of the original :exc:`TypeError` when +reporting an unhashable :class:`dict` key or :class:`set` element. The original +exception is chained as the :attr:`~BaseException.__cause__` if it has a +traceback or a cause; otherwise, only its notes are copied. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 88a8d61828503f7..a85781a5bf4e60e 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -2506,7 +2506,7 @@ dict_unhashable_type(PyObject *op, PyObject *key) else { errmsg = "cannot use '%T' as a dict key (%S)"; } - PyErr_Format(PyExc_TypeError, errmsg, key, exc); + _PyErr_ReplaceTypeError(exc, errmsg, key, exc); Py_DECREF(exc); } diff --git a/Objects/setobject.c b/Objects/setobject.c index 8c5f244d4894d8a..00046d28b0f7bf4 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -352,9 +352,9 @@ set_unhashable_type(PyObject *key) return; } - PyErr_Format(PyExc_TypeError, - "cannot use '%T' as a set element (%S)", - key, exc); + _PyErr_ReplaceTypeError(exc, + "cannot use '%T' as a set element (%S)", + key, exc); Py_DECREF(exc); } diff --git a/Python/errors.c b/Python/errors.c index 74fd2f0502f69c6..63752a9b4a03f70 100644 --- a/Python/errors.c +++ b/Python/errors.c @@ -1293,6 +1293,64 @@ _PyErr_FormatNote(const char *format, ...) } +/* Raise a TypeError with a formatted message, replacing exc and preserving + its information. + + If exc has a traceback or a cause, chain it as the cause of the new + exception. Otherwise, chaining would only repeat its message, so just copy + its notes, if any. If formatting the message fails, its error is left set + unchanged. */ +void +_PyErr_ReplaceTypeError(PyObject *exc, const char *format, ...) +{ + va_list vargs; + va_start(vargs, format); + PyObject *msg = PyUnicode_FromFormatV(format, vargs); + va_end(vargs); + if (msg == NULL) { + return; + } + PyErr_SetObject(PyExc_TypeError, msg); + Py_DECREF(msg); + + PyObject *new_exc = PyErr_GetRaisedException(); + if (!Py_IS_TYPE(new_exc, (PyTypeObject *)PyExc_TypeError)) { + PyErr_SetRaisedException(new_exc); + return; + } + + PyObject *tb = PyException_GetTraceback(exc); + PyObject *cause = PyException_GetCause(exc); + int chain = (tb != NULL || cause != NULL); + Py_XDECREF(tb); + Py_XDECREF(cause); + + if (chain) { + PyException_SetCause(new_exc, Py_NewRef(exc)); + } + else { + PyObject *notes; + if (PyObject_GetOptionalAttr(exc, &_Py_ID(__notes__), ¬es) < 0) { + PyErr_Clear(); + } + else if (notes != NULL) { + // A list is copied to not share it with exc. Other values are + // not supported by add_note() and are kept as they are. + Py_SETREF(notes, PyList_Check(notes) + ? PyList_GetSlice(notes, 0, PY_SSIZE_T_MAX) + : Py_NewRef(notes)); + if (notes == NULL + || PyObject_SetAttr(new_exc, &_Py_ID(__notes__), notes) < 0) + { + PyErr_Clear(); + } + Py_XDECREF(notes); + } + } + PyErr_SetRaisedException(new_exc); +} + + PyObject * PyErr_NewException(const char *name, PyObject *base, PyObject *dict) {