diff --git a/src/hamcrest/core/core/future.py b/src/hamcrest/core/core/future.py new file mode 100644 index 00000000..8d4a2e1f --- /dev/null +++ b/src/hamcrest/core/core/future.py @@ -0,0 +1,122 @@ +import re +import sys +import asyncio +from typing import Any, Callable, Mapping, Optional, Tuple, Type, TypeVar, Union, Generator, Awaitable, cast +from weakref import ref + +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') + +# Same as used for asyncio.ensure_future +FutureT = Union[asyncio.Future[T], Generator[Any, None, T], Awaitable[T]] + + +class FutureException(BaseMatcher[asyncio.Future[T]]): + 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[T]) -> bool: + if not asyncio.isfuture(future): + return False + + if not future.done(): + 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[T], 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 + + 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[T], 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[T]]: + """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[T]) -> asyncio.Future[T]: + """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..238ffa87 --- /dev/null +++ b/tests/hamcrest_unit_test/core/future_test.py @@ -0,0 +1,33 @@ +import sys +import unittest + +import pytest +from hamcrest import has_properties, not_ +from hamcrest.core.core.future import resolved, future_exception +from hamcrest_unit_test.matcher_test import MatcherTest, assert_mismatch_description + +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)) + + +class FutureExceptionTest(MatcherTest, unittest.IsolatedAsyncioTestCase): + async def testMatchesIfFutureHasTheExactExceptionExpected(self): + self.assert_matches("Right exception", future_exception(AssertionError), await resolved(raise_exception())) + + async def testDoesNotMatchTypeErrorIfActualIsNotAFuture(self): + self.assert_does_not_match("Not a future", future_exception(TypeError), 23) +