pare down implementation to mimimum viable, add assert_matches that has assertions with descriptive outputs for why a match failed

This commit is contained in:
jakkdl 2024-01-21 17:12:56 +01:00
parent 2023fa7de8
commit 43f62eb6b5
3 changed files with 186 additions and 322 deletions

View File

@ -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

View File

@ -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",

View File

@ -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:
def test_raises_group() -> None:
# wrong type to constructor
with pytest.raises(
ValueError,
match="^Invalid argument {exc} must be exception type, Matcher, or RaisesGroup.$",
TypeError,
match="^expected exception must be a BaseException type, not ValueError$",
):
RaisesGroup(ValueError())
RaisesGroup(ValueError()) # type: ignore[arg-type]
# working example
with RaisesGroup(ValueError):
raise ExceptionGroup("foo", (ValueError(),))
with RaisesGroup(SyntaxError):
with RaisesGroup(ValueError, check=lambda x: True):
raise ExceptionGroup("foo", (ValueError(),))
# wrong subexception
with pytest.raises(
AssertionError,
match="Wrong type in group: got <class 'SyntaxError'>, expected <class 'ValueError'>",
):
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 pytest.raises(
AssertionError, match="Wrong number of exceptions: got 2, expected 1"
):
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
# double nested exceptions is not (currently) supported (contrary to expect*)
with pytest.raises(
ValueError,
match="^You cannot specify a nested structure inside a RaisesGroup with strict=False$",
AssertionError,
match="Wrong type in group: got <class '(exceptiongroup.)?ExceptionGroup'>, expected <class 'ValueError'>",
):
RaisesGroup(RaisesGroup(ValueError), strict=False)
with RaisesGroup(ValueError):
raise ExceptionGroup("", (ExceptionGroup("", (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):
# 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 <class 'ValueError'."
):
with RaisesGroup(ValueError):
raise ValueError
def test_match(self) -> None:
# supports match string
with RaisesGroup(ValueError, match="bar"):
raise ExceptionGroup("bar", (ValueError(),))
with pytest.raises(
AssertionError,
match=re.escape("Check failed on ExceptionGroup('foo', (ValueError(),))."),
):
with RaisesGroup(ValueError, check=lambda x: False):
raise ExceptionGroup("foo", (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:
def test_RaisesGroup_matches() -> 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(),)))
def test_message(self) -> None:
try:
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(),)))
def test_message() -> None:
with pytest.raises(
Failed,
match=re.escape(
f"DID NOT RAISE ANY EXCEPTION, expected ExceptionGroup({repr(ValueError)})"
),
):
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.$"
Failed,
match=re.escape(
f"DID NOT RAISE ANY EXCEPTION, expected BaseExceptionGroup({repr(KeyboardInterrupt)})"
),
):
Matcher()
with RaisesGroup(KeyboardInterrupt):
...
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"
if TYPE_CHECKING:
# 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:
def test_types_1() -> 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:
def test_types_2() -> None:
exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup(
"", (ValueError(),)
)
if RaisesGroup(ValueError).matches(exc):
if RaisesGroup(ValueError).assert_matches(exc):
assert_type(exc, BaseExceptionGroup[ValueError])
def test_types_3(self) -> None:
def test_types_3() -> 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]],
def test_types_4() -> None:
e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup(
"", (KeyboardInterrupt(),)
)
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]])
# not currently possible: https://github.com/python/typing/issues/930
RaisesGroup(ValueError).assert_matches(e)
assert_type(e, BaseExceptionGroup[ValueError]) # type: ignore[assert-type]