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],