assertpy2 2.9.0__tar.gz → 2.10.0__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.9.0 → assertpy2-2.10.0}/PKG-INFO +8 -2
- {assertpy2-2.9.0 → assertpy2-2.10.0}/README.md +7 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/base.py +70 -52
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/extracting.py +2 -2
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/matchers.py +55 -53
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/assertions.md +1 -1
- assertpy2-2.10.0/docs/assets/diff-equal.png +0 -0
- assertpy2-2.10.0/docs/assets/diff-equal.svg +51 -0
- assertpy2-2.10.0/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/assets/diff-match.svg +1 -2
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/assets/diff-sequence.svg +1 -2
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/assets/diff-set.svg +1 -2
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/comparison.md +14 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/errors.md +1 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/getting-started.md +1 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/index.md +1 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/matchers.md +25 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/type-safety.md +8 -3
- {assertpy2-2.9.0 → assertpy2-2.10.0}/pyproject.toml +2 -2
- assertpy2-2.10.0/tests/test_callable.py +48 -0
- assertpy2-2.10.0/tests/test_chaining.py +22 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_datetime.py +71 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_expected_exception.py +50 -1
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_extracting.py +49 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_file.py +9 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_list.py +70 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_matchers.py +268 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_rich_diff.py +172 -0
- assertpy2-2.10.0/tests/test_satisfy.py +93 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_structural.py +91 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/uv.lock +23 -23
- assertpy2-2.9.0/docs/assets/diff-equal.png +0 -0
- assertpy2-2.9.0/docs/assets/diff-equal.svg +0 -64
- assertpy2-2.9.0/tests/test_matchers_phase3.py +0 -269
- assertpy2-2.9.0/tests/test_phase2.py +0 -349
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.codecov.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.gitattributes +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/.gitignore +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/LICENSE +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/SECURITY.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_compat.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_introspection.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_typing.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/collection.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/contains.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/date.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/errors.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/file.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/helpers.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/string.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/data.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/extending.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/fluent.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/integrations.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/logo.svg +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/migration.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/testing.md +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/mkdocs.yml +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_async.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_bool.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_class.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_collection.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_core.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_description.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_dict.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_dict_compare.py +0 -0
- /assertpy2-2.9.0/tests/test_dyn.py → /assertpy2-2.10.0/tests/test_dynamic.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_equals.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_errors.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_fail.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_in.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_json.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_mutation_hardening.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_none.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_not.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_property_based.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_readme.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_snapshots.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_soft.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_string.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_type.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_typing.py +0 -0
- {assertpy2-2.9.0 → assertpy2-2.10.0}/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.10.0
|
|
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
|
|
@@ -62,6 +62,8 @@ Description-Content-Type: text/markdown
|
|
|
62
62
|
<a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
|
|
63
63
|
<a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/assertpy2/badge" alt="OpenSSF Scorecard"></a>
|
|
64
64
|
<a href="https://www.bestpractices.dev/projects/12990"><img src="https://www.bestpractices.dev/projects/12990/badge" alt="OpenSSF Best Practices"></a>
|
|
65
|
+
<br>
|
|
66
|
+
<a href="https://solganis.github.io/assertpy2/type-safety/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="public overloads type-checked by ty, mypy --strict, and pyright with zero suppressions"></a>
|
|
65
67
|
</p>
|
|
66
68
|
|
|
67
69
|
---
|
|
@@ -119,12 +121,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
|
|
|
119
121
|
assert_that(response).is_equal_to(expected)
|
|
120
122
|
```
|
|
121
123
|
|
|
122
|
-
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal:
|
|
124
|
+
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: user.role shown with its path, removal in red and addition in green">
|
|
123
125
|
|
|
124
126
|
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
125
127
|
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
126
128
|
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
127
129
|
|
|
130
|
+
The same path-level treatment for dicts, lists, sets, and matcher predicates:
|
|
131
|
+
|
|
132
|
+
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
|
|
133
|
+
|
|
128
134
|
## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
|
|
129
135
|
|
|
130
136
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
<a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
|
|
25
25
|
<a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/assertpy2/badge" alt="OpenSSF Scorecard"></a>
|
|
26
26
|
<a href="https://www.bestpractices.dev/projects/12990"><img src="https://www.bestpractices.dev/projects/12990/badge" alt="OpenSSF Best Practices"></a>
|
|
27
|
+
<br>
|
|
28
|
+
<a href="https://solganis.github.io/assertpy2/type-safety/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="public overloads type-checked by ty, mypy --strict, and pyright with zero suppressions"></a>
|
|
27
29
|
</p>
|
|
28
30
|
|
|
29
31
|
---
|
|
@@ -81,12 +83,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
|
|
|
81
83
|
assert_that(response).is_equal_to(expected)
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal:
|
|
86
|
+
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: user.role shown with its path, removal in red and addition in green">
|
|
85
87
|
|
|
86
88
|
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
87
89
|
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
88
90
|
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
89
91
|
|
|
92
|
+
The same path-level treatment for dicts, lists, sets, and matcher predicates:
|
|
93
|
+
|
|
94
|
+
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
|
|
95
|
+
|
|
90
96
|
## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
|
|
91
97
|
|
|
92
98
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
@@ -48,7 +48,7 @@ from .snapshot import SnapshotMixin
|
|
|
48
48
|
from .string import StringMixin
|
|
49
49
|
from .warning import WarningMixin
|
|
50
50
|
|
|
51
|
-
__version__ = "2.
|
|
51
|
+
__version__ = "2.10.0"
|
|
52
52
|
|
|
53
53
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
54
54
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -187,6 +187,58 @@ class BaseMixin(_MixinBase):
|
|
|
187
187
|
expected=expected_item,
|
|
188
188
|
)
|
|
189
189
|
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _sequence_diff_entries(actual, expected, prefix, seen) -> list[DiffEntry]:
|
|
192
|
+
"""Diff two sequences element-by-element, recursing into nested containers.
|
|
193
|
+
|
|
194
|
+
``seen`` must already include the ids of ``actual``/``expected`` so a self-referential element
|
|
195
|
+
is caught. Shared by the top-level (:meth:`_build_equality_diff`) and nested
|
|
196
|
+
(:meth:`_sub_diff_entries`) paths so both decompose sequences identically.
|
|
197
|
+
"""
|
|
198
|
+
entries: list[DiffEntry] = []
|
|
199
|
+
max_len = max(len(actual), len(expected))
|
|
200
|
+
for i in range(max_len):
|
|
201
|
+
path = f"{prefix}[{i}]" if prefix else f"[{i}]"
|
|
202
|
+
if i >= len(actual):
|
|
203
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected[i]))
|
|
204
|
+
elif i >= len(expected):
|
|
205
|
+
entries.append(DiffEntry(path=path, actual=actual[i], expected=None))
|
|
206
|
+
elif actual[i] != expected[i]:
|
|
207
|
+
sub_entries = BaseMixin._sub_diff_entries(actual[i], expected[i], path, _seen=seen)
|
|
208
|
+
if sub_entries:
|
|
209
|
+
entries.extend(sub_entries)
|
|
210
|
+
else:
|
|
211
|
+
entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
|
|
212
|
+
return entries
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _dataclass_diff_entries(actual, expected, prefix, seen) -> list[DiffEntry]:
|
|
216
|
+
"""Diff two dataclasses over the sorted union of field names, both directions, recursing.
|
|
217
|
+
|
|
218
|
+
Reports fields present on only one side, and recurses into nested containers. ``seen`` must
|
|
219
|
+
already include the ids of ``actual``/``expected``. Shared by the top-level and nested paths
|
|
220
|
+
so both report dataclass fields identically.
|
|
221
|
+
"""
|
|
222
|
+
entries: list[DiffEntry] = []
|
|
223
|
+
actual_names = {field.name for field in dataclasses.fields(actual)}
|
|
224
|
+
expected_names = {field.name for field in dataclasses.fields(expected)}
|
|
225
|
+
for field in sorted(actual_names | expected_names):
|
|
226
|
+
path = f"{prefix}.{field}"
|
|
227
|
+
if field not in expected_names:
|
|
228
|
+
entries.append(DiffEntry(path=path, actual=getattr(actual, field), expected=None))
|
|
229
|
+
elif field not in actual_names:
|
|
230
|
+
entries.append(DiffEntry(path=path, actual=None, expected=getattr(expected, field)))
|
|
231
|
+
else:
|
|
232
|
+
actual_value = getattr(actual, field)
|
|
233
|
+
expected_value = getattr(expected, field)
|
|
234
|
+
if actual_value != expected_value:
|
|
235
|
+
sub_entries = BaseMixin._sub_diff_entries(actual_value, expected_value, path, _seen=seen)
|
|
236
|
+
if sub_entries is not None:
|
|
237
|
+
entries.extend(sub_entries)
|
|
238
|
+
else:
|
|
239
|
+
entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
|
|
240
|
+
return entries
|
|
241
|
+
|
|
190
242
|
@staticmethod
|
|
191
243
|
def _build_equality_diff(
|
|
192
244
|
actual: object, expected: object, *, _prefix: str = "", _seen: set[int] | None = None
|
|
@@ -229,21 +281,10 @@ class BaseMixin(_MixinBase):
|
|
|
229
281
|
and dataclasses.is_dataclass(expected)
|
|
230
282
|
and not isinstance(expected, type)
|
|
231
283
|
):
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
path = f"{_prefix}.{field}"
|
|
237
|
-
if field not in expected_names:
|
|
238
|
-
entries.append(DiffEntry(path=path, actual=getattr(actual, field), expected=None))
|
|
239
|
-
elif field not in actual_names:
|
|
240
|
-
entries.append(DiffEntry(path=path, actual=None, expected=getattr(expected, field)))
|
|
241
|
-
else:
|
|
242
|
-
actual_value = getattr(actual, field)
|
|
243
|
-
expected_value = getattr(expected, field)
|
|
244
|
-
if actual_value != expected_value:
|
|
245
|
-
entries.extend(_field_entries(actual_value, expected_value, path))
|
|
246
|
-
return DiffResult(kind="dataclass", entries=entries)
|
|
284
|
+
return DiffResult(
|
|
285
|
+
kind="dataclass",
|
|
286
|
+
entries=BaseMixin._dataclass_diff_entries(actual, expected, _prefix, _seen),
|
|
287
|
+
)
|
|
247
288
|
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
248
289
|
actual_dict = actual.model_dump()
|
|
249
290
|
expected_dict = expected.model_dump()
|
|
@@ -262,21 +303,10 @@ class BaseMixin(_MixinBase):
|
|
|
262
303
|
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
263
304
|
return DiffResult(kind="model", entries=entries)
|
|
264
305
|
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if i >= len(actual):
|
|
270
|
-
entries.append(DiffEntry(path=path, actual=None, expected=expected[i]))
|
|
271
|
-
elif i >= len(expected):
|
|
272
|
-
entries.append(DiffEntry(path=path, actual=actual[i], expected=None))
|
|
273
|
-
elif actual[i] != expected[i]:
|
|
274
|
-
sub_entries = BaseMixin._sub_diff_entries(actual[i], expected[i], path, _seen=_seen)
|
|
275
|
-
if sub_entries:
|
|
276
|
-
entries.extend(sub_entries)
|
|
277
|
-
else:
|
|
278
|
-
entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
|
|
279
|
-
return DiffResult(kind="sequence", entries=entries)
|
|
306
|
+
return DiffResult(
|
|
307
|
+
kind="sequence",
|
|
308
|
+
entries=BaseMixin._sequence_diff_entries(actual, expected, _prefix, _seen),
|
|
309
|
+
)
|
|
280
310
|
if isinstance(actual, (set, frozenset)) and isinstance(expected, (set, frozenset)):
|
|
281
311
|
entries = []
|
|
282
312
|
for item in sorted(actual - expected, key=repr):
|
|
@@ -333,23 +363,7 @@ class BaseMixin(_MixinBase):
|
|
|
333
363
|
and not isinstance(expected, type)
|
|
334
364
|
):
|
|
335
365
|
child_seen = _seen | {id(actual), id(expected)}
|
|
336
|
-
|
|
337
|
-
for field in dataclasses.fields(actual):
|
|
338
|
-
actual_value = getattr(actual, field.name)
|
|
339
|
-
expected_value = getattr(expected, field.name, _SENTINEL)
|
|
340
|
-
if expected_value is _SENTINEL:
|
|
341
|
-
entries.append(DiffEntry(path=f"{prefix}.{field.name}", actual=actual_value, expected=None))
|
|
342
|
-
elif actual_value != expected_value:
|
|
343
|
-
sub_entries = BaseMixin._sub_diff_entries(
|
|
344
|
-
actual_value, expected_value, f"{prefix}.{field.name}", _seen=child_seen
|
|
345
|
-
)
|
|
346
|
-
if sub_entries is not None:
|
|
347
|
-
entries.extend(sub_entries)
|
|
348
|
-
else:
|
|
349
|
-
entries.append(
|
|
350
|
-
DiffEntry(path=f"{prefix}.{field.name}", actual=actual_value, expected=expected_value)
|
|
351
|
-
)
|
|
352
|
-
return entries or None
|
|
366
|
+
return BaseMixin._dataclass_diff_entries(actual, expected, prefix, child_seen) or None
|
|
353
367
|
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
354
368
|
child_seen = _seen | {id(actual), id(expected)}
|
|
355
369
|
entries = []
|
|
@@ -394,6 +408,9 @@ class BaseMixin(_MixinBase):
|
|
|
394
408
|
else:
|
|
395
409
|
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
396
410
|
return entries or None
|
|
411
|
+
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
412
|
+
child_seen = _seen | {id(actual), id(expected)}
|
|
413
|
+
return BaseMixin._sequence_diff_entries(actual, expected, prefix, child_seen) or None
|
|
397
414
|
return None
|
|
398
415
|
|
|
399
416
|
def satisfies(self, matcher) -> Self:
|
|
@@ -488,11 +505,12 @@ class BaseMixin(_MixinBase):
|
|
|
488
505
|
return self
|
|
489
506
|
|
|
490
507
|
def matches_structure(self, spec: dict[Any, Any]) -> Self:
|
|
491
|
-
"""Asserts that val
|
|
508
|
+
"""Asserts that val matches the given structure specification.
|
|
492
509
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
510
|
+
``val`` may be a dict or a pydantic-style model (anything exposing ``model_dump()``), which is
|
|
511
|
+
normalized to its dict before matching. Each key in ``spec`` maps to either a
|
|
512
|
+
:class:`~assertpy2.matchers.Matcher`, a raw value (checked via ``==``), or a nested ``dict``
|
|
513
|
+
for recursive matching. Extra keys in val that are absent from the spec are allowed.
|
|
496
514
|
|
|
497
515
|
Args:
|
|
498
516
|
spec: a dict where values can be Matcher instances, raw values, or nested dicts
|
|
@@ -515,7 +533,7 @@ class BaseMixin(_MixinBase):
|
|
|
515
533
|
Raises:
|
|
516
534
|
AssertionError: if val does **not** match the structure spec
|
|
517
535
|
"""
|
|
518
|
-
if not isinstance(self.val, dict):
|
|
536
|
+
if not isinstance(self.val, dict) and not is_model_dump_object(self.val):
|
|
519
537
|
raise TypeError("val must be a dict")
|
|
520
538
|
if not isinstance(spec, dict):
|
|
521
539
|
raise TypeError("given arg must be a dict")
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import collections.abc
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
|
-
from ._introspection import is_namedtuple
|
|
6
|
+
from ._introspection import is_model_dump_object, is_namedtuple
|
|
7
7
|
from ._mixin_base import _MixinBase
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
@@ -155,7 +155,7 @@ class ExtractingMixin(_MixinBase):
|
|
|
155
155
|
return getattr(item, name)
|
|
156
156
|
else: # val has no attribute <foo>
|
|
157
157
|
raise ValueError(f"item attributes {item._fields} did not contain attribute <{name}>")
|
|
158
|
-
elif isinstance(item, collections.abc.Iterable):
|
|
158
|
+
elif isinstance(item, collections.abc.Iterable) and not is_model_dump_object(item):
|
|
159
159
|
self._check_iterable(item, name="item")
|
|
160
160
|
return item[name]
|
|
161
161
|
elif hasattr(item, name):
|
|
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import re
|
|
4
4
|
import threading
|
|
5
5
|
import uuid as _uuid_mod
|
|
6
|
-
from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from ._introspection import is_model_dump_object
|
|
7
9
|
|
|
8
10
|
if TYPE_CHECKING:
|
|
9
11
|
from collections.abc import Callable
|
|
@@ -548,95 +550,95 @@ def _describe_spec_value(value: object) -> str:
|
|
|
548
550
|
return f"<{value}>"
|
|
549
551
|
|
|
550
552
|
|
|
553
|
+
class _SpecMismatch(NamedTuple):
|
|
554
|
+
"""One structural mismatch produced by a single spec walk.
|
|
555
|
+
|
|
556
|
+
``expected_desc`` is the public "expected" half (as exposed by ``collect_mismatches``); ``detail``
|
|
557
|
+
carries a matcher's ``describe_mismatch(actual)`` so fail-fast failure text keeps its per-value
|
|
558
|
+
detail without a second traversal.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
path: str
|
|
562
|
+
actual: object
|
|
563
|
+
expected_desc: str
|
|
564
|
+
detail: str | None
|
|
565
|
+
|
|
566
|
+
|
|
551
567
|
class StructureMatcher(BaseMatcher):
|
|
552
|
-
"""Matches dicts
|
|
568
|
+
"""Matches dicts (or pydantic-style models via ``model_dump()``) against a structure spec whose
|
|
569
|
+
values are matchers, raw values, or nested dicts."""
|
|
553
570
|
|
|
554
571
|
def __init__(self, spec: dict[Any, Any]):
|
|
555
572
|
self._spec = spec
|
|
556
573
|
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _as_mapping(value: Any) -> Any:
|
|
576
|
+
"""Normalize a pydantic-style model (anything exposing ``model_dump()``) to its dict, so a
|
|
577
|
+
model can be matched structurally just like a plain dict; pass other values through."""
|
|
578
|
+
return value.model_dump() if is_model_dump_object(value) else value
|
|
579
|
+
|
|
557
580
|
def matches(self, value: Any) -> bool:
|
|
581
|
+
value = self._as_mapping(value)
|
|
558
582
|
if not isinstance(value, dict):
|
|
559
583
|
return False
|
|
560
|
-
return self.
|
|
584
|
+
return not self._walk(value, self._spec, "", set())
|
|
561
585
|
|
|
562
586
|
def describe(self) -> str:
|
|
563
587
|
return f"a dict matching structure {_describe_spec_value(self._spec)}"
|
|
564
588
|
|
|
565
589
|
def describe_mismatch(self, value: Any) -> str:
|
|
590
|
+
value = self._as_mapping(value)
|
|
566
591
|
if not isinstance(value, dict):
|
|
567
592
|
return f"was not a dict: <{value}>"
|
|
568
|
-
|
|
569
|
-
if
|
|
570
|
-
return
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
current_path = f"{path}.{key}" if path else str(key)
|
|
582
|
-
if key not in value:
|
|
583
|
-
return f"missing key <{current_path}>"
|
|
584
|
-
actual = value[key]
|
|
585
|
-
if isinstance(expected, StructureMatcher):
|
|
586
|
-
if not isinstance(actual, dict):
|
|
587
|
-
return f"at <{current_path}>: expected a dict, but was <{actual}>"
|
|
588
|
-
error = self._match_recursive(actual, expected._spec, current_path, seen)
|
|
589
|
-
if error:
|
|
590
|
-
return error
|
|
591
|
-
elif isinstance(expected, Matcher):
|
|
592
|
-
if not expected.matches(actual):
|
|
593
|
-
return (
|
|
594
|
-
f"at <{current_path}>: expected {expected.describe()}, but {expected.describe_mismatch(actual)}"
|
|
595
|
-
)
|
|
596
|
-
elif isinstance(expected, dict):
|
|
597
|
-
if not isinstance(actual, dict):
|
|
598
|
-
return f"at <{current_path}>: expected a dict, but was <{actual}>"
|
|
599
|
-
error = self._match_recursive(actual, expected, current_path, seen)
|
|
600
|
-
if error:
|
|
601
|
-
return error
|
|
602
|
-
elif actual != expected:
|
|
603
|
-
return f"at <{current_path}>: expected <{expected}>, but was <{actual}>"
|
|
604
|
-
return None
|
|
605
|
-
|
|
606
|
-
def collect_mismatches(self, value: dict[Any, Any]) -> list[tuple[str, object, str]]:
|
|
593
|
+
mismatches = self._walk(value, self._spec, "", set())
|
|
594
|
+
if not mismatches:
|
|
595
|
+
return f"was <{value}>"
|
|
596
|
+
first = mismatches[0]
|
|
597
|
+
if first.actual is _MISSING:
|
|
598
|
+
return f"missing key <{first.path}>"
|
|
599
|
+
if first.expected_desc == "<circular ref>": # cycle sentinel emitted by _walk
|
|
600
|
+
return f"circular reference detected at <{first.path}>"
|
|
601
|
+
if first.detail is not None:
|
|
602
|
+
return f"at <{first.path}>: expected {first.expected_desc}, but {first.detail}"
|
|
603
|
+
return f"at <{first.path}>: expected {first.expected_desc}, but was <{first.actual}>"
|
|
604
|
+
|
|
605
|
+
def collect_mismatches(self, value: Any) -> list[tuple[str, object, str]]:
|
|
607
606
|
"""Collect every structural mismatch as ``(path, actual, expected_description)``.
|
|
608
607
|
|
|
609
608
|
Unlike :meth:`describe_mismatch`, this does not stop at the first failure and joins nested
|
|
610
609
|
paths, so callers can build a path-level :class:`~assertpy2.errors.DiffResult`.
|
|
611
610
|
"""
|
|
612
|
-
|
|
611
|
+
value = self._as_mapping(value)
|
|
612
|
+
return [(m.path, m.actual, m.expected_desc) for m in self._walk(value, self._spec, "", set())]
|
|
613
613
|
|
|
614
|
-
def
|
|
614
|
+
def _walk(
|
|
615
615
|
self, value: dict[Any, Any], spec: dict[Any, Any], path: str, seen: set[tuple[int, int]]
|
|
616
|
-
) -> list[
|
|
616
|
+
) -> list[_SpecMismatch]:
|
|
617
617
|
pair_id = (id(value), id(spec))
|
|
618
618
|
if pair_id in seen:
|
|
619
|
-
return [(path or "root", "<circular ref>", "<circular ref>")]
|
|
619
|
+
return [_SpecMismatch(path or "root", "<circular ref>", "<circular ref>", None)]
|
|
620
620
|
seen = seen | {pair_id}
|
|
621
|
-
mismatches: list[
|
|
621
|
+
mismatches: list[_SpecMismatch] = []
|
|
622
622
|
for key, expected in spec.items():
|
|
623
623
|
current_path = f"{path}.{key}" if path else str(key)
|
|
624
624
|
if key not in value:
|
|
625
|
-
mismatches.append((current_path, _MISSING, _describe_spec_value(expected)))
|
|
625
|
+
mismatches.append(_SpecMismatch(current_path, _MISSING, _describe_spec_value(expected), None))
|
|
626
626
|
continue
|
|
627
627
|
actual = value[key]
|
|
628
628
|
if isinstance(expected, StructureMatcher) and isinstance(actual, dict):
|
|
629
|
-
mismatches.extend(self.
|
|
629
|
+
mismatches.extend(self._walk(actual, expected._spec, current_path, seen))
|
|
630
630
|
elif isinstance(expected, Matcher):
|
|
631
631
|
if not expected.matches(actual):
|
|
632
|
-
mismatches.append(
|
|
632
|
+
mismatches.append(
|
|
633
|
+
_SpecMismatch(current_path, actual, expected.describe(), expected.describe_mismatch(actual))
|
|
634
|
+
)
|
|
633
635
|
elif isinstance(expected, dict):
|
|
634
636
|
if isinstance(actual, dict):
|
|
635
|
-
mismatches.extend(self.
|
|
637
|
+
mismatches.extend(self._walk(actual, expected, current_path, seen))
|
|
636
638
|
else:
|
|
637
|
-
mismatches.append((current_path, actual, "a dict"))
|
|
639
|
+
mismatches.append(_SpecMismatch(current_path, actual, "a dict", None))
|
|
638
640
|
elif actual != expected:
|
|
639
|
-
mismatches.append((current_path, actual, f"<{expected}>"))
|
|
641
|
+
mismatches.append(_SpecMismatch(current_path, actual, f"<{expected}>", None))
|
|
640
642
|
return mismatches
|
|
641
643
|
|
|
642
644
|
|
|
@@ -335,7 +335,7 @@ assert_that(people).extracting("name").contains("Fred Smith", "Bob Barr")
|
|
|
335
335
|
assert_that(people).extracting("say_hello").contains("Hello, Fred!", "Hello, Bob!") # method
|
|
336
336
|
```
|
|
337
337
|
|
|
338
|
-
It also works on collections of dicts (extracting by key) and across subclasses in a mixed collection.
|
|
338
|
+
It also works on collections of dicts (extracting by key), Pydantic models, and across subclasses in a mixed collection.
|
|
339
339
|
|
|
340
340
|
#### Filtering
|
|
341
341
|
|
|
Binary file
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<svg width="250" height="120" viewBox="0 0 250 119.6" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<style>
|
|
3
|
+
|
|
4
|
+
.terminal-346716669-matrix {
|
|
5
|
+
font-family: Fira Code, monospace;
|
|
6
|
+
font-size: 20px;
|
|
7
|
+
line-height: 24.4px;
|
|
8
|
+
font-variant-east-asian: full-width;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.terminal-346716669-title {
|
|
12
|
+
font-size: 18px;
|
|
13
|
+
font-weight: bold;
|
|
14
|
+
font-family: arial;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.terminal-346716669-r1 { fill: #68a0b3 }
|
|
18
|
+
.terminal-346716669-r2 { fill: #c5c8c6 }
|
|
19
|
+
.terminal-346716669-r3 { fill: #cc555a }
|
|
20
|
+
.terminal-346716669-r4 { fill: #98a84b }
|
|
21
|
+
</style>
|
|
22
|
+
|
|
23
|
+
<defs>
|
|
24
|
+
<clipPath id="terminal-346716669-clip-terminal">
|
|
25
|
+
<rect x="0" y="0" width="230.79999999999998" height="96.6" />
|
|
26
|
+
</clipPath>
|
|
27
|
+
<clipPath id="terminal-346716669-line-0">
|
|
28
|
+
<rect x="0" y="1.5" width="231.8" height="24.65"/>
|
|
29
|
+
</clipPath>
|
|
30
|
+
<clipPath id="terminal-346716669-line-1">
|
|
31
|
+
<rect x="0" y="25.9" width="231.8" height="24.65"/>
|
|
32
|
+
</clipPath>
|
|
33
|
+
<clipPath id="terminal-346716669-line-2">
|
|
34
|
+
<rect x="0" y="50.3" width="231.8" height="24.65"/>
|
|
35
|
+
</clipPath>
|
|
36
|
+
</defs>
|
|
37
|
+
|
|
38
|
+
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="248" height="117.6" rx="8"/>
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
<g transform="translate(9, 13)" clip-path="url(#terminal-346716669-clip-terminal)">
|
|
42
|
+
|
|
43
|
+
<g class="terminal-346716669-matrix">
|
|
44
|
+
<text class="terminal-346716669-r1" x="0" y="20" textLength="146.4" clip-path="url(#terminal-346716669-line-0)">diff (dict):</text><text class="terminal-346716669-r2" x="231.8" y="20" textLength="12.2" clip-path="url(#terminal-346716669-line-0)">
|
|
45
|
+
</text><text class="terminal-346716669-r2" x="0" y="44.4" textLength="146.4" clip-path="url(#terminal-346716669-line-1)">  user.role:</text><text class="terminal-346716669-r2" x="231.8" y="44.4" textLength="12.2" clip-path="url(#terminal-346716669-line-1)">
|
|
46
|
+
</text><text class="terminal-346716669-r3" x="48.8" y="68.8" textLength="170.8" clip-path="url(#terminal-346716669-line-2)">- 'superadmin'</text><text class="terminal-346716669-r2" x="231.8" y="68.8" textLength="12.2" clip-path="url(#terminal-346716669-line-2)">
|
|
47
|
+
</text><text class="terminal-346716669-r4" x="48.8" y="93.2" textLength="109.8" clip-path="url(#terminal-346716669-line-3)">+ 'admin'</text><text class="terminal-346716669-r2" x="231.8" y="93.2" textLength="12.2" clip-path="url(#terminal-346716669-line-3)">
|
|
48
|
+
</text>
|
|
49
|
+
</g>
|
|
50
|
+
</g>
|
|
51
|
+
</svg>
|
|
Binary file
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
<svg
|
|
2
|
-
<!-- Generated with Rich https://www.textualize.io -->
|
|
1
|
+
<svg width="933" height="120" viewBox="0 0 933 119.6" xmlns="http://www.w3.org/2000/svg">
|
|
3
2
|
<style>
|
|
4
3
|
|
|
5
4
|
.terminal-4172749894-matrix {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
<svg
|
|
2
|
-
<!-- Generated with Rich https://www.textualize.io -->
|
|
1
|
+
<svg width="226" height="120" viewBox="0 0 226 119.6" xmlns="http://www.w3.org/2000/svg">
|
|
3
2
|
<style>
|
|
4
3
|
|
|
5
4
|
.terminal-4133779467-matrix {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
<svg
|
|
2
|
-
<!-- Generated with Rich https://www.textualize.io -->
|
|
1
|
+
<svg width="275" height="95" viewBox="0 0 275 95.19999999999999" xmlns="http://www.w3.org/2000/svg">
|
|
3
2
|
<style>
|
|
4
3
|
|
|
5
4
|
.terminal-3363794957-matrix {
|
|
@@ -117,6 +117,19 @@ Only assertpy2 prints the path (`user.role`) and the exact predicate that failed
|
|
|
117
117
|
assertpy2 `==` form both hand rendering to pytest, which dumps the whole differing container for you to
|
|
118
118
|
scan. The fluent form trades the zero-import convenience of `==` for a path-level diff.
|
|
119
119
|
|
|
120
|
+
### With a Pydantic model
|
|
121
|
+
|
|
122
|
+
When the value is a Pydantic model, `matches_structure()` accepts it directly, with no `.model_dump()`
|
|
123
|
+
step, and prints a path-level diff just like the one above:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
assert_that(user).matches_structure({"role": match.is_in("admin", "user")})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
dirty-equals cannot compare a model against a spec dict, because Pydantic's `__eq__` only matches another
|
|
130
|
+
model. You dump it first (`assert user.model_dump() == {"role": IsOneOf("admin", "user")}`), and pytest
|
|
131
|
+
again dumps the whole differing container. assertpy2 keeps a path-level diff on a model.
|
|
132
|
+
|
|
120
133
|
## Style and typing
|
|
121
134
|
|
|
122
135
|
| | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
|
|
@@ -161,7 +174,7 @@ scan. The fluent form trades the zero-import convenience of `==` for a path-leve
|
|
|
161
174
|
|
|
162
175
|
| | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
|
|
163
176
|
|---|:---:|:---:|:---:|:---:|:---:|
|
|
164
|
-
|
|
|
177
|
+
| Maintained | built-in | Yes | No (since 2020) | Yes | **Yes** |
|
|
165
178
|
| Property-based tests | n/a | No | No | No | **Yes** |
|
|
166
179
|
| Runtime dependencies | **none** | **none** | **none** | **none** | **none on 3.11+** |
|
|
167
180
|
| License | MIT | BSD | BSD | MIT | BSD-3 |
|
|
@@ -39,7 +39,7 @@ rendered by the plugin as colored diff sections.
|
|
|
39
39
|
|
|
40
40
|
| Type | Diff kind | How it works |
|
|
41
41
|
|---|---|---|
|
|
42
|
-
| `list`, `tuple` | `sequence` | Element-by-element, recursive into nested dicts
|
|
42
|
+
| `list`, `tuple` | `sequence` | Element-by-element, recursive into nested dicts, lists, dataclasses, and models |
|
|
43
43
|
| `set`, `frozenset` | `set` | Extra and missing items |
|
|
44
44
|
| `str` | `string` | Line-by-line comparison |
|
|
45
45
|
| `dict` | `dict` | Key-by-key, recursive into nested dicts and lists |
|
|
@@ -43,7 +43,7 @@ straight at the differing field instead of dumping the whole value:
|
|
|
43
43
|
assert_that(actual).is_equal_to(expected)
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-

|
|
47
47
|
|
|
48
48
|
The same path-level diff backs `matches_structure()`, `satisfies()`, and `each()`. See
|
|
49
49
|
[Errors & Reporting](errors.md) for the full diff format and configuration.
|
|
@@ -12,7 +12,7 @@ rich structural diffs on failure.
|
|
|
12
12
|
ones, and a type checker rejects `assert_that("foo").is_positive()` before the test runs. The core
|
|
13
13
|
advantage over `assertpy` and most alternatives - [see how it works](type-safety.md).
|
|
14
14
|
- **Composable matchers.** `match.greater_than(5)`, `match.is_uuid()`, combined with `&`, `|`, `~`.
|
|
15
|
-
- **Structural matching.** Declarative validation of dicts and API responses, with the exact path to each mismatch on failure.
|
|
15
|
+
- **Structural matching.** Declarative validation of dicts, Pydantic models, and API responses, with the exact path to each mismatch on failure.
|
|
16
16
|
- **Soft and async assertions.** Collect multiple failures; poll for eventual consistency with `eventually()`.
|
|
17
17
|
- **Structured failures.** `AssertionFailure` exposes `.actual`, `.expected`, and `.diff`; the pytest plugin
|
|
18
18
|
renders recursive diffs for lists, dicts, dataclasses, namedtuples, and Pydantic models.
|
|
@@ -124,7 +124,7 @@ assert 42 == (match.is_positive() & match.less_than(100))
|
|
|
124
124
|
| `match.is_non_empty_string()` | a non-empty string |
|
|
125
125
|
| `match.ignore()` | anything (placeholder for structural matching) |
|
|
126
126
|
| `match.each_item(matcher)` | an iterable whose every item matches `matcher` |
|
|
127
|
-
| `match.structure(spec)` | a dict matching a nested `spec` |
|
|
127
|
+
| `match.structure(spec)` | a dict or model matching a nested `spec` |
|
|
128
128
|
| `match.all_of(*matchers)` | a value matching all of `matchers` |
|
|
129
129
|
| `match.any_of(*matchers)` | a value matching any of `matchers` |
|
|
130
130
|
| `match.not_(matcher)` | a value not matching `matcher` |
|
|
@@ -153,6 +153,30 @@ assert_that(response).matches_structure({
|
|
|
153
153
|
})
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
The value under test can be a plain dict or a Pydantic model (anything exposing `model_dump()`); a
|
|
157
|
+
model is normalized to its dict before matching, so the same spec works either way, including inside
|
|
158
|
+
`satisfies()` and the `==` form:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from pydantic import BaseModel
|
|
162
|
+
|
|
163
|
+
class User(BaseModel):
|
|
164
|
+
id: str
|
|
165
|
+
name: str
|
|
166
|
+
|
|
167
|
+
user = User(id="550e8400-e29b-41d4-a716-446655440000", name="Alice")
|
|
168
|
+
|
|
169
|
+
assert_that(user).matches_structure({"id": match.is_uuid(), "name": match.equal_to("Alice")})
|
|
170
|
+
assert_that(user).satisfies(match.structure({"id": match.is_uuid()}))
|
|
171
|
+
assert user == match.structure({"id": match.is_uuid()})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
!!! note
|
|
175
|
+
A model is matched in its `model_dump()` form: nested models become dicts, `@field_serializer` and
|
|
176
|
+
`@computed_field` outputs are applied, and spec keys are the model's field names (not aliases). The
|
|
177
|
+
spec is matched against this serialized shape, not the live attributes. This is a runtime structural
|
|
178
|
+
check; the spec keys and values are not type-checked against the model's schema.
|
|
179
|
+
|
|
156
180
|
### Nested structures
|
|
157
181
|
|
|
158
182
|
Use `match.structure()` for nested dicts:
|
|
@@ -42,9 +42,14 @@ assert_that("foo").is_positive() # type error: is_positive is not a strin
|
|
|
42
42
|
assert_that(42).is_instance_of("int") # type error: expected `type`, got `str`
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
[ty](https://github.com/astral-sh/ty), [
|
|
46
|
-
[
|
|
47
|
-
bugs into errors you see while typing.
|
|
45
|
+
[ty](https://github.com/astral-sh/ty), [mypy `--strict`](https://github.com/python/mypy), and
|
|
46
|
+
[Pyright](https://github.com/microsoft/pyright) all report these in the editor and in CI, turning a class
|
|
47
|
+
of test bugs into errors you see while typing.
|
|
48
|
+
|
|
49
|
+
Every public `assert_that` overload is pinned by an `assert_type` check in
|
|
50
|
+
[`tests/test_typing.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_typing.py); CI runs all
|
|
51
|
+
three checkers against that file on every push, with **zero suppressions**, so a regression that broadens or
|
|
52
|
+
changes a return type fails the build. `ty` additionally type-checks the whole package.
|
|
48
53
|
|
|
49
54
|
!!! note "Callables and captured values stay typed too"
|
|
50
55
|
`assert_that(func).raises(...).when_called_with(...)` exposes string assertions on the captured
|