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

✨Object oriented base matcher class #36

Merged
merged 5 commits into from
Dec 11, 2022
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
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]"