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 1/3] 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) { From 293f80d7a7562cda7227940eb4ff0169fffc6b11 Mon Sep 17 00:00:00 2001 From: WakaTaira <50697207+WakaTaira@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:56:37 +0900 Subject: [PATCH 2/3] gh-149313: Address review comments Use PyErr_FormatUnraisable() instead of PyErr_WriteUnraisable(), since it does not only report the error but can also add a message, its name fits this use better, and it is already the one used for reporting errors in most code in Objects/ and Python/. Keep raising the exception from C with the C API in the tests that need it, without a helper. Helpers like this are rare in the test suite, and writing one here would end up as a function that returns a class, so the code would not get much shorter. The code is also too small to need a helper. --- Lib/test/test_dict.py | 99 +++++++++++++++++++++++++---------------- Lib/test/test_set.py | 100 ++++++++++++++++++++++++++---------------- Python/errors.c | 33 ++++++++------ 3 files changed, 145 insertions(+), 87 deletions(-) diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 6c6983a1947e0f4..67df7cd23b0de47 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -22,6 +22,13 @@ def __repr__(self): return f'' +class HashError: + def __init__(self, exc): + self.exc = exc + def __hash__(self): + raise self.exc + + class DictTest(unittest.TestCase): def test_invalid_keyword_arguments(self): @@ -1664,48 +1671,56 @@ class Shenanigans: 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 + exc = TypeError('error') + exc.add_note('note') - with self.assertRaisesRegex(TypeError, "as a dict key") as cm: - {Key(): 1} + with self.assertRaisesRegex(TypeError, 'as a dict key') as cm: + {HashError(exc): 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. + def test_unhashable_key_format_error_badstr(self): + # gh-149313: If formatting the message fails, the error + # is reported as unraisable and the original exception is raised. 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) - + with support.catch_unraisable_exception() as ucm: + with self.assertRaises(TypeError) as cm: + {HashError(TypeError(BadStr())): 1} + self.assertIsInstance(cm.exception.args[0], BadStr) + self.assertIs(ucm.unraisable.exc_type, RuntimeError) + self.assertIsInstance( + ucm.unraisable.exc_value.__cause__, ValueError + ) + self.assertEqual( + ucm.unraisable.err_msg, + 'Exception ignored while formatting the TypeError message', + ) + + def test_unhashable_key_format_error_reraise(self): + # gh-149313: If formatting the message fails, the error + # is reported as unraisable and the original exception is raised. 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__) + with support.catch_unraisable_exception() as ucm: + with self.assertRaises(TypeError) as cm: + {HashError(exc): 1} + self.assertIs(cm.exception, exc) + self.assertIsNone(exc.__cause__) + self.assertIs(ucm.unraisable.exc_value, exc) + self.assertEqual( + ucm.unraisable.err_msg, + 'Exception ignored while formatting the TypeError message', + ) def test_unhashable_key_not_chained(self): # gh-149313: Chaining would only repeat the message. @@ -1725,33 +1740,43 @@ def test_unhashable_key_not_chained(self): def test_unhashable_key_notes_copied(self): # gh-149313: Without a traceback or a cause, only notes are preserved. + with self.assertRaisesRegex(TypeError, 'as a dict key') as cm: + {([1],): 1} + self.assertIsNone(cm.exception.__cause__) + self.assertEqual(cm.exception.__notes__, ['when hashing tuple item 0']) + + def test_unhashable_key_notes_list_subclass(self): + # gh-149313: A list subclass is copied without calling its methods. _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__) + functools.partial(_testcapi.err_set_raised, exc) + ) - # 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: + with self.assertRaisesRegex(TypeError, 'as a dict key') as cm: {Key(): 1} + self.assertIsNot(cm.exception.__notes__, exc.__notes__) self.assertEqual(cm.exception.__notes__, ['note2']) - # A cause alone is preserved by chaining. + def test_unhashable_key_cause_only(self): + # gh-149313: A cause alone is preserved by chaining. + _testcapi = import_helper.import_module('_testcapi') + exc = TypeError('error') exc.__cause__ = ValueError('cause') - with self.assertRaisesRegex(TypeError, "as a dict key") as cm: + + class Key: + __hash__ = staticmethod( + functools.partial(_testcapi.err_set_raised, exc) + ) + + with self.assertRaisesRegex(TypeError, 'as a dict key') as cm: {Key(): 1} self.assertIs(cm.exception.__cause__, exc) diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index ee4dc239731d49e..bf47ecfac833dea 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -48,6 +48,14 @@ def __hash__(self): self.hash_count += 1 return int.__hash__(self) + +class HashError: + def __init__(self, exc): + self.exc = exc + def __hash__(self): + raise self.exc + + class TestJointOps: # Tests common to both set and frozenset @@ -689,48 +697,56 @@ def __hash__(self): 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 + exc = TypeError('error') + exc.add_note('note') - with self.assertRaisesRegex(TypeError, "as a set element") as cm: - {Elem()} + with self.assertRaisesRegex(TypeError, 'as a set element') as cm: + {HashError(exc)} 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. + def test_unhashable_element_format_error_badstr(self): + # gh-149313: If formatting the message fails, the error + # is reported as unraisable and the original exception is raised. 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) - + with support.catch_unraisable_exception() as ucm: + with self.assertRaises(TypeError) as cm: + {HashError(TypeError(BadStr()))} + self.assertIsInstance(cm.exception.args[0], BadStr) + self.assertIs(ucm.unraisable.exc_type, RuntimeError) + self.assertIsInstance( + ucm.unraisable.exc_value.__cause__, ValueError + ) + self.assertEqual( + ucm.unraisable.err_msg, + 'Exception ignored while formatting the TypeError message', + ) + + def test_unhashable_element_format_error_reraise(self): + # gh-149313: If formatting the message fails, the error + # is reported as unraisable and the original exception is raised. 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__) + with support.catch_unraisable_exception() as ucm: + with self.assertRaises(TypeError) as cm: + {HashError(exc)} + self.assertIs(cm.exception, exc) + self.assertIsNone(exc.__cause__) + self.assertIs(ucm.unraisable.exc_value, exc) + self.assertEqual( + ucm.unraisable.err_msg, + 'Exception ignored while formatting the TypeError message', + ) def test_unhashable_element_not_chained(self): # gh-149313: Chaining would only repeat the message. @@ -750,33 +766,43 @@ def test_unhashable_element_not_chained(self): def test_unhashable_element_notes_copied(self): # gh-149313: Without a traceback or a cause, only notes are preserved. + with self.assertRaisesRegex(TypeError, 'as a set element') as cm: + {([1],)} + self.assertIsNone(cm.exception.__cause__) + self.assertEqual(cm.exception.__notes__, ['when hashing tuple item 0']) + + def test_unhashable_element_notes_list_subclass(self): + # gh-149313: A list subclass is copied without calling its methods. _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__) + functools.partial(_testcapi.err_set_raised, exc) + ) - # 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: + with self.assertRaisesRegex(TypeError, 'as a set element') as cm: {Elem()} + self.assertIsNot(cm.exception.__notes__, exc.__notes__) self.assertEqual(cm.exception.__notes__, ['note2']) - # A cause alone is preserved by chaining. + def test_unhashable_element_cause_only(self): + # gh-149313: A cause alone is preserved by chaining. + _testcapi = import_helper.import_module('_testcapi') + exc = TypeError('error') exc.__cause__ = ValueError('cause') - with self.assertRaisesRegex(TypeError, "as a set element") as cm: + + class Elem: + __hash__ = staticmethod( + functools.partial(_testcapi.err_set_raised, exc) + ) + + with self.assertRaisesRegex(TypeError, 'as a set element') as cm: {Elem()} self.assertIs(cm.exception.__cause__, exc) diff --git a/Python/errors.c b/Python/errors.c index 63752a9b4a03f70..97a3f950de1506a 100644 --- a/Python/errors.c +++ b/Python/errors.c @@ -1298,24 +1298,27 @@ _PyErr_FormatNote(const char *format, ...) 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. */ + its notes, if any. If formatting the message fails, its error is reported + instead of being raised, and the original exception is raised. */ void _PyErr_ReplaceTypeError(PyObject *exc, const char *format, ...) { + PyThreadState *tstate = _PyThreadState_GET(); va_list vargs; va_start(vargs, format); PyObject *msg = PyUnicode_FromFormatV(format, vargs); va_end(vargs); if (msg == NULL) { + PyErr_FormatUnraisable("Exception ignored while " + "formatting the TypeError message"); + _PyErr_SetRaisedException(tstate, Py_NewRef(exc)); 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); + PyObject *new_exc = PyObject_CallOneArg(PyExc_TypeError, msg); + Py_DECREF(msg); + if (new_exc == NULL) { + /* caller will expect error to be set anyway */ return; } @@ -1331,23 +1334,27 @@ _PyErr_ReplaceTypeError(PyObject *exc, const char *format, ...) else { PyObject *notes; if (PyObject_GetOptionalAttr(exc, &_Py_ID(__notes__), ¬es) < 0) { - PyErr_Clear(); + PyErr_FormatUnraisable("Exception ignored while " + "copying the exception notes"); } 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 (PyList_Check(notes)) { + Py_SETREF(notes, + PyList_GetSlice(notes, 0, PY_SSIZE_T_MAX)); + } if (notes == NULL || PyObject_SetAttr(new_exc, &_Py_ID(__notes__), notes) < 0) { - PyErr_Clear(); + PyErr_FormatUnraisable("Exception ignored while " + "copying the exception notes"); } Py_XDECREF(notes); } } - PyErr_SetRaisedException(new_exc); + _PyErr_SetObject(tstate, (PyObject*)Py_TYPE(new_exc), new_exc); + Py_DECREF(new_exc); } From e51cad1203ebfcb18541b6e19ff9363fb067ae21 Mon Sep 17 00:00:00 2001 From: WakaTaira <50697207+WakaTaira@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:20:35 +0900 Subject: [PATCH 3/3] gh-149313: Add "Patch by" to the NEWS entry --- .../2026-11-07-12-00-00.gh-issue-149313.xHn7Qe.rst | 1 + 1 file changed, 1 insertion(+) 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 index 53ea1c5805c7a6e..2442f3207b30457 100644 --- 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 @@ -2,3 +2,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. +Patch by WakaTaira.