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) {