Skip to content

Commit c6f30fc

Browse files
committed
PEP-785: initial draft
1 parent 7a093da commit c6f30fc

File tree

2 files changed

+388
-0
lines changed

2 files changed

+388
-0
lines changed

.github/CODEOWNERS

+1
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,7 @@ peps/pep-0783.rst @hoodmane @ambv
665665
peps/pep-0784.rst @gpshead
666666
# ...
667667
peps/pep-0789.rst @njsmith
668+
peps/pep-0790.rst @gpshead
668669
# ...
669670
peps/pep-0801.rst @warsaw
670671
# ...

peps/pep-0785.rst

+387
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
PEP: 785
2+
Title: New methods for easier handling of :class:`ExceptionGroup`\ s
3+
Author: Zac Hatfield-Dodds <[email protected]>
4+
Sponsor: Gregory P. Smith <[email protected]>
5+
Status: Draft
6+
Type: Standards Track
7+
Created: 08-Apr-2025
8+
Python-Version: 3.14
9+
10+
Abstract
11+
========
12+
13+
As :pep:`654` :class:`ExceptionGroup` has come into widespread use across the
14+
Python community, some common but awkward patterns have emerged. We therefore
15+
propose adding two new methods to exception objects:
16+
17+
- :meth:`!BaseExceptionGroup.flat_exceptions`, returning the 'leaf' exceptions as
18+
a list, with each traceback composited from any intermediate groups.
19+
20+
- :meth:`!BaseException.preserve_context`, a context manager which
21+
saves and restores the :attr:`!self.__context__` attribute of ``self``,
22+
so that raising the exception within an ``except:`` block.
23+
24+
We expect this to enable more concise expression of error handling logic in
25+
many medium-complexity cases. Without them, exception-group handlers will
26+
continue to discard intermediate tracebacks and mis-handle ``__context__``
27+
exceptions, to the detriment of anyone debugging async code.
28+
29+
30+
Motivation
31+
==========
32+
33+
As exception groups come into widespread use, library authors and end users
34+
often write code to process or respond to individual leaf exceptions, for
35+
example when implementing middleware, error logging, or response handlers in
36+
a web framework.
37+
38+
`Searching GitHub`__ found four implementations of :meth:`!flat_exceptions` by
39+
various names in the first sixty hits, of which none handle
40+
tracebacks.\ [#numbers]_ The same search found thirteen cases where
41+
:meth:`!.flat_exceptions` could be used. We therefore believe that providing
42+
a method on the :class:`BaseException` type with proper traceback preservation
43+
will improve error-handling and debugging experiences across the ecosystem.
44+
45+
__ https://github.com/search?q=%2Ffor+%5Cw%2B+in+%5Beg%5D%5Cw*%5C.exceptions%3A%2F+language%3APython&type=code
46+
47+
The rise of exception groups has also made re-raising exceptions caught by an
48+
earlier handler much more common: for example, web-server middleware might
49+
unwrap ``HTTPException`` if that is the sole leaf of a group:
50+
51+
.. code-block:: python
52+
53+
except* HTTPException as group:
54+
first, *rest = group.flat_exceptions() # get the whole traceback :-)
55+
if not rest:
56+
raise first
57+
raise
58+
59+
However, this innocent-seeming code has a problem: ``raise first`` will do
60+
``first.__context__ = group`` as a side effect. This discards the original
61+
context of the error, which may contain crucial information to understand why
62+
the exception was raised. In many production apps it also causes tracebacks
63+
to balloon from hundreds of lines, to tens or even `hundreds of thousands of
64+
lines`__ - a volume which makes understanding errors far more difficult than
65+
it should be.
66+
67+
__ https://github.com/python-trio/trio/issues/2001#issuecomment-931928509
68+
69+
70+
A new :meth:`!BaseException.preserve_context` method would be a discoverable,
71+
readable, and easy-to-use solution for these cases.
72+
73+
74+
Specification
75+
=============
76+
77+
A new method ``flat_exceptions()`` will be added to ``BaseExceptionGroup``, with the
78+
following signature:
79+
80+
.. code-block:: python
81+
82+
def flat_exceptions(self, *, fix_tracebacks=True) -> list[BaseException]:
83+
"""
84+
Return a flat list of all 'leaf' exceptions in the group.
85+
86+
If fix_tracebacks is True, each leaf will have the traceback replaced
87+
with a composite so that frames attached to intermediate groups are
88+
still visible when debugging. Pass fix_tracebacks=False to disable
89+
this modification, e.g. if you expect to raise the group unchanged.
90+
"""
91+
92+
A new method ``preserve_context()`` will be added to ``BaseException``, with the
93+
following signature:
94+
95+
.. code-block:: python
96+
97+
def preserve_context(self) -> contextlib.AbstractContextManager[Self]:
98+
"""
99+
Context manager that preserves the exception's __context__ attribute.
100+
101+
When entering the context, the current values of __context__ is saved.
102+
When exiting, the saved value is restored, which allows raising an
103+
exception inside an except block without changing its context chain.
104+
"""
105+
106+
Usage example:
107+
108+
.. code-block:: python
109+
110+
# We're an async web framework, where user code can raise an HTTPException
111+
# to return a particular HTTP error code to the client. However, it may
112+
# (or may not) be raised inside a TaskGroup, so we need to use `except*`;
113+
# and if there are *multiple* such exceptions we'll treat that as a bug.
114+
try:
115+
user_code_here()
116+
except* HTTPException as group:
117+
first, *rest = group.flat_exceptions()
118+
if rest:
119+
raise # handled by internal-server-error middleware
120+
... # logging, cache updates, etc.
121+
with first.preserve_context():
122+
raise first
123+
124+
Without ``.preserve_context()``, this could would have to either:
125+
126+
* arrange for the exception to be raised *after* the ``except*`` block,
127+
making code difficult to follow in nontrivial cases, or
128+
* discard the existing ``__context__`` of the ``first`` exception, replacing
129+
it with an ``ExceptionGroup`` which is simply an implementation detail, or
130+
* use ``try/except`` instead of ``except*``, handling the possibility that
131+
the group doesn't contain an ``HTTPException`` at all,[#catch-raw-group]_ or
132+
* implement the semantics of ``.preserve_context()`` inline::
133+
134+
prev_ctx = first.__context__
135+
try:
136+
raise first # or `raise first from None`, etc.
137+
finally:
138+
first.__context__ = prev_ctx
139+
del prev_ctx # break gc cycle
140+
141+
which is not *literally unheard-of*, but remains very very rare.
142+
143+
144+
Backwards Compatibility
145+
=======================
146+
147+
Adding new methods to built-in classes, especially those as widely used as
148+
``BaseException``, can have substantial impacts. However, GitHub search shows
149+
no collisions for these method names (`zero hits <flat-exceptions>`_ and
150+
`three unrelated hits <preserve-context>`_ respectively). If user-defined
151+
methods with these names exist in private code they will shadow those proposed
152+
in the PEP, without changing runtime behavior.
153+
154+
.. _flat-exceptions: https://github.com/search?q=%2F%5C.flat_exceptions%5C%28%2F+language%3APython&type=code
155+
.. _preserve-context: https://github.com/search?q=%2F%5C.preserve_context%5C%28%2F+language%3APython&type=code
156+
157+
158+
How to Teach This
159+
=================
160+
161+
Working with exception groups is an intermediate-to-advanced topic, unlikely
162+
to arise for beginning programmers. We therefore suggest teaching this topic
163+
via documentation, and via just-in-time feedback from static analysis tools.
164+
In intermediate classes, we recommend teaching ``.flat_exceptions()`` together
165+
with the ``.split()`` and ``.subgroup()`` methods, and mentioning
166+
``.preserve_context()`` as an advanced option to address specific pain points.
167+
168+
Both the API reference and the existing `ExceptionGroup tutorial
169+
<https://docs.python.org/3/tutorial/errors.html#exception-groups>`_ should
170+
be updated to demonstrate and explain the new methods. The tutorial should
171+
include examples of common patterns where ``.flat_exceptions()`` and
172+
``.preserve_context()`` help simplify error handling logic. Downstream
173+
libraries which often use exception groups could include similar docs.
174+
175+
We have also designed lint rules for inclusion in ``flake8-async`` which will
176+
suggest using ``.flat_exceptions()`` when iterating over ``group.exceptions``
177+
or re-raising a leaf exception, and suggest using ``.preserve_context()`` when
178+
re-raising a leaf exception inside an ``except*`` block would override any
179+
existing context.
180+
181+
182+
Reference Implementation
183+
========================
184+
185+
While the methods on built-in exceptions will be implemented in C if this PEP
186+
is accepted, we hope that the following Python implementation will be useful
187+
on older versions of Python, and can demonstrate the intended semantics.
188+
189+
We have found these helper functions quite useful when working with
190+
:class:`ExceptionGroup`\ s in a large production codebase.
191+
192+
A ``flat_exceptions()`` helper function
193+
---------------------------------------
194+
195+
.. code-block:: python
196+
197+
import copy
198+
import types
199+
from types import TracebackType
200+
201+
202+
def flat_exceptions(
203+
self: BaseExceptionGroup, *, fix_traceback: bool = True
204+
) -> list[BaseException]:
205+
"""
206+
Return a flat list of all 'leaf' exceptions.
207+
208+
If fix_tracebacks is True, each leaf will have the traceback replaced
209+
with a composite so that frames attached to intermediate groups are
210+
still visible when debugging. Pass fix_tracebacks=False to disable
211+
this modification, e.g. if you expect to raise the group unchanged.
212+
"""
213+
214+
def _flatten(group: BaseExceptionGroup, parent_tb: TracebackType | None = None):
215+
group_tb = group.__traceback__
216+
combined_tb = _combine_tracebacks(parent_tb, group_tb)
217+
result = []
218+
for exc in group.exceptions:
219+
if isinstance(exc, BaseExceptionGroup):
220+
result.extend(_flatten(exc, combined_tb))
221+
elif fix_tracebacks:
222+
tb = _combine_tracebacks(combined_tb, exc.__traceback__)
223+
result.append(exc.with_traceback(tb))
224+
else:
225+
result.append(exc)
226+
return result
227+
228+
return _flatten(self)
229+
230+
231+
def _combine_tracebacks(
232+
tb1: TracebackType | None,
233+
tb2: TracebackType | None,
234+
) -> TracebackType | None:
235+
"""
236+
Combine two tracebacks, putting tb1 frames before tb2 frames.
237+
238+
If either is None, return the other.
239+
"""
240+
if tb1 is None:
241+
return tb2
242+
if tb2 is None:
243+
return tb1
244+
245+
# Convert tb1 to a list of frames
246+
frames = []
247+
current = tb1
248+
while current is not None:
249+
frames.append((current.tb_frame, current.tb_lasti, current.tb_lineno))
250+
current = current.tb_next
251+
252+
# Create a new traceback starting with tb2
253+
new_tb = tb2
254+
255+
# Add frames from tb1 to the beginning (in reverse order)
256+
for frame, lasti, lineno in reversed(frames):
257+
new_tb = types.TracebackType(
258+
tb_next=new_tb, tb_frame=frame, tb_lasti=lasti, tb_lineno=lineno
259+
)
260+
261+
return new_tb
262+
263+
264+
A ``preserve_context()`` context manager
265+
----------------------------------------
266+
267+
.. code-block:: python
268+
269+
class preserve_context:
270+
def __init__(self, exc: BaseException):
271+
self.__exc = exc
272+
self.__context = exc.__context__
273+
274+
def __enter__(self):
275+
return self.__exc
276+
277+
def __exit__(self, exc_type, exc_value, traceback):
278+
assert exc_value is self.__exc, f"did not raise the expected exception {self.__exc!r}"
279+
exc_value.__context__ = self.__context
280+
del self.__context # break gc cycle
281+
282+
283+
Rejected Ideas
284+
==============
285+
286+
Add utility functions instead of methods
287+
----------------------------------------
288+
289+
Rather than adding methods to exceptions, we could provide utility functions
290+
like the reference implementations above.
291+
292+
There are however several reasons to prefer methods: there's no obvious place
293+
where helper functions should live, they take exactly one argument which must
294+
be an instance of ``BaseException``, and methods are both more convenient and
295+
more discoverable.
296+
297+
298+
Add ``BaseException.as_group()`` (or group methods)
299+
---------------------------------------------------
300+
301+
Our survey of ``ExceptionGroup``-related error handling code also observed
302+
many cases of duplicated logic to handle both a bare exception, and the same
303+
kind of exception inside a group (often incorrectly, motivating
304+
``.flat_exceptions()``).
305+
306+
We briefly `proposed <https://github.com/python/cpython/issues/125825>`__
307+
adding ``.split(...)`` and ``.subgroup(...)`` methods too all exceptions,
308+
before considering ``.flat_exceptions()`` made us feel this was too clumsy.
309+
As a cleaner alternative, we sketched out an ``.as_group()`` method:
310+
311+
.. code-block:: python
312+
313+
def as_group(self):
314+
if not isinstance(self, BaseExceptionGroup):
315+
return BaseExceptionGroup("", [self])
316+
return self
317+
318+
However, applying this method to refactor existing code was a negligible
319+
improvement over writing the trivial inline version. We also hope that many
320+
current uses for such a method will be addressed by ``except*`` as older
321+
Python versions reach end-of-life.
322+
323+
We recommend documenting a "convert to group" recipe for de-duplicated error
324+
handling, instead of adding group-related methods to ``BaseException``.
325+
326+
327+
Add ``e.raise_with_preserved_context()`` instead of a context manager
328+
---------------------------------------------------------------------
329+
330+
We prefer the context-manager form because it allows ``raise ... from ...``
331+
if the user wishes to (re)set the ``__cause__``, and is overall somewhat
332+
less magical and tempting to use in cases where it would not be appropriate.
333+
We could be argued around though, if others prefer this form.
334+
335+
336+
Footnotes
337+
=========
338+
339+
.. [#numbers]
340+
From the first sixty `GitHub search results
341+
<https://github.com/search?q=%2Ffor+%5Cw%2B+in+%5Beg%5D%5Cw*%5C.exceptions%3A%2F+language%3APython&type=code>`__
342+
for ``for \w+ in [eg]\w*\.exceptions:``, we find:
343+
344+
* Four functions implementing ``flat_exceptions()`` semantics, none of
345+
which preserve tracebacks:
346+
(`one <https://github.com/nonebot/nonebot2/blob/570bd9587af99dd01a7d5421d3105d8a8e2aba32/nonebot/utils.py#L259-L266>`__,
347+
`two <https://github.com/HypothesisWorks/hypothesis/blob/7c49f2daf602bc4e51161b6c0bc21720d64de9eb/hypothesis-python/src/hypothesis/core.py#L763-L770>`__,
348+
`three <https://github.com/BCG-X-Official/pytools/blob/9d6d37280b72724bd64f69fe7c98d687cbfa5317/src/pytools/asyncio/_asyncio.py#L269-L280>`__,
349+
`four <https://github.com/M-o-a-T/moat/blob/ae174b0947288364f3ae580cb05522624f4f6f39/moat/util/exc.py#L10-L18>`__)
350+
351+
* Six handlers which raise the first exception in a group, discarding
352+
any subsequent errors; these would benefit from both proposed methods.
353+
(`one <https://github.com/Lancetnik/FastDepends/blob/239cd1a58028782a676934f7d420fbecf5cb6851/fast_depends/core/model.py#L488-L490>`__,
354+
`two <https://github.com/estuary/connectors/blob/677824209290c0a107e63d5e2fccda7c8388101e/source-hubspot-native/source_hubspot_native/buffer_ordered.py#L108-L111>`__,
355+
`three <https://github.com/MobileTeleSystems/data-rentgen/blob/7525f7ecafe5994a6eb712d9e66b8612f31436ef/data_rentgen/consumer/__init__.py#L65-L67>`__,
356+
`four <https://github.com/ljmf00/simbabuild/blob/ac7e0999563b3a1b13f4e445a99285ea71d4c7ab/simbabuild/builder_async.py#L22-L24>`__,
357+
`five <https://github.com/maxjo020418/BAScraper/blob/cd5c2ef24f45f66e7f0fb26570c2c1529706a93f/BAScraper/BAScraper_async.py#L170-L174>`__,
358+
`six <https://github.com/sobolevn/faststream/blob/0d6c9ee6b7703efab04387c51c72876e25ad91a7/faststream/app.py#L54-L56>`__)
359+
360+
* Seven cases which mishandle nested exception groups, and would thus
361+
benefit from ``flat_exceptions()``. We were surprised to note that only
362+
one of these cases could straightforwardly be replaced by use of an
363+
``except*`` clause or ``.subgroup()`` method.
364+
(`one <https://github.com/vertexproject/synapse/blob/ed8148abb857d4445d727768d4c57f4f11b0d20a/synapse/lib/stormlib/iters.py#L82-L88>`__,
365+
`two <https://github.com/mhdzumair/MediaFusion/blob/ff906378f32fb8419ef06c6f1610c08946dfaeee/scrapers/base_scraper.py#L375-L386>`__,
366+
`three <https://github.com/SonySemiconductorSolutions/local-console/blob/51f5af806336e169d3dd9b9f8094a29618189f5e/local-console/src/local_console/commands/server.py#L61-L67>`__,
367+
`four <https://github.com/SonySemiconductorSolutions/local-console/blob/51f5af806336e169d3dd9b9f8094a29618189f5e/local-console/src/local_console/commands/broker.py#L66-L69>`__,
368+
`five <https://github.com/HexHive/Tango/blob/5c8472d1679068daf0f041dbbda21e05281b10a3/tango/fuzzer.py#L143-L160>`__,
369+
`six <https://github.com/PaLora16/ExceptionsGroupsValidators/blob/41152a86eec695168fdec74653694658ddc788fc/main.py#L39-L44>`__,
370+
`seven <https://github.com/reactive-python/reactpy/blob/178fc05de7756f7402ed2ee1e990af0bdad42d9e/src/reactpy/backend/starlette.py#L164-L170>`__)
371+
372+
indicating that more than a quarter of _all_ hits for this fairly general
373+
search would benefit from the methods proposed in this PEP.
374+
375+
.. [#catch-raw-group]
376+
This remains very rare, and most cases duplicate logic across
377+
``except FooError:`` and ``except ExceptionGroup: # containing FooError``
378+
clauses rather than using something like the as_group trick.
379+
We expect that ``except*`` will be widely used in such cases, before
380+
the methods proposed by this PEP are widely available.
381+
382+
383+
Copyright
384+
=========
385+
386+
This document is placed in the public domain or under the CC0-1.0-Universal license,
387+
whichever is more permissive.

0 commit comments

Comments
 (0)