From 2e0da13cbfd81122aa5a9ec3bc89edc9d40dca67 Mon Sep 17 00:00:00 2001 From: David Keijser Date: Thu, 11 Mar 2021 17:43:33 +0100 Subject: [PATCH] Add matcher for exceptions in asyncio future WIP: Based on raises matcher but adapted to deal with future objects. Example of use ``` assert_that( await resolved(raise_exception()), future_exception(AssertionError)) ) ``` The resolved helper is used to create resolved future objects in async code. It takes a "future like" object and waits for it to complete. Ref #155 --- src/hamcrest/core/core/future.py | 138 ++++++++++++++ tests/hamcrest_unit_test/core/future_test.py | 184 +++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 src/hamcrest/core/core/future.py create mode 100644 tests/hamcrest_unit_test/core/future_test.py diff --git a/src/hamcrest/core/core/future.py b/src/hamcrest/core/core/future.py new file mode 100644 index 00000000..305e2e35 --- /dev/null +++ b/src/hamcrest/core/core/future.py @@ -0,0 +1,138 @@ +import sys +import re +import asyncio +from typing import ( + Any, + Optional, + Type, + TypeVar, + Union, + Generator, + Awaitable, +) + +from hamcrest.core.base_matcher import BaseMatcher +from hamcrest.core.description import Description +from hamcrest.core.matcher import Matcher + +__author__ = "David Keijser" +__copyright__ = "Copyright 2021 hamcrest.org" +__license__ = "BSD, see License.txt" + +T = TypeVar("T") + +if sys.version_info > (3, 9): + # Same as used for asyncio.ensure_future + FutureT = Union[asyncio.Future[T], Generator[Any, None, T], Awaitable[T]] +else: + # Future is not a parametrised type in earlier version of python + FutureT = Union[asyncio.Future, Generator, Awaitable] + + +class FutureException(BaseMatcher[asyncio.Future]): + def __init__( + self, + expected: Type[Exception], + pattern: Optional[str] = None, + matching: Optional[Matcher] = None, + ) -> None: + self.pattern = pattern + self.matcher = matching + self.expected = expected + + def _matches(self, future: asyncio.Future) -> bool: + if not asyncio.isfuture(future): + return False + + if not future.done(): + return False + + if future.cancelled(): + return False + + exc = future.exception() + if exc is None: + return False + + if isinstance(exc, self.expected): + if self.pattern is not None: + if re.search(self.pattern, str(exc)) is None: + return False + if self.matcher is not None: + if not self.matcher.matches(exc): + return False + return True + + return False + + def describe_to(self, description: Description) -> None: + description.append_text("Expected a future with exception %s" % self.expected) + + def describe_mismatch(self, future: asyncio.Future, description: Description) -> None: + if not asyncio.isfuture(future): + description.append_text("%s is not a future" % future) + return + + if not future.done(): + description.append_text("%s is not completed yet" % future) + return + + if future.cancelled(): + description.append_text("%s is cancelled" % future) + return + + exc = future.exception() + if exc is None: + description.append_text("No exception raised.") + elif isinstance(exc, self.expected): + if self.pattern is not None or self.matcher is not None: + description.append_text("Correct assertion type raised, but ") + if self.pattern is not None: + description.append_text('the expected pattern ("%s") ' % self.pattern) + if self.pattern is not None and self.matcher is not None: + description.append_text("and ") + if self.matcher is not None: + description.append_description_of(self.matcher) + description.append_text(" ") + description.append_text('not found. Exception message was: "%s"' % str(exc)) + else: + description.append_text("%r of type %s was raised instead" % (exc, type(exc))) + + def describe_match(self, future: asyncio.Future, match_description: Description) -> None: + exc = future.exception() + match_description.append_text("%r of type %s was raised." % (exc, type(exc))) + + +def future_exception( + exception: Type[Exception], pattern=None, matching=None +) -> Matcher[asyncio.Future]: + """Matches a future with the expected exception. + + :param exception: The class of the expected exception + :param pattern: Optional regular expression to match exception message. + :param matching: Optional Hamcrest matchers to apply to the exception. + + Expects the actual to be an already resolved future. Optional argument + pattern should be a string containing a regular expression. If provided, + the string representation of the actual exception - e.g. `str(actual)` - + must match pattern. + + Examples:: + + assert_that(somefuture, future_exception(ValueError)) + assert_that( + await resolved(async_http_get()), + future_exception(HTTPError, matching=has_properties(status_code=500) + ) + """ + return FutureException(exception, pattern, matching) + + +async def resolved(obj: FutureT) -> asyncio.Future: + """Wait for a async operation to finish and return a resolved future object of the result. + + :param obj: A future like object, a coroutine, or an awaitable object. + """ + fut = asyncio.ensure_future(obj) + await asyncio.wait([fut]) + return fut diff --git a/tests/hamcrest_unit_test/core/future_test.py b/tests/hamcrest_unit_test/core/future_test.py new file mode 100644 index 00000000..8ba13a4d --- /dev/null +++ b/tests/hamcrest_unit_test/core/future_test.py @@ -0,0 +1,184 @@ +import sys + +import pytest +import asyncio +from hamcrest import has_properties +from hamcrest.core.core.future import resolved, future_exception +from hamcrest_unit_test.matcher_test import MatcherTest + +if __name__ == "__main__": + sys.path.insert(0, "..") + sys.path.insert(0, "../..") + + +__author__ = "David Keijser" +__copyright__ = "Copyright 2021 hamcrest.org" +__license__ = "BSD, see License.txt" + + +async def no_exception(*args, **kwargs): + return + + +async def raise_exception(*args, **kwargs): + raise AssertionError(str(args) + str(kwargs)) + + +async def raise_exception_with_properties(**kwargs): + err = AssertionError("boom") + for k, v in kwargs.items(): + setattr(err, k, v) + raise err + + +# From python 3.8 this could be simplified by using unittest.IsolatedAsyncioTestCase +class FutureExceptionTest(MatcherTest): + def testMatchesIfFutureHasTheExactExceptionExpected(self): + async def test(): + self.assert_matches( + "Right exception", + future_exception(AssertionError), + await resolved(raise_exception()), + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testDoesNotMatchIfActualIsNotAFuture(self): + async def test(): + self.assert_does_not_match("Not a future", future_exception(TypeError), 23) + + asyncio.get_event_loop().run_until_complete(test()) + + def testDoesNotMatchIfFutureIsNotDone(self): + future = asyncio.Future() + self.assert_does_not_match("Unresolved future", future_exception(TypeError), future) + + def testDoesNotMatchIfFutureIsCancelled(self): + future = asyncio.Future() + future.cancel() + self.assert_does_not_match("Cancelled future", future_exception(TypeError), future) + + @pytest.mark.skipif( + not (3, 0) <= sys.version_info < (3, 7), reason="Message differs between Python versions" + ) + def testDoesNotMatchIfFutureHasTheWrongExceptionTypePy3(self): + async def test(): + self.assert_does_not_match( + "Wrong exception", future_exception(IOError), await resolved(raise_exception()) + ) + expected_message = ( + "AssertionError('(){}',) of type was raised instead" + ) + self.assert_mismatch_description( + expected_message, future_exception(TypeError), await resolved(raise_exception()) + ) + + asyncio.get_event_loop().run_until_complete(test()) + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="Message differs between Python versions") + def testDoesNotMatchIfFutureHasTheWrongExceptionTypePy37(self): + async def test(): + self.assert_does_not_match( + "Wrong exception", future_exception(IOError), await resolved(raise_exception()) + ) + expected_message = ( + "AssertionError('(){}') of type was raised instead" + ) + self.assert_mismatch_description( + expected_message, future_exception(TypeError), await resolved(raise_exception()) + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testMatchesIfFutureHasASubclassOfTheExpectedException(self): + async def test(): + self.assert_matches( + "Subclassed Exception", + future_exception(Exception), + await resolved(raise_exception()), + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testDoesNotMatchIfFutureDoesNotHaveException(self): + async def test(): + self.assert_does_not_match( + "No exception", future_exception(ValueError), await resolved(no_exception()) + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testDoesNotMatchExceptionIfRegularExpressionDoesNotMatch(self): + async def test(): + self.assert_does_not_match( + "Bad regex", + future_exception(AssertionError, "Phrase not found"), + await resolved(raise_exception()), + ) + self.assert_mismatch_description( + '''Correct assertion type raised, but the expected pattern ("Phrase not found") not found. Exception message was: "(){}"''', + future_exception(AssertionError, "Phrase not found"), + await resolved(raise_exception()), + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testMatchesRegularExpressionToStringifiedException(self): + async def test(): + self.assert_matches( + "Regex", + future_exception(AssertionError, "(3, 1, 4)"), + await resolved(raise_exception(3, 1, 4)), + ) + + self.assert_matches( + "Regex", + future_exception(AssertionError, r"([\d, ]+)"), + await resolved(raise_exception(3, 1, 4)), + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testMachesIfExceptionMatchesAdditionalMatchers(self): + async def test(): + self.assert_matches( + "Properties", + future_exception(AssertionError, matching=has_properties(prip="prop")), + await resolved(raise_exception_with_properties(prip="prop")), + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testDoesNotMatchIfAdditionalMatchersDoesNotMatch(self): + async def test(): + self.assert_does_not_match( + "Bad properties", + future_exception(AssertionError, matching=has_properties(prop="prip")), + await resolved(raise_exception_with_properties(prip="prop")), + ) + self.assert_mismatch_description( + '''Correct assertion type raised, but an object with a property 'prop' matching 'prip' not found. Exception message was: "boom"''', + future_exception(AssertionError, matching=has_properties(prop="prip")), + await resolved(raise_exception_with_properties(prip="prop")), + ) + + asyncio.get_event_loop().run_until_complete(test()) + + def testDoesNotMatchIfNeitherPatternOrMatcherMatch(self): + async def test(): + self.assert_does_not_match( + "Bad pattern and properties", + future_exception( + AssertionError, pattern="asdf", matching=has_properties(prop="prip") + ), + await resolved(raise_exception_with_properties(prip="prop")), + ) + self.assert_mismatch_description( + '''Correct assertion type raised, but the expected pattern ("asdf") and an object with a property 'prop' matching 'prip' not found. Exception message was: "boom"''', + future_exception( + AssertionError, pattern="asdf", matching=has_properties(prop="prip") + ), + await resolved(raise_exception_with_properties(prip="prop")), + ) + + asyncio.get_event_loop().run_until_complete(test())