-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow pytest.raises cooperate with ExceptionGroups #11538
Comments
Related to #10441 which was marked as fixed in #11424 (not yet released). The main blocker here is someone designing an interface which:
We're generally happy to review suggestions, if you'd like to propose something! |
I suggested something above. That test generally matches the semantics of except* |
Okey it's simple, it does ignore nest level. But that is one very common need. Ps You generally don't use raises () to test for exception chains with the old api. |
I'm not sure whether this should go in here, or in #10441 (which was maybe closed prematurely), or in a new issue - but when writing a helper for Example usage: from pytest import ExpectedExceptionGroup
with pytest.raises(ExpectedExceptionGroup((ValueError,))):
... supports nested structures: with pytest.raises(ExpectedExceptionGroup(ExpectedExceptionGroup((KeyError,)), RuntimeError)):
... and works with the current feature of passing a tuple to with pytest.raises(
(
ExpectedExceptionGroup(KeyError),
ExpectedExceptionGroup(ValueError),
RuntimeError,
)
):
... Can additionally work in keyword args for matching message and/or note. with pytest.raises(ExpectedExceptionGroup((KeyError, ValueError), message="foo", note="bar")):
... and possibly also accept instances of exceptions, to be able to match the message in sub-exceptions with pytest.raises(ExpectedExceptionGroup((KeyError(".* very bad"), RuntimeError)):
... This would fulfill all of the requested bullet points, be a minimal change to the API, and shouldn't be very complicated to implement. To experiment outside of pytest (as requested in #10441 (comment)) should be somewhat doable - if a bit messy* *can duplicate the functionality of |
A while back I discussed the idea of matchers with @asottile I believe having a matcher as single argument beats making a ever growing complexity of the raises arguments |
|
The extension to We're passing inner exception instances, but interpreting their with pytest.raises(
group=( # asserts that we raise a (subclass of) `BaseExceptionGroup`, with exceptions matching the sequence given:
pytest.raises(ValueError, match="vmsg"),
..., # Ellipsis literal to indicate "one or more exceptions omitted"
AssertionError, # also valid, ignores the message
),
match="message", # interpreted as matching the message (and notes, if any) of the ExceptionGroup
):
raise ExceptionGroup("message", [ValueError("vmsg"), KeyError("kmsg"), AssertionError()]) Thoughts? |
How does this proposal handle the case of matching behaviour of except*, which does not care about nesting level of group? try:
raise ExceptionGroup([ExceptionGroup([ValueError(0])
except* ValueError: try:
raise ExceptionGroup([ValueError(0])
except* ValueError: The nesting level is an implementation detail that an API user does not care about. I would expect to be able to use raises to catch both above cases, same as you catch both cases with the same exception handler. |
@Zac-HD i find the extension quite hard to grasp, unless we externalize the complexity id rather see a new entrypoint to exceptiongroups the raises api already is overcomplicated what you pass in as group is basically a matcher without being explicit or defined i say NO to that type of compexitx, its hard to read, hard to grasp and hard to define propperly |
good question - I was not thinking of that use case ~at all, since I was thinking about it from the POV of a library maintainer that cares about the nesting level. I'll see if I can add a kwarg that toggles this. with pytest.raises(ExpectedExceptionGroup(ValueError)):
raise ValueError should maybe work as well. . |
Yeah this isn't great, I encountered problems when using it with an Though a small wrapper around the exception, instead of instantiating it, seems like a good idea. Could be something like this: with pytest.raises(
ExpectedExceptionGroup(
ValueError,
pytest.matcher(ValueError, match=r'foo[bar]'),
# can add a fully customizable kwarg that takes a callable
pytest.matcher(OSError, check=lambda x: x.bar == 7),
# it could even make the pos arg skippable
# e.g. when you want to check `== type` instead of using `isinstance(..., type)`
pytest.matcher(check=lambda x: type(x) == my_error_generator()),
)
): |
Seems messy to specify nested exceptiongroups - would be something like this? with pytest.raises(
group=(
pytest.raises(
group=(
pytest.raises(ValueError, match="vmsg"),
)
)
)
):
... |
If we want to step away from with pytest.groupraises(
ValueError,
pytest.groupraises(
SyntaxError,
)
):
... or as a type with pytest.RaisedGroup(
ValueError,
pytest.RaisedGroup(
SyntaxError,
)
):
... |
Definitely -1 for me, custom exceptions might not even accept a string at all as argument, a real world example: class ReaderError(Exception):
def __init__(self, reader_code: int, cause: Cause, reason: str) -> None: ... One thing we should consider is that Python decided to go with a different syntax for "unpacking" exception groups, so we probably should do the same and implement a separate function, say
Trying to shoehorn exception group unpacking into |
I recall recommending external Experiments before trying to get this into core |
main downside to this is needing to duplicate functionality from pytest in the external package, or relying on pytest internals not to break. But am currently doing a dirty one in python-trio/trio#2886 where the functionality change causes us to extensively catch exception groups |
Yeah I went with a wrapping
yeah I'm definitely coming around to the upsides of doing |
@nicoddemus see #11671 for a sketch implementation that uses with pytest.RaisesGroup(ValueError):
raise ExceptionGroup("", (ValueError,)) or a more complicated example with pytest.RaisesGroup(
ValueError,
Matcher(SyntaxError, match="syntaxerror message"),
pytest.RaisesGroup(TypeError, match="nested_group_message")
):
raise ExceptionGroup("", (
ValueError(),
SyntaxError("syntaxerror message"),
ExceptionGroup("nested_group_message", (TypeError(),))
)) and supports "loose" matching with pytest.RaisesGroup(ValueError, strict=False):
raise ExceptionGroup("", (ExceptionGroup("", (ValueError,)),)) it's pretty much just #11656 except replacing |
I'd probably favor a way that encodes the expected hierarchy using some kind of helper class (inspired by #11671, but not the same). Additional parameters that control how to match (like # existing behavior
with pytest.raises(ValueError):
...
# same behavior as above
with pytest.raises(Match(ValueError)):
...
# with match
with pytest.raises(Match(ValueError, match="pattern")):
...
# matches multiple exceptions with the same pattern ("can match a ValueError or KeyError with pattern2")
with pytest.raises(Match((ValueError, KeyError), match="pattern"):
...
# multiple matches ("can match a ValueError with pattern1 or a KeyError with pattern2")
with pytest.raises((Match(ValueError, pattern="pattern1"), Match(KeyError, pattern="pattern2"))):
...
# new behavior
with pytest.raises(Match(ExceptionGroup, match="pattern")):
# match only the message of the root group
...
# matches on exception groups can optionally take a list of matches for sub-groups (and it is possible
# to nest ExceptionGroup matchers if desired). For normal exceptions this is not allowed.
with pytest.raises(
Match(
ExceptionGroup,
[
ValueError,
Match(ValueError, match="error pattern1"),
Match((ValueError, KeyError), match="error pattern2"),
Match(ExceptionGroup, match="subgroup pattern"),
],
match="group pattern",
)
):
... I'm not sure if accepting a Note: the generic name of |
Quite similar, I wouldn't mind if something like the above got adopted. The tuples definitely causes a bit of confusion, e.g. is the following allowed: with pytest.raises(
Match(
(MyExceptionGroupSubclass1, MyExceptiongroupSubClass2),
[
...
]
)
):
... If it is, I assume that this isn't: with pytest.raises(
Match(
(ExceptionGroup, ValueError),
[
...
]
)
): ... To avoid the confusing of tuples I prefer the solution of it being possible to pass a callable to pytest/testing/python/expected_exception_group.py Lines 176 to 181 in 2023fa7
and then they can do whatever logiccing they want. The other downside of your version, that Zac's version also had (#11538 (comment)), is nesting becomes messy/verbose once you're 2+ levels down: with pytest.raises(
Match(
ExceptionGroup,
[
Match(
ExceptionGroup,
[
Match(
ValueError,
match="doobiedoo",
),
],
),
],
),
):
... imo it isn't really tenable to test for 2+ nesting levels regularly if you have to bracket twice per level. Maybe that's rare enough you punt it off to plugins though, or let the user subclass The custom logic on whether the second parameter is allowed or not is a bit messy too, it kind of implies that other exceptions that can take args would also be allowed. |
I'm going ahead and adding a helper to Trio for now though, publicly exposing it among with its other test helpers, and we'll see if that garners any feedback from users. |
with pytest.raises(
Match(
(ExceptionGroup, ValueError),
[
...
]
)
): ... yes, that should raise (but the example before that would make sense to me). Instead, you'd have to use with pytest.raises((Match(ExceptionGroup, [...]), ValueError)):
... The only downside is that sharing the pattern is not as easy. For a more crazy thought (so it may be a bad idea, but it would certainly be easier to understand): maybe we can replace the tuple with Regarding the nesting: true, this syntax would become hard to read quickly with increased nesting. In this case I think it would make sense to take inspiration from libraries that focus on dealing with trees / graphs. For example, Match.from_dict({"exceptions": ExceptionGroup, "children": (ValueError, RuntimeError), "match": "pattern"})
Match.from_dict({"exceptions": (ExceptionGroup1, ValueError), "match": "pattern"})
Match.from_dict({"exceptions": ExceptionGroup1, "children": [{"exceptions": ExceptionGroup2, "match": "pattern", "children": [ValueError]}, RuntimeError], "match": "pattern"})
Match.from_dict({"exceptions": (ExceptionGroup1, ExceptionGroup2), "children": [{"exceptions": ExceptionGroup3, "match": "pattern", "children": [ValueError]}, RuntimeError], "match": "pattern"}) Which might be a bit easier to read even at deeper nesting levels, especially with proper formatting? The tuple in |
Seems pytest 8 solves this issue. Looks to have taken my preferred route of matching as old raises. |
(Note: I just opened #11882 after reading the linked docs above, in case anybody is having trouble with running that example) I think I've voiced my issues with import pytest
class EXTREMELYBADERROR(Exception):
...
def test_foo():
with pytest.raises(ExceptionGroup) as excinfo:
raise ExceptionGroup("", (ValueError(), EXTREMELYBADERROR()))
assert excinfo.group_contains(ValueError) In the first example in the docs they have a random If you're using trio, or you're fine with adding it as a test dependency, I recommend using https://trio.readthedocs.io/en/stable/reference-testing.html#exceptiongroup-helpers |
having slept on it, you can make with pytest.raises(ExceptionGroup) as excinfo:
...
assert excinfo.group_contains(ValueError, depth=1)
assert len(excinfo.value.exceptions) == 1 (that's perhaps something to add to the docs) but once you get more than a depth of 1 it quickly gets super messy, especially with multiple expected exceptions. |
I've found neither Use case: testing code that may optionally wrap single exceptions with Here is my workaround for now: _got_value_error = Mock()
with exceptiongroup.catch({ValueError: _got_value_error}):
...
_got_value_error.assert_called_once() I may be missing something, but it seems important to be able to mirror the actual semantics of |
Note that I agree that matching |
with #11656 it would've been possible with with pytest.raises(ValueError, ExpectedExceptionGroup(ValueError)):
... but since pytest devs preferred a separation from One workaround you can do with the current tooling is: with pytest.raises(ValueError, ExceptionGroup) as excinfo:
...
assert isinstance(excinfo.value, ValueError) or trio.testing.RaisesGroup(ValueError).matches(excinfo.value) But what I probably should do is add a parameter |
With python-trio/trio#2989 merged this will now be possible with # will catch all the different raises
with trio.testing.RaisesGroup(ValueError, allow_unwrapped=True, flatten_subgroups=True):
if ...:
# handled with allow_unwrapped=True
raise ValueError()
elif ...:
# always handled
raise ExceptionGroup("", [ValueError()])
else:
# handled with flatten_subgroups=True
raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) ( See https://trio.readthedocs.io/en/latest/reference-testing.html#exceptiongroup-helpers for more details. |
What's the problem this feature will solve?
Let pytest.raises() handle ExceptionGroup unwrapping, similar to what an "except*" clause would do. The following is valid python code that will catch the ValueError exception:
However pytest.raises will not manage this, and will fail with catching the ExceptionGroup instead.
Describe the solution you'd like
Assert some code throws an exception or an exception as part of an exception group.
anyio/trio will now always wrap exception in ExceptionGroups when raised from taskgroups. This leads to silly checks like: https://github.com/agronholm/anyio/blob/3f1eca1addcd782e2347350a6ddb2ad2b80c6354/tests/test_taskgroups.py#L279C1-L287C31 to unwrap the exceptiongroup.
A basic solution to handle this (without the bells and whistles) would be:
Alternative Solutions
Additional context
The text was updated successfully, but these errors were encountered: