Skip to content
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
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[flake8]
max-line-length = 100
exclude = .venv
exclude = .venv, build
8 changes: 8 additions & 0 deletions majava/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .matchers import Matcher, And, Or, Any, MayBe, Absent, matcher
from .basic import InInterval, IsInstance, DictContains


__all__ = [
"Matcher", "And", "Or", "Any", "MayBe", "Absent", "matcher",
"InInterval", "IsInstance", "DictContains"
]
42 changes: 18 additions & 24 deletions majava/basic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from majava.matchers import Matcher, Mismatch, _match
from majava.matchers import Matcher, Mismatch, _match_dict


class DictContains(Matcher):
Expand All @@ -13,37 +13,31 @@ def __repr__(self):
return f"DictContains({repr(self.expected)})"

def _match(self, other):
for k, val in self.expected.items():
if k not in other:
raise Mismatch(other, k, f"key '{k}' not found")
actual_val = other[k]
try:
if self.recursive and isinstance(val, dict):
val = DictContains(val)
_match(val, actual_val)
except Mismatch as e:
raise e.prepend(k)
_match_dict(self.expected, other, allow_unexpected=True)


class InInterval:
# The matcher matches, if a given value (other) lies in the interval

def __init__(self, *value):
self.first_value, self.second_value = value

def __eq__(self, other):
return self.first_value <= other <= self.second_value
class InInterval(Matcher):
def __init__(self, low, high):
self.low, self.high = low, high

def __repr__(self):
return f"{self.first_value}, {self.second_value}"
return f"InInterval({self.low}, {self.high})"

def _match(self, other):
if not (self.low <= other <= self.high):
raise Mismatch(other, "", f"not {self}")


class IsType:
class IsInstance(Matcher):
def __init__(self, *types):
self.types = types

def __eq__(self, other):
return isinstance(other, self.types)
def _types_str(self):
return "|".join(i.__name__ for i in self.types)

def __repr__(self):
return ', '.join(it.__name__ for it in self.types)
return f"IsInstance({self._types_str()})"

def _match(self, other):
if not isinstance(other, self.types):
raise Mismatch(other, "", f"not {self}")
11 changes: 6 additions & 5 deletions majava/formats.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from .matchers import Matcher, Mismatch, _match
from .matchers import Matcher, Mismatch, _match, Any
import json


class IsJson(Matcher):
def __init__(self, expected=None):
def __init__(self, expected=Any):
self.expected = expected

def __repr__(self):
Expand All @@ -13,10 +13,11 @@ def _match(self, other):
try:
other_obj = json.loads(other)
except TypeError as e:
raise Mismatch(other, "FromJSON()", f"invalid type - {e}")
raise Mismatch(other, "FromJSON", f"invalid type - {e}")
except json.JSONDecodeError as e:
raise Mismatch(other, "FromJSON()", f"invalid JSON - {e}")
raise Mismatch(other, "FromJSON", f"invalid JSON - {e}")

try:
_match(self.expected, other_obj)
except Mismatch as e:
raise e.prepend("FromJSON()")
raise e.prepend("FromJSON")
130 changes: 127 additions & 3 deletions majava/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@


class Mismatch(Exception):
@classmethod
def invalid_type(cls, value, expected_type, path=""):
return cls(value, path, f"invalid type - got {type(value)}, expected {expected_type}")

@classmethod
def invalid_len(cls, value, expected_len, path=""):
return cls(value, path, f"invalid length - got {len(value)}, expected {expected_len}")

@classmethod
def key_missing(cls, value, key, path=""):
return cls(value, path, f"key '{key}' not found")

def __init__(self, value, path, msg):
self.value = value
self.path = path
Expand All @@ -13,8 +25,8 @@ def prepend(self, path):

def __str__(self):
if not self.path:
return f"{repr(self.value)} - {self.msg}"
return f"Value {repr(self.value)} at {repr(self.path)} does not match - {self.msg}"
return f"Value {repr(self.value)} does not match: {self.msg}"
return f"Value {repr(self.value)} at {repr(self.path)} does not match: {self.msg}"


class Matcher:
Expand All @@ -39,9 +51,45 @@ def _match(self, other) -> Optional[str]:
pass


class _MatcherWrap(Matcher):
def __init__(self, v):
self.v = v

def __repr__(self):
return repr(self.v)

def _match(self, other):
_match(self.v, other)


def matcher(value) -> Matcher:
"""
Makes a matcher from the given value.
It allows to get similar message on AssertionError in pytest.
"""

return _MatcherWrap(value)


def _check_type(value, types):
if not isinstance(value, types):
raise Mismatch.invalid_type(value, types)


def _check_len(value, expected):
v_len = len(value)
if v_len != expected:
raise Mismatch(value, "", f"invalid length - got {v_len}, expected {expected}")


def _match(matcher, value):
if isinstance(matcher, Matcher):
return matcher._match(value)

if isinstance(matcher, dict):
_check_type(value, dict)
return _match_dict(matcher, value)

if matcher != value:
raise Mismatch(value, "", f"{repr(value)} != {repr(matcher)}")

Expand Down Expand Up @@ -79,4 +127,80 @@ def _match(self, other):
return
except Mismatch as e:
mismatches.append(e)
raise Mismatch(other, "", ", ".join(str(i) for i in mismatches))

or_str = " nor ".join(repr(i) for i in self.matchers)
raise Mismatch(other, "", f"is not {or_str}")


class _Any(Matcher):
def __eq__(self, other):
return True

def __repr__(self):
return "<Any>"


Any = _Any()


class _Absent:
def __eq__(self, other):
return self is other
# if self is not other:
# raise Mismatch("asd", "", "item is missing")

def __repr__(self):
return "<Absent>"


Absent = _Absent()


class MayBe(Matcher):
def __init__(self, v):
self.v = v

def __repr__(self):
return f"MayBe({repr(self.v)})"

def _match(self, other):
if self is Absent:
return
_match(self.v, other)


def _is_missing(val):
return not isinstance(val, (MayBe, _Absent))


def _match_dict(matcher: dict, value: dict, allow_unexpected=False):
missing_keys = set(matcher.keys())
unexpected_keys = []

for key, value_v in value.items():
try:
matcher_v = matcher[key]
except KeyError:
if not allow_unexpected:
unexpected_keys.append(key)
continue

if matcher_v is Absent:
unexpected_keys.append(key)
continue

try:
_match(matcher_v, value_v)
except Mismatch as e:
raise e.prepend(key)

missing_keys.remove(key)

missing_keys = sorted(filter(lambda k: _is_missing(matcher[k]), missing_keys))
if missing_keys:
missing_keys_str = ", ".join(repr(i) for i in sorted(missing_keys))
raise Mismatch(value, "", f"missing items with keys: {missing_keys_str}")

if unexpected_keys:
unexpected_keys_str = ", ".join(repr(i) for i in unexpected_keys)
raise Mismatch(value, "", f"unexpected items with keys: {unexpected_keys_str}")
Empty file added tests/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import pytest
import logging
from contextlib import contextmanager


def check_assertion_reason(raises_ctx, expected_reason):
msg = str(raises_ctx.value)
logging.debug("Error message: %s", msg)

reason_ln = msg.rsplit("\n", 1)[-1].strip()
if reason_ln != expected_reason:
raise AssertionError(
"reason line missmatch:\n"
f"expected: {expected_reason}\n"
f"actual: {reason_ln}"
) from None


@contextmanager
def raises_assertion_error(reason=None):
with pytest.raises(AssertionError) as rc:
yield rc
if reason is not None:
check_assertion_reason(rc, reason)
55 changes: 38 additions & 17 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,64 @@
import pytest
from majava.basic import InInterval, IsType, DictContains
from majava import InInterval, IsInstance, DictContains, Absent
from .common import raises_assertion_error


def test_ininterval():
assert 7 == InInterval(1, 7)
assert 10 != InInterval(1, 7)
def test_in_interval():
interval = InInterval(1, 7)

assert 1 == interval
assert 4 == interval
assert 7 == interval

def test_istype():
assert 2 != IsType(str)
assert 2 == IsType(int, str)
assert "b" == IsType(int, str)
assert "b" != IsType(int)
assert "b" == IsType(str)
with raises_assertion_error("Value 10 does not match: not InInterval(1, 7)"):
assert 10 == interval


def test_dict__plain_match():
def test_is_instance():
int_or_str = IsInstance(int, str)

assert str(int_or_str) == "IsInstance(int|str)"

assert 1 == int_or_str
assert "2" == int_or_str

with raises_assertion_error("Value 1.1 does not match: not IsInstance(int|str)"):
assert 1.1 == int_or_str


def test_dict_contains__plain():
assert {"a": 1, "b": 2} == DictContains({"a": 1, "b": 2})
assert {"a": 1, "b": 2} == DictContains({"a": 1})
assert {"a": 1, "b": 2} == DictContains({"b": 2})
assert {"a": 1, "b": 2} == DictContains({})


def test_dict_contains__absent():
m = DictContains({"a": 1, "b": Absent})

assert {"a": 1} == m
with raises_assertion_error(
"Value {'a': 1, 'b': 2, 'c': 3} does not match: unexpected items with keys: 'b'"
):
assert {"a": 1, "b": 2, "c": 3} == m


@pytest.mark.parametrize("actual, expected, message", [
({"a": 1, "b": 2}, {"a": 1, "b": 3}, "Value 2 at 'b' does not match - 2 != 3"),
({"a": 1}, {"b": 1}, "Value {'a': 1} at 'b' does not match - key 'b' not found")
({"a": 1, "b": 2}, {"a": 1, "b": 3}, "Value 2 at 'b' does not match: 2 != 3"),
({"a": 1}, {"b": 1}, "Value {'a': 1} does not match: missing items with keys: 'b'")
])
def test_dict__plain_mismatch(actual, expected, message):
with pytest.raises(AssertionError, match=f"\n *{message}$"):
def test_dict_contains__mismatch(actual, expected, message):
with raises_assertion_error(message):
assert actual == DictContains(expected)


@pytest.mark.parametrize("actual, expected, message", [
(
{"a": 1, "b": {"c": {"d": 2, "e": 3}}},
{"b": {"c": {"e": 4}}},
"Value 3 at 'b.c.e' does not match - 3 != 4"
"Value 3 at 'b.c.e' does not match: 3 != 4"
),
])
def test_dict__nested_mismatch(actual, expected, message):
with pytest.raises(AssertionError, match=f"\n *{message}$"):
with raises_assertion_error(message):
assert actual == DictContains(expected)
Loading