diff --git a/Lib/test/test_tuple.py b/Lib/test/test_tuple.py index 5650dec09adef43..a3bd60eca4f6b15 100644 --- a/Lib/test/test_tuple.py +++ b/Lib/test/test_tuple.py @@ -450,6 +450,45 @@ def test_lexicographic_ordering(self): self.assertLess(a, b) self.assertLess(b, c) + def test_hash_unhashable_note(self): + with self.assertRaisesRegex(TypeError, "unhashable type: 'list'") as cm: + hash(("alice", [])) + self.assertEqual(cm.exception.__notes__, + ["when hashing tuple item 1"]) + + with self.assertRaisesRegex(TypeError, "unhashable type: 'list'") as cm: + hash((("alice", []), 42)) + self.assertEqual(cm.exception.__notes__, + ["when hashing tuple item 1", + "when hashing tuple item 0"]) + + class BrokenHash: + def __hash__(self): + raise TypeError("custom failure") + + with self.assertRaisesRegex(TypeError, "custom failure") as cm: + hash((BrokenHash(),)) + self.assertEqual(cm.exception.__notes__, ["when hashing tuple item 0"]) + + class BrokenHashNote: + def __hash__(self): + exc = TypeError("custom failure") + exc.add_note("from custom hash") + raise exc + + with self.assertRaisesRegex(TypeError, "custom failure") as cm: + hash((BrokenHashNote(),)) + self.assertEqual(cm.exception.__notes__, + ["from custom hash", "when hashing tuple item 0"]) + + class BrokenHashValueError: + def __hash__(self): + raise ValueError("custom failure") + + with self.assertRaisesRegex(ValueError, "custom failure") as cm: + hash((BrokenHashValueError(),)) + self.assertFalse(hasattr(cm.exception, "__notes__")) + # Notes on testing hash codes. The primary thing is that Python doesn't # care about "random" hash codes. To the contrary, we like them to be # very regular when possible, so that the low-order bits are as evenly diff --git a/Misc/ACKS b/Misc/ACKS index 65f6f423eb7c428..4178ae3108128aa 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -2048,6 +2048,7 @@ Sjoerd de Vries Jonas Wagner Daniel Wagner-Hall Niki W. Waibel +WakaTaira Wojtek Walczak Charles Waldman Richard Walker diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-11-06-15-15-00.gh-issue-161433.A9b3Kq.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-06-15-15-00.gh-issue-161433.A9b3Kq.rst new file mode 100644 index 000000000000000..251837ac6d632a4 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-11-06-15-15-00.gh-issue-161433.A9b3Kq.rst @@ -0,0 +1,2 @@ +Add context notes to :exc:`TypeError` raised while hashing a +:class:`tuple`, identifying the index of the item whose hash failed. diff --git a/Objects/tupleobject.c b/Objects/tupleobject.c index 68ca2772507295e..992699756ac770f 100644 --- a/Objects/tupleobject.c +++ b/Objects/tupleobject.c @@ -8,6 +8,7 @@ #include "pycore_list.h" // _Py_memory_repeat() #include "pycore_modsupport.h" // _PyArg_NoKwnames() #include "pycore_object.h" // _PyObject_GC_TRACK() +#include "pycore_pyerrors.h" // _PyException_AddNote() #include "pycore_stackref.h" // PyStackRef_AsPyObjectSteal() #include "pycore_tuple.h" // _PyTupleIterObject @@ -392,6 +393,23 @@ tuple_hash(PyObject *op) for (Py_ssize_t i = 0; i < len; i++) { Py_uhash_t lane = PyObject_Hash(item[i]); if (lane == (Py_uhash_t)-1) { + if (PyErr_ExceptionMatches(PyExc_TypeError)) { + /* Preserve the original error while providing context for + the element that could not be hashed. */ + PyObject *exc = PyErr_GetRaisedException(); + PyObject *note = PyUnicode_FromFormat( + "when hashing tuple item %zd", i); + if (note != NULL) { + if (_PyException_AddNote(exc, note) < 0) { + PyErr_Clear(); + } + Py_DECREF(note); + } + else { + PyErr_Clear(); + } + PyErr_SetRaisedException(exc); + } _Py_LeaveRecursiveCall(); return -1; }