You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
New rule: raising ExceptionGroup child exception loses context/cause #298
defraise_error():
try:
1/0finally:
raiseValueErrordefwrap_error():
try:
raise_error()
exceptExceptionase:
raiseExceptionGroup("aoeu", [e])
defunwrap_error():
try:
wrap_error()
exceptExceptionGroupasexc_group:
if ...:
# Oops: we now lost the DivisionByZeroError as cause/contextraiseexc_group.exceptions[0] fromexc_groupif ...:
# could catch this with type trackinge=exc_group.exceptions[0]
raiseefromNoneif ...:
# this is fineraiseNewErrorfromexc_group
I realized I've done this in several PRs (at least the one in trio-websocket), and nobody has commented on the fact. It could also be a better fit for flake8-bugbear (although they don't have any type-tracking infrastructure).
I haven't yet figured out the best way of avoiding it though, options include:
defunwrap_error2():
my_exc=Nonetry:
wrap_error()
exceptExceptionGroupasexc_group:
if ...:
my_exc=exc_group.exceptions[0]
# child exception cause/context is fully preserved, but you lose all info about the# group. This might be good or bad depending on the situationifmy_excisnotNone:
raisemy_excdefunwrap_error_3():
try:
wrap_error()
exceptExceptionGroupasexc_group:
# this seems sketchy??importcopymy_exc=copy.copy(exc_group.exceptions[0])
raisemy_excfromexc_group
after discussion in gitter and thinking about it we can likely suggest copy.copy() as a robust solution. Even if users have complex data structures on their exceptions they probably won't manipulate them on several levels of the context chain.
oooh, yeah, copy.copy() sounds like a good solution.
I'd be happy to have a lint rule for this, it seems like a common mistake in a rare-ish situation and I'd definitely prefer to get the full context when it happens.
edit: actually I think you don't even need from ... to trigger this, so it's not even that rare - but pretty much the most obvious way that someone would try to unwrap a group!
ooooh, I just realized I could check for this in trio.testing.RaisesGroup! Perhaps unconventional, but that would have a wide reach and would be more robust than trying to lint for it. nvm where this is actually needed is in pytest.raises since the vast majority of the time you will only ever encounter this when expecting a single unwrapped exception.
Note that copy.copying an exception doesn't copy most of its metadata. You'll lose the traceback, context, etc. All you get is the type, args (which is usually just a message) and dict (which includes notes, but is otherwise empty unless you put something there). So I'm not sure if it's wise to suggest this idiom?
ah, I think in most cases it doesn't matter that we copy none of the metadata - since those will be set on the new exception object as we raise it. Writing docs/suggestion on this will be a major pain though.
That'll misbehave for any class where the __init__ method does more than just assign values to attributes, unfortunately.
do people do that in exception classes? And if they do, do we want to re-execute whatever code is in the __init__? I guess there's no universal answer... which sucks for libraries. Reraising outside the except also gets real tricky once you have a finally (that could raise). But I'll give that another go in trio-websocket I suppose.
I opened python/cpython#125782 to see if upstream wants to address copy.copy+BaseException.__init__
I have no clue why copy.copy fails to pick up the args if you define an __init__ which calls super().__init__()
In your example, you aren't passing the args to parent __init__, so the original exception object doesn't have them either!
>>> class MyExc(Exception):
... def __init__(self, x):
... super().__init__()
...
>>> a = MyExc(5)
>>> a
MyExc()
If you fix that, they do get copied.
>>> class MyExc(Exception):
... def __init__(self, x):
... super().__init__(x)
...
>>> a = MyExc(5)
>>> import copy
>>> copy.copy(a)
MyExc(5)
Regardless, it seems like copy.copy is not viable as a suggestion. My current workaround is [...] which seems to handle everything I've thrown at it so far.
I don't think you looked at the results very carefully...
Exception objects store their state in three places:
slots for the most of the "dunders": traceback, cause, context, suppress_context
an args tuple, which is just whatever you passed to BaseException.__init__
a dictionary, which gets any attributes you assign later, as well as the __notes__ since it doesn't have a dedicated slot
copy.copy() copies the args and dictionary but not the dunder slots. Your copy_exc only copies the dictionary, which is strictly less good. Most of the problems discussed in this thread are from not copying the dunder slots. I don't think there's any brief way to copy them (the non-brief way is by making four setattr calls).
This also displays a further thorn if trying to write a linter for it, because it doesn't directly inherit from [Base]Exception. If the cpython issue does end up as docs-only I'll likely give a best-effort rule in flake8-bugbear a shot anyway.
copy.copy() copies the args and dictionary but not the dunder slots. Your copy_exc only copies the dictionary, which is strictly less good. Most of the problems discussed in this thread are from not copying the dunder slots. I don't think there's any brief way to copy them (the non-brief way is by making four setattr calls).
we don't need to copy the dunder slots for this usage, because they will all be set on the copy when we raise it. But yeah it appears my testing was overly brief.
Activity
jakkdl commentedon Oct 2, 2024
after discussion in gitter and thinking about it we can likely suggest
copy.copy()as a robust solution. Even if users have complex data structures on their exceptions they probably won't manipulate them on several levels of the context chain.Zac-HD commentedon Oct 3, 2024
oooh, yeah,
copy.copy()sounds like a good solution.I'd be happy to have a lint rule for this, it seems like a common mistake in a rare-ish situation and I'd definitely prefer to get the full context when it happens.
edit: actually I think you don't even need
from ...to trigger this, so it's not even that rare - but pretty much the most obvious way that someone would try to unwrap a group!jakkdl commentedon Oct 9, 2024
ooooh, I just realized I could check for this innvm where this is actually needed is intrio.testing.RaisesGroup! Perhaps unconventional, but that would have a wide reach and would be more robust than trying to lint for it.pytest.raisessince the vast majority of the time you will only ever encounter this when expecting a single unwrapped exception.jakkdl commentedon Oct 9, 2024
hm, I guess you can't universally
copy.copyexceptionsoremanj commentedon Oct 9, 2024
Note that
copy.copying an exception doesn't copy most of its metadata. You'll lose the traceback, context, etc. All you get is the type, args (which is usually just a message) and dict (which includes notes, but is otherwise empty unless you put something there). So I'm not sure if it's wise to suggest this idiom?jakkdl commentedon Oct 9, 2024
ah, I think in most cases it doesn't matter that we copy none of the metadata - since those will be set on the new exception object as we raise it. Writing docs/suggestion on this will be a major pain though.
jakkdl commentedon Oct 20, 2024
It appears it's not just
trio.Cancelledthat causes trouble forcopy.copy.I've narrowed it down to this minimal repro:
I have no clue why
copy.copyfails to pick up the args if you define an__init__which callssuper().__init__()Regardless, it seems like
copy.copyis not viable as a suggestion. My current workaround iswhich seems to handle everything I've thrown at it so far.
Zac-HD commentedon Oct 20, 2024
That'll misbehave for any class where the
__init__method does more than just assign values to attributes, unfortunately.jakkdl commentedon Oct 21, 2024
do people do that in exception classes? And if they do, do we want to re-execute whatever code is in the
__init__? I guess there's no universal answer... which sucks for libraries. Reraising outside theexceptalso gets real tricky once you have afinally(that could raise). But I'll give that another go in trio-websocket I suppose.I opened python/cpython#125782 to see if upstream wants to address
copy.copy+BaseException.__init__oremanj commentedon Oct 21, 2024
In your example, you aren't passing the args to parent
__init__, so the original exception object doesn't have them either!If you fix that, they do get copied.
I don't think you looked at the results very carefully...
Exception objects store their state in three places:
argstuple, which is just whatever you passed toBaseException.__init____notes__since it doesn't have a dedicated slotcopy.copy()copies the args and dictionary but not the dunder slots. Yourcopy_exconly copies the dictionary, which is strictly less good. Most of the problems discussed in this thread are from not copying the dunder slots. I don't think there's any brief way to copy them (the non-brief way is by making four setattr calls).jakkdl commentedon Oct 21, 2024
Yeah that was intentional, though I perhaps went too far in minifying the repro only to cause confusion. The original code in trio-websocket is https://github.com/python-trio/trio-websocket/blob/f5fd6d77db16a7b527d670c4045fa1d53e621c35/trio_websocket/_impl.py#L587
This also displays a further thorn if trying to write a linter for it, because it doesn't directly inherit from
[Base]Exception. If the cpython issue does end up as docs-only I'll likely give a best-effort rule in flake8-bugbear a shot anyway.we don't need to copy the dunder slots for this usage, because they will all be set on the copy when we raise it. But yeah it appears my testing was overly brief.
Thank you for your thorough research! ❤️