Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b1afcb8
Use `self.exceptions` in `BaseExceptionGroup` repr by supporting cust…
dr-carlos Nov 19, 2025
59bc3c7
Update `ExceptionGroup` tests for new repr and add mutation test
dr-carlos Nov 19, 2025
ca2d21d
Fix typo in test comment
dr-carlos Nov 19, 2025
493ab01
Update docs for `BaseExceptionGroup` repr change
dr-carlos Nov 19, 2025
cd90d51
Add NEWS entry
dr-carlos Nov 19, 2025
177b535
Update `ExceptionGroup` repr in `test_traceback`
dr-carlos Nov 19, 2025
af4f086
Revert "Update `ExceptionGroup` repr in `test_traceback`"
dr-carlos Nov 21, 2025
72918ea
Update `ExceptionGroup` tests to no longer change the repr
dr-carlos Nov 21, 2025
b45402a
Store exceptions copy in `BaseExceptionGroup->excs_orig` and use in `…
dr-carlos Nov 21, 2025
62d72a4
Revert "Update docs for `BaseExceptionGroup` repr change"
dr-carlos Nov 21, 2025
f93ae8f
Remove `BaseExceptionGroup->excs_orig` and reconstruct in `repr` instead
dr-carlos Nov 21, 2025
56eefee
Ensure that the exceptions copy in `BaseExceptionGroup` repr is freed
dr-carlos Nov 21, 2025
79dd615
Move `ExceptionGroup` repr tests into new function
dr-carlos Nov 22, 2025
e9fec3d
Use the exceptions tuple for all non-lists in `ExceptionGroup` repr
dr-carlos Nov 22, 2025
f5acfef
Test custom sequence's `ExceptionGroup` repr
dr-carlos Nov 22, 2025
2fca592
Update code style in `exceptions.c`
dr-carlos Nov 22, 2025
4885a6b
Update code style in `exceptions.c`
dr-carlos Nov 22, 2025
4801399
Remove unused `name` variable with new `%Y` format
dr-carlos Nov 23, 2025
1bcddeb
Revert "Remove unused `name` variable with new `%Y` format"
dr-carlos Nov 23, 2025
ccef7c5
Revert "Update code style in `exceptions.c`"
dr-carlos Nov 23, 2025
9ca573e
Save repr of non-standard sequences in `BaseExceptionGroup` constructor
dr-carlos Nov 25, 2025
9531fe9
Update tests for non-standard exceptions sequence in `ExceptionGroup`
dr-carlos Nov 25, 2025
1da9238
Document implementation details for `ExceptionGroup` exception sequence
dr-carlos Nov 25, 2025
fcaf314
Update Misc/NEWS.d/next/Core_and_Builtins/2025-11-19-16-40-24.gh-issu…
iritkatriel Nov 26, 2025
2c67e71
Merge branch 'main' into exception-group-repr
iritkatriel Nov 26, 2025
d962581
Reword 'optimum' to 'optimal' in `exceptions.rst`
dr-carlos Nov 26, 2025
ce39eec
Update NEWS Entry
dr-carlos Nov 26, 2025
9211d01
Handle error on `__repr__` for exceptions sequence passed to `Excepti…
dr-carlos Nov 26, 2025
737206a
Change grammar in `exceptions.c`
dr-carlos Nov 26, 2025
8d17ef3
Update comment relating to `Py_XNewRef` in `BaseExceptionGroup_repr()`
dr-carlos Nov 26, 2025
383af2f
Handle `PySequence_List()` and `PyObject_Repr()` errors in `BaseExcep…
dr-carlos Nov 26, 2025
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
6 changes: 6 additions & 0 deletions Doc/library/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 optimum performance,
pass a tuple as ``excs``.

.. attribute:: message

The ``msg`` argument to the constructor. This is a read-only attribute.
Expand Down
1 change: 1 addition & 0 deletions Include/cpython/pyerrors.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ typedef struct {
PyException_HEAD
PyObject *msg;
PyObject *excs;
PyObject *excs_str;
} PyBaseExceptionGroupObject;

typedef struct {
Expand Down
34 changes: 33 additions & 1 deletion Lib/test/test_exception_group.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -193,6 +193,38 @@ 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), f"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 create_simple_eg():
excs = []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Ensure the 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.
68 changes: 58 additions & 10 deletions Objects/exceptions.c
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand Down Expand Up @@ -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__().");


Expand Down Expand Up @@ -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.");

/*
Expand All @@ -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__",
Expand All @@ -905,6 +906,11 @@ BaseExceptionGroup_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
return NULL;
}

/* Save initial exceptions sequence as a string incase sequence is mutated */
if (!PyList_Check(exceptions) && !PyTuple_Check(exceptions)) {
exceptions_str = PyObject_Repr(exceptions);
}

exceptions = PySequence_Tuple(exceptions);
if (!exceptions) {
return NULL;
Expand Down Expand Up @@ -988,9 +994,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_str);
return NULL;
}

Expand Down Expand Up @@ -1029,6 +1037,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);
}

Expand All @@ -1046,6 +1055,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);
}

Expand All @@ -1063,6 +1073,42 @@ 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 = Py_XNewRef(self->excs_str);
Copy link
Member

@picnixz picnixz Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead:

PyObject *exception_str = NULL;
if (!self->excs_str) {
    exception_str = ...
}
else {
    exception_str = Py_NewRef(self->excs_str);
}

assert(exception_str != NULL);
/* format */
Py_DECREF(exception_str);

It's not faster, but I think it's clearer to the reader. Up to you to make the change though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't sure so I attempted to clarify with a comment instead. Happy to do something different if that's preferable :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem wasn't the comment but rather the fact that we are using Py_XNewRef and then checking its result instead of first checking whethe Py_NewRef could be called.


/* If the initial exceptions string was not saved in the constructor. */
if (!exceptions_str) {
assert(self->excs);

/* Older versions of this code delegated to BaseException's repr, inserting
* the current value of self.args[1]. However, mutating that sequence makes
* the repr appear as if the ExceptionGroup itself has changed, which it hasn't.
* So we use the actual exceptions tuple for accuracy, but make it look like the
* original exception sequence if possible, for backwards compatibility. */
if (PyList_Check(PyTuple_GET_ITEM(self->args, 1))) {
PyObject *exceptions_list = PySequence_List(self->excs);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to check the result of PySequence_List even though we've already run a PyList_Check?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Because the errors are not only on the type.

exceptions_str = PyObject_Repr(exceptions_list);
Py_DECREF(exceptions_list);
}
else {
exceptions_str = PyObject_Repr(self->excs);
}
}

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
Expand Down Expand Up @@ -1682,6 +1728,8 @@ static PyMemberDef BaseExceptionGroup_members[] = {
PyDoc_STR("exception message")},
{"exceptions", _Py_T_OBJECT, offsetof(PyBaseExceptionGroupObject, excs), Py_READONLY,
PyDoc_STR("nested exceptions")},
{"_exceptions_str", _Py_T_OBJECT, offsetof(PyBaseExceptionGroupObject, excs_str), Py_READONLY,
PyDoc_STR("private string representation of initial exceptions sequence")},
{NULL} /* Sentinel */
};

Expand All @@ -1697,7 +1745,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.");

/*
Expand Down Expand Up @@ -2425,7 +2473,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.");


Expand Down Expand Up @@ -2566,7 +2614,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
Expand Down Expand Up @@ -2700,7 +2748,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
Expand Down Expand Up @@ -2899,7 +2947,7 @@ static PyMemberDef SyntaxError_members[] = {

ComplexExtendsException(PyExc_Exception, SyntaxError, SyntaxError,
0, 0, SyntaxError_members, 0,
SyntaxError_str, "Invalid syntax.");
SyntaxError_str, 0, "Invalid syntax.");


/*
Expand Down Expand Up @@ -2959,7 +3007,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.");


/*
Expand Down
Loading