Skip to content
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

Add support to testing.RaisesGroup for catching unwrapped exceptions #2989

Merged
merged 30 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4497b3d
Add support to testing.RaisesGroup for catching unwrapped exceptions …
jakkdl Apr 15, 2024
62c0ea0
Merge branch 'master' into looser_excgroups
jakkdl Apr 15, 2024
f50f808
add test case to get full coverage
jakkdl Apr 15, 2024
f5c755f
fix type error by adding covariance to typevar
jakkdl Apr 16, 2024
cf64533
rewrite RaisesGroup docstring
jakkdl Apr 16, 2024
38c950b
Merge branch 'master' into looser_excgroups
jakkdl Apr 16, 2024
c506a89
Work around +E typevar issue in docs for _raises_group
TeamSpen210 Apr 17, 2024
0bff4e6
Fix docs issue with type property in _ExceptionInfo
TeamSpen210 Apr 17, 2024
62246f1
Apply suggestions from code review
jakkdl Apr 18, 2024
3a56911
split 'strict' into 'flatten_subgroups' and 'allow_unwrapped', fix bu…
jakkdl Apr 18, 2024
cc5d980
add deprecation of strict, add newsfragments
jakkdl Apr 18, 2024
1a89cd7
the great flattening ...
jakkdl Apr 18, 2024
5af79ac
kinda weird that verifytypes thought this was ambiguous
jakkdl Apr 18, 2024
8f72967
update newsfragments after review
jakkdl Apr 18, 2024
ff3e5fc
update docstring to match new parameter names
jakkdl Apr 18, 2024
2df4c1c
sphinx does not like `...`s
jakkdl Apr 18, 2024
c4dbb78
moar newsfragment improvements
jakkdl Apr 18, 2024
b0d3408
bump exceptiongroup to 1.2.1
jakkdl Apr 22, 2024
36e757a
minor test changes after review
jakkdl Apr 23, 2024
5179221
fix ^$ matching on exceptiongroups
jakkdl Apr 24, 2024
ff5d4eb
Merge branch 'master' into looser_excgroups
jakkdl Apr 24, 2024
8b7aefc
use warn_deprecated instead of DeprecationWarning, disallow allow_unw…
jakkdl May 1, 2024
2183e70
Merge remote-tracking branch 'origin/master' into looser_excgroups
jakkdl May 1, 2024
8c3f1f6
mention $ in bugfix newsfragment, fix test
jakkdl May 1, 2024
f736120
fix coverage
jakkdl May 3, 2024
0c7591a
add test case for nested exceptiongroup + allow_unwrapped
jakkdl May 14, 2024
553df3d
add signature overloads for RaisesGroup to raise type errors when doi…
jakkdl May 14, 2024
04636ac
add pytest.deprecated_call() test
jakkdl May 16, 2024
6f9a8a1
add type tests for narrowing of check argument
jakkdl May 16, 2024
6ef442b
Merge branch 'master' into looser_excgroups
jakkdl May 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import collections.abc
import os
import sys
import types
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
Expand Down Expand Up @@ -98,14 +99,22 @@
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#event-autodoc-process-signature
def autodoc_process_signature(
app: Sphinx,
what: object,
what: str,
name: str,
obj: object,
options: object,
signature: str,
return_annotation: str,
) -> tuple[str, str]:
"""Modify found signatures to fix various issues."""
if name == "trio.testing._raises_group._ExceptionInfo.type":
# This has the type "type[E]", which gets resolved into the property itself.
# That means Sphinx can't resolve it. Fix the issue by overwriting with a fully-qualified
# name.
assert isinstance(obj, property), obj
assert isinstance(obj.fget, types.FunctionType), obj.fget
assert obj.fget.__annotations__["return"] == "type[E]", obj.fget.__annotations__
obj.fget.__annotations__["return"] = "type[~trio.testing._raises_group.E]"
if signature is not None:
signature = signature.replace("~_contextvars.Context", "~contextvars.Context")
if name == "trio.lowlevel.RunVar": # Typevar is not useful here.
Expand All @@ -114,6 +123,13 @@ def autodoc_process_signature(
# Strip the type from the union, make it look like = ...
signature = signature.replace(" | type[trio._core._local._NoValue]", "")
signature = signature.replace("<class 'trio._core._local._NoValue'>", "...")
if (
name in ("trio.testing.RaisesGroup", "trio.testing.Matcher")
and "+E" in signature
):
# This typevar being covariant isn't handled correctly in some cases, strip the +
# and insert the fully-qualified name.
signature = signature.replace("+E", "~trio.testing._raises_group.E")
if "DTLS" in name:
signature = signature.replace("SSL.Context", "OpenSSL.SSL.Context")
# Don't specify PathLike[str] | PathLike[bytes], this is just for humans.
Expand Down
1 change: 1 addition & 0 deletions newsfragments/2989.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:ref:`testing.RaisesGroup` can now catch unwrapped exceptions with ``strict=False``, similar to ``except*``.
jakkdl marked this conversation as resolved.
Show resolved Hide resolved
35 changes: 32 additions & 3 deletions src/trio/_tests/test_testing_raisesgroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,40 @@ def test_raises_group() -> None:
):
RaisesGroup(RaisesGroup(ValueError), strict=False)

# currently not fully identical in behaviour to expect*, which would also catch an unwrapped exception
with pytest.raises(ValueError, match="^value error text$"):
with RaisesGroup(ValueError, strict=False):

def test_catch_unwrapped_exceptions() -> None:
# Catches lone exceptions with strict=False
# just as expect* would
jakkdl marked this conversation as resolved.
Show resolved Hide resolved
with RaisesGroup(ValueError, strict=False):
raise ValueError

with pytest.raises(ValueError, match="foo"):
# This not being caught is perhaps confusing for users used to pytest.raises
with RaisesGroup(SyntaxError, ValueError, strict=False):
raise ValueError("foo")
with pytest.raises(ExceptionGroup, match="foo"):
# but if we made that work, then it would be unexpected that wrapping the exception
# does not get caught
with RaisesGroup(SyntaxError, ValueError, strict=False):
raise ExceptionGroup("foo", (ValueError(),))
# and changing *that* is not really an option, as the entire point of RaisesGroup is
# to verify *all* exceptions specified are there and *no others*.
# Users are required to instead do
with RaisesGroup(
Matcher(check=lambda x: isinstance(x, (SyntaxError, ValueError))), strict=False
):
raise ValueError("foo")

# with strict=True (default) it will not be caught
with pytest.raises(ValueError, match="value error text"):
jakkdl marked this conversation as resolved.
Show resolved Hide resolved
with RaisesGroup(ValueError):
raise ValueError("value error text")

# code coverage
with pytest.raises(TypeError):
with RaisesGroup(ValueError, strict=False):
raise TypeError

A5rocks marked this conversation as resolved.
Show resolved Hide resolved

def test_match() -> None:
# supports match string
Expand Down
27 changes: 26 additions & 1 deletion src/trio/_tests/type_tests/raisesgroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ def check_filenotfound(exc: FileNotFoundError) -> bool:
Matcher(exception_type=ValueError)
Matcher(match="regex")
Matcher(check=check_exc)
Matcher(check=check_filenotfound) # type: ignore
Matcher(ValueError, match="regex")
Matcher(FileNotFoundError, check=check_filenotfound)
Matcher(check=check_filenotfound) # type: ignore # not narrowed
CoolCat467 marked this conversation as resolved.
Show resolved Hide resolved
Matcher(match="regex", check=check_exc)
Matcher(FileNotFoundError, match="regex", check=check_filenotfound)

Expand Down Expand Up @@ -134,3 +134,28 @@ def check_nested_raisesgroups_matches() -> None:
# has the same problems as check_nested_raisesgroups_contextmanager
if RaisesGroup(RaisesGroup(ValueError)).matches(exc):
assert_type(exc, BaseExceptionGroup[RaisesGroup[ValueError]])


def check_multiple_exceptions_1() -> None:
a = RaisesGroup(ValueError, ValueError)
b = RaisesGroup(Matcher(ValueError), Matcher(ValueError))
c = RaisesGroup(ValueError, Matcher(ValueError))

d: BaseExceptionGroup[ValueError]
d = a
d = b
d = c
assert d


def check_multiple_exceptions_2() -> None:
# This previously failed due to lack of covariance in the TypeVar
a = RaisesGroup(Matcher(ValueError), Matcher(TypeError))
b = RaisesGroup(Matcher(ValueError), TypeError)
c = RaisesGroup(ValueError, TypeError)

d: BaseExceptionGroup[Exception]
d = a
d = b
d = c
assert d
28 changes: 24 additions & 4 deletions src/trio/testing/_raises_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup

E = TypeVar("E", bound=BaseException)
E = TypeVar("E", bound=BaseException, covariant=True)


@final
Expand Down Expand Up @@ -263,8 +263,19 @@ class RaisesGroup(ContextManager[ExceptionInfo[BaseExceptionGroup[E]]], SuperCla
This works similar to ``pytest.raises``, and a version of it will hopefully be added upstream, after which this can be deprecated and removed. See https://github.com/pytest-dev/pytest/issues/11538


This differs from :ref:`except* <except_star>` in that all specified exceptions must be present, *and no others*. It will similarly not catch exceptions *not* wrapped in an exceptiongroup.
If you don't care for the nesting level of the exceptions you can pass ``strict=False``.
The catching behaviour differs from :ref:`except* <except_star>` in multiple different ways, being much stricter by default.
#. All specified exceptions must be present, *and no others*.

* If you expect a variable number of exceptions you need to use ``pytest.raises(ExceptionGroup)`` and manually check the contained exceptions. Consider making use of :func:`Matcher.matches`.

#. With ``strict=True`` (the default) it will only catch exceptions wrapped in an exceptiongroup.

* With ``strict=False`` and a single expected exception or `Matcher` it will try matching against it. If you want to catch an unwrapped exception and expect one of several different exception types you need to use a `Matcher` object.

#. With ``strict=True`` (the default) it cares about the nesting level of the exceptions.

* With ``strict=False`` it will "flatten" the raised `ExceptionGroup` before matching against it.

It currently does not care about the order of the exceptions, so ``RaisesGroups(ValueError, TypeError)`` is equivalent to ``RaisesGroups(TypeError, ValueError)``.

This class is not as polished as ``pytest.raises``, and is currently not as helpful in e.g. printing diffs when strings don't match, suggesting you use ``re.escape``, etc.
Expand All @@ -287,7 +298,7 @@ class RaisesGroup(ContextManager[ExceptionInfo[BaseExceptionGroup[E]]], SuperCla
`RaisesGroup.matches` can also be used directly to check a standalone exception group.


This class is also not perfectly smart, e.g. this will likely fail currently::
The matching algorithm is greedy, which means cases such as this may fail::

with RaisesGroups(ValueError, Matcher(ValueError, match="hello")):
raise ExceptionGroup("", (ValueError("hello"), ValueError("goodbye")))
Expand Down Expand Up @@ -383,6 +394,15 @@ def matches(
# maybe have a list of strings logging failed matches, that __exit__ can
# recursively step through and print on a failing match.
if not isinstance(exc_val, BaseExceptionGroup):
if not self.strict and len(self.expected_exceptions) == 1:
exp_exc = self.expected_exceptions[0]
if isinstance(exp_exc, Matcher) and exp_exc.matches(exc_val):
return True
if isinstance(exp_exc, type) and isinstance(exc_val, exp_exc):
return True
# Consider printing a helpful message on unwrapped exception, strict=False and
# `len(self.expected_exceptions) > 1`; since they might expect the exceptions
# to be 'or'd like with `pytest.raises`.
TeamSpen210 marked this conversation as resolved.
Show resolved Hide resolved
return False
if len(exc_val.exceptions) != len(self.expected_exceptions):
return False
Expand Down