-
Notifications
You must be signed in to change notification settings - Fork 4
Introducing try..catch #3
Comments
I very much like the semantics and look of the proposed My only hesitation really is about selling the deprecation and eventual removal of Also, I anticipate that if During the phase that
I'd expect that if there was a As an alternative, maybe we could use something along the lines of the pattern matching syntax to catch an exception based on its arguments. However, that does come with the potential issue of causing breakages when exception messages change (which is not something that should be currently relied on since they're not guaranteed to be the same across versions).
This does not solve the issue, but I think part of this could be simplified if we were to specifically forbid |
Regarding catch semantics for multiple exceptions of the same type - we could do this:
And this is a parser error:
|
It looks like at least the two of us agree on the semantics of multiple exceptions of the same type -- those examples do a good job of effectively expressing what I wrote in my above comment (other than the parser error, but I agree with that as well). :-) |
Actually currently you can do this:
I guess the first matching block wins. So maybe we don't need to trouble the parser with this. |
Although that's the current behavior, I'm not sure that it would be at all desirable, and might be a bit more of a subtle issue with there being a >>> match ...:
... case spam | eggs if whatever():
... pass
...
File "<stdin>", line 2
SyntaxError: the previous pattern always matches, making this one unreachable I think a somewhat similar approach could be taken with exception groups, and would likely help users that are just learning how |
Multiple exceptions of the same typeWhat if the try block raises Note that there would also be a question about two different exception classes that derive from the same base class, where the Another concern here is that static type checkers expect the type of Another approach might be to allow a
However I don't know how this would work if a superclass of some exception type is being caught:
To unpack this problem I need to take a few steps back. === Translation into pseudo bytecode Let's consider how try/except is currently translated into bytecode. Consider:
This currently translates to something like
(This looks differently from when I first invented Python bytecode, though not terribly so. :-) Let's try to translate it to Python code. We get something like
Except that the
So if we factor that out, our rewrite looks a little simpler, even if we add a second except block:
Now I think we have the tools in hand to propose rewrites for multi-errors. Handling multi-errors this wayThe semantics that call the handler block once for each exception it matches would be like this, modulo several details:
But each
(It's really a bit more complicated still due to exception context for bare There's also another complication, the additional structure inside Anyway, these added complexities seem manageable. (Another issue is how to handle non-multi-errors without adding more code. Another exercise for the reader.) But now for the alternative proposal, using similar primitives: Handling multi-errors using
|
The alternative at the end ("Another Option") means that as the user of an async library, if you raise X you must catch *X. If you try to catch what you raised you won't get it. I think this will be confusing. |
|
|
Now we use it in @gvanrossum's code snippets like this:
At the end of all the except clauses:
and raise _multi |
Thanks! We should probably compare this to python-trio/trio#611 |
Perhaps OT, but here's a list of things (probably incomplete) in the life of an exception.
In all cases, "traceback item pushed" is basically
Also, |
There's also raise e.with_traceback() |
Oh! So
seems to be equivalent to
|
Anyway, the relevance of that list is that in all cases where an exception is raised, either nothing is done to it (bare With this proposal, none of the places that do that have to change, and that seems a big advantage over having to change the code to reverse the linked list (note that the public API exposes the linked list so it somehow has to be reversed when the traceback goes public) or having to push a traceback item on each of several exceptions. The code to catch multi-errors will be complicated no matter what we do. Constructing and raising multi-errors should probably be done in "userspace", i.e. Python, not C, at least for the initial release, maybe forever. I imagine that task groups will be implemented in userspace, catching exceptions one at a time and constructing a multi-error by hand from these, then raising the multi-error using Internally, the "push traceback item" is then implemented in PyTraceback_Here(), which just creates a new traceback object pointing to the current frame and pushes it onto the separately maintained list of tracebacks in the thread state (curexc_traceback). The final piece of the puzzle (for me) is how the exception and traceback are recombined after some frames have been popped and corresponding traceback items pushed on the thread state. This seems to be the call to PyException_SetTraceback() in ceval.c, in the block labeled exception_unwind. It looks like at that point the traceback alread present in the exception is simply overwritten (by PyException_SetTraceback()) with the traceback from the thread state. Effectively this just extends the traceback with items that were pushed on the thread state since the RAISE opcode (unless the intervening C code messed with this stuff: you can only reach Python code by catching the exception, and you only go back to C by raising it). [1] There are two separate RAISE opcodes, RERAISE which is used for bare |
(Note that pushing a traceback item also records the frame's current instruction pointer and linenumber, so you can tell exceptions apart that were raised at different points in the same frame.) |
Experimental implementation of exception groups: https://github.com/iritkatriel/cpython/tree/exceptionGroup There are two demo files there - exception_group.py along with its output.txt. Comments:
|
@iritkatriel Great! I've created a PR against your repo: iritkatriel/cpython#1. The PR has an implementation of asyncio.TaskGroups that I wrote for EdgeDB. It's relatively well tested and we can use it as a tool to discover polish our ExceptionGroup implementation. So far the output of a test script ( import asyncio
import types
async def t1():
await asyncio.sleep(0.5)
1 / 0
async def t2():
async with asyncio.TaskGroup() as tg:
tg.create_task(t21())
tg.create_task(t22())
async def t21():
await asyncio.sleep(0.3)
raise ValueError
async def t22():
await asyncio.sleep(0.7)
raise TypeError
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(t1())
tg.create_task(t2())
def run(*args):
try:
asyncio.run(*args)
except types.ExceptionGroup as e:
print('============')
types.ExceptionGroup.render(e)
print('^^^^^^^^^^^^')
raise
run(main())
Looks like the traceback includes a bunch of lines from Update: For those who're curious why t22 isn't in the traceback output: it was likely cancelled early because t21 has crashed. |
I think this shows that Irit's strategy for grouping tracebacks is viable. In theory we could productionize this by itself, to help experiments with multi-error implementation (then again, Trio already did that without any C changes). I think our next step is to reevaluate how to change the semantics of try..except -- switch to try..catch, or keep try..except but use Here's my strawman: multi-error is a final subclass of BaseException. Looking at .NET's AggregateException, it seems clear that multi-errors can be nested (the nesting reflects the nesting of task groups), but the splitting should recurse into nested multi-errors and "just do the right thing" there. In terms of naming, I propose to name our multi-error AggregateException, since it's clear that .NET's task groups went before us, so we might as well pay homage (similar to |
We have three basic strategies now:
Am I right that this is incremental, so we can do 2 and then add 3? |
But why would we want to do 3 if we can do everything with 2? Certainly we can make I don't think I would ever want OTOH We could use I think we probably should not allow some clauses using Finally, if after handling a bunch of exceptions the multi-error has exactly one inner exception left -- do we simplify (unwrap) it? I think not, because (again) that would make it harder for the user to realize that they could get a multi-error and they're not handling it right. However multi-errors should probably have methods to let user code do the unwrapping. |
BTW I forgot one important aspect of my strawman -- the presence of try:
raise AggregateError([E1("a"), E2("b"), E1("c")])
except *E1:
print("caught E1")
except *E2:
print("caught E2") will print both lines (once each). Occasionally I forget this scenario and I believe that we can do it without changes in except syntax or handling by using except AggregateException[E1]:
print("caught E1") but without changes to the generated code that would not work with the previous example (it would catch E1 but not E2). So we'll have to drop that idea -- |
I see, this could indeed work in (2). I thought (see above at #3 (comment)) that the suggestion is that "catch *E" lets you handle the list of Es in one go while "catch E" executes the except clause once for each E in the list. That would not be possible in (2). But your new suggestion would be possible, and I think it's better anyway. It's unlikely that you really need to process the whole list, and if you do you can construct it. |
Wait, are we talking about the same thing? I know in the past we were talking about some form where the handler would run once for each exception. I think I've changed my mind about that though and I'd rather run each handler 0 or 1 times, so So for try:
...
except *E1 as e:
handler1(e)
except *E2 as e:
handler2(e) would become try:
...
except BaseException as _err:
if not isinstance(_err, ExceptionGroup):
_err = ExceptionGroup(_err)
if (e := _err.split(E1)) is not None:
handler1(e)
if (e := _err.split(E2)) is not None:
handler2(e)
if _err.excs:
raise # Cleverly, this raises the *original* exception if it wasn't an ExceptionGroup If there are |
So e is a sequence of E1s. Makes sense. I like it. Should there be an option to break - as in, if I got this type of exception then I don't want to process any of the others? |
Details:
You mean "if _err.excs" (so it's not empty), right? |
Hm, I thought the type of |
I don't know how common that use case would be -- they can solve it with a flag the set and test in the other handlers. |
Oops, yes. Correcting. |
That's why I have
|
break/continue can always be added later if we decide to. |
No, they can't be - there might be a break in an except block where the whole try-except is inside a loop. |
I've been thinking about how task groups and exceptions groups would be used by asyncio users and independently arrived to what Guido proposed in #3 (comment) and in #3 (comment). Below is my thought train that led me to it. Apologies for the long write up, but I hope this will help us. Types of errorsFundamentally there are two kinds of exceptions: control flow exceptions and operation exceptions. The examples of former are When writing async/await code that uses a concept of TaskGroups (or Trio's nurseries) to schedule different code concurrently, the users should approach these kinds in a fundamentally different way. Operation exceptions such as try:
dct[key]
except KeyError:
# handle the exception and this is what they shouldn't do: try:
async with asyncio.TaskGroup() as g:
g.create_task(task1); g.create_task(task2)
except *KeyError:
# handling KeyError here is meaningless, there's
# no context to do anything with it but to log it. Control flow exceptions are a different beast. If, for example, we want to cancel an asyncio Task that spawned multiple concurrent Tasks in it with a TaskGroup, we need to make sure that:
So suppose we have the try:
async with asyncio.TaskGroup() as g:
g.create_task(task1); g.create_task(task2)
except *CancelledError:
log('cancelling server bootstrap')
await server.stop()
raise
except CancelledError:
# Same code, really.
log('cancelling server bootstrap')
await server.stop()
raise Which led me to the conclusion that
Why "handle all exceptions at once with one run of the code code in except *"? Why not run the code in the Separating exceptions kinds to two distinct groups (operation & control flow) leads to another conclusion: an individual try:
# code
except KeyError:
# handle
except ValueError:
# handle is the old and familiar try:
# code
except *TimeoutError:
# handle
except *CancelledError:
# handle is an entirely different mode and it's OK, and moreover, almost expected from the user standpoint, to run both And: try:
# code
except ValueError:
# handle
except *CancelledError:
# handle is weird and most likely suggests that the code should be refactored. Types of user codeFundamentally we have applications and libraries. Applications are somewhat simpler -- they typically can dictate what version of Python they require. Which makes introducing TaskGroups and the new Library developers are in a worse position: they'll need to maintain backwards compatibility with older Pythons, so they can't start using the new This means that we'll need to have a proper programmatic API to work with ExceptionGroups, so that libraries can write code like: try:
# run user code
except CancelledError:
# handle cancellation
except ExceptionGroup as e:
g1, g2 = e.split(CancelledError)
# handle cancellation The API isn't going to be pretty, but that's OK, because a lot of existing asyncio libraries don't run user-provided coroutines that might use TaskGroups. In other words, a mysql or redis driver will never be able to catch an ExceptionGroup until it starts using TaskGroups itself. SummaryI just cannot wrap my head around introducing
|
Oh, any flow control other than But we need to think more about this later. |
Adding to #3 (comment):
This is also the strategy we use when designing APIs/syntax for EdgeDB: if there's no clear need to allow both |
Oh, definitely. |
Perhaps we should open a new issue, since this one is still called "Introducing try..catch"? The new one could be called "Introducing 'except *'", and cleanly introduce the new proposal (probably linking to Irit's traceback group prototype). |
If you want I can take a stab at it. |
Go for it. |
Done: #4. |
This embeds [1] and [2] pretty much verbatim (with 79 characters per line rule applied.) [1] #3 (comment) [2] #4
What's the current status of this proposal? Will this go-ahead to also soft-introduce multierrors/exception groups in the language? Or is this specific idea forgone for a different one? |
A variation of this is now being proposed as PEP 654. |
According to this header, this specific idea ( |
If we find we really need new syntax to flag that people have thought about the consequences of multi-exceptions, we could switch the existing
try..except
syntax totry..catch
. It seems most other languages use the latter keyword anyway.Syntax
The syntax would just replace
except
withcatch
, leaving everything else the same.We have the option however of disallowing
catch:
(i.e. with no exception, the catch-all block) -- forcing people (and automatic translators) to writecatch BaseException:
.Note that
catch
would have to be a soft keyword (supported by the new PEG parser, see PEP 617), since there are plenty of other uses ofcatch
in existing code that we don't want to break.Transition
The transition plan would be that
try..exept
will eventually be removed from the language. There would be three stages:try..catch
andtry..except
can both be used.try..except
works but gives a deprecation warning.try..except
stops working.Possibly stage 2 can be split and
try..except
insideasync
functions can be deprecated sooner than in other contexts.During stages 1 and 2, each
try
statement must use eithercatch
orexcept
-- you cannot have bothcatch
andexcept
blocks in the same statement. (But you can have them in the same file.)Semantics
When the raised exception is not a multi-exception the semantics of
try..catch
is the same as fortry..except
.When the raised exception is a multi-error the semantics change.
Basically when a multi-error contains different exception types it is possible that more than one
catch
block runs. E.g.would print "VE" and "RE" and then raise (bubble up)
RuntimeError()
(orMultiError([RuntimeError()]
).If there's an
else
block it only gets run if no exceptions were raised in the first place.If there's a
finally
block it gets run after allcatch
blocks (if any) have run, before bubbling up the unhandled exceptions (if any).The order in which the exceptions in the multi-error are handled is just the order in which the
MultiError
object regurgitates them.Multiple exceptions of the same type
This is an open issue.
What if the try block raises
MultiError([ValueError("A"), ValueError("B")])
? We could define different semantics.Note that there would also be a question about two different exception classes that derive from the same base class, where the
catch
class specifies that base (or ultimatelycatch BaseException
). So we cannot rely onMultiError
to split exceptions based on class before we start matching exceptions to catch blocks.TO BE CONTINUED IN A LATER COMMENT (I deleted some meta-comments related to this.)
The text was updated successfully, but these errors were encountered: