Except..if instead of except*?

I’m puzzled by except* in PEP 654; the overarching purpose of this new syntax seems to be to place a condition on whether or not to catch an exception, but the condition is immensely special-case. It’s strange to see syntax being added to the language that is so narrow in scope that I have my doubts about whether I’m ever going to use it, even once.

So how about a general exception conditioning mechanism instead? E.g., adapting a random PEP 654 example:

try:
    low_level_os_operation()
except ExceptionGroup as eg if (oserr := eg.find(OSError)):
   for e in oserr.exceptions:
        print(type(e).__name__)

This seems like an obvious idea, so I searched the forum and indeed found this post by Nathaniel J. Smith, which I’d probably seen before, so I’m not taking credit for the idea. Please read Nathaniel’s post.

/cc @iritkatriel @guido

1 Like

Hi Anders,

That’s an interesting syntax, I wasn’t familiar with it. It may be suitable for, as you say, placing a condition on whether or not to catch an exception, but this is not quite what except* is going.

Rather, we have a collection of exceptions and except* is splitting out part of this collection in order to catch that part and allow the rest to be either caught by the following except* clauses, or reraised.

If we have

try:
  raise ExceptionGroup("eg", TypeError(1), ValueError(2))
except *TypeError as e:
  print(e)
except *ValueError as e:
  print(e)

then both of the except clauses execute, each one consuming the relevant exception.

And if we have just

try:
  raise ExceptionGroup("eg", TypeError(1), ValueError(2))
except *TypeError as e:
  print(e)

then the ValueError is re-raised, as if this except* was never there (the frame of the except* is not added to the traceback of the ValueError, and its context in unchanged).

This gives except* natural exception handling semantics which I don’t think we can achieve through except, even if we add conditions.

1 Like

cc @yselivanov

1 Like

Two other things that ‘except … if …’ doesn’t give us that we need:

  • The static type of the capture variable – in except *ValueError as eg this implies eg: ExceptionGroup[ValueError].
  • Robust propagation of all unhandled exceptions if the handling code hits a bug.
1 Like

Thank you for the explanation. It’s a long PEP and I didn’t catch the part about executing multiple except suites.

It doesn’t make me like the syntax any better, though, because that means it’s a blend of two features that might have been orthogonal. Executing multiple except suites can be useful for regular, non-grouped exceptions as well.

So, armed with my newfound understanding of how except* would work, let’s rewrite the example to do without it:

try:
    raise ExceptionGroup("eg", TypeError(1), ValueError(2))
except ExceptionGroup as eg:
    if (e := eg.extract(TypeError)):
        print(e)
    if (e:= eg.extract(ValueError)):
        print(e)
    eg.reraise_remaining()

That wasn’t too bad, was it? 8 lines instead of 6, no new syntax needed.

Meaning what? Can you point me to where in the PEP that is described?

Hi Anders,

It is indeed a long PEP, because there is more to exception handling than this toy example. If you try to re-implement all the examples in the PEP with your approach, I think you will soon see where it runs into problems.

I don’t see how.

Irit

The examples all look structurally the same to me.

There’s one or more clauses on the form:

except *E as v:
    suite

which translates into:

    if (v := eg.extract(E)):
        suite

in an exception handler taking the form:

except ExceptionGroup as eg:
    <cases go here>
    eg.reraise_remaining()

For example, applying that to the motivating example at the end of the PEP:

try:
    async with trio.open_nursery() as nursery:
        # Make two concurrent calls to child()
        nursery.start_soon(child)
        nursery.start_soon(child)
except *ValueError:
    pass

This becomes:

try:
    async with trio.open_nursery() as nursery:
        # Make two concurrent calls to child()
        nursery.start_soon(child)
        nursery.start_soon(child)
except ExceptionGroup as eg:
     eg.extract(ValueError)
     eg.reraise_remaining()

What happens when your exception handling code raises an exception?

The same question came up in the thread that Anders linked, and I believe @storchaka’s answer would still apply. Might need a bit of extra care to also display all exceptions that remain in the eg, but that should pretty much boil down to also adding the repr of eg.reraise_remaining().

That answer doesn’t apply to an exception group - if you get an exception while handling the first exception of the group, what happens to the second exception you haven’t handled yet but were about to? Is it lost?

But that was exactly my point… If you get an exception while handling other exceptions, all remaining (so far unhandled) exceptions could still be shown as well. E.g. (adapted from Anders’ example):

>>> try:
...     raise ExceptionGroup("eg", TypeError(1), ValueError(2), OSError(3))
... except ExceptionGroup as eg:
...     if (e := eg.extract(TypeError)):
...         print(e)
...     if (e:= eg.extract(ValueError)):
...         1/0
...     if (e:= eg.extract(OSError)):
...         print(e)
...     eg.reraise_remaining()
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ExceptionGroup: eg
  |  with 2 sub-exceptions:
  +-+---------------- 1 ----------------
    | ValueError: 2
    +---------------- 2 ----------------
    | OSError: 3
    +-----------------------------------

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 7, in <module>
ZeroDivisionError: division by zero

(notice how TypeError has been handled already at the point where 1/0 happens, and doesn’t need to be shown in the stacktrace anymore)

The OSError was supposed to be handled here, not reraised. The failure was in the block that handles the ValueError, the OSError should not have been impacted. Furthermore, the context of the ZeroDivisionError is only the ValueError. If there was no handler for OSError and OSError was reraised, it would be grouped together with the ZeroDivisionError into a new ExceptionGroup.

We have such examples in the PEP: PEP 654 -- Exception Groups and except* | Python.org

I disagree that this is the only interpretation. It’s completely fine IMO if the exception handling proceeds linearly through the code, and doesn’t magically jump ahead - in fact, based on the code I would be more surprised about the result if the OSError had been filtered out already.

And of course you’re right, it would be consistent to package the remaining exception group and the newly raised exception into a new group - I didn’t think of that when adapting the example from Serhiy.

The exceptions in an exception group are unrelated. So the only interpretation is that the OSError should not be impacted by the presence or absence of other exceptions in the group. Otherwise they are related.

First off, thanks for taking the time to discuss this.

Not sure I understand what you mean here. The errors within the exception group are unrelated, I agree.

What I’m saying is that the handling of an exception group can be linear, i.e. it’s not necessary IMO to handle the OSError before raising the ZeroDivisionError in the example above, simply because the latter happens - from a linear reading of the code - before the former is handled.

To me, this would satisfy the “principle of least surprise” more than having the OSError disappear (bearing in mind that the semantics in this example are by necessity simpler than for except*, since there’s no new syntax).

I mean that the presence of another exception in the ExceptionGroup should not change what happens to the OSError. Which is not true in your example.

It will need more than a bit of extra care, which is exactly what the PEP wants to avoid.

The requirement for extra care cuases bugs. You run into annoying incorrectly-handled corner cases which you can’t test for because you can’t generate every possible combination of errors in your test suite.