Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Include/internal/pycore_pyerrors.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
94 changes: 94 additions & 0 deletions Lib/test/test_dict.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import collections
import collections.abc
import functools
import gc
import pickle
import random
Expand Down Expand Up @@ -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]
Expand Down
96 changes: 95 additions & 1 deletion Lib/test/test_set.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import collections.abc
import copy
import functools
import gc
import itertools
import operator
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion Objects/dictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
6 changes: 3 additions & 3 deletions Objects/setobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
58 changes: 58 additions & 0 deletions Python/errors.c
Original file line number Diff line number Diff line change
Expand Up @@ -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__), &notes) < 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)
{
Expand Down
Loading