Introducing try..except* #4
Description
The design discussed in this issue has been consolidated in https://github.com/python/exceptiongroups/blob/master/except_star.md.
Disclaimer
- I'm going to be using the
ExceptionGroup
name in this issue, even though there are other alternatives, e.g.AggregateException
. Naming of the "exception group" object is outside of the scope of this issue. - This issue is primarily focused on discussing the new syntax modification proposal for the
try..except
construct, shortly called "except*". - I use the term "naked" exception for regular Python exceptions not wrapped in an ExceptionGroup. E.g. a regular
ValueError
propagating through the stack is "naked". - I assume that
ExceptionGroup
would be an iterable object. E.g.list(ExceptionGroup(ValueError('a'), TypeError('b')))
would be equal to[ValueError('a'), TypeError('b')]
- I assume that
ExceptionGroup
won't be an indexable object; essentially it's similar to Pythonset
. The motivation for this is that exceptions can occur in random order, and letting users writegroup[0]
to access the "first" error is error prone. The actual implementation ofExceptionGroup
will likely use an ordered list of errors though. - I assume that
ExceptionGroup
will be a subclass ofBaseException
, which means it's assignable toException.__context__
and can be directly handled withexcept ExceptionGroup
. - The behavior of good and old regular
try..except
will not be modified.
Syntax
We're considering to introduce a new variant of the try..except
syntax to simplify working with exception groups:
try:
...
except *SpamError:
...
except *BazError as e:
...
except *(BarError, FooError) as e:
...
The new syntax can be viewed as a variant of the tuple unpacking syntax. The *
symbol indicates that zero or more exceptions can be "caught" and processed by one except *
clause.
We also propose to enable "unpacking" in the raise
statement:
errors = (ValueError('hello'), TypeError('world'))
raise *errors
Semantics
Overview
The except *SpamError
block will be run if the try
code raised an ExceptionGroup
with one or more instances of SpamError
. It would also be triggered if a naked instance of SpamError
was raised.
The except *BazError as e
block would aggregate all instances of BazError
into a list, wrap that list into an ExceptionGroup
instance, and assign the resultant object to e
. The type of e
would be ExceptionGroup[BazError]
. If there was just one naked instance of BazError
, it would be wrapped into a list and assigned to e
.
The except *(BarError, FooError) as e
would aggregate all instances of BarError
or FooError
into a list and assign that wrapped list to e
. The type of e
would be ExceptionGroup[Union[BarError, FooError]]
.
Even though every except*
star can be called only once, any number of them can be run during handling of an ExceptionGroup
. E.g. in the above example, both except *SpamError:
and except *(BarError, FooError) as e:
could get executed during handling of one ExceptionGroup
object, or all of the except*
clauses, or just one of them.
It is not allowed to use both regular except
clauses and the new except*
clauses in the same try
block. E.g. the following example would raise a SyntaxErorr
:
try:
...
except ValueError:
pass
except *CancelledError:
pass
Exceptions are mached using a subclass check. For example:
try:
low_level_os_operation()
except *OSerror as errors:
for e in errors:
print(type(e).__name__)
could output:
BlockingIOError
ConnectionRefusedError
OSError
InterruptedError
BlockingIOError
New raise* Syntax
The new raise *
syntax allows to users to only process some exceptions out of the matched set, e.g.:
try:
low_level_os_operation()
except *OSerror as errors:
new_errors = []
for e in errors:
if e.errno != errno.EPIPE:
new_errors.append(e)
raise *new_errors
The above code ignores all EPIPE
OS errors, while letting all others propagate.
raise *
syntax is special: it effectively extends the exception group with a list of errors without creating a new ExceptionGroup
instance:
try:
raise *(ValueError('a'), TypeError('b'))
except *ValueError:
raise *(KeyError('x'), KeyError('y'))
# would result in:
# ExceptionGroup({KeyError('x'), KeyError('y'), TypeError('b')})
A regular raise would behave similarly:
try:
raise *(ValueError('a'), TypeError('b'))
except *ValueError:
raise KeyError('x')
# would result in:
# ExceptionGroup({KeyError('x'), TypeError('b')})
raise *
accepts arguments of type Iterable[BaseException]
.
Unmatched Exceptions
Example:
try:
raise *(ValueError('a'), TypeError('b'), TypeError('c'), KeyError('e'))
except *ValueError as e:
print(f'got some ValueErrors: {e}')
except *TypeError as e:
print(f'got some TypeErrors: {e}')
raise *e
The above code would print:
got some ValueErrors: ExceptionGroup({ValueError('a')})
got some TypeErrors: ExceptionGroup({TypeError('b'), TypeError('c')})
And then crash with an unhandled KeyError('e')
error.
Basically, before interpreting except *
clauses, the interpreter will have an exception group object with a list of exceptions in it. Every except *
clause, evaluated from top to bottom, can filter some of the exceptions out of the group and process them. In the end, if the exception group has no exceptions left in it, it wold mean that all exceptions were processed. If the exception group has some unprocessed exceptions, the current frame will be "pushed" to the group's traceback and the group would be propagated up the stack.
Exception Chaining
If an error occur during processing a set of exceptions in a except *
block, all matched errors would be put in a new ExceptionGroup
which would have its __context__
attribute set to the just occurred exception:
try:
raise *(ValueError('a'), ValueError('b'), TypeError('z'))
except *ValueError:
1 / 0
# would result in:
#
# ExceptionGroup({
# TypeError('z'),
# ZeroDivisionError()
# })
#
# where the `ZeroDivizionError()` instance would have
# its __context__ attribute set to
#
# ExceptionGroup({
# ValueError('a'), ValueError('b')
# })
It's also possible to explicitly chain exceptions:
try:
raise *(ValueError('a'), ValueError('b'), TypeError('z'))
except *ValueError as errors:
raise RuntimeError('unexpected values') from errors
# would result in:
#
# ExceptionGroup(
# TypeError('z'),
# RuntimeError('unexpected values')
# )
#
# where the `RuntimeError()` instance would have
# its __cause__ attribute set to
#
# ExceptionGroup({
# ValueError('a'), ValueError('b')
# })
See Also
- An analysis of how exception groups will likely be used in asyncio programs: Introducing try..catch #3 (comment)
- A WIP implementation of the
ExceptionGroup
type by @iritkatriel tracked here: GitHub - iritkatriel/cpython at exceptionGroup