-
Notifications
You must be signed in to change notification settings - Fork 112
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
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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
await
ed 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.