From 2023fa7de83b22997b55ede52f9e81b3f41f9ae0 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 1 Dec 2023 17:45:50 +0100 Subject: [PATCH 1/4] draft implementation of RaisesGroup --- src/_pytest/python_api.py | 157 +++++++++++++ testing/python/expected_exception_group.py | 250 +++++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 testing/python/expected_exception_group.py diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 07db0f234..d7594cbf9 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,5 +1,7 @@ import math import pprint +import re +import sys from collections.abc import Collection from collections.abc import Sized from decimal import Decimal @@ -10,6 +12,8 @@ from typing import Callable from typing import cast from typing import ContextManager from typing import final +from typing import Generic +from typing import Iterable from typing import List from typing import Mapping from typing import Optional @@ -28,6 +32,10 @@ from _pytest.outcomes import fail if TYPE_CHECKING: from numpy import ndarray + from typing_extensions import TypeGuard + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup def _non_numeric_type_error(value, at: Optional[str]) -> TypeError: @@ -987,6 +995,155 @@ def raises( # noqa: F811 raises.Exception = fail.Exception # type: ignore +class Matcher(Generic[E]): + def __init__( + self, + exception_type: Optional[Type[E]] = None, + match: Optional[Union[str, Pattern[str]]] = None, + check: Optional[Callable[[E], bool]] = None, + ): + if exception_type is None and match is None and check is None: + raise ValueError("You must specify at least one parameter to match on.") + self.exception_type = exception_type + self.match = match + self.check = check + + def matches(self, exception: E) -> "TypeGuard[E]": + if self.exception_type is not None and not isinstance( + exception, self.exception_type + ): + return False + if self.match is not None and not re.search(self.match, str(exception)): + return False + if self.check is not None and not self.check(exception): + return False + return True + + +if TYPE_CHECKING: + SuperClass = BaseExceptionGroup +else: + SuperClass = Generic + + +@final +class RaisesGroup( + ContextManager[_pytest._code.ExceptionInfo[BaseExceptionGroup[E]]], SuperClass[E] +): + # My_T = TypeVar("My_T", bound=Union[Type[E], Matcher[E], "RaisesGroup[E]"]) + def __init__( + self, + exceptions: Union[Type[E], Matcher[E], E], + *args: Union[Type[E], Matcher[E], E], + strict: bool = True, + match: Optional[Union[str, Pattern[str]]] = None, + ): + # could add parameter `notes: Optional[Tuple[str, Pattern[str]]] = None` + self.expected_exceptions = (exceptions, *args) + self.strict = strict + self.match_expr = match + self.message = f"DID NOT RAISE ExceptionGroup{repr(self.expected_exceptions)}" # type: ignore[misc] + + for exc in self.expected_exceptions: + if not isinstance(exc, (Matcher, RaisesGroup)) and not ( + isinstance(exc, type) and issubclass(exc, BaseException) + ): + raise ValueError( + "Invalid argument {exc} must be exception type, Matcher, or RaisesGroup." + ) + if isinstance(exc, RaisesGroup) and not strict: # type: ignore[unreachable] + raise ValueError( + "You cannot specify a nested structure inside a RaisesGroup with strict=False" + ) + + def __enter__(self) -> _pytest._code.ExceptionInfo[BaseExceptionGroup[E]]: + self.excinfo: _pytest._code.ExceptionInfo[ + BaseExceptionGroup[E] + ] = _pytest._code.ExceptionInfo.for_later() + return self.excinfo + + def _unroll_exceptions( + self, exceptions: Iterable[BaseException] + ) -> Iterable[BaseException]: + res: list[BaseException] = [] + for exc in exceptions: + if isinstance(exc, BaseExceptionGroup): + res.extend(self._unroll_exceptions(exc.exceptions)) + + else: + res.append(exc) + return res + + def matches( + self, + exc_val: Optional[BaseException], + ) -> "TypeGuard[BaseExceptionGroup[E]]": + if exc_val is None: + return False + if not isinstance(exc_val, BaseExceptionGroup): + return False + if not len(exc_val.exceptions) == len(self.expected_exceptions): + return False + remaining_exceptions = list(self.expected_exceptions) + actual_exceptions: Iterable[BaseException] = exc_val.exceptions + if not self.strict: + actual_exceptions = self._unroll_exceptions(actual_exceptions) + + # it should be possible to get RaisesGroup.matches typed so as not to + # need these type: ignores, but I'm not sure that's possible while also having it + # transparent for the end user. + for e in actual_exceptions: + for rem_e in remaining_exceptions: + # TODO: how to print string diff on mismatch? + # Probably accumulate them, and then if fail, print them + # Further QoL would be to print how the exception structure differs on non-match + if ( + (isinstance(rem_e, type) and isinstance(e, rem_e)) + or ( + isinstance(e, BaseExceptionGroup) + and isinstance(rem_e, RaisesGroup) + and rem_e.matches(e) + ) + or ( + isinstance(rem_e, Matcher) + and rem_e.matches(e) # type: ignore[arg-type] + ) + ): + remaining_exceptions.remove(rem_e) # type: ignore[arg-type] + break + else: + return False + return True + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> bool: + __tracebackhide__ = True + if exc_type is None: + fail(self.message) + assert self.excinfo is not None + + if not self.matches(exc_val): + return False + + # Cast to narrow the exception type now that it's verified. + exc_info = cast( + Tuple[Type[BaseExceptionGroup[E]], BaseExceptionGroup[E], TracebackType], + (exc_type, exc_val, exc_tb), + ) + self.excinfo.fill_unfilled(exc_info) + if self.match_expr is not None: + self.excinfo.match(self.match_expr) + return True + + def __repr__(self) -> str: + # TODO: [Base]ExceptionGroup + return f"ExceptionGroup{self.expected_exceptions}" + + @final class RaisesContext(ContextManager[_pytest._code.ExceptionInfo[E]]): def __init__( diff --git a/testing/python/expected_exception_group.py b/testing/python/expected_exception_group.py new file mode 100644 index 000000000..bea04acfc --- /dev/null +++ b/testing/python/expected_exception_group.py @@ -0,0 +1,250 @@ +import sys +from typing import TYPE_CHECKING + +import pytest +from _pytest.python_api import Matcher +from _pytest.python_api import RaisesGroup + +# TODO: make a public export + +if TYPE_CHECKING: + from typing_extensions import assert_type + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + +class TestRaisesGroup: + def test_raises_group(self) -> None: + with pytest.raises( + ValueError, + match="^Invalid argument {exc} must be exception type, Matcher, or RaisesGroup.$", + ): + RaisesGroup(ValueError()) + + with RaisesGroup(ValueError): + raise ExceptionGroup("foo", (ValueError(),)) + + with RaisesGroup(SyntaxError): + with RaisesGroup(ValueError): + raise ExceptionGroup("foo", (SyntaxError(),)) + + # multiple exceptions + with RaisesGroup(ValueError, SyntaxError): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # order doesn't matter + with RaisesGroup(SyntaxError, ValueError): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # nested exceptions + with RaisesGroup(RaisesGroup(ValueError)): + raise ExceptionGroup("foo", (ExceptionGroup("bar", (ValueError(),)),)) + + with RaisesGroup( + SyntaxError, + RaisesGroup(ValueError), + RaisesGroup(RuntimeError), + ): + raise ExceptionGroup( + "foo", + ( + SyntaxError(), + ExceptionGroup("bar", (ValueError(),)), + ExceptionGroup("", (RuntimeError(),)), + ), + ) + + # will error if there's excess exceptions + with pytest.raises(ExceptionGroup): + with RaisesGroup(ValueError): + raise ExceptionGroup("", (ValueError(), ValueError())) + + with pytest.raises(ExceptionGroup): + with RaisesGroup(ValueError): + raise ExceptionGroup("", (RuntimeError(), ValueError())) + + # will error if there's missing exceptions + with pytest.raises(ExceptionGroup): + with RaisesGroup(ValueError, ValueError): + raise ExceptionGroup("", (ValueError(),)) + + with pytest.raises(ExceptionGroup): + with RaisesGroup(ValueError, SyntaxError): + raise ExceptionGroup("", (ValueError(),)) + + # loose semantics, as with expect* + with RaisesGroup(ValueError, strict=False): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + # mixed loose is possible if you want it to be at least N deep + with RaisesGroup(RaisesGroup(ValueError, strict=True)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + with RaisesGroup(RaisesGroup(ValueError, strict=False)): + raise ExceptionGroup( + "", (ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)),) + ) + + # but not the other way around + with pytest.raises( + ValueError, + match="^You cannot specify a nested structure inside a RaisesGroup with strict=False$", + ): + RaisesGroup(RaisesGroup(ValueError), strict=False) + + # currently not fully identical in behaviour to expect*, which would also catch an unwrapped exception + with pytest.raises(ValueError): + with RaisesGroup(ValueError, strict=False): + raise ValueError + + def test_match(self) -> None: + # supports match string + with RaisesGroup(ValueError, match="bar"): + raise ExceptionGroup("bar", (ValueError(),)) + + try: + with RaisesGroup(ValueError, match="foo"): + raise ExceptionGroup("bar", (ValueError(),)) + except AssertionError as e: + assert str(e).startswith("Regex pattern did not match.") + else: + raise AssertionError("Expected pytest.raises.Exception") + + def test_RaisesGroup_matches(self) -> None: + eeg = RaisesGroup(ValueError) + assert not eeg.matches(None) + assert not eeg.matches(ValueError()) + assert eeg.matches(ExceptionGroup("", (ValueError(),))) + + def test_message(self) -> None: + try: + with RaisesGroup(ValueError): + ... + except pytest.fail.Exception as e: + assert e.msg == f"DID NOT RAISE ExceptionGroup({repr(ValueError)},)" + else: + assert False, "Expected pytest.raises.Exception" + try: + with RaisesGroup(RaisesGroup(ValueError)): + ... + except pytest.fail.Exception as e: + assert ( + e.msg + == f"DID NOT RAISE ExceptionGroup(ExceptionGroup({repr(ValueError)},),)" + ) + else: + assert False, "Expected pytest.raises.Exception" + + def test_matcher(self) -> None: + with pytest.raises( + ValueError, match="^You must specify at least one parameter to match on.$" + ): + Matcher() + + with RaisesGroup(Matcher(ValueError)): + raise ExceptionGroup("", (ValueError(),)) + try: + with RaisesGroup(Matcher(TypeError)): + raise ExceptionGroup("", (ValueError(),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + def test_matcher_match(self) -> None: + with RaisesGroup(Matcher(ValueError, "foo")): + raise ExceptionGroup("", (ValueError("foo"),)) + try: + with RaisesGroup(Matcher(ValueError, "foo")): + raise ExceptionGroup("", (ValueError("bar"),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + # Can be used without specifying the type + with RaisesGroup(Matcher(match="foo")): + raise ExceptionGroup("", (ValueError("foo"),)) + try: + with RaisesGroup(Matcher(match="foo")): + raise ExceptionGroup("", (ValueError("bar"),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + def test_Matcher_check(self) -> None: + def check_oserror_and_errno_is_5(e: BaseException) -> bool: + return isinstance(e, OSError) and e.errno == 5 + + with RaisesGroup(Matcher(check=check_oserror_and_errno_is_5)): + raise ExceptionGroup("", (OSError(5, ""),)) + + # specifying exception_type narrows the parameter type to the callable + def check_errno_is_5(e: OSError) -> bool: + return e.errno == 5 + + with RaisesGroup(Matcher(OSError, check=check_errno_is_5)): + raise ExceptionGroup("", (OSError(5, ""),)) + + try: + with RaisesGroup(Matcher(OSError, check=check_errno_is_5)): + raise ExceptionGroup("", (OSError(6, ""),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + if TYPE_CHECKING: + # getting the typing working satisfactory is very tricky + # but with RaisesGroup being seen as a subclass of BaseExceptionGroup + # most end-user cases of checking excinfo.value.foobar should work fine now. + def test_types_0(self) -> None: + _: BaseExceptionGroup[ValueError] = RaisesGroup(ValueError) + _ = RaisesGroup(RaisesGroup(ValueError)) # type: ignore[arg-type] + a: BaseExceptionGroup[BaseExceptionGroup[ValueError]] + a = RaisesGroup(RaisesGroup(ValueError)) + a = BaseExceptionGroup("", (BaseExceptionGroup("", (ValueError(),)),)) + assert a + + def test_types_1(self) -> None: + with RaisesGroup(ValueError) as e: + raise ExceptionGroup("foo", (ValueError(),)) + assert_type(e.value, BaseExceptionGroup[ValueError]) + # assert_type(e.value, RaisesGroup[ValueError]) + + def test_types_2(self) -> None: + exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup( + "", (ValueError(),) + ) + if RaisesGroup(ValueError).matches(exc): + assert_type(exc, BaseExceptionGroup[ValueError]) + + def test_types_3(self) -> None: + e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup( + "", (KeyboardInterrupt(),) + ) + if RaisesGroup(ValueError).matches(e): + assert_type(e, BaseExceptionGroup[ValueError]) + + def test_types_4(self) -> None: + with RaisesGroup(Matcher(ValueError)) as e: + ... + _: BaseExceptionGroup[ValueError] = e.value + assert_type(e.value, BaseExceptionGroup[ValueError]) + + def test_types_5(self) -> None: + with RaisesGroup(RaisesGroup(ValueError)) as excinfo: + raise ExceptionGroup("foo", (ValueError(),)) + _: BaseExceptionGroup[BaseExceptionGroup[ValueError]] = excinfo.value + assert_type( + excinfo.value, + BaseExceptionGroup[RaisesGroup[ValueError]], + ) + print(excinfo.value.exceptions[0].exceptions[0]) + + def test_types_6(self) -> None: + exc: ExceptionGroup[ExceptionGroup[ValueError]] = ... # type: ignore[assignment] + if RaisesGroup(RaisesGroup(ValueError)).matches(exc): # type: ignore[arg-type] + # ugly + assert_type(exc, BaseExceptionGroup[RaisesGroup[ValueError]]) From 43f62eb6b5e01320224d8f6241886b9bd209dc3e Mon Sep 17 00:00:00 2001 From: jakkdl Date: Sun, 21 Jan 2024 17:12:56 +0100 Subject: [PATCH 2/4] pare down implementation to mimimum viable, add assert_matches that has assertions with descriptive outputs for why a match failed --- src/_pytest/python_api.py | 163 ++++------ src/pytest/__init__.py | 2 + testing/python/expected_exception_group.py | 343 ++++++++------------- 3 files changed, 186 insertions(+), 322 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index d7594cbf9..7231b28af 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,6 +1,5 @@ import math import pprint -import re import sys from collections.abc import Collection from collections.abc import Sized @@ -12,7 +11,6 @@ from typing import Callable from typing import cast from typing import ContextManager from typing import final -from typing import Generic from typing import Iterable from typing import List from typing import Mapping @@ -995,66 +993,39 @@ def raises( # noqa: F811 raises.Exception = fail.Exception # type: ignore -class Matcher(Generic[E]): - def __init__( - self, - exception_type: Optional[Type[E]] = None, - match: Optional[Union[str, Pattern[str]]] = None, - check: Optional[Callable[[E], bool]] = None, - ): - if exception_type is None and match is None and check is None: - raise ValueError("You must specify at least one parameter to match on.") - self.exception_type = exception_type - self.match = match - self.check = check - - def matches(self, exception: E) -> "TypeGuard[E]": - if self.exception_type is not None and not isinstance( - exception, self.exception_type - ): - return False - if self.match is not None and not re.search(self.match, str(exception)): - return False - if self.check is not None and not self.check(exception): - return False - return True - - -if TYPE_CHECKING: - SuperClass = BaseExceptionGroup -else: - SuperClass = Generic - - @final -class RaisesGroup( - ContextManager[_pytest._code.ExceptionInfo[BaseExceptionGroup[E]]], SuperClass[E] -): - # My_T = TypeVar("My_T", bound=Union[Type[E], Matcher[E], "RaisesGroup[E]"]) +class RaisesGroup(ContextManager[_pytest._code.ExceptionInfo[BaseExceptionGroup[E]]]): + """Helper for catching exceptions wrapped in an ExceptionGroup. + + Similar to pytest.raises, except: + * It requires that the exception is inside an exceptiongroup + * It is only able to be used as a contextmanager + * Due to the above, is not split into a caller function and a cm class + Similar to trio.RaisesGroup, except: + * does not handle multiple levels of nested groups. + * does not have trio.Matcher, to add matching on the sub-exception + * does not handle multiple exceptions in the exceptiongroup. + + TODO: copy over docstring example usage from trio.RaisesGroup + """ + def __init__( self, - exceptions: Union[Type[E], Matcher[E], E], - *args: Union[Type[E], Matcher[E], E], - strict: bool = True, - match: Optional[Union[str, Pattern[str]]] = None, + exception: Type[E], + check: Optional[Callable[[BaseExceptionGroup[E]], bool]] = None, ): - # could add parameter `notes: Optional[Tuple[str, Pattern[str]]] = None` - self.expected_exceptions = (exceptions, *args) - self.strict = strict - self.match_expr = match - self.message = f"DID NOT RAISE ExceptionGroup{repr(self.expected_exceptions)}" # type: ignore[misc] + # copied from raises() above + if not isinstance(exception, type) or not issubclass(exception, BaseException): + msg = "expected exception must be a BaseException type, not {}" # type: ignore[unreachable] + not_a = ( + exception.__name__ + if isinstance(exception, type) + else type(exception).__name__ + ) + raise TypeError(msg.format(not_a)) - for exc in self.expected_exceptions: - if not isinstance(exc, (Matcher, RaisesGroup)) and not ( - isinstance(exc, type) and issubclass(exc, BaseException) - ): - raise ValueError( - "Invalid argument {exc} must be exception type, Matcher, or RaisesGroup." - ) - if isinstance(exc, RaisesGroup) and not strict: # type: ignore[unreachable] - raise ValueError( - "You cannot specify a nested structure inside a RaisesGroup with strict=False" - ) + self.exception = exception + self.check = check def __enter__(self) -> _pytest._code.ExceptionInfo[BaseExceptionGroup[E]]: self.excinfo: _pytest._code.ExceptionInfo[ @@ -1078,41 +1049,33 @@ class RaisesGroup( self, exc_val: Optional[BaseException], ) -> "TypeGuard[BaseExceptionGroup[E]]": - if exc_val is None: - return False - if not isinstance(exc_val, BaseExceptionGroup): - return False - if not len(exc_val.exceptions) == len(self.expected_exceptions): - return False - remaining_exceptions = list(self.expected_exceptions) - actual_exceptions: Iterable[BaseException] = exc_val.exceptions - if not self.strict: - actual_exceptions = self._unroll_exceptions(actual_exceptions) + return ( + exc_val is not None + and isinstance(exc_val, BaseExceptionGroup) + and len(exc_val.exceptions) == 1 + and isinstance(exc_val.exceptions[0], self.exception) + and (self.check is None or self.check(exc_val)) + ) + + def assert_matches( + self, + exc_val: Optional[BaseException], + ) -> "TypeGuard[BaseExceptionGroup[E]]": + assert ( + exc_val is not None + ), "Internal Error: exc_type is not None but exc_val is" + assert isinstance( + exc_val, BaseExceptionGroup + ), f"Expected an ExceptionGroup, not {type(exc_val)}" + assert ( + len(exc_val.exceptions) == 1 + ), f"Wrong number of exceptions: got {len(exc_val.exceptions)}, expected 1." + assert isinstance( + exc_val.exceptions[0], self.exception + ), f"Wrong type in group: got {type(exc_val.exceptions[0])}, expected {self.exception}" + if self.check is not None: + assert self.check(exc_val), f"Check failed on {repr(exc_val)}." - # it should be possible to get RaisesGroup.matches typed so as not to - # need these type: ignores, but I'm not sure that's possible while also having it - # transparent for the end user. - for e in actual_exceptions: - for rem_e in remaining_exceptions: - # TODO: how to print string diff on mismatch? - # Probably accumulate them, and then if fail, print them - # Further QoL would be to print how the exception structure differs on non-match - if ( - (isinstance(rem_e, type) and isinstance(e, rem_e)) - or ( - isinstance(e, BaseExceptionGroup) - and isinstance(rem_e, RaisesGroup) - and rem_e.matches(e) - ) - or ( - isinstance(rem_e, Matcher) - and rem_e.matches(e) # type: ignore[arg-type] - ) - ): - remaining_exceptions.remove(rem_e) # type: ignore[arg-type] - break - else: - return False return True def __exit__( @@ -1123,11 +1086,10 @@ class RaisesGroup( ) -> bool: __tracebackhide__ = True if exc_type is None: - fail(self.message) - assert self.excinfo is not None + fail("DID NOT RAISE ANY EXCEPTION, expected " + self.expected_type()) + assert self.excinfo is not None, "__exit__ without __enter__" - if not self.matches(exc_val): - return False + self.assert_matches(exc_val) # Cast to narrow the exception type now that it's verified. exc_info = cast( @@ -1135,13 +1097,14 @@ class RaisesGroup( (exc_type, exc_val, exc_tb), ) self.excinfo.fill_unfilled(exc_info) - if self.match_expr is not None: - self.excinfo.match(self.match_expr) return True - def __repr__(self) -> str: - # TODO: [Base]ExceptionGroup - return f"ExceptionGroup{self.expected_exceptions}" + def expected_type(self) -> str: + if not issubclass(self.exception, Exception): + base = "Base" + else: + base = "" + return f"{base}ExceptionGroup({self.exception})" @final diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 0aa496a2f..238292992 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -57,6 +57,7 @@ from _pytest.python import Module from _pytest.python import Package from _pytest.python_api import approx from _pytest.python_api import raises +from _pytest.python_api import RaisesGroup from _pytest.recwarn import deprecated_call from _pytest.recwarn import WarningsRecorder from _pytest.recwarn import warns @@ -146,6 +147,7 @@ __all__ = [ "PytestUnraisableExceptionWarning", "PytestWarning", "raises", + "RaisesGroup", "RecordedHookCall", "register_assert_rewrite", "RunResult", diff --git a/testing/python/expected_exception_group.py b/testing/python/expected_exception_group.py index bea04acfc..b6edf3d82 100644 --- a/testing/python/expected_exception_group.py +++ b/testing/python/expected_exception_group.py @@ -1,11 +1,10 @@ +import re import sys from typing import TYPE_CHECKING import pytest -from _pytest.python_api import Matcher -from _pytest.python_api import RaisesGroup - -# TODO: make a public export +from _pytest.outcomes import Failed +from pytest import RaisesGroup if TYPE_CHECKING: from typing_extensions import assert_type @@ -14,237 +13,137 @@ if sys.version_info < (3, 11): from exceptiongroup import ExceptionGroup -class TestRaisesGroup: - def test_raises_group(self) -> None: - with pytest.raises( - ValueError, - match="^Invalid argument {exc} must be exception type, Matcher, or RaisesGroup.$", - ): - RaisesGroup(ValueError()) +def test_raises_group() -> None: + # wrong type to constructor + with pytest.raises( + TypeError, + match="^expected exception must be a BaseException type, not ValueError$", + ): + RaisesGroup(ValueError()) # type: ignore[arg-type] + # working example + with RaisesGroup(ValueError): + raise ExceptionGroup("foo", (ValueError(),)) + + with RaisesGroup(ValueError, check=lambda x: True): + raise ExceptionGroup("foo", (ValueError(),)) + + # wrong subexception + with pytest.raises( + AssertionError, + match="Wrong type in group: got , expected ", + ): with RaisesGroup(ValueError): + raise ExceptionGroup("foo", (SyntaxError(),)) + + # will error if there's excess exceptions + with pytest.raises( + AssertionError, match="Wrong number of exceptions: got 2, expected 1" + ): + with RaisesGroup(ValueError): + raise ExceptionGroup("", (ValueError(), ValueError())) + + # double nested exceptions is not (currently) supported (contrary to expect*) + with pytest.raises( + AssertionError, + match="Wrong type in group: got , expected ", + ): + with RaisesGroup(ValueError): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + # you'd need to write + with RaisesGroup(ExceptionGroup) as excinfo: + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + RaisesGroup(ValueError).assert_matches(excinfo.value.exceptions[0]) + + # unwrapped exceptions are not accepted (contrary to expect*) + with pytest.raises( + AssertionError, match="Expected an ExceptionGroup, not None: + eeg = RaisesGroup(ValueError) + # exc_val is None + assert not eeg.matches(None) + # exc_val is not an exceptiongroup + assert not eeg.matches(ValueError()) + # wrong length + assert not eeg.matches(ExceptionGroup("", (ValueError(), ValueError()))) + # wrong type + assert not eeg.matches(ExceptionGroup("", (TypeError(),))) + # check fails + assert not RaisesGroup(ValueError, check=lambda _: False).matches( + ExceptionGroup("", (ValueError(),)) + ) + # success + assert eeg.matches(ExceptionGroup("", (ValueError(),))) - # order doesn't matter - with RaisesGroup(SyntaxError, ValueError): - raise ExceptionGroup("foo", (ValueError(), SyntaxError())) - # nested exceptions - with RaisesGroup(RaisesGroup(ValueError)): - raise ExceptionGroup("foo", (ExceptionGroup("bar", (ValueError(),)),)) +def test_RaisesGroup_assert_matches() -> None: + """Check direct use of RaisesGroup.assert_matches, without a context manager""" + eeg = RaisesGroup(ValueError) + with pytest.raises(AssertionError): + eeg.assert_matches(None) + with pytest.raises(AssertionError): + eeg.assert_matches(ValueError()) + eeg.assert_matches(ExceptionGroup("", (ValueError(),))) - with RaisesGroup( - SyntaxError, - RaisesGroup(ValueError), - RaisesGroup(RuntimeError), - ): - raise ExceptionGroup( - "foo", - ( - SyntaxError(), - ExceptionGroup("bar", (ValueError(),)), - ExceptionGroup("", (RuntimeError(),)), - ), - ) - # will error if there's excess exceptions - with pytest.raises(ExceptionGroup): - with RaisesGroup(ValueError): - raise ExceptionGroup("", (ValueError(), ValueError())) +def test_message() -> None: + with pytest.raises( + Failed, + match=re.escape( + f"DID NOT RAISE ANY EXCEPTION, expected ExceptionGroup({repr(ValueError)})" + ), + ): + with RaisesGroup(ValueError): + ... - with pytest.raises(ExceptionGroup): - with RaisesGroup(ValueError): - raise ExceptionGroup("", (RuntimeError(), ValueError())) + with pytest.raises( + Failed, + match=re.escape( + f"DID NOT RAISE ANY EXCEPTION, expected BaseExceptionGroup({repr(KeyboardInterrupt)})" + ), + ): + with RaisesGroup(KeyboardInterrupt): + ... - # will error if there's missing exceptions - with pytest.raises(ExceptionGroup): - with RaisesGroup(ValueError, ValueError): - raise ExceptionGroup("", (ValueError(),)) - with pytest.raises(ExceptionGroup): - with RaisesGroup(ValueError, SyntaxError): - raise ExceptionGroup("", (ValueError(),)) +if TYPE_CHECKING: - # loose semantics, as with expect* - with RaisesGroup(ValueError, strict=False): - raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + def test_types_1() -> None: + with RaisesGroup(ValueError) as e: + raise ExceptionGroup("foo", (ValueError(),)) + assert_type(e.value, BaseExceptionGroup[ValueError]) - # mixed loose is possible if you want it to be at least N deep - with RaisesGroup(RaisesGroup(ValueError, strict=True)): - raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) - with RaisesGroup(RaisesGroup(ValueError, strict=False)): - raise ExceptionGroup( - "", (ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)),) - ) + def test_types_2() -> None: + exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup( + "", (ValueError(),) + ) + if RaisesGroup(ValueError).assert_matches(exc): + assert_type(exc, BaseExceptionGroup[ValueError]) - # but not the other way around - with pytest.raises( - ValueError, - match="^You cannot specify a nested structure inside a RaisesGroup with strict=False$", - ): - RaisesGroup(RaisesGroup(ValueError), strict=False) + def test_types_3() -> None: + e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup( + "", (KeyboardInterrupt(),) + ) + if RaisesGroup(ValueError).matches(e): + assert_type(e, BaseExceptionGroup[ValueError]) - # currently not fully identical in behaviour to expect*, which would also catch an unwrapped exception - with pytest.raises(ValueError): - with RaisesGroup(ValueError, strict=False): - raise ValueError - - def test_match(self) -> None: - # supports match string - with RaisesGroup(ValueError, match="bar"): - raise ExceptionGroup("bar", (ValueError(),)) - - try: - with RaisesGroup(ValueError, match="foo"): - raise ExceptionGroup("bar", (ValueError(),)) - except AssertionError as e: - assert str(e).startswith("Regex pattern did not match.") - else: - raise AssertionError("Expected pytest.raises.Exception") - - def test_RaisesGroup_matches(self) -> None: - eeg = RaisesGroup(ValueError) - assert not eeg.matches(None) - assert not eeg.matches(ValueError()) - assert eeg.matches(ExceptionGroup("", (ValueError(),))) - - def test_message(self) -> None: - try: - with RaisesGroup(ValueError): - ... - except pytest.fail.Exception as e: - assert e.msg == f"DID NOT RAISE ExceptionGroup({repr(ValueError)},)" - else: - assert False, "Expected pytest.raises.Exception" - try: - with RaisesGroup(RaisesGroup(ValueError)): - ... - except pytest.fail.Exception as e: - assert ( - e.msg - == f"DID NOT RAISE ExceptionGroup(ExceptionGroup({repr(ValueError)},),)" - ) - else: - assert False, "Expected pytest.raises.Exception" - - def test_matcher(self) -> None: - with pytest.raises( - ValueError, match="^You must specify at least one parameter to match on.$" - ): - Matcher() - - with RaisesGroup(Matcher(ValueError)): - raise ExceptionGroup("", (ValueError(),)) - try: - with RaisesGroup(Matcher(TypeError)): - raise ExceptionGroup("", (ValueError(),)) - except ExceptionGroup: - pass - else: - assert False, "Expected pytest.raises.Exception" - - def test_matcher_match(self) -> None: - with RaisesGroup(Matcher(ValueError, "foo")): - raise ExceptionGroup("", (ValueError("foo"),)) - try: - with RaisesGroup(Matcher(ValueError, "foo")): - raise ExceptionGroup("", (ValueError("bar"),)) - except ExceptionGroup: - pass - else: - assert False, "Expected pytest.raises.Exception" - - # Can be used without specifying the type - with RaisesGroup(Matcher(match="foo")): - raise ExceptionGroup("", (ValueError("foo"),)) - try: - with RaisesGroup(Matcher(match="foo")): - raise ExceptionGroup("", (ValueError("bar"),)) - except ExceptionGroup: - pass - else: - assert False, "Expected pytest.raises.Exception" - - def test_Matcher_check(self) -> None: - def check_oserror_and_errno_is_5(e: BaseException) -> bool: - return isinstance(e, OSError) and e.errno == 5 - - with RaisesGroup(Matcher(check=check_oserror_and_errno_is_5)): - raise ExceptionGroup("", (OSError(5, ""),)) - - # specifying exception_type narrows the parameter type to the callable - def check_errno_is_5(e: OSError) -> bool: - return e.errno == 5 - - with RaisesGroup(Matcher(OSError, check=check_errno_is_5)): - raise ExceptionGroup("", (OSError(5, ""),)) - - try: - with RaisesGroup(Matcher(OSError, check=check_errno_is_5)): - raise ExceptionGroup("", (OSError(6, ""),)) - except ExceptionGroup: - pass - else: - assert False, "Expected pytest.raises.Exception" - - if TYPE_CHECKING: - # getting the typing working satisfactory is very tricky - # but with RaisesGroup being seen as a subclass of BaseExceptionGroup - # most end-user cases of checking excinfo.value.foobar should work fine now. - def test_types_0(self) -> None: - _: BaseExceptionGroup[ValueError] = RaisesGroup(ValueError) - _ = RaisesGroup(RaisesGroup(ValueError)) # type: ignore[arg-type] - a: BaseExceptionGroup[BaseExceptionGroup[ValueError]] - a = RaisesGroup(RaisesGroup(ValueError)) - a = BaseExceptionGroup("", (BaseExceptionGroup("", (ValueError(),)),)) - assert a - - def test_types_1(self) -> None: - with RaisesGroup(ValueError) as e: - raise ExceptionGroup("foo", (ValueError(),)) - assert_type(e.value, BaseExceptionGroup[ValueError]) - # assert_type(e.value, RaisesGroup[ValueError]) - - def test_types_2(self) -> None: - exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup( - "", (ValueError(),) - ) - if RaisesGroup(ValueError).matches(exc): - assert_type(exc, BaseExceptionGroup[ValueError]) - - def test_types_3(self) -> None: - e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup( - "", (KeyboardInterrupt(),) - ) - if RaisesGroup(ValueError).matches(e): - assert_type(e, BaseExceptionGroup[ValueError]) - - def test_types_4(self) -> None: - with RaisesGroup(Matcher(ValueError)) as e: - ... - _: BaseExceptionGroup[ValueError] = e.value - assert_type(e.value, BaseExceptionGroup[ValueError]) - - def test_types_5(self) -> None: - with RaisesGroup(RaisesGroup(ValueError)) as excinfo: - raise ExceptionGroup("foo", (ValueError(),)) - _: BaseExceptionGroup[BaseExceptionGroup[ValueError]] = excinfo.value - assert_type( - excinfo.value, - BaseExceptionGroup[RaisesGroup[ValueError]], - ) - print(excinfo.value.exceptions[0].exceptions[0]) - - def test_types_6(self) -> None: - exc: ExceptionGroup[ExceptionGroup[ValueError]] = ... # type: ignore[assignment] - if RaisesGroup(RaisesGroup(ValueError)).matches(exc): # type: ignore[arg-type] - # ugly - assert_type(exc, BaseExceptionGroup[RaisesGroup[ValueError]]) + def test_types_4() -> None: + e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup( + "", (KeyboardInterrupt(),) + ) + # not currently possible: https://github.com/python/typing/issues/930 + RaisesGroup(ValueError).assert_matches(e) + assert_type(e, BaseExceptionGroup[ValueError]) # type: ignore[assert-type] From 620f19b70c696bfbf102dffa47f0c1665f25f4d5 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Sun, 21 Jan 2024 17:22:51 +0100 Subject: [PATCH 3/4] rename test file --- testing/python/{expected_exception_group.py => raises_group.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename testing/python/{expected_exception_group.py => raises_group.py} (100%) diff --git a/testing/python/expected_exception_group.py b/testing/python/raises_group.py similarity index 100% rename from testing/python/expected_exception_group.py rename to testing/python/raises_group.py From 2a12ed97ce10462389ebbf25fd4a5d94a3628232 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Sun, 21 Jan 2024 17:32:55 +0100 Subject: [PATCH 4/4] remove unused _unroll_exceptions --- src/_pytest/python_api.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 7231b28af..7b3b71db3 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -11,7 +11,6 @@ from typing import Callable from typing import cast from typing import ContextManager from typing import final -from typing import Iterable from typing import List from typing import Mapping from typing import Optional @@ -1033,18 +1032,6 @@ class RaisesGroup(ContextManager[_pytest._code.ExceptionInfo[BaseExceptionGroup[ ] = _pytest._code.ExceptionInfo.for_later() return self.excinfo - def _unroll_exceptions( - self, exceptions: Iterable[BaseException] - ) -> Iterable[BaseException]: - res: list[BaseException] = [] - for exc in exceptions: - if isinstance(exc, BaseExceptionGroup): - res.extend(self._unroll_exceptions(exc.exceptions)) - - else: - res.append(exc) - return res - def matches( self, exc_val: Optional[BaseException],