https://github.com/python/cpython/commit/ff2577f56eb2170ef0afafa90f78c693df7ca562
commit: ff2577f56eb2170ef0afafa90f78c693df7ca562
branch: main
author: dr-carlos <[email protected]>
committer: iritkatriel <[email protected]>
date: 2025-12-07T21:04:04Z
summary:
gh-141732: Fix `ExceptionGroup` repr changing when original exception sequence
is mutated (#141736)
files:
A
Misc/NEWS.d/next/Core_and_Builtins/2025-11-19-16-40-24.gh-issue-141732.PTetqp.rst
M Doc/library/exceptions.rst
M Include/cpython/pyerrors.h
M Lib/test/test_exception_group.py
M Objects/exceptions.c
diff --git a/Doc/library/exceptions.rst b/Doc/library/exceptions.rst
index 16d42c010f6df0..b5e3a84b4556dd 100644
--- a/Doc/library/exceptions.rst
+++ b/Doc/library/exceptions.rst
@@ -978,6 +978,12 @@ their subgroups based on the types of the contained
exceptions.
raises a :exc:`TypeError` if any contained exception is not an
:exc:`Exception` subclass.
+ .. impl-detail::
+
+ The ``excs`` parameter may be any sequence, but lists and tuples are
+ specifically processed more efficiently here. For optimal performance,
+ pass a tuple as ``excs``.
+
.. attribute:: message
The ``msg`` argument to the constructor. This is a read-only attribute.
diff --git a/Include/cpython/pyerrors.h b/Include/cpython/pyerrors.h
index 6b63d304b0d929..be2e3b641c25cb 100644
--- a/Include/cpython/pyerrors.h
+++ b/Include/cpython/pyerrors.h
@@ -18,6 +18,7 @@ typedef struct {
PyException_HEAD
PyObject *msg;
PyObject *excs;
+ PyObject *excs_str;
} PyBaseExceptionGroupObject;
typedef struct {
diff --git a/Lib/test/test_exception_group.py b/Lib/test/test_exception_group.py
index 5df2c41c6b56bc..ace7ec72917934 100644
--- a/Lib/test/test_exception_group.py
+++ b/Lib/test/test_exception_group.py
@@ -1,4 +1,4 @@
-import collections.abc
+import collections
import types
import unittest
from test.support import skip_emscripten_stack_overflow,
skip_wasi_stack_overflow, exceeds_recursion_limit
@@ -193,6 +193,77 @@ class MyEG(ExceptionGroup):
"MyEG('flat', [ValueError(1), TypeError(2)]), "
"TypeError(2)])"))
+ def test_exceptions_mutation(self):
+ class MyEG(ExceptionGroup):
+ pass
+
+ excs = [ValueError(1), TypeError(2)]
+ eg = MyEG('test', excs)
+
+ self.assertEqual(repr(eg), "MyEG('test', [ValueError(1),
TypeError(2)])")
+ excs.clear()
+
+ # Ensure that clearing the exceptions sequence doesn't change the repr.
+ self.assertEqual(repr(eg), "MyEG('test', [ValueError(1),
TypeError(2)])")
+
+ # Ensure that the args are still as passed.
+ self.assertEqual(eg.args, ('test', []))
+
+ excs = (ValueError(1), KeyboardInterrupt(2))
+ eg = BaseExceptionGroup('test', excs)
+
+ # Ensure that immutable sequences still work fine.
+ self.assertEqual(
+ repr(eg),
+ "BaseExceptionGroup('test', (ValueError(1), KeyboardInterrupt(2)))"
+ )
+
+ # Test non-standard custom sequences.
+ excs = collections.deque([ValueError(1), TypeError(2)])
+ eg = ExceptionGroup('test', excs)
+
+ self.assertEqual(
+ repr(eg),
+ "ExceptionGroup('test', deque([ValueError(1), TypeError(2)]))"
+ )
+ excs.clear()
+
+ # Ensure that clearing the exceptions sequence doesn't change the repr.
+ self.assertEqual(
+ repr(eg),
+ "ExceptionGroup('test', deque([ValueError(1), TypeError(2)]))"
+ )
+
+ def test_repr_raises(self):
+ class MySeq(collections.abc.Sequence):
+ def __init__(self, raises):
+ self.raises = raises
+
+ def __len__(self):
+ return 1
+
+ def __getitem__(self, index):
+ if index == 0:
+ return ValueError(1)
+ raise IndexError
+
+ def __repr__(self):
+ if self.raises:
+ raise self.raises
+ return None
+
+ seq = MySeq(None)
+ with self.assertRaisesRegex(
+ TypeError,
+ r".*MySeq\.__repr__\(\) must return a str, not NoneType"
+ ):
+ ExceptionGroup("test", seq)
+
+ seq = MySeq(ValueError)
+ with self.assertRaises(ValueError):
+ BaseExceptionGroup("test", seq)
+
+
def create_simple_eg():
excs = []
diff --git
a/Misc/NEWS.d/next/Core_and_Builtins/2025-11-19-16-40-24.gh-issue-141732.PTetqp.rst
b/Misc/NEWS.d/next/Core_and_Builtins/2025-11-19-16-40-24.gh-issue-141732.PTetqp.rst
new file mode 100644
index 00000000000000..08420fd5f4d18a
--- /dev/null
+++
b/Misc/NEWS.d/next/Core_and_Builtins/2025-11-19-16-40-24.gh-issue-141732.PTetqp.rst
@@ -0,0 +1,2 @@
+Ensure the :meth:`~object.__repr__` for :exc:`ExceptionGroup` and
:exc:`BaseExceptionGroup` does
+not change when the exception sequence that was original passed in to its
constructor is subsequently mutated.
diff --git a/Objects/exceptions.c b/Objects/exceptions.c
index 244d8f39e2bae5..9a43057b383d29 100644
--- a/Objects/exceptions.c
+++ b/Objects/exceptions.c
@@ -694,12 +694,12 @@ PyTypeObject _PyExc_ ## EXCNAME = { \
#define ComplexExtendsException(EXCBASE, EXCNAME, EXCSTORE, EXCNEW, \
EXCMETHODS, EXCMEMBERS, EXCGETSET, \
- EXCSTR, EXCDOC) \
+ EXCSTR, EXCREPR, EXCDOC) \
static PyTypeObject _PyExc_ ## EXCNAME = { \
PyVarObject_HEAD_INIT(NULL, 0) \
# EXCNAME, \
sizeof(Py ## EXCSTORE ## Object), 0, \
- EXCSTORE ## _dealloc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, \
+ EXCSTORE ## _dealloc, 0, 0, 0, 0, EXCREPR, 0, 0, 0, 0, 0, \
EXCSTR, 0, 0, 0, \
Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, \
PyDoc_STR(EXCDOC), EXCSTORE ## _traverse, \
@@ -792,7 +792,7 @@ StopIteration_traverse(PyObject *op, visitproc visit, void
*arg)
}
ComplexExtendsException(PyExc_Exception, StopIteration, StopIteration,
- 0, 0, StopIteration_members, 0, 0,
+ 0, 0, StopIteration_members, 0, 0, 0,
"Signal the end from iterator.__next__().");
@@ -865,7 +865,7 @@ static PyMemberDef SystemExit_members[] = {
};
ComplexExtendsException(PyExc_BaseException, SystemExit, SystemExit,
- 0, 0, SystemExit_members, 0, 0,
+ 0, 0, SystemExit_members, 0, 0, 0,
"Request to exit from the interpreter.");
/*
@@ -890,6 +890,7 @@ BaseExceptionGroup_new(PyTypeObject *type, PyObject *args,
PyObject *kwds)
PyObject *message = NULL;
PyObject *exceptions = NULL;
+ PyObject *exceptions_str = NULL;
if (!PyArg_ParseTuple(args,
"UO:BaseExceptionGroup.__new__",
@@ -905,6 +906,18 @@ BaseExceptionGroup_new(PyTypeObject *type, PyObject *args,
PyObject *kwds)
return NULL;
}
+ /* Save initial exceptions sequence as a string in case sequence is
mutated */
+ if (!PyList_Check(exceptions) && !PyTuple_Check(exceptions)) {
+ exceptions_str = PyObject_Repr(exceptions);
+ if (exceptions_str == NULL) {
+ /* We don't hold a reference to exceptions, so clear it before
+ * attempting a decref in the cleanup.
+ */
+ exceptions = NULL;
+ goto error;
+ }
+ }
+
exceptions = PySequence_Tuple(exceptions);
if (!exceptions) {
return NULL;
@@ -988,9 +1001,11 @@ BaseExceptionGroup_new(PyTypeObject *type, PyObject
*args, PyObject *kwds)
self->msg = Py_NewRef(message);
self->excs = exceptions;
+ self->excs_str = exceptions_str;
return (PyObject*)self;
error:
- Py_DECREF(exceptions);
+ Py_XDECREF(exceptions);
+ Py_XDECREF(exceptions_str);
return NULL;
}
@@ -1029,6 +1044,7 @@ BaseExceptionGroup_clear(PyObject *op)
PyBaseExceptionGroupObject *self = PyBaseExceptionGroupObject_CAST(op);
Py_CLEAR(self->msg);
Py_CLEAR(self->excs);
+ Py_CLEAR(self->excs_str);
return BaseException_clear(op);
}
@@ -1046,6 +1062,7 @@ BaseExceptionGroup_traverse(PyObject *op, visitproc
visit, void *arg)
PyBaseExceptionGroupObject *self = PyBaseExceptionGroupObject_CAST(op);
Py_VISIT(self->msg);
Py_VISIT(self->excs);
+ Py_VISIT(self->excs_str);
return BaseException_traverse(op, visit, arg);
}
@@ -1063,6 +1080,54 @@ BaseExceptionGroup_str(PyObject *op)
self->msg, num_excs, num_excs > 1 ? "s" : "");
}
+static PyObject *
+BaseExceptionGroup_repr(PyObject *op)
+{
+ PyBaseExceptionGroupObject *self = PyBaseExceptionGroupObject_CAST(op);
+ assert(self->msg);
+
+ PyObject *exceptions_str = NULL;
+
+ /* Use the saved exceptions string for custom sequences. */
+ if (self->excs_str) {
+ exceptions_str = Py_NewRef(self->excs_str);
+ }
+ else {
+ assert(self->excs);
+
+ /* Older versions delegated to BaseException, inserting the current
+ * value of self.args[1]; but this can be mutable and go out-of-sync
+ * with self.exceptions. Instead, use self.exceptions for accuracy,
+ * making it look like self.args[1] for backwards compatibility. */
+ if (PyList_Check(PyTuple_GET_ITEM(self->args, 1))) {
+ PyObject *exceptions_list = PySequence_List(self->excs);
+ if (!exceptions_list) {
+ return NULL;
+ }
+
+ exceptions_str = PyObject_Repr(exceptions_list);
+ Py_DECREF(exceptions_list);
+ }
+ else {
+ exceptions_str = PyObject_Repr(self->excs);
+ }
+
+ if (!exceptions_str) {
+ return NULL;
+ }
+ }
+
+ assert(exceptions_str != NULL);
+
+ const char *name = _PyType_Name(Py_TYPE(self));
+ PyObject *repr = PyUnicode_FromFormat(
+ "%s(%R, %U)", name,
+ self->msg, exceptions_str);
+
+ Py_DECREF(exceptions_str);
+ return repr;
+}
+
/*[clinic input]
@critical_section
BaseExceptionGroup.derive
@@ -1697,7 +1762,7 @@ static PyMethodDef BaseExceptionGroup_methods[] = {
ComplexExtendsException(PyExc_BaseException, BaseExceptionGroup,
BaseExceptionGroup, BaseExceptionGroup_new /* new */,
BaseExceptionGroup_methods, BaseExceptionGroup_members,
- 0 /* getset */, BaseExceptionGroup_str,
+ 0 /* getset */, BaseExceptionGroup_str, BaseExceptionGroup_repr,
"A combination of multiple unrelated exceptions.");
/*
@@ -2425,7 +2490,7 @@ static PyGetSetDef OSError_getset[] = {
ComplexExtendsException(PyExc_Exception, OSError,
OSError, OSError_new,
OSError_methods, OSError_members, OSError_getset,
- OSError_str,
+ OSError_str, 0,
"Base class for I/O related errors.");
@@ -2566,7 +2631,7 @@ static PyMethodDef NameError_methods[] = {
ComplexExtendsException(PyExc_Exception, NameError,
NameError, 0,
NameError_methods, NameError_members,
- 0, BaseException_str, "Name not found globally.");
+ 0, BaseException_str, 0, "Name not found globally.");
/*
* UnboundLocalError extends NameError
@@ -2700,7 +2765,7 @@ static PyMethodDef AttributeError_methods[] = {
ComplexExtendsException(PyExc_Exception, AttributeError,
AttributeError, 0,
AttributeError_methods, AttributeError_members,
- 0, BaseException_str, "Attribute not found.");
+ 0, BaseException_str, 0, "Attribute not found.");
/*
* SyntaxError extends Exception
@@ -2899,7 +2964,7 @@ static PyMemberDef SyntaxError_members[] = {
ComplexExtendsException(PyExc_Exception, SyntaxError, SyntaxError,
0, 0, SyntaxError_members, 0,
- SyntaxError_str, "Invalid syntax.");
+ SyntaxError_str, 0, "Invalid syntax.");
/*
@@ -2959,7 +3024,7 @@ KeyError_str(PyObject *op)
}
ComplexExtendsException(PyExc_LookupError, KeyError, BaseException,
- 0, 0, 0, 0, KeyError_str, "Mapping key not found.");
+ 0, 0, 0, 0, KeyError_str, 0, "Mapping key not found.");
/*
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]