assert-matcher 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-22.04
12
+ steps:
13
+ - uses: actions/checkout@v3
14
+ - name: Set up Python 3.10
15
+ uses: actions/setup-python@v4
16
+ with:
17
+ python-version: "3.10"
18
+ - name: Lint
19
+ run: |
20
+ pip install flake8
21
+ flake8 assert_matcher
22
+ flake8 tests
23
+
24
+ test:
25
+ runs-on: ubuntu-22.04
26
+ strategy:
27
+ fail-fast: false
28
+ matrix:
29
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.9", "pypy3.10", "pypy3.11"]
30
+
31
+ steps:
32
+ - uses: actions/checkout@v3
33
+ - name: Set up Python ${{ matrix.python-version }}
34
+ uses: actions/setup-python@v4
35
+ with:
36
+ python-version: ${{ matrix.python-version }}
37
+ - name: Install dependencies
38
+ run: |
39
+ python -m pip install --upgrade pip
40
+ pip install -r test_requirements.txt
41
+ pip install -e . # Need to install for pytest plugin to work
42
+ - name: Run tests
43
+ run: pytest -W error
@@ -0,0 +1,10 @@
1
+ *.pyc
2
+ dist
3
+ *.egg-info
4
+ build
5
+ .tags*
6
+ .tox
7
+ .coverage
8
+ htmlcov
9
+ .cache
10
+ .pytest_cache
@@ -0,0 +1,2 @@
1
+ 0.5
2
+ - initial version
@@ -0,0 +1,42 @@
1
+ # Project Overview
2
+
3
+ This project is a Python library called `assert-matcher`. Its purpose is to simplify asserts containing objects and nested data structures in tests.
4
+
5
+ It will work with any test framework by overloaing equality, it also provides pytest plugin to show nice diffs with its matcher objects.
6
+
7
+ The project is structured as a standard Python package with a `setup.py` file and a `assert_matcher` module.
8
+
9
+ # Building and Running
10
+
11
+ **Installation:**
12
+
13
+ ```bash
14
+ pip install -r test_requirements.txt
15
+ ```
16
+
17
+ **Running tests:**
18
+
19
+ To run tests with the default python version:
20
+
21
+ ```bash
22
+ pytest
23
+ ```
24
+
25
+ To run tests for a specific python version with `tox`:
26
+
27
+ ```bash
28
+ tox -e py310
29
+ ```
30
+
31
+ To run linting checks:
32
+
33
+ ```bash
34
+ flake8
35
+ ```
36
+
37
+ # Development Conventions
38
+
39
+ * The code is formatted with a maximum line length of 100 characters.
40
+ * The project uses `flake8` for linting.
41
+ * Tests are located in the `tests/` directory.
42
+ * The project uses `tox` to automate testing in different python environments.
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2022-2026, Alexander Schepanovski.
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification,
5
+ are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice,
8
+ this list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright
11
+ notice, this list of conditions and the following disclaimer in the
12
+ documentation and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
18
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
21
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,3 @@
1
+ include LICENSE
2
+ include CHANGELOG
3
+ include README.md
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: assert-matcher
3
+ Version: 0.9.1
4
+ Summary: Makes comparing nested data structures easy and glancable
5
+ Home-page: http://github.com/Suor/assert-macther
6
+ Author: Alexander Schepanovski
7
+ Author-email: suor.web@gmail.com
8
+ License: BSD-2-Clause
9
+ Keywords: tests
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: Implementation :: CPython
25
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
26
+ Classifier: Topic :: Software Development :: Testing
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.9
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Dynamic: license-file
@@ -0,0 +1,196 @@
1
+ # Assert Matcher [![Build Status](https://github.com/Suor/assert-matcher/actions/workflows/ci.yml/badge.svg)](https://github.com/Suor/assert-matcher/actions/workflows/test.yml?query=branch%3Amaster)
2
+
3
+ The purpose of this library is to simplify asserts containing objects and nested data structures, i.e.:
4
+
5
+ ```python
6
+ def test_smth(M): # A special Matcher fixture
7
+ # ...
8
+ assert result.errors == [
9
+ M(message=M.re("^Smth went wrong:"), extensions=M.dict(code=523)),
10
+ M(message=M.any, tags=M.unordered("one", "two")),
11
+ ]
12
+ ```
13
+
14
+ Here all the structures like lists and dicts are followed as usual both outside and inside
15
+ a matcher object, while the matcher object and other helpers provide their own equality. These could be freely intermixed.
16
+
17
+ Alternatively may import Matcher directly this is more verbose but also more universal, i.e. works outside of pytest:
18
+
19
+ ```python
20
+ from assert_matcher import M
21
+ ```
22
+
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install assert-matcher
28
+ pip install git+https://github.com/Suor/assert-matcher.git@master # the newest one
29
+ ```
30
+
31
+
32
+ ## Matchers
33
+
34
+ The `M` fixture provides a number of matchers to help you write more expressive assertions.
35
+
36
+ #### `M(**attrs)`
37
+
38
+ Matches an object's attributes.
39
+
40
+ ```python
41
+ User = namedtuple("User", ["name", "email", "is_active"])
42
+
43
+ users = [User("Alice", "alice@example.com", True), User("Bob", "bob@example.com", False)]
44
+
45
+ assert users == [
46
+ M(name="Alice", is_active=True),
47
+ M(name="Bob", is_active=False),
48
+ ]
49
+
50
+ # May test class if that is important
51
+ assert user == M(__class__=User, name="Alice")
52
+ ```
53
+
54
+ #### `M.re(pattern, flags=0)`
55
+
56
+ Matches a string against a regular expression using `re.search`.
57
+
58
+ ```python
59
+ assert "hello world" == M.re(r"h.*o")
60
+ ```
61
+
62
+ #### `M.dict(d=None, **kwargs)`
63
+
64
+ Matches a dictionary against a subset of keys. It checks that the keys exist and their values match, but ignores other keys.
65
+
66
+ ```python
67
+ assert {"a": 1, "b": 2} == M.dict(a=1)
68
+ assert {"a": 1, "b": 2} == M.dict({"a": 1}) # same
69
+ ```
70
+
71
+ #### M.any
72
+
73
+ Matches any value. This is useful when you want to ignore a particular field value in a larger data structure, but still want to test for a key presence.
74
+
75
+ ```python
76
+ assert {"a": 1, "b": 2, "c": "hi"} == M.dict(a=1, b=M.any)
77
+ ```
78
+
79
+ #### `M.any_of(*items)`
80
+
81
+ Matches if the value is one of the provided items.
82
+
83
+ ```python
84
+ assert "a" == M.any_of("a", "b", "c")
85
+ ```
86
+
87
+ #### `M.unordered(*items)`
88
+
89
+ Compares a list to the provided items, ignoring the order.
90
+
91
+ ```python
92
+ assert [1, 2, 3] == M.unordered(3, 1, 2)
93
+ ```
94
+
95
+ #### `M.isa(*types)`
96
+
97
+ Checks if an object is an instance of one of the provided types.
98
+
99
+ ```python
100
+ assert "hello" == M.isa(str)
101
+ assert 42 == M.isa(int, float)
102
+ ```
103
+
104
+ #### `M.approx(expected, rel=None, abs=None, nan_ok=False)`
105
+
106
+ Performs approximate comparison of numbers. It's a wrapper around `pytest.approx`.
107
+
108
+ ```python
109
+ assert 0.1 + 0.2 == M.approx(0.3)
110
+ assert end_time == M.approx(start_time + timedelta(seconds=1), abs=0.1)
111
+ ```
112
+
113
+
114
+ ## Running tests
115
+
116
+ To run the tests using your default python:
117
+
118
+ ```
119
+ pip install -r test_requirements.txt
120
+ pytest
121
+ ```
122
+
123
+ To fully run `tox` you need all the supported pythons to be installed. These are
124
+ 3.9+ and PyPy3. You can run it for particular environment even in absense
125
+ of all of the above:
126
+
127
+ ```
128
+ tox -e py314
129
+ tox -e pypy311
130
+ tox -e lint
131
+ ```
132
+
133
+ ## Pytest Plugin
134
+
135
+ `assert-matcher` provides enhanced assertion representation for pytest, making diffs for nested data structures more readable when using `Matcher` objects.
136
+
137
+ When a test fails, pytest usually prints a diff of the two objects that were compared. However, these diffs can be hard to read. `assert-matcher` plugin overrides the default diff representation to pinpoint an exact path and difference.
138
+
139
+ For example, consider the following failing test:
140
+
141
+ ```python
142
+ def test_user(M):
143
+ user = User("Alice", "alice@example.com", True)
144
+ assert user == M(name="Bob", is_active=False)
145
+ ```
146
+
147
+ Without the plugin, the diff would look something like this:
148
+
149
+ ```
150
+ E AssertionError: assert User(name='Alice', email='alice@example.com', is_active=True) == M(name='Bob', is_active=False)
151
+ E + where M(name='Bob', is_active=False) = <class 'assert_matcher.matchers.Matcher'>(name='Bob', is_active=False)
152
+ ```
153
+
154
+ With the `assert-matcher` plugin enabled, the diff is much more readable:
155
+
156
+ ```
157
+ E AssertionError: assert User(name=...tive=True) == M(name='Bo...ive=False)
158
+ E name: 'Alice' != 'Bob'
159
+ E is_active: True != False
160
+ ```
161
+
162
+ This is even more useful when you compare nested data structures. For example, consider the following test:
163
+
164
+ ```python
165
+ def test_nested_structure(M):
166
+ result = {
167
+ "users": [alice, bob],
168
+ "meta": ...
169
+ }
170
+ expected = {
171
+ "users": [
172
+ M(name="Alice", status="active"),
173
+ M(name="Bob", email=M.re(r"@example\.com$")),
174
+ ],
175
+ "meta": M.any
176
+ }
177
+ assert result == expected
178
+ ```
179
+
180
+ Without the plugin, the diff is hard to read (imagine these objects having 10+ fields):
181
+
182
+ ```
183
+ E AssertionError: assert {'meta': {'er... 'inactive'}]} == {'meta': M.di...ample.com$')]}
184
+ E
185
+ E Omitting 1 identical items, use -vv to show
186
+ E Differing items:
187
+ E {'users': [{'email': 'alice@example.com', 'name': 'Alice', 'status': 'active'}, {'email': 'bob@invalid', 'name': 'Bob', 'status': 'inactive'}]} != {'users': [M.dict(name='Alice', status='active'), M.dict(name='Bob', email=r'@example.com$')]}
188
+ E Use -v to get more diff
189
+ ```
190
+
191
+ With the plugin, the diff is much more concise and points to the exact location of the error:
192
+
193
+ ```
194
+ E AssertionError: assert {'meta': {...active'}} == {'meta': M...e.com$')]}
195
+ E users[1].email: 'bob@invalid' != r'@example.com$'
196
+ ```
@@ -0,0 +1,2 @@
1
+ VERSION = '0.9.1'
2
+ from .matchers import Matcher, Matcher as M # noqa
@@ -0,0 +1,35 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ import pytest
4
+ from _pytest.python_api import ApproxBase
5
+
6
+
7
+ def approx(expected, rel=None, abs=None, nan_ok=False):
8
+ if isinstance(expected, datetime):
9
+ if rel is not None:
10
+ raise TypeError("rel doesn't make sense with a datetime")
11
+ return ApproxDatetime(expected, abs=abs)
12
+
13
+ return pytest.approx(expected, rel=rel, abs=abs, nan_ok=nan_ok)
14
+
15
+
16
+ class ApproxDatetime(ApproxBase):
17
+ """Perform approximate comparisons between datetime."""
18
+ default_abs = timedelta(seconds=1)
19
+
20
+ def __init__(self, expected, abs=None):
21
+ assert isinstance(expected, datetime)
22
+
23
+ if abs is None:
24
+ abs = self.default_abs
25
+ elif isinstance(abs, int):
26
+ abs = timedelta(seconds=abs)
27
+
28
+ assert abs >= timedelta(0), f"absolute tolerance can't be negative: {abs}"
29
+ super().__init__(expected, abs=abs)
30
+
31
+ def __repr__(self):
32
+ return f"approx({self.expected} ± {self.abs})"
33
+
34
+ def __eq__(self, actual: object) -> bool:
35
+ return abs(self.expected - actual) <= self.abs
@@ -0,0 +1,244 @@
1
+ import pprint
2
+ from collections import defaultdict
3
+ from collections.abc import Mapping, Set
4
+ from functools import partial
5
+ from itertools import count
6
+
7
+ from _pytest._io.saferepr import safeformat, saferepr
8
+ from _pytest.assertion.util import issequence, assertrepr_compare
9
+
10
+ from .matchers import Matcher, MatcherDict
11
+
12
+
13
+ def pytest_assertrepr_compare(config, op: str, left, right):
14
+ import shutil
15
+ import _pytest._code
16
+
17
+ verbose = config.get_verbosity() if hasattr(config, "get_verbosity") \
18
+ else config.getoption("verbose")
19
+ if verbose > 1:
20
+ left_repr = safeformat(left)
21
+ right_repr = safeformat(right)
22
+ else:
23
+ hspace = max(80, shutil.get_terminal_size().columns - 10)
24
+ hspace -= len("E AssertionError: assert") + len(op) + 2 # 2 spaces
25
+ left_repr = saferepr(left, maxsize=hspace // 2)
26
+ right_repr = saferepr(right, maxsize=hspace - len(left_repr))
27
+
28
+ try:
29
+ explanation = to_lines(compare(config, op, left, right))
30
+ # NOTE: not calling _compare_eq_iterable() anywhere for now
31
+ except Exception:
32
+ crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
33
+ explanation = [
34
+ "assert_matcher comparison failed:",
35
+ f" {crash}",
36
+ "Probably an object has a faulty __repr__.",
37
+ ]
38
+
39
+ if not explanation:
40
+ return None
41
+
42
+ summary = f"{left_repr} {op} {right_repr}"
43
+ return [summary] + explanation
44
+
45
+
46
+ def compare(config, op, left, right):
47
+ explanation = []
48
+ for test, explain in EXPLAINERS[op]:
49
+ if test(left, right):
50
+ return explain(config, left, right)
51
+ return explanation
52
+
53
+
54
+ def compare_eq(config, left, right):
55
+ return compare(config, "==", left, right) or [f"{left!r} != {right!r}"]
56
+
57
+
58
+ # Explainers framework
59
+
60
+ EXPLAINERS = defaultdict(list)
61
+
62
+
63
+ def register_explainer(op, test, explain=None):
64
+ if explain is None:
65
+ return partial(register_explainer, op, test)
66
+ EXPLAINERS[op].append((test, explain))
67
+ return explain # Return the function when we use this as a decorator
68
+
69
+
70
+ def both(test):
71
+ return lambda left, right: test(left) and test(right)
72
+
73
+
74
+ def bothis(cls):
75
+ return lambda left, right: isinstance(left, cls) and isinstance(right, cls)
76
+
77
+
78
+ def anyis(cls):
79
+ return lambda left, right: isinstance(left, cls) or isinstance(right, cls)
80
+
81
+
82
+ @register_explainer("==", anyis(MatcherDict))
83
+ def _explain_m_dict(config, left, right):
84
+ if isinstance(left, MatcherDict) and isinstance(right, MatcherDict):
85
+ return ["Should not compare two M.dict() instances"]
86
+
87
+ if isinstance(left, MatcherDict):
88
+ matcher_side, val, matcher, left_d, right_d = "Left", right, left, left.d, right
89
+ else:
90
+ matcher_side, val, matcher, left_d, right_d = "Right", left, right, left, right.d
91
+
92
+ explanation = _dict_extra(matcher_side, matcher.d, val)
93
+
94
+ # NOTE: not using sets here to use the matchers keys order
95
+ diff = _dict_diff(config, [k for k in matcher.d if k in val], left_d, right_d)
96
+ return explanation + diff
97
+
98
+
99
+ @register_explainer("==", bothis(Mapping))
100
+ def _explain_dict(config, left, right):
101
+ explanation = []
102
+
103
+ for side, more, less in [("Left", left, right), ("Right", right, left)]:
104
+ explanation.extend(_dict_extra(side, more, less))
105
+
106
+ # NOTE: not using sets here to preserve keys order
107
+ diff = _dict_diff(config, [k for k in right if k in left], left, right)
108
+ return explanation + diff
109
+
110
+
111
+ def _dict_extra(side, more, less):
112
+ explanation = []
113
+ extra = {k: v for k, v in more.items() if k not in less}
114
+ if extra:
115
+ n = len(extra)
116
+ explanation.append(f"{side} contains {n} more item{'' if n == 1 else 's'}:")
117
+ explanation.extend(pprint.pformat(extra).splitlines())
118
+ return explanation
119
+
120
+
121
+ def _dict_diff(config, keys, left, right):
122
+ # NOTE: we use simple key with no ["..."] wrapping to make it cleaner
123
+ items = {k: compare_eq(config, left[k], right[k]) for k in keys if left[k] != right[k]}
124
+ return Explanation(prefix="Differing items:", items=items) if items else []
125
+
126
+
127
+ class Missing:
128
+ def __str__(self):
129
+ return "<missing>"
130
+
131
+ __repr__ = __str__
132
+
133
+
134
+ missing = Missing()
135
+
136
+
137
+ @register_explainer("==", anyis(Matcher))
138
+ def _explain_m(config, left, right):
139
+ if isinstance(left, Matcher) and isinstance(right, Matcher):
140
+ return ["Should not compare two M() instances"]
141
+
142
+ if isinstance(left, Matcher):
143
+ matcher, left_d = left, left.attrs
144
+ right_d = {k: getattr(right, k, missing) for k in left_d}
145
+ else:
146
+ matcher, right_d = right, right.attrs
147
+ left_d = {k: getattr(left, k, missing) for k in right_d}
148
+
149
+ return _dict_diff(config, matcher.attrs, left_d, right_d)
150
+
151
+
152
+ @register_explainer("==", bothis(Set))
153
+ def _explain_set(config, left, right):
154
+ return assertrepr_compare(config, "==", left, right)
155
+
156
+
157
+ @register_explainer("==", bothis(str))
158
+ def _explain_str(config, left, right):
159
+ return assertrepr_compare(config, "==", left, right)
160
+
161
+
162
+ # TODO: catch a situation when some item was removed in the middle?
163
+ # what about swap?
164
+ @register_explainer("==", both(issequence))
165
+ def _explain_seq(config, left, right):
166
+ if isinstance(left, bytes) and isinstance(right, bytes):
167
+ return assertrepr_compare(config, "==", left, right)
168
+
169
+ explanation = []
170
+ for i, l, r in zip(count(), left, right):
171
+ if l != r: # noqa
172
+ explanation = Explanation("First mismatch:", {f"[{i}]": compare_eq(config, l, r)})
173
+ # NOTE: we are ignoring other mismatches, and "First mismatch:" above won't be shown
174
+ # if the below section will yield nothing
175
+ break
176
+
177
+ len_diff = len(left) - len(right)
178
+ if len_diff:
179
+ if len_diff > 0:
180
+ side, extra = "Left", left[len(right)]
181
+ else:
182
+ side, extra = "Right", right[len(left)]
183
+ len_diff = -len_diff
184
+
185
+ if len_diff == 1:
186
+ explanation += [f"{side} contains one more item: {extra!r}"]
187
+ else:
188
+ explanation += [f"{side} contains {len_diff} more items, first one: {extra!r}"]
189
+ return explanation
190
+
191
+
192
+ class Explanation(dict):
193
+ """An explanation of a diff for dicts and objects.
194
+
195
+ Converted to lines of strings before returning to pytest. This delaying of rendering allows to
196
+ join keys when we find differences in nested structures.
197
+
198
+ Allows separating diff collection from rendering partially. We want to reuse some of
199
+ the _pytest.assertion.util code, so we work with both this and list[str].
200
+ """
201
+ def __init__(self, prefix, items):
202
+ self._prefix = prefix
203
+ self.update(items)
204
+
205
+ def add(self, other, reverse=False):
206
+ if not other:
207
+ return self
208
+ elif isinstance(other, list):
209
+ rendered = [self._prefix] + to_lines(self)
210
+ return other + rendered if reverse else rendered + other
211
+ raise NotImplementedError
212
+
213
+ def __add__(self, other):
214
+ return self.add(other)
215
+
216
+ def __radd__(self, other):
217
+ return self.add(other, reverse=True)
218
+
219
+
220
+ def to_lines(expl, path=""):
221
+ """Coerce explanation to lines of strings"""
222
+ if isinstance(expl, list):
223
+ return expl
224
+
225
+ # NOTE: we only escape string keys if they contain "." for readablity.
226
+ # This makes object attrs look the same as dict keys though.
227
+ def escape_key(k):
228
+ return repr(k) if not k.startswith("[") and "." in k else k
229
+
230
+ explanation = []
231
+ for k, lines in expl.items():
232
+ # full_path = f"{path}.{escape_key(k)}" if path else escape_key(k)
233
+ if path:
234
+ full_path = path + k if k.startswith("[") else f"{path}.{escape_key(k)}"
235
+ else:
236
+ full_path = escape_key(k)
237
+ if isinstance(lines, dict):
238
+ explanation.extend(to_lines(lines, path=full_path))
239
+ elif len(lines) == 1:
240
+ explanation.append(f"{full_path}: " + lines[0])
241
+ else:
242
+ explanation.append(f"{full_path}:")
243
+ explanation.extend([" " + l for l in lines]) # noqa
244
+ return explanation
@@ -0,0 +1,150 @@
1
+ import re
2
+
3
+
4
+ class MatcherRegex:
5
+ """Special class to eq by matching regex"""
6
+ def __init__(self, pattern, flags=0):
7
+ self.regex = re.compile(pattern, flags)
8
+
9
+ def __repr__(self):
10
+ flags = self.regex.flags & ~32 # 32 is default
11
+ if flags:
12
+ return f"M.re(r'{self.regex.pattern}', {flags})"
13
+ else:
14
+ return f"r'{self.regex.pattern}'"
15
+
16
+ def __eq__(self, other):
17
+ return self.regex.search(other)
18
+
19
+
20
+ class MatcherDict:
21
+ """Special class to eq by matching only presented dict keys"""
22
+ # NOTE: can't inherit from dict because that makes D() == M.dict() to not call our __eq__,
23
+ # if D is a subclass of a dict
24
+ def __init__(self, __d=None, **keys):
25
+ self.d = __d or {}
26
+ if keys:
27
+ self.d = self.d.copy()
28
+ self.d.update(keys)
29
+
30
+ def __repr__(self):
31
+ return f"M.dict({', '.join(f'{k}={repr(v)}' for k, v in self.d.items())})"
32
+
33
+ def __len__(self):
34
+ return len(self.d)
35
+
36
+ def __eq__(self, other):
37
+ missing = object()
38
+ return all(other.get(name, missing) == v for name, v in self.d.items())
39
+
40
+
41
+ class MatcherAny:
42
+ """Equals to anything, a way to ignore parts of data structures on comparison"""
43
+ def __repr__(self):
44
+ return "M.any"
45
+
46
+ def __eq__(self, other):
47
+ return True
48
+
49
+
50
+ class MatcherAnyOf:
51
+ def __init__(self, *items):
52
+ self.items = items
53
+
54
+ def __repr__(self) -> str:
55
+ inner = ", ".join(map(repr, self.items))
56
+ return f"M.any_of({inner})"
57
+
58
+ def __eq__(self, other: object) -> bool:
59
+ return other in self.items
60
+
61
+
62
+ class MatcherUnordered:
63
+ """Compare list contents, but do not care about ordering. (E.g. sort lists first,
64
+ then compare.) If you care about ordering, then just compare lists directly."""
65
+ def __init__(self, items):
66
+ self.items = sorted(items)
67
+
68
+ def __repr__(self):
69
+ inner = ", ".join(map(repr, self.items))
70
+ return f"M.unordered({inner})"
71
+
72
+ def __eq__(self, other):
73
+ return self.items == sorted(other)
74
+
75
+
76
+ class MatcherIsa:
77
+ def __init__(self, *types):
78
+ assert all(isinstance(t, type) for t in types)
79
+ self.types = types
80
+
81
+ def __repr__(self):
82
+ inner = ", ".join(t.__name__ for t in self.types)
83
+ return f"M.isa({inner})"
84
+
85
+ def __eq__(self, other):
86
+ return isinstance(other, self.types)
87
+
88
+
89
+ class Matcher:
90
+ """Special class to eq by existing attrs. Also provides contructors for other types of matching:
91
+ - regex
92
+ - dict
93
+ - unordered sequence
94
+ - by class
95
+ - "any of"
96
+ - any wildcard
97
+
98
+ The purpose is to simplify asserts containing objects and nested data structures, i.e.:
99
+
100
+ assert result.errors == [
101
+ M(message=M.re("^Smth went wrong:"), extensions=M.dict(code=523)),
102
+ M(message=M.any, tags=M.unordered("one", "two")),
103
+ ]
104
+
105
+ Here all the structures like lists and dicts are followed as usual both outside and inside
106
+ a mather object. These could be freely intermixed.
107
+ """
108
+ def __init__(self, **attrs):
109
+ self.attrs = attrs
110
+
111
+ def __repr__(self):
112
+ name = "M"
113
+ attrs = self.attrs
114
+ if "__class__" in attrs:
115
+ name += "." + attrs["__class__"].__name__
116
+ attrs = {k: v for k, v in attrs.items() if k != "__class__"}
117
+ return f"{name}({', '.join(f'{k}={repr(v)}' for k, v in attrs.items())})"
118
+
119
+ def __eq__(self, other):
120
+ # Unforturnately this doesn't work with classes with slots
121
+ # self.__class__ = other.__class__
122
+ missing = object()
123
+ return all(getattr(other, name, missing) == v for name, v in self.attrs.items())
124
+
125
+ any = MatcherAny()
126
+
127
+ @staticmethod
128
+ def any_of(*items):
129
+ return MatcherAnyOf(*items)
130
+
131
+ @staticmethod
132
+ def re(pattern, flags=0):
133
+ return MatcherRegex(pattern, flags)
134
+
135
+ @staticmethod
136
+ def dict(_d=None, **keys):
137
+ return MatcherDict(_d, **keys)
138
+
139
+ @staticmethod
140
+ def unordered(*elems):
141
+ return MatcherUnordered(elems)
142
+
143
+ @staticmethod
144
+ def isa(*elems):
145
+ return MatcherIsa(*elems)
146
+
147
+ def approx(expected, rel=None, abs=None, nan_ok=False):
148
+ from .approx import approx # Requires pytest
149
+
150
+ return approx(expected, rel=rel, abs=abs, nan_ok=nan_ok)
@@ -0,0 +1,9 @@
1
+ import pytest
2
+
3
+ from .matchers import Matcher
4
+ from .compare import pytest_assertrepr_compare # noqa
5
+
6
+
7
+ @pytest.fixture
8
+ def M():
9
+ return Matcher
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: assert-matcher
3
+ Version: 0.9.1
4
+ Summary: Makes comparing nested data structures easy and glancable
5
+ Home-page: http://github.com/Suor/assert-macther
6
+ Author: Alexander Schepanovski
7
+ Author-email: suor.web@gmail.com
8
+ License: BSD-2-Clause
9
+ Keywords: tests
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: Implementation :: CPython
25
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
26
+ Classifier: Topic :: Software Development :: Testing
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.9
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Dynamic: license-file
@@ -0,0 +1,24 @@
1
+ .gitignore
2
+ CHANGELOG
3
+ GEMINI.md
4
+ LICENSE
5
+ MANIFEST.in
6
+ README.md
7
+ publish.sh
8
+ setup.cfg
9
+ setup.py
10
+ test_requirements.txt
11
+ tox.ini
12
+ .github/workflows/ci.yml
13
+ assert_matcher/__init__.py
14
+ assert_matcher/approx.py
15
+ assert_matcher/compare.py
16
+ assert_matcher/matchers.py
17
+ assert_matcher/plugin.py
18
+ assert_matcher.egg-info/PKG-INFO
19
+ assert_matcher.egg-info/SOURCES.txt
20
+ assert_matcher.egg-info/dependency_links.txt
21
+ assert_matcher.egg-info/entry_points.txt
22
+ assert_matcher.egg-info/top_level.txt
23
+ tests/test_compare.py
24
+ tests/test_matchers.py
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ assert_matcher = assert_matcher.plugin
@@ -0,0 +1 @@
1
+ assert_matcher
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/bash
2
+
3
+ set -ex
4
+
5
+ NAME=assert_matcher
6
+ VERSION=`awk '/VERSION = /{gsub(/'\''/, "", $3); print $3}' assert_matcher/__init__.py`
7
+
8
+ echo "Publishing $NAME-$VERSION..."
9
+ python setup.py sdist bdist_wheel
10
+ twine check dist/$NAME-$VERSION*
11
+ twine upload --skip-existing -u__token__ dist/$NAME-$VERSION*
@@ -0,0 +1,50 @@
1
+ [metadata]
2
+ name = assert-matcher
3
+ version = attr: assert_matcher.VERSION
4
+ author = Alexander Schepanovski
5
+ author_email = suor.web@gmail.com
6
+ url = http://github.com/Suor/assert-macther
7
+ description = Makes comparing nested data structures easy and glancable
8
+ long_description = file: README.rst
9
+ long_description_content_type = text/markdown
10
+ keywords = tests
11
+ license = BSD-2-Clause
12
+ license_files = LICENSE
13
+ classifiers =
14
+ Development Status :: 4 - Beta
15
+ Framework :: Pytest
16
+ Intended Audience :: Developers
17
+ License :: OSI Approved :: BSD License
18
+ Operating System :: OS Independent
19
+ Programming Language :: Python
20
+ Programming Language :: Python :: 3
21
+ Programming Language :: Python :: 3 :: Only
22
+ Programming Language :: Python :: 3.9
23
+ Programming Language :: Python :: 3.10
24
+ Programming Language :: Python :: 3.11
25
+ Programming Language :: Python :: 3.12
26
+ Programming Language :: Python :: 3.13
27
+ Programming Language :: Python :: 3.14
28
+ Programming Language :: Python :: Implementation :: CPython
29
+ Programming Language :: Python :: Implementation :: PyPy
30
+ Topic :: Software Development :: Testing
31
+ Topic :: Utilities
32
+
33
+ [options]
34
+ packages = find:
35
+ python_requires = >=3.9
36
+
37
+ [options.entry_points]
38
+ pytest11 =
39
+ assert_matcher = assert_matcher.plugin
40
+
41
+ [bdist_wheel]
42
+ universal = 1
43
+
44
+ [tool:pytest]
45
+ minversion = 7.1.2
46
+
47
+ [egg_info]
48
+ tag_build =
49
+ tag_date = 0
50
+
@@ -0,0 +1,4 @@
1
+ from setuptools import setup
2
+
3
+
4
+ setup()
@@ -0,0 +1 @@
1
+ pytest
@@ -0,0 +1,99 @@
1
+ from itertools import takewhile
2
+ from unittest.mock import Mock
3
+ from assert_matcher.compare import compare, to_lines
4
+
5
+
6
+ _config = Mock()
7
+ _config.get_terminal_writer.return_value._highlight = lambda source, lexer="python": source
8
+ _config.get_verbosity.return_value = 0
9
+ _config.getoption.return_value = 0 # older pytest versions
10
+
11
+
12
+ def comp_eq(left, right):
13
+ lines = to_lines(compare(_config, "==", left, right))
14
+ # Filter out pytest's verbose hints that vary between local and CI environments
15
+ # On CI: pytest shows "Full diff:" when running_on_ci() is True
16
+ # Locally: pytest shows "Use -v to get more diff" when verbose is low
17
+ return list(takewhile(
18
+ lambda line: line.strip() not in ("Use -v to get more diff", "Full diff:"),
19
+ # Also skip whitespace-only strings, these are pytest version dependent
20
+ (line for line in lines if not line.isspace())
21
+ ))
22
+
23
+
24
+ def test_list(M):
25
+ assert comp_eq([1, 2], [1, 3]) == ["[1]: 2 != 3"]
26
+ assert comp_eq([1, 2, 3], [1, 2]) == ["Left contains one more item: 3"]
27
+ assert comp_eq([1, 2], [1, 2, 3, 4]) == ["Right contains 2 more items, first one: 3"]
28
+ assert comp_eq([1, 2], [1, 0, 2]) == [
29
+ "First mismatch:",
30
+ "[1]: 2 != 0",
31
+ "Right contains one more item: 2",
32
+ ]
33
+ assert comp_eq([1, [2, 3]], [1, [3, 3]]) == ["[1][0]: 2 != 3"]
34
+
35
+
36
+ def test_dict(M):
37
+ assert comp_eq({"a": 1, "b": 2}, M.dict(a=2)) == ["a: 1 != 2"]
38
+ assert comp_eq({"a": {"b": 1}}, M.dict(a={"b": 2})) == ["a.b: 1 != 2"]
39
+ assert comp_eq({"a": {}}, M.dict(a={"b": 2, "c": 3})) == [
40
+ "a:",
41
+ " Right contains 2 more items:",
42
+ " {'b': 2, 'c': 3}",
43
+ ]
44
+
45
+ assert comp_eq({"a": {"b.c": 1}}, M.dict(a={"b.c": 2})) == ["a.'b.c': 1 != 2"]
46
+
47
+
48
+ def test_nested_strings(M):
49
+ result = {"data": {"text": "This is a long string with some content."}}
50
+ expected = {"data": {"text": "This is a long string with different content."}}
51
+ assert comp_eq(result, expected) == [
52
+ "data.text:",
53
+ " 'This is a lo...some content.' == 'This is a lo...rent content.'",
54
+ " - This is a long string with different content.",
55
+ " ? ^^^^ ----",
56
+ " + This is a long string with some content.",
57
+ " ? ^^^",
58
+ ]
59
+
60
+
61
+ def test_nested_sets(M):
62
+ result = {"data": {1, 2, 3}}
63
+ expected = {"data": {1, 2, 4}}
64
+ assert comp_eq(result, expected) == [
65
+ "data:",
66
+ " {1, 2, 3} == {1, 2, 4}",
67
+ " Extra items in the left set:",
68
+ " 3",
69
+ " Extra items in the right set:",
70
+ " 4",
71
+ ]
72
+
73
+
74
+ def test_nested_bytes(M):
75
+ result = {"data": b"hello"}
76
+ expected = {"data": b"world"}
77
+ assert comp_eq(result, expected) == [
78
+ "data:",
79
+ " b'hello' == b'world'",
80
+ " At index 0 diff: b'h' != b'w'",
81
+ ]
82
+
83
+
84
+ # def test_nested_strings_demo(M):
85
+ # result = {"data": {"text": "This is a long string with some content."}}
86
+ # expected = {"data": {"text": "This is a long string with different content."}}
87
+ # assert result == expected
88
+
89
+
90
+ # def test_nested_sets_demo(M):
91
+ # result = {"data": {1, 2, 3}}
92
+ # expected = {"data": {1, 2, 4}}
93
+ # assert result == expected
94
+
95
+
96
+ # def test_nested_bytes_demo(M):
97
+ # result = {"data": b"hello"}
98
+ # expected = {"data": b"world"}
99
+ # assert result == expected
@@ -0,0 +1,110 @@
1
+ from datetime import datetime, timedelta
2
+ import re
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+
7
+
8
+ def test_object(M):
9
+ assert repr(M(foo="foo")) == "M(foo='foo')"
10
+
11
+ obj = SimpleNamespace(foo="foo", bar="bar")
12
+ assert obj == M(foo="foo")
13
+ assert obj != M(bar="zoo")
14
+ assert obj != M(missing=42)
15
+ assert obj != M(foo="foo", more="bar")
16
+
17
+
18
+ def test_object_class(M):
19
+ class A:
20
+ pass
21
+
22
+ assert repr(M(__class__=A, x=1, y=2)) == "M.A(x=1, y=2)"
23
+ assert A() == M(__class__=A)
24
+ assert A() != M(__class__=SimpleNamespace)
25
+
26
+
27
+ def test_dict(M):
28
+ assert repr(M.dict(foo="foo", n=123)) == "M.dict(foo='foo', n=123)"
29
+
30
+ # pytest needs len() to be there when there is no explanation
31
+ assert len(M.dict({"a": 1, "b": 2})) == 2
32
+
33
+ actual = {"base_url": "url", "verify": "bundle", "timeout": 10}
34
+ assert actual == M.dict(verify="bundle", timeout=10)
35
+ assert actual == M.dict(actual, verify="bundle")
36
+ assert actual != M.dict(verify="bundle.pem")
37
+ assert actual != M.dict(missing=42)
38
+
39
+
40
+ def test_dict_subclass(M):
41
+ class SubDict(dict):
42
+ pass
43
+
44
+ assert SubDict(a=1) == M.dict()
45
+ assert M.dict() == SubDict(a=1)
46
+
47
+
48
+ def test_re(M):
49
+ assert repr(M.re(r"^plots\.csv-\w+$")) == r"r'^plots\.csv-\w+$'"
50
+ assert repr(M.re(r"\w+\.csv", re.I)) == r"M.re(r'\w+\.csv', 2)"
51
+
52
+ assert "500 Internal Error" == M.re(r"^500 Internal")
53
+ assert "200 OK" != M.re(r"^500 Internal")
54
+ assert "... 500" != M.re(r"^500")
55
+ assert "... Hostname ..." == M.re(r"host", re.I)
56
+
57
+
58
+ def test_any(M):
59
+ assert repr(M.any) == "M.any"
60
+ assert "a" == M.any
61
+ assert {"x": 1, "y": 2} == {"x": 1, "y": M.any}
62
+
63
+
64
+ def test_any_of(M):
65
+ assert repr(M.any_of(3, 4)) == "M.any_of(3, 4)"
66
+
67
+ assert 3 == M.any_of(3, 4)
68
+ assert 'x' != M.any_of(3, 4)
69
+ assert M.any_of(3, 4) in [0, 4]
70
+ assert M.any_of(3, 4) not in [0, 2]
71
+
72
+
73
+ def test_isa(M):
74
+ assert repr(M.isa(str)) == "M.isa(str)"
75
+ assert repr(M.isa(str, bytes)) == "M.isa(str, bytes)"
76
+
77
+ assert "5" == M.isa(str) == "5"
78
+ assert 5 != M.isa(str) != 5
79
+
80
+ assert 5 == M.isa(int, str) == "5"
81
+ assert 5 != M.isa(str, bytes) == b"5"
82
+
83
+
84
+ def test_unordered(M):
85
+ assert repr(M.unordered("foo", "bar")) == "M.unordered('bar', 'foo')"
86
+
87
+ assert ["c", "b", "a"] == M.unordered("a", "b", "c")
88
+ assert ("a", "c", "b") == M.unordered("b", "c", "a")
89
+ assert ["a", "b"] != M.unordered("a", "c")
90
+
91
+
92
+ def test_approx_datetime(M):
93
+ assert repr(M.approx(datetime(2023, 3, 31))) == "approx(2023-03-31 00:00:00 ± 0:00:01)"
94
+
95
+ now = datetime.now()
96
+ delta10s = timedelta(seconds=10)
97
+ assert M.approx(now) == datetime.now()
98
+ assert M.approx(now - delta10s) != now
99
+ assert M.approx(now - delta10s, abs=delta10s) == now
100
+ assert M.approx(now - delta10s, abs=10) == now
101
+
102
+ with pytest.raises(TypeError, match="rel doesn't make sense"):
103
+ M.approx(now, 1)
104
+ with pytest.raises(TypeError, match="rel doesn't make sense"):
105
+ M.approx(now, rel=1)
106
+
107
+
108
+ def test_approx_fallback(M):
109
+ assert M.approx(3.0 + 1e-6) == 3
110
+ assert M.approx([1, 2], 0.01) == [1, 2.01]
@@ -0,0 +1,20 @@
1
+ [tox]
2
+ envlist = py39, py310, py311, py312, py313, py314, pypy39, pypy310, pypy311, lint
3
+
4
+ [testenv]
5
+ deps = -r test_requirements.txt
6
+ commands = pytest -W error {posargs}
7
+
8
+ [flake8]
9
+ max-line-length = 100
10
+ exclude = .tox
11
+
12
+ [testenv:lint]
13
+ basepython = python3.10
14
+ passenv = PYTHONPATH
15
+ deps =
16
+ flake8
17
+ commands =
18
+ python --version
19
+ flake8 assert_matcher
20
+ flake8 tests