Skip to content

Commit

Permalink
Merge pull request #9 from MartinGotelli/matchers_as_plugin
Browse files Browse the repository at this point in the history
feat: Create custom assert and messages to help to debug errors
  • Loading branch information
MartinGotelli authored Aug 22, 2024
2 parents ff4e3fb + 43f26d7 commit dbc90c6
Show file tree
Hide file tree
Showing 92 changed files with 996 additions and 109 deletions.
1 change: 1 addition & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
run: |
python -m pip install --upgrade pip
if [ -f requirements_dev.txt ]; then pip install -r requirements_dev.txt; fi
pip install -e .
- name: Lint with pylint
run: |
make lint
Expand Down
1 change: 0 additions & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
[MESSAGES CONTROL]

disable=
missing-function-docstring,
missing-module-docstring,
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ spell:
codespell

tests:
pytest src/pytest_matchers/tests
pytest src/tests

requirements: validate_env
@make requirements_dev
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ vulture = "^2.11"
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[project.entry-points.pytest11]
myproject = "pytest_matchers.plugin"


[tool.coverage.run]
branch = true
command_line = "-m pytest src/pytest_matchers/tests"
command_line = "-m pytest src/tests"

[tool.coverage.report]
show_missing = true
Expand Down
2 changes: 1 addition & 1 deletion scripts/python_folders.sh
Original file line number Diff line number Diff line change
@@ -1 +1 @@
echo "src/pytest_matchers/pytest_matchers src/pytest_matchers/tests"
echo "src/pytest_matchers/pytest_matchers src/tests"
File renamed without changes.
1 change: 1 addition & 0 deletions src/pytest_matchers/pytest_matchers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .asserts.asserts import assert_match, assert_not_match
from .main import (
anything,
between,
Expand Down
25 changes: 25 additions & 0 deletions src/pytest_matchers/pytest_matchers/asserts/asserts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import Any

from pytest_matchers.asserts.comparer import Comparer


def _match(actual: Any, expected: Any) -> bool:
return Comparer().compare(actual, expected)


def assert_match(actual: Any, expected: Any) -> None:
try:
assert _match(actual, expected)
except AssertionError:
assert actual == expected # Return the original assertion error


def _not_match(actual: Any, expected: Any) -> bool:
return not _match(actual, expected)


def assert_not_match(actual: Any, expected: Any) -> None:
try:
assert _not_match(actual, expected)
except AssertionError:
assert actual != expected # Return the original assertion error
22 changes: 22 additions & 0 deletions src/pytest_matchers/pytest_matchers/asserts/comparer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from typing import Any

from pytest_matchers.asserts.comparers.dict import DictComparer
from pytest_matchers.asserts.comparers.list import ListComparer
from pytest_matchers.asserts.comparers.set import SetComparer
from pytest_matchers.matchers import Matcher


class Comparer:
def compare(self, actual: Any, expected: Any, *, fail_fast: bool = True) -> bool:
if isinstance(expected, Matcher) and not isinstance(actual, Matcher):
return self.compare(expected, actual, fail_fast=fail_fast) # Reverse the order
return self._compare_by_type(actual, expected, fail_fast=fail_fast)

def _compare_by_type(self, actual: Any, expected: Any, fail_fast: bool = True) -> bool:
if isinstance(expected, (list, tuple)):
return ListComparer(actual, expected, fail_fast=fail_fast).compare()
if isinstance(expected, dict):
return DictComparer(actual, expected, fail_fast=fail_fast).compare()
if isinstance(expected, set):
return SetComparer(actual, expected, fail_fast=fail_fast).compare()
return actual == expected
19 changes: 19 additions & 0 deletions src/pytest_matchers/pytest_matchers/asserts/comparers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from abc import ABC, abstractmethod
from typing import Any


class BaseComparer(ABC):
def __init__(self, actual: Any, expected: Any, *, fail_fast: bool = True):
self._actual = actual
self._expected = expected
self._fail_fast = fail_fast

@abstractmethod
def compare(self) -> bool:
pass

def _base_compare(self, actual: Any, expected: Any) -> bool:
# pylint: disable=import-outside-toplevel
from pytest_matchers.asserts.comparer import Comparer

return Comparer().compare(actual, expected, fail_fast=self._fail_fast)
25 changes: 25 additions & 0 deletions src/pytest_matchers/pytest_matchers/asserts/comparers/dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import Generator

from pytest_matchers.asserts.comparers.base import BaseComparer


class DictComparer(BaseComparer):
def compare(self) -> bool:
same_length = len(self._actual) == len(self._expected)
if not same_length and self._fail_fast:
return False

return self._items_compare() and same_length

def _items_compare(self) -> bool:
compared_items = self._compared_items()
if not self._fail_fast:
compared_items = list(compared_items)
return all(compared_items)

def _compared_items(self) -> Generator:
for key in self._expected:
if key in self._actual:
yield self._base_compare(self._actual[key], self._expected[key])
else:
yield False
19 changes: 19 additions & 0 deletions src/pytest_matchers/pytest_matchers/asserts/comparers/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pytest_matchers.asserts.comparers.base import BaseComparer


class ListComparer(BaseComparer):
def compare(self) -> bool:
same_length = len(self._actual) == len(self._expected)
if not same_length and self._fail_fast:
return False

return self._items_compare() and same_length

def _items_compare(self) -> bool:
compared_items = (
self._base_compare(actual_item, expected_item)
for actual_item, expected_item in zip(self._actual, self._expected)
)
if not self._fail_fast:
compared_items = list(compared_items) # Force full generation
return all(compared_items)
26 changes: 26 additions & 0 deletions src/pytest_matchers/pytest_matchers/asserts/comparers/set.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from typing import Any, Set

from pytest_matchers.asserts.comparers.base import BaseComparer


class SetComparer(BaseComparer):
def compare(self) -> bool:
same_length = len(self._actual) == len(self._expected)
if not same_length and self._fail_fast:
return False

return self._items_compare() and same_length

def _items_compare(self) -> bool:
expected = self._expected.copy()
compared_items = (self._in(actual_value, expected) for actual_value in self._actual)
if not self._fail_fast:
compared_items = list(compared_items)
return all(compared_items) and not expected

def _in(self, actual_value: Any, expected: Set[Any]) -> bool:
for expected_value in expected:
if self._base_compare(actual_value, expected_value):
expected.discard(expected_value)
return True
return False
18 changes: 9 additions & 9 deletions src/pytest_matchers/pytest_matchers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
HasAttribute,
If,
IsInstance,
IsList,
IsNumber,
IsString,
List,
Number,
String,
JSON,
Matcher,
Or,
Expand All @@ -31,18 +31,18 @@ def is_instance(match_type: Type) -> IsInstance:
return IsInstance(match_type)


def is_list(match_type: Type = None, **kwargs) -> IsList | IsInstance:
def is_list(match_type: Type = None, **kwargs) -> List | IsInstance:
if match_type is None and not kwargs:
return is_instance(list)
return IsList(match_type, **kwargs)
return List(match_type, **kwargs)


def is_string(**kwargs) -> IsString:
return IsString(**kwargs)
def is_string(**kwargs) -> String:
return String(**kwargs)


def is_number(match_type: Type = None, **kwargs) -> IsNumber:
return IsNumber(match_type, **kwargs)
def is_number(match_type: Type = None, **kwargs) -> Number:
return Number(match_type, **kwargs)


def one_of(*values: Matcher | Any) -> Or:
Expand Down
7 changes: 4 additions & 3 deletions src/pytest_matchers/pytest_matchers/matchers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .base import Matcher
from .matcher_factory import MatcherFactory
from .length import Length
from .contains import Contains
from .starts_with import StartsWith
Expand All @@ -8,11 +9,11 @@

from .and_matcher import And
from .is_instance import IsInstance
from .is_list import IsList
from .is_string import IsString
from .list import List
from .string import String
from .or_matcher import Or
from .not_matcher import Not
from .is_number import IsNumber
from .number import Number
from .anything import Anything
from .datetime import Datetime
from .datetime_string import DatetimeString
Expand Down
3 changes: 3 additions & 0 deletions src/pytest_matchers/pytest_matchers/matchers/and_matcher.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from typing import Any

from pytest_matchers.matchers import Matcher
from pytest_matchers.matchers.matcher_factory import matcher
from pytest_matchers.utils.repr_utils import concat_reprs


@matcher
class And(Matcher):
def __init__(self, *matchers: Matcher):
super().__init__()
self._matchers = matchers

def matches(self, value: Any) -> bool:
Expand Down
2 changes: 2 additions & 0 deletions src/pytest_matchers/pytest_matchers/matchers/anything.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from typing import Any

from pytest_matchers.matchers import Matcher
from pytest_matchers.matchers.matcher_factory import matcher


@matcher
class Anything(Matcher):
def matches(self, _value: Any) -> bool:
return True
Expand Down
21 changes: 20 additions & 1 deletion src/pytest_matchers/pytest_matchers/matchers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@


class Matcher(ABC):
def __init__(self):
self._compared_values = []
self._compared_values_index = 0

def __eq__(self, other: Any) -> bool:
return self.matches(other)
result = self.matches(other)
if result:
self._compared_values.append(other)
return result

def __and__(self, other: "Matcher") -> "Matcher":
from pytest_matchers.matchers import And
Expand All @@ -22,6 +29,9 @@ def __invert__(self) -> "Matcher":

return Not(self)

def __hash__(self):
return hash(id(self))

@abstractmethod
def matches(self, value: Any) -> bool:
pass
Expand All @@ -30,3 +40,12 @@ def concatenated_repr(self) -> str:
from pytest_matchers.utils.repr_utils import non_capitalized

return non_capitalized(repr(self))

def next_compared_value_repr(self) -> str:
try:
compare_value = self._compared_values[self._compared_values_index]
self._compared_values_index += 1
value = compare_value
return f"{value!r}"
except IndexError:
return repr(self)
3 changes: 3 additions & 0 deletions src/pytest_matchers/pytest_matchers/matchers/between.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from typing import Any

from pytest_matchers.matchers import Eq, Matcher
from pytest_matchers.matchers.matcher_factory import matcher
from pytest_matchers.utils.repr_utils import concat_reprs, non_capitalized


@matcher
class Between(Matcher):
def __init__(
self,
Expand All @@ -13,6 +15,7 @@ def __init__(
min_inclusive: bool = None,
max_inclusive: bool = None,
):
super().__init__()
if min_value is None and max_value is None:
raise ValueError("At least one of min or max must be specified")
self._min = min_value
Expand Down
3 changes: 3 additions & 0 deletions src/pytest_matchers/pytest_matchers/matchers/case.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from typing import Any

from pytest_matchers.matchers import Matcher
from pytest_matchers.matchers.matcher_factory import matcher
from pytest_matchers.utils.matcher_utils import as_matcher, as_matcher_or_none


@matcher
class Case(Matcher):
def __init__(
self,
case_value: Any,
expectations: dict[Any, Matcher | Any],
default_expectation: Matcher | Any | None = None,
):
super().__init__()
self._case_value = case_value
self._expectations = expectations
self._default_expectation = (
Expand Down
3 changes: 3 additions & 0 deletions src/pytest_matchers/pytest_matchers/matchers/contains.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from typing import Any

from pytest_matchers.matchers import Matcher
from pytest_matchers.matchers.matcher_factory import matcher


@matcher
class Contains(Matcher):
def __init__(self, contained_value: Any):
super().__init__()
self._contained_value = contained_value

def matches(self, value: Any) -> bool:
Expand Down
3 changes: 3 additions & 0 deletions src/pytest_matchers/pytest_matchers/matchers/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pytest_matchers.matchers import Matcher
from pytest_matchers.matchers.has_attribute import has_attribute_matcher
from pytest_matchers.matchers.matcher_factory import matcher
from pytest_matchers.utils.matcher_utils import (
between_matcher,
is_instance_matcher,
Expand All @@ -10,6 +11,7 @@
from pytest_matchers.utils.repr_utils import concat_reprs


@matcher
class Datetime(Matcher): # pylint: disable=too-many-instance-attributes
def __init__(
self,
Expand All @@ -22,6 +24,7 @@ def __init__(
minute: int = None,
second: int = None,
):
super().__init__()
self._is_instance_matcher = is_instance_matcher(datetime)
self._limit_matcher = between_matcher(min_value, max_value, None, None, None)
self._year_matcher = has_attribute_matcher("year", year, value_needed=True)
Expand Down
Loading

0 comments on commit dbc90c6

Please sign in to comment.