Skip to content

Commit

Permalink
✨Object oriented base matcher class (#36)
Browse files Browse the repository at this point in the history
  • Loading branch information
bdsoha committed Dec 12, 2022
1 parent 8943214 commit 96855a0
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 19 deletions.
29 changes: 29 additions & 0 deletions src/expycted/core/exceptions.py
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}"
76 changes: 76 additions & 0 deletions src/expycted/core/matchers.py
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)
15 changes: 5 additions & 10 deletions src/expycted/core/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ def __init__(
method: str,
*,
operation: Optional[str] = None,
negated: bool = False
negated: bool = False,
message: Optional[Union[DetailMessage, str]] = None
):
self._method = method
self._operation = operation
self._negated = negated
self._message = message

@staticmethod
def _format_values(*values: str) -> Tuple[str, str]:
Expand All @@ -44,13 +46,7 @@ def signature(self, *, actual: Any, expected: Any = SENTINEL) -> str:
f" # Using `{self._operation}`" if self._operation else ""
])

def details(
self,
*,
actual: Any,
expected: Any = SENTINEL,
message: Optional[Union[DetailMessage, str]] = None
) -> str:
def details(self, *, actual: Any, expected: Any = SENTINEL) -> str:
"""Detail difference between the ``actual`` and ``expected`` values."""

actual, expected = self._format_values(actual, expected)
Expand All @@ -63,8 +59,7 @@ def details(
method_split=self._method.replace("_", " ")
)

if not message:
message = DetailMessage()
message = DetailMessage() if not self._message else self._message

if isinstance(message, str):
return message.format(**placeholders)
Expand Down
23 changes: 23 additions & 0 deletions test/unit/core/test_exceptions.py
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`"
61 changes: 61 additions & 0 deletions test/unit/core/test_matchers.py
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)
19 changes: 10 additions & 9 deletions test/unit/core/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,23 @@ def test_negated(self):
assert message.details(actual="4", expected=4) == "Expected\t: 4\nActual\t: \"4\""

def test_with_detail_message(self):
message = Message("be_empty", negated=True)

details = message.details(
actual=[4, 1],
message = Message(
"be_empty",
negated=True,
message=DetailMessage(expected="Expected {to} be empty")
)

details = message.details(actual=[4, 1])

assert details == "Expected to not be empty\nActual\t: [4, 1]"

def test_with_detail_message_as_string(self):
message = Message("be_empty", negated=True)

details = message.details(
actual=[4, 1],
message = Message(
"be_empty",
negated=True,
message="Expected {to} {method_split}, but was actually {actual}"
)

details = message.details(actual=[4, 1])

assert details == "Expected to not be empty, but was actually [4, 1]"

0 comments on commit 96855a0

Please sign in to comment.