-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨Object oriented base matcher class (#36)
- Loading branch information
Showing
6 changed files
with
204 additions
and
19 deletions.
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
from typing import Type | ||
|
||
|
||
class MatcherError(TypeError): | ||
"""Matcher cannot handle the provided type.""" | ||
|
||
def __init__(self, *allowed: Type): | ||
super().__init__() | ||
self._allowed = allowed | ||
|
||
def __str__(self) -> str: | ||
allowed = sorted(map(lambda t: f"`{t.__name__}`", self._allowed)) | ||
|
||
size = len(allowed) | ||
|
||
if size == 1: | ||
allowed = allowed[0] | ||
|
||
if size == 2: | ||
allowed = " or ".join(allowed) | ||
|
||
if size > 2: | ||
last = allowed.pop() | ||
allowed = ", ".join(allowed) | ||
allowed += f", or {last}" | ||
|
||
a_or_an = "an" if allowed[1] in ["a", "e", "i", "o", "u"] else "a" | ||
|
||
return f"Matcher Error: recieved value must be {a_or_an} {allowed}" |
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,76 @@ | ||
from abc import ABC, abstractmethod | ||
from typing import Any, Optional, Tuple, Type, Union | ||
from .messages import DetailMessage, Message | ||
from .utilities import SENTINEL | ||
from .exceptions import MatcherError | ||
|
||
import re | ||
|
||
try: | ||
from typing import Literal | ||
except ImportError: | ||
from typing_extensions import Literal | ||
|
||
|
||
TAllowedTypes = Union[Literal["*"], Tuple[Type, ...]] | ||
|
||
|
||
class BaseMatcher(ABC): | ||
OPERATION: Optional[str] = None | ||
MESSAGE: Optional[Union[str, DetailMessage]] = None | ||
ALLOWED_TYPES = TAllowedTypes = "*" | ||
|
||
def __init__(self, actual: Any, *, negated: bool = False): | ||
self._actual = actual | ||
self._negated = negated | ||
|
||
@abstractmethod | ||
def _matches(self, *, expected: Any, **kwargs) -> bool: | ||
... | ||
|
||
def _negate(self, *, expected: Any, **kwargs) -> bool: | ||
return not self._matches(expected=expected, **kwargs) | ||
|
||
def _get_allowed_types(self, **kwargs) -> TAllowedTypes: | ||
return self.ALLOWED_TYPES | ||
|
||
def _get_message(self, **kwargs): | ||
return self.MESSAGE | ||
|
||
def _validate_allowed_types(self, **kwargs): | ||
allowed_types = self._get_allowed_types(**kwargs) | ||
|
||
if allowed_types == "*": | ||
return | ||
|
||
if not isinstance(self._actual, allowed_types): | ||
raise MatcherError(*allowed_types) | ||
|
||
def name(self, **kwargs) -> str: | ||
"""Simplifed name for the matcher based on the class name.""" | ||
|
||
name = self.__class__.__name__ | ||
|
||
name = re.sub("Matcher$", "", name) | ||
|
||
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower() | ||
|
||
def message(self, **kwargs) -> Message: | ||
"""Build the assertion message.""" | ||
|
||
return Message( | ||
method=self.name(**kwargs), | ||
negated=self._negated, | ||
operation= self.OPERATION, | ||
message=self._get_message(**kwargs), | ||
**kwargs | ||
) | ||
|
||
def __call__(self, expected: Any = SENTINEL, **kwargs) -> bool: | ||
self._validate_allowed_types(**kwargs) | ||
|
||
method_name = "_negate" if self._negated else "_matches" | ||
|
||
method = getattr(self, method_name) | ||
|
||
return method(expected=expected, **kwargs) |
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,23 @@ | ||
from expycted.core.exceptions import MatcherError | ||
|
||
|
||
def test_matcher_error(): | ||
error = MatcherError(str) | ||
|
||
assert str(error) == "Matcher Error: recieved value must be a `str`" | ||
|
||
|
||
def test_matcher_error_two_types(): | ||
error = MatcherError(tuple, list) | ||
|
||
assert str(error) == "Matcher Error: recieved value must be a `list` or `tuple`" | ||
|
||
def test_matcher_error_multiple_types_sorted(): | ||
error = MatcherError(str, tuple, list) | ||
|
||
assert str(error) == "Matcher Error: recieved value must be a `list`, `str`, or `tuple`" | ||
|
||
def test_matcher_error_with_an_instead_of_a(): | ||
error = MatcherError(int) | ||
|
||
assert str(error) == "Matcher Error: recieved value must be an `int`" |
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,61 @@ | ||
from traceback import StackSummary | ||
from unittest.mock import Mock | ||
from expycted.core.matchers import BaseMatcher | ||
from expycted.core.messages import Message | ||
from expycted.core.utilities import SENTINEL | ||
from expycted.core.exceptions import MatcherError | ||
|
||
import pytest | ||
|
||
|
||
class AlwaysTrueMatcher(BaseMatcher): | ||
def __init__(self, actual, *, negated: bool = False): | ||
super().__init__(actual, negated=negated) | ||
self.mock = Mock() | ||
self.mock.return_value = True | ||
|
||
def _matches(self, *, expected, **kwargs) -> bool: | ||
return self.mock(expected, **kwargs) | ||
|
||
|
||
class AllowedTypesMatcher(AlwaysTrueMatcher): | ||
ALLOWED_TYPES = (list, str) | ||
|
||
|
||
@pytest.fixture | ||
def matcher(): | ||
return AlwaysTrueMatcher(True) | ||
|
||
def test_matches(matcher): | ||
assert matcher() is True | ||
|
||
matcher.mock.assert_called_once_with(SENTINEL) | ||
|
||
def test_negated(): | ||
matcher = AlwaysTrueMatcher(True, negated=True) | ||
|
||
assert matcher() is False | ||
matcher.mock.assert_called_once_with(SENTINEL) | ||
|
||
def test_with_expected(matcher): | ||
assert matcher("any expected value") is True | ||
|
||
matcher.mock.assert_called_once_with("any expected value") | ||
|
||
@pytest.mark.parametrize("actual", [(1, 2), b"hello"]) | ||
def test_allowed_types(actual): | ||
matcher = AllowedTypesMatcher(actual) | ||
|
||
with pytest.raises(MatcherError): | ||
matcher() | ||
|
||
matcher.mock.assert_not_called() | ||
|
||
def test_name(matcher): | ||
allowed = AllowedTypesMatcher(True) | ||
|
||
assert matcher.name() == "always_true" | ||
assert allowed.name() == "allowed_types" | ||
|
||
def test_message(matcher): | ||
assert isinstance(matcher.message(), Message) |
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