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 math
import pprint import pprint
import re
import sys import sys
from collections.abc import Collection from collections.abc import Collection
from collections.abc import Sized from collections.abc import Sized
@ -12,7 +11,6 @@ from typing import Callable
from typing import cast from typing import cast
from typing import ContextManager from typing import ContextManager
from typing import final from typing import final
from typing import Generic
from typing import Iterable from typing import Iterable
from typing import List from typing import List
from typing import Mapping from typing import Mapping
@ -995,66 +993,39 @@ def raises( # noqa: F811
raises.Exception = fail.Exception # type: ignore 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 @final
class RaisesGroup( class RaisesGroup(ContextManager[_pytest._code.ExceptionInfo[BaseExceptionGroup[E]]]):
ContextManager[_pytest._code.ExceptionInfo[BaseExceptionGroup[E]]], SuperClass[E] """Helper for catching exceptions wrapped in an ExceptionGroup.
):
# My_T = TypeVar("My_T", bound=Union[Type[E], Matcher[E], "RaisesGroup[E]"]) 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__( def __init__(
self, self,
exceptions: Union[Type[E], Matcher[E], E], exception: Type[E],
*args: Union[Type[E], Matcher[E], E], check: Optional[Callable[[BaseExceptionGroup[E]], bool]] = None,
strict: bool = True,
match: Optional[Union[str, Pattern[str]]] = None,
): ):
# could add parameter `notes: Optional[Tuple[str, Pattern[str]]] = None` # copied from raises() above
self.expected_exceptions = (exceptions, *args) if not isinstance(exception, type) or not issubclass(exception, BaseException):
self.strict = strict msg = "expected exception must be a BaseException type, not {}" # type: ignore[unreachable]
self.match_expr = match not_a = (
self.message = f"DID NOT RAISE ExceptionGroup{repr(self.expected_exceptions)}" # type: ignore[misc] exception.__name__
if isinstance(exception, type)
else type(exception).__name__
)
raise TypeError(msg.format(not_a))
for exc in self.expected_exceptions: self.exception = exception
if not isinstance(exc, (Matcher, RaisesGroup)) and not ( self.check = check
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]]: def __enter__(self) -> _pytest._code.ExceptionInfo[BaseExceptionGroup[E]]:
self.excinfo: _pytest._code.ExceptionInfo[ self.excinfo: _pytest._code.ExceptionInfo[
@ -1078,41 +1049,33 @@ class RaisesGroup(
self, self,
exc_val: Optional[BaseException], exc_val: Optional[BaseException],
) -> "TypeGuard[BaseExceptionGroup[E]]": ) -> "TypeGuard[BaseExceptionGroup[E]]":
if exc_val is None: return (
return False exc_val is not None
if not isinstance(exc_val, BaseExceptionGroup): and isinstance(exc_val, BaseExceptionGroup)
return False and len(exc_val.exceptions) == 1
if not len(exc_val.exceptions) == len(self.expected_exceptions): and isinstance(exc_val.exceptions[0], self.exception)
return False and (self.check is None or self.check(exc_val))
remaining_exceptions = list(self.expected_exceptions) )
actual_exceptions: Iterable[BaseException] = exc_val.exceptions
if not self.strict: def assert_matches(
actual_exceptions = self._unroll_exceptions(actual_exceptions) 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 return True
def __exit__( def __exit__(
@ -1123,11 +1086,10 @@ class RaisesGroup(
) -> bool: ) -> bool:
__tracebackhide__ = True __tracebackhide__ = True
if exc_type is None: if exc_type is None:
fail(self.message) fail("DID NOT RAISE ANY EXCEPTION, expected " + self.expected_type())
assert self.excinfo is not None assert self.excinfo is not None, "__exit__ without __enter__"
if not self.matches(exc_val): self.assert_matches(exc_val)
return False
# Cast to narrow the exception type now that it's verified. # Cast to narrow the exception type now that it's verified.
exc_info = cast( exc_info = cast(
@ -1135,13 +1097,14 @@ class RaisesGroup(
(exc_type, exc_val, exc_tb), (exc_type, exc_val, exc_tb),
) )
self.excinfo.fill_unfilled(exc_info) self.excinfo.fill_unfilled(exc_info)
if self.match_expr is not None:
self.excinfo.match(self.match_expr)
return True return True
def __repr__(self) -> str: def expected_type(self) -> str:
# TODO: [Base]ExceptionGroup if not issubclass(self.exception, Exception):
return f"ExceptionGroup{self.expected_exceptions}" base = "Base"
else:
base = ""
return f"{base}ExceptionGroup({self.exception})"
@final @final

View File

@ -57,6 +57,7 @@ from _pytest.python import Module
from _pytest.python import Package from _pytest.python import Package
from _pytest.python_api import approx from _pytest.python_api import approx
from _pytest.python_api import raises from _pytest.python_api import raises
from _pytest.python_api import RaisesGroup
from _pytest.recwarn import deprecated_call from _pytest.recwarn import deprecated_call
from _pytest.recwarn import WarningsRecorder from _pytest.recwarn import WarningsRecorder
from _pytest.recwarn import warns from _pytest.recwarn import warns
@ -146,6 +147,7 @@ __all__ = [
"PytestUnraisableExceptionWarning", "PytestUnraisableExceptionWarning",
"PytestWarning", "PytestWarning",
"raises", "raises",
"RaisesGroup",
"RecordedHookCall", "RecordedHookCall",
"register_assert_rewrite", "register_assert_rewrite",
"RunResult", "RunResult",

View File

@ -1,11 +1,10 @@
import re
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
from _pytest.python_api import Matcher from _pytest.outcomes import Failed
from _pytest.python_api import RaisesGroup from pytest import RaisesGroup
# TODO: make a public export
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import assert_type from typing_extensions import assert_type
@ -14,237 +13,137 @@ if sys.version_info < (3, 11):
from exceptiongroup import ExceptionGroup from exceptiongroup import ExceptionGroup
class TestRaisesGroup: def test_raises_group() -> None:
def test_raises_group(self) -> None: # wrong type to constructor
with pytest.raises( with pytest.raises(
ValueError, TypeError,
match="^Invalid argument {exc} must be exception type, Matcher, or RaisesGroup.$", 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(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): 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 <class '(exceptiongroup.)?ExceptionGroup'>, expected <class 'ValueError'>",
):
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 <class 'ValueError'."
):
with RaisesGroup(ValueError):
raise 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(),)) raise ExceptionGroup("foo", (ValueError(),))
with RaisesGroup(SyntaxError):
with RaisesGroup(ValueError):
raise ExceptionGroup("foo", (SyntaxError(),))
# multiple exceptions def test_RaisesGroup_matches() -> None:
with RaisesGroup(ValueError, SyntaxError): eeg = RaisesGroup(ValueError)
raise ExceptionGroup("foo", (ValueError(), SyntaxError())) # 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 def test_RaisesGroup_assert_matches() -> None:
with RaisesGroup(RaisesGroup(ValueError)): """Check direct use of RaisesGroup.assert_matches, without a context manager"""
raise ExceptionGroup("foo", (ExceptionGroup("bar", (ValueError(),)),)) 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 def test_message() -> None:
with pytest.raises(ExceptionGroup): with pytest.raises(
with RaisesGroup(ValueError): Failed,
raise ExceptionGroup("", (ValueError(), ValueError())) match=re.escape(
f"DID NOT RAISE ANY EXCEPTION, expected ExceptionGroup({repr(ValueError)})"
),
):
with RaisesGroup(ValueError):
...
with pytest.raises(ExceptionGroup): with pytest.raises(
with RaisesGroup(ValueError): Failed,
raise ExceptionGroup("", (RuntimeError(), ValueError())) 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): if TYPE_CHECKING:
with RaisesGroup(ValueError, SyntaxError):
raise ExceptionGroup("", (ValueError(),))
# loose semantics, as with expect* def test_types_1() -> None:
with RaisesGroup(ValueError, strict=False): with RaisesGroup(ValueError) as e:
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) raise ExceptionGroup("foo", (ValueError(),))
assert_type(e.value, BaseExceptionGroup[ValueError])
# mixed loose is possible if you want it to be at least N deep def test_types_2() -> None:
with RaisesGroup(RaisesGroup(ValueError, strict=True)): exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup(
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) "", (ValueError(),)
with RaisesGroup(RaisesGroup(ValueError, strict=False)): )
raise ExceptionGroup( if RaisesGroup(ValueError).assert_matches(exc):
"", (ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)),) assert_type(exc, BaseExceptionGroup[ValueError])
)
# but not the other way around def test_types_3() -> None:
with pytest.raises( e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup(
ValueError, "", (KeyboardInterrupt(),)
match="^You cannot specify a nested structure inside a RaisesGroup with strict=False$", )
): if RaisesGroup(ValueError).matches(e):
RaisesGroup(RaisesGroup(ValueError), strict=False) assert_type(e, BaseExceptionGroup[ValueError])
# currently not fully identical in behaviour to expect*, which would also catch an unwrapped exception def test_types_4() -> None:
with pytest.raises(ValueError): e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup(
with RaisesGroup(ValueError, strict=False): "", (KeyboardInterrupt(),)
raise ValueError )
# not currently possible: https://github.com/python/typing/issues/930
def test_match(self) -> None: RaisesGroup(ValueError).assert_matches(e)
# supports match string assert_type(e, BaseExceptionGroup[ValueError]) # type: ignore[assert-type]
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]])