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.
- assert_matcher-0.9.1/.github/workflows/ci.yml +43 -0
- assert_matcher-0.9.1/.gitignore +10 -0
- assert_matcher-0.9.1/CHANGELOG +2 -0
- assert_matcher-0.9.1/GEMINI.md +42 -0
- assert_matcher-0.9.1/LICENSE +23 -0
- assert_matcher-0.9.1/MANIFEST.in +3 -0
- assert_matcher-0.9.1/PKG-INFO +31 -0
- assert_matcher-0.9.1/README.md +196 -0
- assert_matcher-0.9.1/assert_matcher/__init__.py +2 -0
- assert_matcher-0.9.1/assert_matcher/approx.py +35 -0
- assert_matcher-0.9.1/assert_matcher/compare.py +244 -0
- assert_matcher-0.9.1/assert_matcher/matchers.py +150 -0
- assert_matcher-0.9.1/assert_matcher/plugin.py +9 -0
- assert_matcher-0.9.1/assert_matcher.egg-info/PKG-INFO +31 -0
- assert_matcher-0.9.1/assert_matcher.egg-info/SOURCES.txt +24 -0
- assert_matcher-0.9.1/assert_matcher.egg-info/dependency_links.txt +1 -0
- assert_matcher-0.9.1/assert_matcher.egg-info/entry_points.txt +2 -0
- assert_matcher-0.9.1/assert_matcher.egg-info/top_level.txt +1 -0
- assert_matcher-0.9.1/publish.sh +11 -0
- assert_matcher-0.9.1/setup.cfg +50 -0
- assert_matcher-0.9.1/setup.py +4 -0
- assert_matcher-0.9.1/test_requirements.txt +1 -0
- assert_matcher-0.9.1/tests/test_compare.py +99 -0
- assert_matcher-0.9.1/tests/test_matchers.py +110 -0
- assert_matcher-0.9.1/tox.ini +20 -0
|
@@ -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,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,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 [](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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
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
|