From e4ef72a78c2c17d7eaba0a964805f697adb4fe7c Mon Sep 17 00:00:00 2001 From: wsnk Date: Sun, 16 Feb 2025 22:32:28 +0200 Subject: [PATCH 1/2] Contains, Round, Unordered --- README.md | 2 ++ majava/__init__.py | 4 +-- majava/basic.py | 78 +++++++++++++++++++++++++++++++++++++++++++++ majava/matchers.py | 33 ++++++++++++++++--- tests/test_basic.py | 62 ++++++++++++++++++++++++++++++++++- 5 files changed, 171 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4d3bf07..135aa51 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,5 @@ Collection of matchers for testing ..tbd + +TO ADD: WithAttrs, EachIs, Regexp, FileList \ No newline at end of file diff --git a/majava/__init__.py b/majava/__init__.py index 3fb371c..c491586 100644 --- a/majava/__init__.py +++ b/majava/__init__.py @@ -1,8 +1,8 @@ from .matchers import Matcher, And, Or, Any, MayBe, Absent, matcher -from .basic import InInterval, IsInstance, DictContains +from .basic import InInterval, IsInstance, DictContains, Round, Contains, Unordered __all__ = [ "Matcher", "And", "Or", "Any", "MayBe", "Absent", "matcher", - "InInterval", "IsInstance", "DictContains" + "InInterval", "IsInstance", "DictContains", "Round", "Contains", "Unordered" ] diff --git a/majava/basic.py b/majava/basic.py index b398f87..f17f1c6 100644 --- a/majava/basic.py +++ b/majava/basic.py @@ -41,3 +41,81 @@ def __repr__(self): def _match(self, other): if not isinstance(other, self.types): raise Mismatch(other, "", f"not {self}") + + +class Round(Matcher): + def __init__(self, value, digits=0): + self.value = round(value, digits) + self.digits = digits + + def __repr__(self): + return f"Round({self.value})" + + def _match(self, other): + if self.value != round(other, self.digits): + raise Mismatch(other, "", f"not ~{self.value}") + + +class Length(Matcher): + def __init__(self, v): + self.v = v + + def __repr__(self): + return f"Length({self.v})" + + def _match(self, other): + if len(other) != self.v: + raise Mismatch(other, "", f"len is not {self.v}") + + +class ContainsOrdered(Matcher): + def __init__(self, items): + self.items = items + + def __repr__(self): + return f"ContainsOrdered({self.items})" + + def _match(self, other): + idx = 0 + for it in self.items: + try: + idx = other[idx:].index(it) + 1 + except ValueError: + raise Mismatch(other, "", f"{repr(it)} is not in order") + + +class _Contains(Matcher): + def __init__(self, items, ordered=False): + self.items = items + self.ordered = ordered + + def __repr__(self): + return f"Contains({self.items})" + + def _match(self, other): + missing_items = [] + for it in self.items: + if it not in other: + missing_items.append(it) + + if missing_items: + raise Mismatch.missing_items(other, missing_items) + + +class Unordered(_Contains): + """ Value must contains all expected items in any order + """ + + def __repr__(self): + return f"Unordered({self.items})" + + def _match(self, other): + if len(self.items) != len(other): + raise Mismatch(other, "", f"len is not {len(self.items)}") + super()._match(other) + + +def Contains(items, ordered=False): + if ordered: + return ContainsOrdered(items) + return _Contains(items) diff --git a/majava/matchers.py b/majava/matchers.py index 9d14139..fa25006 100644 --- a/majava/matchers.py +++ b/majava/matchers.py @@ -11,8 +11,14 @@ 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 missing_keys(cls, value, keys, path=""): + keys_str = ", ".join(repr(i) for i in keys) + return cls(value, path, f"missing items with keys: {keys_str}") + + @classmethod + def missing_items(cls, value, items, path=""): + items_str = ", ".join(repr(i) for i in items) + raise Mismatch(value, "", f"missing items: {items_str}") def __init__(self, value, path, msg): self.value = value @@ -95,13 +101,16 @@ def _match(matcher, value): class And(Matcher): - def __init__(self, *matchers): + def __init__(self, *matchers, repr=None): self.matchers = matchers + self._repr = repr def __and__(self, other): return And(*self.matchers, other) def __repr__(self): + if self._repr is not None: + return self._repr return '&'.join(repr(it) for it in self.matchers) def _match(self, other): @@ -157,6 +166,9 @@ def __repr__(self): class MayBe(Matcher): + """ To be used in dicts; such items may not exist or must match + """ + def __init__(self, v): self.v = v @@ -169,6 +181,18 @@ def _match(self, other): _match(self.v, other) +class Lambda(Matcher): + def __init__(self, callback): + self.cb = callable + + def __repr__(self): + return f"Lambda({self.cb})" + + def _match(self, other): + if not self.cb(other): + raise Mismatch(other, "", "callback returned False") + + def _is_missing(val): return not isinstance(val, (MayBe, _Absent)) @@ -198,8 +222,7 @@ def _match_dict(matcher: dict, value: dict, allow_unexpected=False): 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}") + raise Mismatch.missing_keys(value, sorted(missing_keys)) if unexpected_keys: unexpected_keys_str = ", ".join(repr(i) for i in unexpected_keys) diff --git a/tests/test_basic.py b/tests/test_basic.py index 1b101ba..0758287 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,5 @@ import pytest -from majava import InInterval, IsInstance, DictContains, Absent +from majava import InInterval, IsInstance, DictContains, Absent, Round, Contains, Unordered from .common import raises_assertion_error @@ -62,3 +62,63 @@ def test_dict_contains__mismatch(actual, expected, message): def test_dict__nested_mismatch(actual, expected, message): with raises_assertion_error(message): assert actual == DictContains(expected) + + +def test_round(): + assert 3.14 == Round(3) + assert 3.144 == Round(3.14, 2) + with raises_assertion_error("Value 3.146 does not match: not ~3.14"): + assert 3.146 == Round(3.14, 2) + + +def test_contains__list(): + m = Contains([1, 2, 3]) + + assert repr(m) == "Contains([1, 2, 3])" + assert [1, 2, 3, 4] == m + assert [4, 3, 2, 1] == m + with raises_assertion_error("Value [3, 4, 5] does not match: missing items: 1, 2"): + assert [3, 4, 5] == m + + +def test_contains_ordered__list(): + m = Contains([2, 4], ordered=True) + + assert repr(m) == "ContainsOrdered([2, 4])" + assert [1, 2, 3, 4] == m + with raises_assertion_error("Value [4, 3, 2] does not match: 4 is not in order"): + assert [4, 3, 2] == m + + +def test_contains__str(): + m = Contains(["ab", "cd"]) + + assert repr(m) == "Contains(['ab', 'cd'])" + assert "_ab_cd_" == m + assert "cd__ab" == m + with raises_assertion_error("Value '_ab_c_d_' does not match: missing items: 'cd'"): + assert "_ab_c_d_" == m + + +def test_contains_ordered__str(): + m = Contains(["ab", "cd"], ordered=True) + + assert repr(m) == "ContainsOrdered(['ab', 'cd'])" + assert "_ab cd_" == m + with raises_assertion_error("Value '_cd ab_' does not match: 'cd' is not in order"): + assert "_cd ab_" == m + + +def test_unordered__list(): + m = Unordered([1, 2, 3]) + + assert repr(m) == "Unordered([1, 2, 3])" + + assert [1, 2, 3] == m + assert [2, 3, 1] == m + assert [3, 1, 2] == m + + with raises_assertion_error("Value [1, 2, 3, 4] does not match: len is not 3"): + assert [1, 2, 3, 4] == m + with raises_assertion_error("Value [1, 2, 4] does not match: missing items: 3"): + assert [1, 2, 4] == m From b234ec31e8feed86d7d8ebc17dd75acb7009bbe9 Mon Sep 17 00:00:00 2001 From: wsnk Date: Sun, 16 Feb 2025 22:33:09 +0200 Subject: [PATCH 2/2] Up micro version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b0e9e49..275028d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license.text = "MIT" authors = [{name = "wsnk"}] -version = "0.1.1" +version = "0.2.0" dependencies = ["pytest"] optional-dependencies.tests = ["flake8"]