Skip to content

Commit

Permalink
Add matcher for exceptions in asyncio future
Browse files Browse the repository at this point in the history
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 hamcrest#155
  • Loading branch information
keis committed Mar 12, 2021
1 parent 3b11537 commit 2e0da13
Show file tree
Hide file tree
Showing 2 changed files with 322 additions and 0 deletions.
138 changes: 138 additions & 0 deletions src/hamcrest/core/core/future.py
Original file line number Diff line number Diff line change
@@ -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
184 changes: 184 additions & 0 deletions tests/hamcrest_unit_test/core/future_test.py
Original file line number Diff line number Diff line change
@@ -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 <class 'AssertionError'> 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 <class 'AssertionError'> 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())

0 comments on commit 2e0da13

Please sign in to comment.