Skip to content
This repository was archived by the owner on Apr 10, 2022. It is now read-only.
This repository was archived by the owner on Apr 10, 2022. It is now read-only.

Introducing try..except* #4

Closed
Closed
@1st1

Description

@1st1

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 Python set. The motivation for this is that exceptions can occur in random order, and letting users write group[0] to access the "first" error is error prone. The actual implementation of ExceptionGroup will likely use an ordered list of errors though.
  • I assume that ExceptionGroup will be a subclass of BaseException, which means it's assignable to Exception.__context__ and can be directly handled with except 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions