UnionType exception handling

My Pythonic intuition led me to accidentally invent new syntactic sugar for handling multiple exceptions, using the union operator:

try:
    fn_that_raises()
except AttributeError | ValueError:
    pass

This currently raises TypeError: catching classes that do not inherit from BaseException is not allowed , forcing the uglier tuple syntax. However I propose that if the type after except is neither an exception nor tuple of exceptions, the interpreter first checks if it is a UnionType , and handles the union in the same way a tuple is handled.

Thanks for your feedback!

6 Likes

I’ve never before contributed to Python, but if people think this is a good idea I’d be keen to give a CPython PR a crack. Not only is the syntax prettier, I believe semantically catching “any of these exception types” is even better conceptualised by a union than a tuple

Would it be possible to do the following?

to_catch = ValueError | TypeError
try:
    maybe_raises()
except to_catch:
    pass

In other words is ErrorType1 | ErrorType2 a valid expression or not?

It’s just my opinion, but I’m afraid I don’t like neither case.

  • If it is just a syntax sugar usable only after an except, it should not look like an expression, because it is not.
  • Otherwise, if it is a valid expression, what is its type? For compatibility with except I would expect a tuple of exceptions, but using OR to build a tuple is somehow strange.

You can catch a tuple
(AttributeError, ValueError)

Yes, it is; it evaluates to a UnionType. This is already the case.

But it isn’t a tuple, and therefore not acceptable to the except statement. So the question is, what changes?

  • Does UnionType become a subclass of tuple? This would be somewhat odd, but I think it would work.
  • Redefine the except statement so it accepts either a tuple or a UnionType?
  • Create some protocol for arbitrary types to give a collection of their matching exception classes, and have tuples return their contents and unions their args?
  • Something else?
  • Or nothing? This is an entirely valid choice, given that tuples are and always have been the normal way to catch more than one exception.

I’d vote for no change. This doesn’t make Python more expressive. It just adds another way of doing something that can already be done, and the existing mechanism cannot be removed.

6 Likes

Using | as a modern way of expressing unions would be more consistent with isinstance, which accepts that as per PEP 604. In fact, Ruff nudges you to write things this way if possible.

Also, because tuples have special meanings in the typing world, | is more intuitive for new Python programmers (it always means union when applied to types).

I’d change except to accept both notations for both consistency and intuitiveness.

4 Likes

Certainly option B, extending except seems to me to be the simplest, most backwards compatible and least surprising option

I would argue that, while it doesn’t add functionality, it does make Python more Pythonic. Perhaps my intuition is led by frequently using UnionTypes in application code recently (SQLAlchemy, Pydantic, FastAPI, etc.), but I also think that the argument of except semantically fits a UnionType (“any of these”) better than a tuple (“this exact ordered sequence”)

Do be aware that there are other places where “any of these” is expressed with a tuple, though. Adding support for unions in except clauses would leave those looking less consistent. Example:

>>> "I like spam".endswith(("spam", "ham"))
True
>>> "I like ham".endswith(("spam", "ham"))
True
>>> "I like eggs".endswith(("spam", "ham"))
False
>>> "I like sausages".endswith("spam" | "ham")
Traceback (most recent call last):
  File "<python-input-4>", line 1, in <module>
    "I like sausages".endswith("spam" | "ham")
                               ~~~~~~~^~~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'str'

To me that doesn’t seem to conflict, since "spam" | "eggs" doesn’t already have a well defined meaning like ValueError | TypeError does, and I can think of performance reasons why order may be important in such a function. Can you think of any examples where the members of the tuple are types?
On a side note, in an admittedly contrived situation:

acceptable_errs = TypeError | ValueError
while True:
    new_acceptable_errs = decide_what_to_catch()  # ValueError | TypeError
    if new_acceptable_errs != acceptable_errs:
        report_errs_changed()

    acceptable_errs = new_acceptable_errs
    try:
        flaky_fn()
    except acceptable_errs:
        pass

is something currently not possible with tuple syntax

If we were designing Python from scratch, then sure, this might make more sense. But we’re not, and as pointed out there are a ton of places where a tuple means “one of these”. And presumably a Union is going to have worse performance. I can’t imagine us changing this, or adding a second way of doing the same thing.

4 Likes

Respectfully, the exact same arguments you’re making apply to PEP 604, which was accepted.

Yes, but with types, | always indicates disjunction whereas tuple does not. For example,

class Bird[T: (Beak, Bill)]: ...

Here, the tuple is not a disjunction, and is completely different than Beak | Bill.

While I agree that it being a “second way” is a strike against the idea, the consistency and intuitiveness are reasonable arguments for implementing it.

3 Likes

Certainly I’m not suggesting replacing the tuple syntax, for backwards compatibility, but given it’s only extending the existing functionality I don’t understand the performance argument. Assuming (?) the except logic is something along the lines of :

if issubclass(exceptable, BaseException):
    try_handle(e, exceptable)
elif isinstance(exceptable, Tuple):
    any(try_handle(e, sub_exceptable) for sub_exceptable in exceptable) 
else:
    raise TypeError()

The proposed extension would add a branch from the bottom of this chain, so not slow down existing code.
Assuming the suggestion became standard, rare performance-critical applications where large numbers of exception types are frequently being checked could still prefer the tuple syntax (in the same way __slots__ is not standard, can be used for performance-critical applications).

I’m starting slowly to become +1 to the idea of using the UnionType whenever we need to pass either one value or several choices.

Reasons FOR:

  • #1: Since Python 3.10, the classinfo argument in isinstance(obj, classinfo) can be given not only as a tuple, but also as an UnionType.

  • #2: Sometimes I need to write a function accepting one value or multiple values. It could take a single value or a tuple just like the startswith, but if a single item itself could be a tuple, that doesn’t work well. You have to put a tuple into a tuple as a special case. It would be nice to have a specialized type that is - unlike the tuple - not normally used as a data type. The UnionType fits the bill IMO, also because it was introduced for other purpose than aggregating data values.

Reasons AGAINST:

  • It would make sense only as a general idea and I do understand the objections against adding a second way of doing things. OTOH it is not ruled out, the Reason #1 is a precedent.

  • The UnionType is currently not iterable. Calling a library function with A | B | C might look good, but the implementation probably has to iterate over it to check one item at a time. I guess the iterability could be added easily.

2 Likes

UnionType has an __args__ attribute which is a tuple of the types that make it up. I’m not sure if that’s formally documented anywhere though.

1 Like

Yesterday I wrote:

Today, it’s no longer true. I’m sorry for the noise. I did not look at the whole picture. It is simply not possible to make an UnionType of anything just by typing A | B | C.

>>> 1 | 2 
3

>>> "yes" | "YES"
    ~~~~~~^~~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'str'

I also thought this was a good idea - but it was previously considered and rejected in this older CPython issue.

It’s OK to re-propose rejected ideas, but you’ll need to provide arguments or evidence or analysis that wasn’t available at the time of the earlier decision for that to be worth considering. “Look, multiple people have the intuition that if isinstance(x, A | B) works then except A | B: should also work” is a decent starting point, e.g. as a response to how-it-looks, but I think you’d also need to understand and address the point about virtual subclasses.

3 Likes

Note that Ruff has deprecated that lint, plans to remove it in a future release, and recommends against using it.

Yeah, I noticed that and having thought about it more, I agree with the removal.

However, the current situation leaves in question exactly what we should do with unions that we intend to use as both annotations and for use in isinstance. Do we create a type alias synchronized with a union variable, or not?

Anyway, I’ve changed my mind about except accepting unions, and honestly I’m not even sure isinstance should accept them (not that I’m suggesting they shouldn’t).

1 Like