assertpy2 2.7.0__tar.gz → 2.8.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.
- {assertpy2-2.7.0 → assertpy2-2.8.1}/PKG-INFO +42 -16
- {assertpy2-2.7.0 → assertpy2-2.8.1}/README.md +41 -15
- assertpy2-2.8.1/assertpy2/_introspection.py +66 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/_mixin_base.py +2 -1
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/assertpy.py +11 -5
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/async_assertions.py +1 -1
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/base.py +58 -46
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/behave_matchers.py +14 -9
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/collection.py +7 -7
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/contains.py +11 -17
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/date.py +2 -2
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/dict.py +8 -10
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/dynamic.py +6 -5
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/errors.py +3 -4
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/exception.py +3 -3
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/extracting.py +4 -6
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/file.py +2 -2
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/helpers.py +12 -12
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/matchers.py +75 -26
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/pytest_plugin.py +17 -11
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/snapshot.py +7 -4
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/string.py +7 -5
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/assertions.md +15 -1
- assertpy2-2.8.1/docs/assets/diff-equal.png +0 -0
- assertpy2-2.8.1/docs/assets/diff-equal.svg +64 -0
- assertpy2-2.8.1/docs/assets/diff-match.svg +51 -0
- assertpy2-2.8.1/docs/assets/diff-sequence.svg +52 -0
- assertpy2-2.8.1/docs/assets/diff-set.svg +48 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/comparison.md +62 -21
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/errors.md +29 -8
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/extending.md +9 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/getting-started.md +14 -1
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/index.md +18 -4
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/matchers.md +28 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/migration.md +1 -1
- assertpy2-2.8.1/docs/type-safety.md +61 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/mkdocs.yml +1 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/pyproject.toml +4 -4
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_datetime.py +16 -8
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_dict_compare.py +10 -5
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_expected_exception.py +4 -2
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_matchers.py +21 -1
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_pytest_plugin.py +19 -1
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_recursive_compare_attrs.py +28 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_recursive_compare_pydantic.py +28 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_rich_diff.py +30 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_structural.py +69 -1
- {assertpy2-2.7.0 → assertpy2-2.8.1}/uv.lock +1 -1
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.codecov.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.gitattributes +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/dependabot.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/.gitignore +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/CONTRIBUTING.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/LICENSE +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/SECURITY.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/__init__.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/_compat.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/_typing.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/numeric.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/py.typed +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/assertpy2/warning.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/data.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/fluent.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/integrations.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/logo-dark.svg +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/logo.svg +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/docs/testing.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_async.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_bool.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_bytes.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_class.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_collection.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_core.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_custom_list.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_description.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_dict.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_dyn.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_equals.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_errors.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_extensions.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_extracting.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_fail.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_file.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_in.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_json.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_list.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_none.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_not.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_numbers.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_overloads.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_phase2.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_pipeline.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_property_based.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_readme.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_same_as.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_snapshots.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_soft.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_string.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_traceback.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_type.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.1}/tests/test_warn.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.1
|
|
4
4
|
Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
|
|
5
5
|
Project-URL: Homepage, https://github.com/Solganis/assertpy2
|
|
6
6
|
Project-URL: Repository, https://github.com/Solganis/assertpy2
|
|
@@ -66,7 +66,7 @@ Description-Content-Type: text/markdown
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
-
## Quick start
|
|
69
|
+
## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
pip install assertpy2 # drop-in replacement for assertpy, just change the import
|
|
@@ -84,27 +84,48 @@ def test_user():
|
|
|
84
84
|
assert_that(user).has_name("Alice")
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
# bare assert - passes, but failure message is useless
|
|
91
|
-
assert user["age"] >= 18
|
|
92
|
-
# AssertionError
|
|
89
|
+
## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
|
|
93
90
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# AssertionError: Expected <16> to be greater than or equal to <18>, but was not.
|
|
91
|
+
A fluent chain reads as one intent and replaces several bare asserts - and your IDE
|
|
92
|
+
offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
|
|
97
93
|
|
|
98
|
-
|
|
94
|
+
```py
|
|
95
|
+
# bare - three statements, no autocomplete help
|
|
99
96
|
assert isinstance(items, list)
|
|
100
97
|
assert len(items) == 3
|
|
101
98
|
assert "admin" in items
|
|
102
99
|
|
|
103
|
-
# assertpy2 - one
|
|
100
|
+
# assertpy2 - one chain, type-aware autocomplete
|
|
104
101
|
assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
105
102
|
```
|
|
106
103
|
|
|
107
|
-
|
|
104
|
+
The real difference shows up when a test fails. Here a nested response has two wrong
|
|
105
|
+
fields. Plain `assert` dumps both structures and leaves you to find them:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
assert response == expected
|
|
109
|
+
E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
|
|
110
|
+
E Omitting 1 identical items, use -vv to show
|
|
111
|
+
E Differing items:
|
|
112
|
+
E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
|
|
113
|
+
E {'status': 'active'} != {'status': 'disabled'}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs), in color:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
assert_that(response).is_equal_to(expected)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
<img src="docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
|
|
123
|
+
|
|
124
|
+
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
125
|
+
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
126
|
+
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
127
|
+
|
|
128
|
+
## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
|
|
108
129
|
|
|
109
130
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
110
131
|
Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
@@ -117,6 +138,8 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
117
138
|
|
|
118
139
|
9 type-specific Protocols instead of one `Any`. Works in PyCharm, VS Code, and any LSP-compatible editor.
|
|
119
140
|
|
|
141
|
+
See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
|
|
142
|
+
|
|
120
143
|
---
|
|
121
144
|
|
|
122
145
|
## Features
|
|
@@ -124,7 +147,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
124
147
|
**Fluent API**
|
|
125
148
|
|
|
126
149
|
- [**Composable matchers**](https://solganis.github.io/assertpy2/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
|
|
127
|
-
- [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation.
|
|
150
|
+
- [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation, reporting the exact path to each mismatch on failure.
|
|
128
151
|
- [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
129
152
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
130
153
|
- [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
|
|
@@ -143,8 +166,9 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
143
166
|
- [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
|
|
144
167
|
- [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
|
|
145
168
|
- [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
|
|
146
|
-
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models. Circular reference protection.
|
|
169
|
+
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
|
|
147
170
|
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
171
|
+
- **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
|
|
148
172
|
|
|
149
173
|
**Type safety**
|
|
150
174
|
|
|
@@ -166,7 +190,7 @@ See the [full documentation](https://solganis.github.io/assertpy2/) for all asse
|
|
|
166
190
|
|
|
167
191
|
---
|
|
168
192
|
|
|
169
|
-
## Integrations
|
|
193
|
+
## [Integrations](https://solganis.github.io/assertpy2/integrations/)
|
|
170
194
|
|
|
171
195
|
### Allure
|
|
172
196
|
|
|
@@ -214,6 +238,8 @@ def step_impl(context, age):
|
|
|
214
238
|
|
|
215
239
|
Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
|
|
216
240
|
|
|
241
|
+
See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
|
|
242
|
+
|
|
217
243
|
---
|
|
218
244
|
|
|
219
245
|
<p align="center">
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
-
## Quick start
|
|
31
|
+
## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
pip install assertpy2 # drop-in replacement for assertpy, just change the import
|
|
@@ -46,27 +46,48 @@ def test_user():
|
|
|
46
46
|
assert_that(user).has_name("Alice")
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
# bare assert - passes, but failure message is useless
|
|
53
|
-
assert user["age"] >= 18
|
|
54
|
-
# AssertionError
|
|
51
|
+
## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# AssertionError: Expected <16> to be greater than or equal to <18>, but was not.
|
|
53
|
+
A fluent chain reads as one intent and replaces several bare asserts - and your IDE
|
|
54
|
+
offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
|
|
59
55
|
|
|
60
|
-
|
|
56
|
+
```py
|
|
57
|
+
# bare - three statements, no autocomplete help
|
|
61
58
|
assert isinstance(items, list)
|
|
62
59
|
assert len(items) == 3
|
|
63
60
|
assert "admin" in items
|
|
64
61
|
|
|
65
|
-
# assertpy2 - one
|
|
62
|
+
# assertpy2 - one chain, type-aware autocomplete
|
|
66
63
|
assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
67
64
|
```
|
|
68
65
|
|
|
69
|
-
|
|
66
|
+
The real difference shows up when a test fails. Here a nested response has two wrong
|
|
67
|
+
fields. Plain `assert` dumps both structures and leaves you to find them:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
assert response == expected
|
|
71
|
+
E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
|
|
72
|
+
E Omitting 1 identical items, use -vv to show
|
|
73
|
+
E Differing items:
|
|
74
|
+
E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
|
|
75
|
+
E {'status': 'active'} != {'status': 'disabled'}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs), in color:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
assert_that(response).is_equal_to(expected)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
<img src="docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
|
|
85
|
+
|
|
86
|
+
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
87
|
+
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
88
|
+
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
89
|
+
|
|
90
|
+
## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
|
|
70
91
|
|
|
71
92
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
72
93
|
Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
@@ -79,6 +100,8 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
79
100
|
|
|
80
101
|
9 type-specific Protocols instead of one `Any`. Works in PyCharm, VS Code, and any LSP-compatible editor.
|
|
81
102
|
|
|
103
|
+
See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) guide for the full walkthrough.
|
|
104
|
+
|
|
82
105
|
---
|
|
83
106
|
|
|
84
107
|
## Features
|
|
@@ -86,7 +109,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
86
109
|
**Fluent API**
|
|
87
110
|
|
|
88
111
|
- [**Composable matchers**](https://solganis.github.io/assertpy2/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`. Also work with plain `assert ==`.
|
|
89
|
-
- [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation.
|
|
112
|
+
- [**Structural matching**](https://solganis.github.io/assertpy2/matchers/#structural-matching): `matches_structure()` for declarative dict/API response validation, reporting the exact path to each mismatch on failure.
|
|
90
113
|
- [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
91
114
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
92
115
|
- [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
|
|
@@ -105,8 +128,9 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
105
128
|
- [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
|
|
106
129
|
- [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
|
|
107
130
|
- [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
|
|
108
|
-
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models. Circular reference protection.
|
|
131
|
+
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
|
|
109
132
|
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
133
|
+
- **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
|
|
110
134
|
|
|
111
135
|
**Type safety**
|
|
112
136
|
|
|
@@ -128,7 +152,7 @@ See the [full documentation](https://solganis.github.io/assertpy2/) for all asse
|
|
|
128
152
|
|
|
129
153
|
---
|
|
130
154
|
|
|
131
|
-
## Integrations
|
|
155
|
+
## [Integrations](https://solganis.github.io/assertpy2/integrations/)
|
|
132
156
|
|
|
133
157
|
### Allure
|
|
134
158
|
|
|
@@ -176,6 +200,8 @@ def step_impl(context, age):
|
|
|
176
200
|
|
|
177
201
|
Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
|
|
178
202
|
|
|
203
|
+
See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
|
|
204
|
+
|
|
179
205
|
---
|
|
180
206
|
|
|
181
207
|
<p align="center">
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Runtime-checkable protocols for duck-typed object introspection.
|
|
2
|
+
|
|
3
|
+
The comparison and diff code accepts arbitrary user objects, so it inspects them structurally: pydantic
|
|
4
|
+
models (``model_dump``), ``attrs`` classes (``__attrs_attrs__``) and namedtuples (``_fields`` /
|
|
5
|
+
``_asdict``). Expressing those shapes as ``@runtime_checkable`` protocols lets the diff code use
|
|
6
|
+
``isinstance`` checks the type checker can follow - narrowing the value instead of probing it with
|
|
7
|
+
``hasattr`` and then suppressing the attribute access.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Protocol, TypeGuard, runtime_checkable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class SupportsModelDump(Protocol):
|
|
17
|
+
"""A pydantic-style model exposing ``model_dump()``."""
|
|
18
|
+
|
|
19
|
+
def model_dump(self) -> dict[str, Any]: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class NamedTupleLike(Protocol):
|
|
24
|
+
"""A ``collections.namedtuple`` / ``typing.NamedTuple`` instance."""
|
|
25
|
+
|
|
26
|
+
_fields: tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
def _asdict(self) -> dict[str, Any]: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class AttrsInstance(Protocol):
|
|
33
|
+
"""An ``attrs``-decorated instance exposing ``__attrs_attrs__``."""
|
|
34
|
+
|
|
35
|
+
__attrs_attrs__: tuple[Any, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class MappingLike(Protocol):
|
|
40
|
+
"""A dict-like object that can be iterated over and subscripted by key."""
|
|
41
|
+
|
|
42
|
+
def keys(self) -> Any: ...
|
|
43
|
+
|
|
44
|
+
def __iter__(self) -> Any: ...
|
|
45
|
+
|
|
46
|
+
def __getitem__(self, key: Any) -> Any: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_model_dump_object(obj: object) -> TypeGuard[SupportsModelDump]:
|
|
50
|
+
"""Return whether ``obj`` exposes a callable ``model_dump()`` (e.g. a pydantic model)."""
|
|
51
|
+
return isinstance(obj, SupportsModelDump) and callable(obj.model_dump)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_namedtuple(obj: object) -> TypeGuard[NamedTupleLike]:
|
|
55
|
+
"""Return whether ``obj`` is a namedtuple instance (a ``tuple`` carrying ``_fields``/``_asdict``)."""
|
|
56
|
+
return isinstance(obj, tuple) and isinstance(obj, NamedTupleLike)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_attrs_instance(obj: object) -> TypeGuard[AttrsInstance]:
|
|
60
|
+
"""Return whether ``obj`` is an ``attrs``-decorated instance."""
|
|
61
|
+
return isinstance(obj, AttrsInstance)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_mapping_like(obj: object) -> TypeGuard[MappingLike]:
|
|
65
|
+
"""Return whether ``obj`` is dict-like: iterable with ``keys()`` and ``[]`` access."""
|
|
66
|
+
return isinstance(obj, MappingLike) and callable(obj.keys)
|
|
@@ -9,17 +9,18 @@ import logging
|
|
|
9
9
|
import os
|
|
10
10
|
import sys
|
|
11
11
|
import types
|
|
12
|
-
from
|
|
13
|
-
from typing import TYPE_CHECKING, overload
|
|
12
|
+
from typing import TYPE_CHECKING, Final, overload
|
|
14
13
|
|
|
15
14
|
if TYPE_CHECKING:
|
|
16
15
|
import datetime
|
|
17
16
|
import pathlib
|
|
17
|
+
from collections.abc import Callable, Iterator
|
|
18
18
|
|
|
19
19
|
from ._compat import Self
|
|
20
20
|
from ._typing import (
|
|
21
21
|
_BytesAssertion,
|
|
22
22
|
_CallableAssertion,
|
|
23
|
+
_CoreAssertion,
|
|
23
24
|
_DateAssertion,
|
|
24
25
|
_DictAssertion,
|
|
25
26
|
_IterableAssertion,
|
|
@@ -47,13 +48,13 @@ from .snapshot import SnapshotMixin
|
|
|
47
48
|
from .string import StringMixin
|
|
48
49
|
from .warning import WarningMixin
|
|
49
50
|
|
|
50
|
-
__version__ = "2.
|
|
51
|
+
__version__ = "2.8.1"
|
|
51
52
|
|
|
52
53
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
53
54
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
54
55
|
|
|
55
56
|
# assertpy files
|
|
56
|
-
ASSERTPY_FILES = [
|
|
57
|
+
ASSERTPY_FILES: Final = [
|
|
57
58
|
os.path.join("assertpy2", file)
|
|
58
59
|
for file in [
|
|
59
60
|
"assertpy.py",
|
|
@@ -253,11 +254,16 @@ def assert_that(val: bytes | bytearray, description: str = "") -> _BytesAssertio
|
|
|
253
254
|
def assert_that(val: Callable[..., object], description: str = "") -> _CallableAssertion: ...
|
|
254
255
|
|
|
255
256
|
|
|
257
|
+
# Fallback returns the concrete AssertionBuilder so object- and union-typed values keep the full API.
|
|
258
|
+
# The specific protocols are not assignable to AssertionBuilder, so mypy --strict reports overload-overlap
|
|
259
|
+
# for each specific overload and pyright one reportOverlappingOverload; ty (the gate) does not flag it. Kept
|
|
260
|
+
# intentionally - returning _CoreAssertion here would strip type-specific assertions from object/union values.
|
|
256
261
|
@overload
|
|
257
262
|
def assert_that(val: object, description: str = "") -> AssertionBuilder: ...
|
|
258
263
|
|
|
259
264
|
|
|
260
|
-
|
|
265
|
+
# Return the common base protocol so each overload stays consistent with the impl (no reportInconsistentOverload).
|
|
266
|
+
def assert_that(val, description="") -> _CoreAssertion:
|
|
261
267
|
"""Set the value to be tested, plus an optional description, and allow assertions to be called.
|
|
262
268
|
|
|
263
269
|
This is a factory method for the :class:`AssertionBuilder`, and the single most important
|
|
@@ -69,7 +69,7 @@ class AsyncAssertionBuilder:
|
|
|
69
69
|
method = getattr(builder, name)
|
|
70
70
|
method(*args, **kwargs)
|
|
71
71
|
return builder
|
|
72
|
-
except AssertionError as exc:
|
|
72
|
+
except AssertionError as exc: # noqa: PERF203 # retry-on-failure needs the try/except per poll iteration
|
|
73
73
|
last_error = exc
|
|
74
74
|
if loop.time() >= deadline:
|
|
75
75
|
raise AssertionError(
|
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import collections.abc
|
|
4
4
|
import dataclasses
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
6
6
|
|
|
7
|
+
from ._introspection import is_model_dump_object, is_namedtuple
|
|
7
8
|
from ._mixin_base import _MixinBase
|
|
8
9
|
from .errors import DiffEntry, DiffResult
|
|
9
10
|
from .matchers import Matcher, StructureMatcher
|
|
@@ -13,11 +14,7 @@ if TYPE_CHECKING:
|
|
|
13
14
|
|
|
14
15
|
__tracebackhide__ = True
|
|
15
16
|
|
|
16
|
-
_SENTINEL = object()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _is_model_dump_object(obj: object) -> bool:
|
|
20
|
-
return hasattr(obj, "model_dump") and callable(obj.model_dump)
|
|
17
|
+
_SENTINEL: Final = object()
|
|
21
18
|
|
|
22
19
|
|
|
23
20
|
class BaseMixin(_MixinBase):
|
|
@@ -80,7 +77,9 @@ class BaseMixin(_MixinBase):
|
|
|
80
77
|
assert_that({'a': 1, 'b': 2, 'c': 3}).is_equal_to({'a': 1}, ignore=['b', 'c'])
|
|
81
78
|
|
|
82
79
|
# ignore nested keys
|
|
83
|
-
assert_that({'a': {'b': 2, 'c': 3, 'd': 4}}).is_equal_to(
|
|
80
|
+
assert_that({'a': {'b': 2, 'c': 3, 'd': 4}}).is_equal_to(
|
|
81
|
+
{'a': {'d': 4}}, ignore=[('a', 'b'), ('a', 'c')]
|
|
82
|
+
)
|
|
84
83
|
|
|
85
84
|
When the val is *dict-like*, only certain keys can be *included* when checking equality::
|
|
86
85
|
|
|
@@ -133,8 +132,8 @@ class BaseMixin(_MixinBase):
|
|
|
133
132
|
if self._dict_not_equal(self.val, other, ignore=ignore, include=include):
|
|
134
133
|
self._dict_err(self.val, other, ignore=ignore, include=include)
|
|
135
134
|
elif ignore or include:
|
|
136
|
-
val_is_namedtuple =
|
|
137
|
-
other_is_namedtuple =
|
|
135
|
+
val_is_namedtuple = is_namedtuple(self.val)
|
|
136
|
+
other_is_namedtuple = is_namedtuple(other)
|
|
138
137
|
if (
|
|
139
138
|
isinstance(self.val, (list, tuple))
|
|
140
139
|
and isinstance(other, (list, tuple))
|
|
@@ -208,14 +207,9 @@ class BaseMixin(_MixinBase):
|
|
|
208
207
|
return sub_entries
|
|
209
208
|
return [DiffEntry(path=path, actual=actual_value, expected=expected_value)]
|
|
210
209
|
|
|
211
|
-
if (
|
|
212
|
-
hasattr(actual, "_fields")
|
|
213
|
-
and isinstance(actual, tuple)
|
|
214
|
-
and hasattr(expected, "_fields")
|
|
215
|
-
and isinstance(expected, tuple)
|
|
216
|
-
):
|
|
210
|
+
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
217
211
|
entries: list[DiffEntry] = []
|
|
218
|
-
for field in actual._fields:
|
|
212
|
+
for field in actual._fields:
|
|
219
213
|
actual_value = getattr(actual, field)
|
|
220
214
|
expected_value = getattr(expected, field, _SENTINEL)
|
|
221
215
|
path = f"{_prefix}.{field}"
|
|
@@ -223,9 +217,11 @@ class BaseMixin(_MixinBase):
|
|
|
223
217
|
entries.append(DiffEntry(path=path, actual=actual_value, expected=None))
|
|
224
218
|
elif actual_value != expected_value:
|
|
225
219
|
entries.extend(_field_entries(actual_value, expected_value, path))
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
220
|
+
entries.extend(
|
|
221
|
+
DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field))
|
|
222
|
+
for field in expected._fields
|
|
223
|
+
if not hasattr(actual, field)
|
|
224
|
+
)
|
|
229
225
|
return DiffResult(kind="namedtuple", entries=entries)
|
|
230
226
|
if (
|
|
231
227
|
dataclasses.is_dataclass(actual)
|
|
@@ -248,9 +244,9 @@ class BaseMixin(_MixinBase):
|
|
|
248
244
|
if actual_value != expected_value:
|
|
249
245
|
entries.extend(_field_entries(actual_value, expected_value, path))
|
|
250
246
|
return DiffResult(kind="dataclass", entries=entries)
|
|
251
|
-
if
|
|
252
|
-
actual_dict = actual.model_dump()
|
|
253
|
-
expected_dict = expected.model_dump()
|
|
247
|
+
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
248
|
+
actual_dict = actual.model_dump()
|
|
249
|
+
expected_dict = expected.model_dump()
|
|
254
250
|
entries = []
|
|
255
251
|
for key in sorted(set(actual_dict) | set(expected_dict)):
|
|
256
252
|
path = f"{_prefix}.{key}" if _prefix else f".{key}"
|
|
@@ -354,37 +350,34 @@ class BaseMixin(_MixinBase):
|
|
|
354
350
|
DiffEntry(path=f"{prefix}.{field.name}", actual=actual_value, expected=expected_value)
|
|
355
351
|
)
|
|
356
352
|
return entries or None
|
|
357
|
-
if (
|
|
358
|
-
hasattr(actual, "_fields")
|
|
359
|
-
and isinstance(actual, tuple)
|
|
360
|
-
and hasattr(expected, "_fields")
|
|
361
|
-
and isinstance(expected, tuple)
|
|
362
|
-
):
|
|
353
|
+
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
363
354
|
child_seen = _seen | {id(actual), id(expected)}
|
|
364
355
|
entries = []
|
|
365
|
-
for
|
|
366
|
-
actual_value = getattr(actual,
|
|
367
|
-
expected_value = getattr(expected,
|
|
356
|
+
for field_name in actual._fields:
|
|
357
|
+
actual_value = getattr(actual, field_name)
|
|
358
|
+
expected_value = getattr(expected, field_name, _SENTINEL)
|
|
368
359
|
if expected_value is _SENTINEL:
|
|
369
|
-
entries.append(DiffEntry(path=f"{prefix}.{
|
|
360
|
+
entries.append(DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=None))
|
|
370
361
|
elif actual_value != expected_value:
|
|
371
362
|
sub_entries = BaseMixin._sub_diff_entries(
|
|
372
|
-
actual_value, expected_value, f"{prefix}.{
|
|
363
|
+
actual_value, expected_value, f"{prefix}.{field_name}", _seen=child_seen
|
|
373
364
|
)
|
|
374
365
|
if sub_entries is not None:
|
|
375
366
|
entries.extend(sub_entries)
|
|
376
367
|
else:
|
|
377
368
|
entries.append(
|
|
378
|
-
DiffEntry(path=f"{prefix}.{
|
|
369
|
+
DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=expected_value)
|
|
379
370
|
)
|
|
380
|
-
for
|
|
381
|
-
if not hasattr(actual,
|
|
382
|
-
entries.append(
|
|
371
|
+
for field_name in expected._fields:
|
|
372
|
+
if not hasattr(actual, field_name):
|
|
373
|
+
entries.append(
|
|
374
|
+
DiffEntry(path=f"{prefix}.{field_name}", actual=None, expected=getattr(expected, field_name))
|
|
375
|
+
)
|
|
383
376
|
return entries or None
|
|
384
|
-
if
|
|
377
|
+
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
385
378
|
child_seen = _seen | {id(actual), id(expected)}
|
|
386
|
-
actual_dict = actual.model_dump()
|
|
387
|
-
expected_dict = expected.model_dump()
|
|
379
|
+
actual_dict = actual.model_dump()
|
|
380
|
+
expected_dict = expected.model_dump()
|
|
388
381
|
entries = []
|
|
389
382
|
for key in sorted(set(actual_dict) | set(expected_dict)):
|
|
390
383
|
path = f"{prefix}.{key}"
|
|
@@ -430,7 +423,13 @@ class BaseMixin(_MixinBase):
|
|
|
430
423
|
"""
|
|
431
424
|
if isinstance(matcher, Matcher):
|
|
432
425
|
if not matcher.matches(self.val):
|
|
433
|
-
|
|
426
|
+
description = matcher.describe()
|
|
427
|
+
return self.error(
|
|
428
|
+
f"Expected {description}, but {matcher.describe_mismatch(self.val)}.",
|
|
429
|
+
actual=self.val,
|
|
430
|
+
expected=description,
|
|
431
|
+
diff=DiffResult(kind="match", entries=[DiffEntry(path=".", actual=self.val, expected=description)]),
|
|
432
|
+
)
|
|
434
433
|
elif callable(matcher):
|
|
435
434
|
if not matcher(self.val):
|
|
436
435
|
return self.error(f"Expected <{self.val}> to satisfy <{matcher}>, but did not.")
|
|
@@ -466,11 +465,17 @@ class BaseMixin(_MixinBase):
|
|
|
466
465
|
if not isinstance(self.val, collections.abc.Iterable):
|
|
467
466
|
raise TypeError("val is not iterable")
|
|
468
467
|
if isinstance(matcher, Matcher):
|
|
468
|
+
description = matcher.describe()
|
|
469
469
|
for i, item in enumerate(self.val):
|
|
470
470
|
if not matcher.matches(item):
|
|
471
471
|
return self.error(
|
|
472
|
-
f"Expected all items to satisfy {
|
|
473
|
-
f" {matcher.describe_mismatch(item)}."
|
|
472
|
+
f"Expected all items to satisfy {description}, but item at index {i} <{item}> did not:"
|
|
473
|
+
f" {matcher.describe_mismatch(item)}.",
|
|
474
|
+
actual=item,
|
|
475
|
+
expected=description,
|
|
476
|
+
diff=DiffResult(
|
|
477
|
+
kind="match", entries=[DiffEntry(path=f"[{i}]", actual=item, expected=description)]
|
|
478
|
+
),
|
|
474
479
|
)
|
|
475
480
|
elif callable(matcher):
|
|
476
481
|
for i, item in enumerate(self.val):
|
|
@@ -482,7 +487,7 @@ class BaseMixin(_MixinBase):
|
|
|
482
487
|
raise TypeError("given arg must be a Matcher or callable")
|
|
483
488
|
return self
|
|
484
489
|
|
|
485
|
-
def matches_structure(self, spec: dict) -> Self:
|
|
490
|
+
def matches_structure(self, spec: dict[Any, Any]) -> Self:
|
|
486
491
|
"""Asserts that val is a dict matching the given structure specification.
|
|
487
492
|
|
|
488
493
|
Each key in ``spec`` maps to either a :class:`~assertpy2.matchers.Matcher`, a raw value
|
|
@@ -515,10 +520,17 @@ class BaseMixin(_MixinBase):
|
|
|
515
520
|
if not isinstance(spec, dict):
|
|
516
521
|
raise TypeError("given arg must be a dict")
|
|
517
522
|
matcher = StructureMatcher(spec)
|
|
518
|
-
|
|
523
|
+
mismatches = matcher.collect_mismatches(self.val)
|
|
524
|
+
if mismatches:
|
|
525
|
+
entries = [
|
|
526
|
+
DiffEntry(path=path, actual=actual, expected=description) for path, actual, description in mismatches
|
|
527
|
+
]
|
|
519
528
|
return self.error(
|
|
520
529
|
f"Expected <{self.val}> to match structure {matcher.describe()}, but"
|
|
521
|
-
f" {matcher.describe_mismatch(self.val)}."
|
|
530
|
+
f" {matcher.describe_mismatch(self.val)}.",
|
|
531
|
+
actual=self.val,
|
|
532
|
+
expected=spec,
|
|
533
|
+
diff=DiffResult(kind="match", entries=entries),
|
|
522
534
|
)
|
|
523
535
|
return self
|
|
524
536
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
from types import MappingProxyType
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
_BOOL_TRUE: Final = frozenset({"true", "yes", "1", "on"})
|
|
7
|
+
_BOOL_FALSE: Final = frozenset({"false", "no", "0", "off"})
|
|
5
8
|
|
|
6
9
|
|
|
7
10
|
def _with_pattern(pattern):
|
|
@@ -51,13 +54,15 @@ def _bool_like(text):
|
|
|
51
54
|
raise ValueError(f"expected boolean-like value, got {text!r}")
|
|
52
55
|
|
|
53
56
|
|
|
54
|
-
ASSERTPY_TYPES =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
ASSERTPY_TYPES: Final = MappingProxyType(
|
|
58
|
+
{
|
|
59
|
+
"PositiveInt": _positive_int,
|
|
60
|
+
"NonNegativeInt": _non_negative_int,
|
|
61
|
+
"PositiveFloat": _positive_float,
|
|
62
|
+
"NonEmptyString": _non_empty_string,
|
|
63
|
+
"BoolLike": _bool_like,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
61
66
|
|
|
62
67
|
|
|
63
68
|
def register_assertpy_types() -> None:
|