From 98d042d03c4f50428006be6cbdc14ccd213939bc Mon Sep 17 00:00:00 2001 From: wsnk Date: Sat, 15 Feb 2025 14:30:39 +0200 Subject: [PATCH 1/2] Tests for IsJson & detailed matching --- majava/__init__.py | 2 ++ majava/formats.py | 11 ++++---- majava/matchers.py | 34 ++++++++++++++++++++++- tests/test_formats.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/test_formats.py diff --git a/majava/__init__.py b/majava/__init__.py index e69de29..ab2dd98 100644 --- a/majava/__init__.py +++ b/majava/__init__.py @@ -0,0 +1,2 @@ +from .matchers import Matcher, And, Or, Any +from .basic import DictContains \ No newline at end of file diff --git a/majava/formats.py b/majava/formats.py index 5a435a7..221f286 100644 --- a/majava/formats.py +++ b/majava/formats.py @@ -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): @@ -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") diff --git a/majava/matchers.py b/majava/matchers.py index 20e8ea0..cf6efae 100644 --- a/majava/matchers.py +++ b/majava/matchers.py @@ -14,7 +14,7 @@ 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)} at {repr(self.path)} does not match: {self.msg}" class Matcher: @@ -42,10 +42,32 @@ def _match(self, other) -> Optional[str]: def _match(matcher, value): if isinstance(matcher, Matcher): return matcher._match(value) + + if isinstance(matcher, dict): + if not isinstance(value, dict): + raise Mismatch(value, "", f"invalid type - got '{type(value)}', expected 'dict'") + if len(matcher) != len(value): + raise Mismatch(value, "", f"invalid size - got {len(value)}, expected {len(matcher)}") + + for k, matcher_v in matcher.items(): + try: + actual_v = value[k] + except KeyError: + raise Mismatch(value, "", f"expected '{k}' element is missing") + try: + _match(matcher_v, actual_v) + except Mismatch as e: + raise e.prepend(k) + if matcher != value: raise Mismatch(value, "", f"{repr(value)} != {repr(matcher)}") + + + + + class And(Matcher): def __init__(self, *matchers): self.matchers = matchers @@ -80,3 +102,13 @@ def _match(self, other): except Mismatch as e: mismatches.append(e) raise Mismatch(other, "", ", ".join(str(i) for i in mismatches)) + + +class _Any(Matcher): + def __eq__(self, other): + return True + def __repr__(self): + return "" + + +Any = _Any() diff --git a/tests/test_formats.py b/tests/test_formats.py new file mode 100644 index 0000000..6e1720c --- /dev/null +++ b/tests/test_formats.py @@ -0,0 +1,63 @@ +import pytest +from majava import DictContains, Any +from majava.formats import IsJson + + +@pytest.mark.parametrize("actual, expected", [ + ("[1, 2, 3]", IsJson()), + ("[1, 2, 3]", IsJson([1, 2, 3])), + ('{"a": 1, "b": 2}', IsJson({"a": 1, "b": 2})), + ( + '{"a": 1, "b": 2}', + IsJson(DictContains({"b": 2})) + ), + ( + '''{ + "a": "[1, 2, 3.14, 4]", + "b": "any value may be here", + "c": {"x": 1, "y": 2} + }''', + IsJson({ + "a": IsJson([1, 2, Any, 4]), + "b": Any, + "c": DictContains({"x": 1}) + }) + ) +]) +def test_is_json__match(actual, expected): + assert actual == expected + + +@pytest.mark.parametrize("actual, expected, reason", [ + (None, {}, ( + "Value None at 'FromJSON' does not match: invalid type - " + "the JSON object must be str, bytes or bytearray, not NoneType" + )), + ("", {}, ( + "Value '' at 'FromJSON' does not match: invalid JSON - " + "Expecting value: line 1 column 1 (char 0)" + )), + ( + '''{ + "a": "[1, 2, 3.14, 4.14]", + "b": "any value may be here", + "c": {"x": 1, "y": 2} + }''', + { + "a": IsJson([1, 2, Any, 4]), + "b": Any, + "c": DictContains({"x": 1}) + }, + ( + "Value [1, 2, 3.14, 4.14] at 'FromJSON.a.FromJSON' does not match: " + "[1, 2, 3.14, 4.14] != [1, 2, , 4]'" + ) + ) +]) +def test_is_json__mismatch(actual, expected, reason): + with pytest.raises(AssertionError) as e: + assert actual == IsJson(expected) + + err_message = str(e.value) + reason = err_message.splitlines()[-1].strip() + assert reason == reason \ No newline at end of file From 05738705d9c6c133b391dd59033a1e0831051bd1 Mon Sep 17 00:00:00 2001 From: wsnk Date: Sun, 16 Feb 2025 19:22:35 +0200 Subject: [PATCH 2/2] Up --- .flake8 | 2 +- majava/__init__.py | 10 +++- majava/basic.py | 42 ++++++------- majava/matchers.py | 134 +++++++++++++++++++++++++++++++++++------- tests/__init__.py | 0 tests/common.py | 24 ++++++++ tests/test_basic.py | 55 +++++++++++------ tests/test_core.py | 44 ++++++++++++++ tests/test_formats.py | 2 +- 9 files changed, 247 insertions(+), 66 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/common.py create mode 100644 tests/test_core.py diff --git a/.flake8 b/.flake8 index d37f57e..4d4940b 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] max-line-length = 100 -exclude = .venv \ No newline at end of file +exclude = .venv, build \ No newline at end of file diff --git a/majava/__init__.py b/majava/__init__.py index ab2dd98..3fb371c 100644 --- a/majava/__init__.py +++ b/majava/__init__.py @@ -1,2 +1,8 @@ -from .matchers import Matcher, And, Or, Any -from .basic import DictContains \ No newline at end of file +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" +] diff --git a/majava/basic.py b/majava/basic.py index 05b12d4..b398f87 100644 --- a/majava/basic.py +++ b/majava/basic.py @@ -1,4 +1,4 @@ -from majava.matchers import Matcher, Mismatch, _match +from majava.matchers import Matcher, Mismatch, _match_dict class DictContains(Matcher): @@ -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}") diff --git a/majava/matchers.py b/majava/matchers.py index cf6efae..9d14139 100644 --- a/majava/matchers.py +++ b/majava/matchers.py @@ -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 @@ -13,7 +25,7 @@ def prepend(self, path): def __str__(self): if not self.path: - return f"{repr(self.value)} - {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}" @@ -39,35 +51,49 @@ 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): - if not isinstance(value, dict): - raise Mismatch(value, "", f"invalid type - got '{type(value)}', expected 'dict'") - if len(matcher) != len(value): - raise Mismatch(value, "", f"invalid size - got {len(value)}, expected {len(matcher)}") - - for k, matcher_v in matcher.items(): - try: - actual_v = value[k] - except KeyError: - raise Mismatch(value, "", f"expected '{k}' element is missing") - try: - _match(matcher_v, actual_v) - except Mismatch as e: - raise e.prepend(k) + _check_type(value, dict) + return _match_dict(matcher, value) if matcher != value: raise Mismatch(value, "", f"{repr(value)} != {repr(matcher)}") - - - - - class And(Matcher): def __init__(self, *matchers): self.matchers = matchers @@ -101,14 +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() + + +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() + + +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}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..184cb20 --- /dev/null +++ b/tests/common.py @@ -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) diff --git a/tests/test_basic.py b/tests/test_basic.py index ba1e937..1b101ba 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,33 +1,54 @@ 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) @@ -35,9 +56,9 @@ def test_dict__plain_mismatch(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) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..941c003 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,44 @@ +import pytest +from majava.matchers import matcher, MayBe, Or, Absent, Any +from .common import raises_assertion_error + + +def test_dict(): + assert {"a": 1, "b": 2} == matcher({"a": 1, "b": 2}) + assert {"a": 1, "b": 2} == matcher({"a": 1, "b": Any}) + assert {"a": 1} == matcher({"a": 1, "b": Absent}) + + +def test_or(): + assert 1 == Or(1, 2) + assert 2 == Or(1, 2) + with raises_assertion_error("Value 4 does not match: is not 1 nor '2' nor MayBe({})"): + assert 4 == Or(1, "2", MayBe({})) + + +def test_dict_with_submatchers(): + assert {"a": 1, "b": 2} == matcher({"a": 1, "b": Or(2, 3)}) + assert {"a": 1, "b": 3} == matcher({"a": 1, "b": Or(2, 3)}) + + assert {"a": 1} == matcher({"a": 1, "b": MayBe(2)}) + assert {"a": 1, "b": 2} == matcher({"a": 1, "b": MayBe(2)}) + + +@pytest.mark.parametrize("value, expectation, reason", [ + ({"a": 1}, {"a": 2}, "Value 1 at 'a' does not match: 1 != 2"), + # unexpected items + ({"a": 1}, {}, "Value {'a': 1} does not match: unexpected items with keys: 'a'"), + ( + {"a": 1, "b": 2}, {}, + "Value {'a': 1, 'b': 2} does not match: unexpected items with keys: 'a', 'b'" + ), + # missing items + ({}, {"a": 1}, "Value {} does not match: missing items with keys: 'a'"), + ({}, {"a": 1, "b": 1}, "Value {} does not match: missing items with keys: 'a', 'b'"), + # submatchers + ({"a": 1}, {"a": MayBe(2)}, "Value 1 at 'a' does not match: 1 != 2"), + ({"a": 1}, {"a": Or(2, 3, 4)}, "Value 1 at 'a' does not match: is not 2 nor 3 nor 4"), +]) +def test_dict_mismatch(value, expectation, reason): + with raises_assertion_error(reason): + assert value == matcher(expectation) diff --git a/tests/test_formats.py b/tests/test_formats.py index 6e1720c..a3961ef 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -60,4 +60,4 @@ def test_is_json__mismatch(actual, expected, reason): err_message = str(e.value) reason = err_message.splitlines()[-1].strip() - assert reason == reason \ No newline at end of file + assert reason == reason