Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add matcher for exceptions in asyncio future #171

Merged
merged 1 commit into from
Feb 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ assure that the right issue was found::
# assert_that(23, raises(IOError))


Asserting exceptions from async methods
---------------------------------------

An async method does not directly return the result or raise an exception but
instead returns a Future-object that represent the async operation that can
later be resolved with the `await` keyword. The
:py:func:`~hamcrest.core.core.future.resolved` utility function can be used to
wait for a future to be done but without retrieving the value or raising the
exception. The :py:func:`~hamcrest.core.core.future.future_raising` matcher can
be used with any future object but combined lets you assert that calling some
async method, and waiting for the result, causes an exception to be raised.

This is best used together with an async test runner like IsolatedAsyncioTestCase or pytest-asyncio::

async def parse(input: str):
...

class Test(unittest.IsolatedAsyncioTestCase):

async def testParse(self):
future = parse("some bad data")
assert_that(await resolved(future), future_raising(ValueError))

But it's possible to use with an async unware runner by explicitly running the event loop in the test::

class Test(unittest.TestCase):
def test_parse(self):
async def test():
future = parse("some bad data")
assert_that(await resolved(future), future_raising(ValueError))

asyncio.get_event_loop().run_until_complete(test())


Predefined matchers
-------------------
Expand Down
137 changes: 137 additions & 0 deletions src/hamcrest/core/core/future.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import sys
import re
import asyncio
from typing import (
Optional,
Type,
TypeVar,
Union,
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 in typeshed for asyncio.ensure_future
FutureT = asyncio.Future[T]
FutureLike = Union[asyncio.Future[T], Awaitable[T]]
else:
# Future is not a parametrised type in earlier version of python
FutureT = asyncio.Future
FutureLike = Union[asyncio.Future, Awaitable]


class FutureRaising(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 completed 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_raising(
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. The :py:func:`~hamcrest:core.core.future.resolved` helper can be used to wait for a future to resolve.
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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be leaning in to assert_that() here, or conceiving of a different assert wrapper?

Copy link
Contributor Author

@keis keis Mar 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about creating a variant that itself could by awaited but I think that would be much more complicated and impact how matcher and everything works. This is really the huge problem with asyncio (and async code in general) it tends to just infect everything and spread to all corners of your code.

assert_that(
await resolved(async_http_get()),
future_exception(HTTPError, matching=has_properties(status_code=500)
)
"""
return FutureRaising(exception, pattern, matching)


async def resolved(obj: FutureLike) -> FutureT:
"""Wait for an async operation to finish and return a resolved future object with the result.

:param obj: A future like object 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_raising
from hamcrest_unit_test.matcher_test import MatcherTest

if __name__ == "__main__":
sys.path.insert(0, "..")
sys.path.insert(0, "../..")


__author__ = "David Keijser"
__copyright__ = "Copyright 2023 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_raising(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_raising(TypeError), 23)

asyncio.get_event_loop().run_until_complete(test())

def testDoesNotMatchIfFutureIsNotDone(self):
future = asyncio.Future()
self.assert_does_not_match("Unresolved future", future_raising(TypeError), future)

def testDoesNotMatchIfFutureIsCancelled(self):
future = asyncio.Future()
future.cancel()
self.assert_does_not_match("Cancelled future", future_raising(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_raising(IOError), await resolved(raise_exception())
)
expected_message = (
"AssertionError('(){}',) of type <class 'AssertionError'> was raised instead"
)
self.assert_mismatch_description(
expected_message, future_raising(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_raising(IOError), await resolved(raise_exception())
)
expected_message = (
"AssertionError('(){}') of type <class 'AssertionError'> was raised instead"
)
self.assert_mismatch_description(
expected_message, future_raising(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_raising(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_raising(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_raising(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_raising(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_raising(AssertionError, "(3, 1, 4)"),
await resolved(raise_exception(3, 1, 4)),
)

self.assert_matches(
"Regex",
future_raising(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_raising(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_raising(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_raising(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_raising(
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_raising(
AssertionError, pattern="asdf", matching=has_properties(prop="prip")
),
await resolved(raise_exception_with_properties(prip="prop")),
)

asyncio.get_event_loop().run_until_complete(test())