diff --git a/Doc/library/exceptions.rst b/Doc/library/exceptions.rst index b5ba86f1b19223..fa095cce0c2079 100644 --- a/Doc/library/exceptions.rst +++ b/Doc/library/exceptions.rst @@ -159,6 +159,35 @@ The following exceptions are used mostly as base classes for other exceptions. .. versionadded:: 3.11 + .. method:: subgroup(condition) + + Return an :class:`BaseExceptionGroup` or :class:`ExceptionGroup` that + contains this exception if it matches a *condition*, or ``None`` if + the result is empty. + + The condition can be an exception type or tuple of exception types, in + which case the exception is checked for a match using the same check + that is used in an ``except`` clause. + + The condition can also be a callable (other than a type object) that + accepts an exception as its single argument and returns true if the + exception should be matched. + + .. seealso:: :meth:`BaseExceptionGroup.subgroup` + + .. versionadded:: 3.14 + + .. method:: split(condition) + + Like :meth:`subgroup`, but returns the pair ``(match, rest)`` where + ``match`` is ``subgroup(condition)`` and ``rest`` is the remaining + non-matching part (either ``None`` or an exception group wrapping + this exception). + + .. seealso:: :meth:`BaseExceptionGroup.split` + + .. versionadded:: 3.14 + .. exception:: Exception @@ -975,6 +1004,8 @@ their subgroups based on the types of the contained exceptions. including the top-level and any nested exception groups. If the condition is true for such an exception group, it is included in the result in full. + .. seealso:: :meth:`BaseException.subgroup` + .. versionadded:: 3.13 ``condition`` can be any callable which is not a type object. @@ -984,6 +1015,8 @@ their subgroups based on the types of the contained exceptions. is ``subgroup(condition)`` and ``rest`` is the remaining non-matching part. + .. seealso:: :meth:`BaseException.split` + .. method:: derive(excs) Returns an exception group with the same :attr:`message`, but which diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index b389e6da4c0ac3..806eb169cdfb67 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -194,6 +194,12 @@ Other language changes :mod:`copyable `. (Contributed by Serhiy Storchaka in :gh:`125767`.) +* Add the :meth:`~BaseException.subgroup` and :meth:`~BaseException.split` + methods for matching and splitting leaf exceptions respectively. This is + roughly equivalent to wrapping the exception in an exception group and + calling the :exc:`BaseExceptionGroup` homonymous methods. + (Contributed by Bénédikt Tran in :gh:`125825`.) + New modules =========== diff --git a/Include/internal/pycore_global_objects_fini_generated.h b/Include/internal/pycore_global_objects_fini_generated.h index 2fd7d5d13a98b2..6ebe00511b7f24 100644 --- a/Include/internal/pycore_global_objects_fini_generated.h +++ b/Include/internal/pycore_global_objects_fini_generated.h @@ -1213,6 +1213,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) { _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(source)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(source_traceback)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(spam)); + _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(split)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(src)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(src_dir_fd)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(stacklevel)); @@ -1231,6 +1232,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) { _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(strict_mode)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(string)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(sub_key)); + _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(subgroup)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(symmetric_difference_update)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(tabsize)); _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(tag)); diff --git a/Include/internal/pycore_global_strings.h b/Include/internal/pycore_global_strings.h index fc3871570cc49d..d3f64da356aec0 100644 --- a/Include/internal/pycore_global_strings.h +++ b/Include/internal/pycore_global_strings.h @@ -702,6 +702,7 @@ struct _Py_global_strings { STRUCT_FOR_ID(source) STRUCT_FOR_ID(source_traceback) STRUCT_FOR_ID(spam) + STRUCT_FOR_ID(split) STRUCT_FOR_ID(src) STRUCT_FOR_ID(src_dir_fd) STRUCT_FOR_ID(stacklevel) @@ -720,6 +721,7 @@ struct _Py_global_strings { STRUCT_FOR_ID(strict_mode) STRUCT_FOR_ID(string) STRUCT_FOR_ID(sub_key) + STRUCT_FOR_ID(subgroup) STRUCT_FOR_ID(symmetric_difference_update) STRUCT_FOR_ID(tabsize) STRUCT_FOR_ID(tag) diff --git a/Include/internal/pycore_runtime_init_generated.h b/Include/internal/pycore_runtime_init_generated.h index 3b80e265b0ca50..d2bcd2fdd5e178 100644 --- a/Include/internal/pycore_runtime_init_generated.h +++ b/Include/internal/pycore_runtime_init_generated.h @@ -1211,6 +1211,7 @@ extern "C" { INIT_ID(source), \ INIT_ID(source_traceback), \ INIT_ID(spam), \ + INIT_ID(split), \ INIT_ID(src), \ INIT_ID(src_dir_fd), \ INIT_ID(stacklevel), \ @@ -1229,6 +1230,7 @@ extern "C" { INIT_ID(strict_mode), \ INIT_ID(string), \ INIT_ID(sub_key), \ + INIT_ID(subgroup), \ INIT_ID(symmetric_difference_update), \ INIT_ID(tabsize), \ INIT_ID(tag), \ diff --git a/Include/internal/pycore_unicodeobject_generated.h b/Include/internal/pycore_unicodeobject_generated.h index eb2eca06ec4d4f..9a83970154f776 100644 --- a/Include/internal/pycore_unicodeobject_generated.h +++ b/Include/internal/pycore_unicodeobject_generated.h @@ -2604,6 +2604,10 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) { _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); assert(PyUnicode_GET_LENGTH(string) != 1); + string = &_Py_ID(split); + _PyUnicode_InternStatic(interp, &string); + assert(_PyUnicode_CheckConsistency(string, 1)); + assert(PyUnicode_GET_LENGTH(string) != 1); string = &_Py_ID(src); _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); @@ -2676,6 +2680,10 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) { _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); assert(PyUnicode_GET_LENGTH(string) != 1); + string = &_Py_ID(subgroup); + _PyUnicode_InternStatic(interp, &string); + assert(_PyUnicode_CheckConsistency(string, 1)); + assert(PyUnicode_GET_LENGTH(string) != 1); string = &_Py_ID(symmetric_difference_update); _PyUnicode_InternStatic(interp, &string); assert(_PyUnicode_CheckConsistency(string, 1)); diff --git a/Lib/test/test_exception_group.py b/Lib/test/test_exception_group.py index b4fc290b1f32b6..babe3091b8c80e 100644 --- a/Lib/test/test_exception_group.py +++ b/Lib/test/test_exception_group.py @@ -308,32 +308,39 @@ class ExceptionGroupSubgroupTests(ExceptionGroupTestBase): def setUp(self): self.eg = create_simple_eg() self.eg_template = [ValueError(1), TypeError(int), ValueError(2)] + self.exc = ValueError('a') def test_basics_subgroup_split__bad_arg_type(self): - class C: - pass - - bad_args = ["bad arg", - C, - OSError('instance not type'), - [OSError, TypeError], - (OSError, 42), - ] - for arg in bad_args: - with self.assertRaises(TypeError): - self.eg.subgroup(arg) - with self.assertRaises(TypeError): - self.eg.split(arg) + for obj in (self.eg, self.exc): + for arg in ( + "bad arg", + type('NewClass', (), {}), + OSError('instance not type'), + [OSError, TypeError], + (OSError, 42), + ): + with self.subTest(obj_type=type(obj), arg=arg): + with self.assertRaises(TypeError): + obj.subgroup(arg) + + with self.assertRaises(TypeError): + obj.split(arg) def test_basics_subgroup_by_type__passthrough(self): - eg = self.eg - self.assertIs(eg, eg.subgroup(BaseException)) - self.assertIs(eg, eg.subgroup(Exception)) - self.assertIs(eg, eg.subgroup(BaseExceptionGroup)) - self.assertIs(eg, eg.subgroup(ExceptionGroup)) + for exc_type in ( + BaseException, Exception, + BaseExceptionGroup, ExceptionGroup, + ): + with self.subTest(exc_type): + self.assertIs(self.eg, self.eg.subgroup(exc_type)) + + wrapped = self.exc.subgroup(ValueError) + self.assertEqual(wrapped.message, str(self.exc)) + self.assertMatchesTemplate(wrapped, ExceptionGroup, [self.exc]) def test_basics_subgroup_by_type__no_match(self): self.assertIsNone(self.eg.subgroup(OSError)) + self.assertIsNone(self.exc.subgroup(OSError)) def test_basics_subgroup_by_type__match(self): eg = self.eg @@ -349,15 +356,24 @@ def test_basics_subgroup_by_type__match(self): self.assertEqual(subeg.message, eg.message) self.assertMatchesTemplate(subeg, ExceptionGroup, template) + wrapped = self.exc.subgroup(ValueError) + self.assertEqual(wrapped.message, str(self.exc)) + self.assertMatchesTemplate(wrapped, ExceptionGroup, [self.exc]) + def test_basics_subgroup_by_predicate__passthrough(self): f = lambda e: True for callable in [f, Predicate(f), Predicate(f).method]: self.assertIs(self.eg, self.eg.subgroup(callable)) + wrapped = self.exc.subgroup(callable) + self.assertEqual(wrapped.message, str(self.exc)) + self.assertMatchesTemplate(wrapped, ExceptionGroup, [self.exc]) + def test_basics_subgroup_by_predicate__no_match(self): f = lambda e: False for callable in [f, Predicate(f), Predicate(f).method]: self.assertIsNone(self.eg.subgroup(callable)) + self.assertIsNone(self.exc.subgroup(callable)) def test_basics_subgroup_by_predicate__match(self): eg = self.eg @@ -371,40 +387,61 @@ def test_basics_subgroup_by_predicate__match(self): f = lambda e: isinstance(e, match_type) for callable in [f, Predicate(f), Predicate(f).method]: with self.subTest(callable=callable): - subeg = eg.subgroup(f) + subeg = eg.subgroup(callable) self.assertEqual(subeg.message, eg.message) self.assertMatchesTemplate(subeg, ExceptionGroup, template) + f = lambda e: isinstance(e, ValueError) + for callable in [f, Predicate(f), Predicate(f).method]: + group = self.exc.subgroup(callable) + self.assertEqual(group.message, str(self.exc)) + self.assertMatchesTemplate(group, ExceptionGroup, [self.exc]) + class ExceptionGroupSplitTests(ExceptionGroupTestBase): def setUp(self): self.eg = create_simple_eg() self.eg_template = [ValueError(1), TypeError(int), ValueError(2)] + self.exc = ValueError('a') + def test_basics_split_by_type__passthrough(self): - for E in [BaseException, Exception, - BaseExceptionGroup, ExceptionGroup]: - match, rest = self.eg.split(E) - self.assertMatchesTemplate( - match, ExceptionGroup, self.eg_template) - self.assertIsNone(rest) + for exc_type in ( + BaseException, Exception, + BaseExceptionGroup, ExceptionGroup, + ): + with self.subTest(exc_type): + match, rest = self.eg.split(exc_type) + self.assertMatchesTemplate(match, ExceptionGroup, + self.eg_template) + self.assertIsNone(rest) + + match, rest = self.exc.split(exc_type) + self.assertEqual(match.message, str(self.exc)) + self.assertMatchesTemplate(match, ExceptionGroup, [self.exc]) + self.assertIsNone(rest) def test_basics_split_by_type__no_match(self): match, rest = self.eg.split(OSError) self.assertIsNone(match) - self.assertMatchesTemplate( - rest, ExceptionGroup, self.eg_template) + self.assertMatchesTemplate(rest, ExceptionGroup, self.eg_template) + + match, rest = self.exc.split(OSError) + self.assertIsNone(match) + self.assertMatchesTemplate(rest, ExceptionGroup, [self.exc]) def test_basics_split_by_type__match(self): eg = self.eg - VE = ValueError - TE = TypeError testcases = [ - # (matcher, match_template, rest_template) - (VE, [VE(1), VE(2)], [TE(int)]), - (TE, [TE(int)], [VE(1), VE(2)]), - ((VE, TE), self.eg_template, None), - ((OSError, VE), [VE(1), VE(2)], [TE(int)]), + # (exc_or_eg, matcher, match_template, rest_template) + (ValueError, [ValueError(1), ValueError(2)], [TypeError(int)]), + (TypeError, [TypeError(int)], [ValueError(1), ValueError(2)]), + ((ValueError, TypeError), self.eg_template, None), + ( + (OSError, ValueError), + [ValueError(1), ValueError(2)], + [TypeError(int)], + ), ] for match_type, match_template, rest_template in testcases: @@ -419,6 +456,11 @@ def test_basics_split_by_type__match(self): else: self.assertIsNone(rest) + match, rest = self.exc.split(ValueError) + self.assertEqual(match.message, str(self.exc)) + self.assertMatchesTemplate(match, ExceptionGroup, [self.exc]) + self.assertIsNone(rest) + def test_basics_split_by_predicate__passthrough(self): f = lambda e: True for callable in [f, Predicate(f), Predicate(f).method]: @@ -426,6 +468,11 @@ def test_basics_split_by_predicate__passthrough(self): self.assertMatchesTemplate(match, ExceptionGroup, self.eg_template) self.assertIsNone(rest) + match, rest = self.exc.split(callable) + self.assertEqual(match.message, str(self.exc)) + self.assertMatchesTemplate(match, ExceptionGroup, [self.exc]) + self.assertIsNone(rest) + def test_basics_split_by_predicate__no_match(self): f = lambda e: False for callable in [f, Predicate(f), Predicate(f).method]: @@ -433,15 +480,17 @@ def test_basics_split_by_predicate__no_match(self): self.assertIsNone(match) self.assertMatchesTemplate(rest, ExceptionGroup, self.eg_template) + match, rest = self.exc.split(callable) + self.assertIsNone(match) + self.assertMatchesTemplate(rest, ExceptionGroup, [self.exc]) + def test_basics_split_by_predicate__match(self): eg = self.eg - VE = ValueError - TE = TypeError testcases = [ # (matcher, match_template, rest_template) - (VE, [VE(1), VE(2)], [TE(int)]), - (TE, [TE(int)], [VE(1), VE(2)]), - ((VE, TE), self.eg_template, None), + (ValueError, [ValueError(1), ValueError(2)], [TypeError(int)]), + (TypeError, [TypeError(int)], [ValueError(1), ValueError(2)]), + ((ValueError, TypeError), self.eg_template, None), ] for match_type, match_template, rest_template in testcases: @@ -456,6 +505,13 @@ def test_basics_split_by_predicate__match(self): self.assertMatchesTemplate( rest, ExceptionGroup, rest_template) + f = lambda e: isinstance(e, ValueError) + for callable in [f, Predicate(f), Predicate(f).method]: + match, rest = self.exc.split(callable) + self.assertEqual(match.message, str(self.exc)) + self.assertMatchesTemplate(match, ExceptionGroup, [self.exc]) + self.assertIsNone(rest) + class DeepRecursionInSplitAndSubgroup(unittest.TestCase): def make_deep_eg(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2024-10-23-17-25-01.gh-issue-125825.94Wkz7.rst b/Misc/NEWS.d/next/Core_and_Builtins/2024-10-23-17-25-01.gh-issue-125825.94Wkz7.rst new file mode 100644 index 00000000000000..a18b6ecd763f35 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2024-10-23-17-25-01.gh-issue-125825.94Wkz7.rst @@ -0,0 +1,4 @@ +Add :meth:`~BaseException.split` and :meth:`~BaseException.subgroup` to +allow splitting and matching simple exceptions as if they were +:ref:`exception groups ` of one exception. Patch by +Bénédikt Tran. diff --git a/Objects/exceptions.c b/Objects/exceptions.c index 6fbe0f197eaebf..c2f3d45aef118e 100644 --- a/Objects/exceptions.c +++ b/Objects/exceptions.c @@ -269,18 +269,77 @@ BaseException_add_note(PyObject *self, PyObject *note) Py_RETURN_NONE; } +/* + * Return an exception group wrapping 'self'. + * + * If 'self' is an exception group, a strong + * reference to 'self' is returned instead. + */ +static PyObject * +base_exception_as_group(PyObject *self) +{ + if (!PyExceptionInstance_Check(self)) { + PyErr_BadInternalCall(); + return NULL; + } + + if (_PyBaseExceptionGroup_Check(self)) { + return Py_NewRef(self); + } + + PyObject *message = PyObject_Str(self); + if (message == NULL) { + return NULL; + } + PyObject *wrapped = PyTuple_New(1); + if (wrapped == NULL) { + Py_DECREF(message); + return NULL; + } + PyTuple_SET_ITEM(wrapped, 0, Py_NewRef(self)); + PyObject *group = PyObject_CallFunctionObjArgs(PyExc_BaseExceptionGroup, + message, wrapped, NULL); + Py_DECREF(wrapped); + Py_DECREF(message); + return group; +} + +static PyObject * +BaseException_subgroup(PyObject *self, PyObject *matcher) +{ + PyObject *group = base_exception_as_group(self); + if (group == NULL) { + return NULL; + } + PyObject *res = PyObject_CallMethodOneArg(group, &_Py_ID(subgroup), matcher); + Py_DECREF(group); + return res; +} + +static PyObject * +BaseException_split(PyObject *self, PyObject *matcher) +{ + PyObject *group = base_exception_as_group(self); + if (group == NULL) { + return NULL; + } + PyObject *res = PyObject_CallMethodOneArg(group, &_Py_ID(split), matcher); + Py_DECREF(group); + return res; +} + PyDoc_STRVAR(add_note_doc, "Exception.add_note(note) --\n\ add a note to the exception"); static PyMethodDef BaseException_methods[] = { - {"__reduce__", (PyCFunction)BaseException_reduce, METH_NOARGS }, - {"__setstate__", (PyCFunction)BaseException_setstate, METH_O }, - {"with_traceback", (PyCFunction)BaseException_with_traceback, METH_O, - with_traceback_doc}, - {"add_note", (PyCFunction)BaseException_add_note, METH_O, - add_note_doc}, - {NULL, NULL, 0, NULL}, + {"__reduce__", (PyCFunction)BaseException_reduce, METH_NOARGS, NULL}, + {"__setstate__", BaseException_setstate, METH_O, NULL}, + {"with_traceback", BaseException_with_traceback, METH_O, with_traceback_doc}, + {"add_note", BaseException_add_note, METH_O, add_note_doc}, + {"split", BaseException_split, METH_O, NULL}, + {"subgroup", BaseException_subgroup, METH_O, NULL}, + {NULL, NULL, 0, NULL}, }; static PyObject *