assertpy2 2.9.1__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.1 → assertpy2-2.10.0}/PKG-INFO +6 -2
- {assertpy2-2.9.1 → assertpy2-2.10.0}/README.md +5 -1
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/base.py +70 -52
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/extracting.py +2 -2
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/matchers.py +14 -2
- {assertpy2-2.9.1 → 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.1 → assertpy2-2.10.0}/docs/assets/diff-match.svg +1 -2
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/assets/diff-sequence.svg +1 -2
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/assets/diff-set.svg +1 -2
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/comparison.md +14 -1
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/errors.md +1 -1
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/getting-started.md +1 -1
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/index.md +1 -1
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/matchers.md +25 -1
- {assertpy2-2.9.1 → assertpy2-2.10.0}/pyproject.toml +2 -2
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_extracting.py +49 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_file.py +9 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_rich_diff.py +172 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_structural.py +81 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/uv.lock +23 -23
- assertpy2-2.9.1/docs/assets/diff-equal.png +0 -0
- assertpy2-2.9.1/docs/assets/diff-equal.svg +0 -64
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.codecov.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.gitattributes +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/.gitignore +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/LICENSE +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/SECURITY.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_compat.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_introspection.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_typing.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/collection.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/contains.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/date.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/errors.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/file.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/helpers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/string.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/data.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/extending.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/fluent.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/integrations.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/logo.svg +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/migration.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/testing.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/type-safety.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/mkdocs.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_async.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_bool.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_callable.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_class.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_collection.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_core.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_description.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_dict.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_dynamic.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_equals.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_errors.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_fail.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_in.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_json.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_list.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_matchers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_mutation_hardening.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_none.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_not.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_property_based.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_readme.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_satisfy.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_snapshots.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_soft.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_string.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_type.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_typing.py +0 -0
- {assertpy2-2.9.1 → 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
|
|
@@ -121,12 +121,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
|
|
|
121
121
|
assert_that(response).is_equal_to(expected)
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
<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">
|
|
125
125
|
|
|
126
126
|
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
127
127
|
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
128
128
|
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
129
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
|
+
|
|
130
134
|
## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
|
|
131
135
|
|
|
132
136
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
@@ -83,12 +83,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
|
|
|
83
83
|
assert_that(response).is_equal_to(expected)
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
<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">
|
|
87
87
|
|
|
88
88
|
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
89
89
|
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
90
90
|
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
91
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
|
+
|
|
92
96
|
## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
|
|
93
97
|
|
|
94
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):
|
|
@@ -5,6 +5,8 @@ import threading
|
|
|
5
5
|
import uuid as _uuid_mod
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, runtime_checkable
|
|
7
7
|
|
|
8
|
+
from ._introspection import is_model_dump_object
|
|
9
|
+
|
|
8
10
|
if TYPE_CHECKING:
|
|
9
11
|
from collections.abc import Callable
|
|
10
12
|
|
|
@@ -563,12 +565,20 @@ class _SpecMismatch(NamedTuple):
|
|
|
563
565
|
|
|
564
566
|
|
|
565
567
|
class StructureMatcher(BaseMatcher):
|
|
566
|
-
"""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."""
|
|
567
570
|
|
|
568
571
|
def __init__(self, spec: dict[Any, Any]):
|
|
569
572
|
self._spec = spec
|
|
570
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
|
+
|
|
571
580
|
def matches(self, value: Any) -> bool:
|
|
581
|
+
value = self._as_mapping(value)
|
|
572
582
|
if not isinstance(value, dict):
|
|
573
583
|
return False
|
|
574
584
|
return not self._walk(value, self._spec, "", set())
|
|
@@ -577,6 +587,7 @@ class StructureMatcher(BaseMatcher):
|
|
|
577
587
|
return f"a dict matching structure {_describe_spec_value(self._spec)}"
|
|
578
588
|
|
|
579
589
|
def describe_mismatch(self, value: Any) -> str:
|
|
590
|
+
value = self._as_mapping(value)
|
|
580
591
|
if not isinstance(value, dict):
|
|
581
592
|
return f"was not a dict: <{value}>"
|
|
582
593
|
mismatches = self._walk(value, self._spec, "", set())
|
|
@@ -591,12 +602,13 @@ class StructureMatcher(BaseMatcher):
|
|
|
591
602
|
return f"at <{first.path}>: expected {first.expected_desc}, but {first.detail}"
|
|
592
603
|
return f"at <{first.path}>: expected {first.expected_desc}, but was <{first.actual}>"
|
|
593
604
|
|
|
594
|
-
def collect_mismatches(self, value:
|
|
605
|
+
def collect_mismatches(self, value: Any) -> list[tuple[str, object, str]]:
|
|
595
606
|
"""Collect every structural mismatch as ``(path, actual, expected_description)``.
|
|
596
607
|
|
|
597
608
|
Unlike :meth:`describe_mismatch`, this does not stop at the first failure and joins nested
|
|
598
609
|
paths, so callers can build a path-level :class:`~assertpy2.errors.DiffResult`.
|
|
599
610
|
"""
|
|
611
|
+
value = self._as_mapping(value)
|
|
600
612
|
return [(m.path, m.actual, m.expected_desc) for m in self._walk(value, self._spec, "", set())]
|
|
601
613
|
|
|
602
614
|
def _walk(
|
|
@@ -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:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "assertpy2"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.10.0"
|
|
4
4
|
description = "Fluent assertion library for Python with composable matchers, structural matching, and full type safety"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -57,7 +57,7 @@ dev = [
|
|
|
57
57
|
"pytest>=9.1.1",
|
|
58
58
|
"pytest-cov>=6.1",
|
|
59
59
|
"ruff>=0.15.19",
|
|
60
|
-
"ty>=0.0.
|
|
60
|
+
"ty>=0.0.54",
|
|
61
61
|
"typing_extensions>=4.0",
|
|
62
62
|
]
|
|
63
63
|
docs = [
|
|
@@ -261,6 +261,55 @@ def test_extracting_iterable_of_lists():
|
|
|
261
261
|
assert_that(matrix).extracting(-1, -2).extracting(0).is_equal_to([3, 6, 9])
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
class _DuckModel:
|
|
265
|
+
"""Duck-types a pydantic v2 model for the dependency-free path: iterable over (field, value)
|
|
266
|
+
pairs and NOT subscriptable, with fields exposed as attributes and a model_dump() method.
|
|
267
|
+
|
|
268
|
+
This is the exact shape that regressed extracting(): the object is iterable, so without the
|
|
269
|
+
model-dump guard it fell into the index branch and item[name] raised (no [] accessor). It must
|
|
270
|
+
stay iterable and non-subscriptable to keep exercising that guard.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(self, **fields):
|
|
274
|
+
self.__dict__.update(fields)
|
|
275
|
+
|
|
276
|
+
def model_dump(self):
|
|
277
|
+
return dict(self.__dict__)
|
|
278
|
+
|
|
279
|
+
def __iter__(self):
|
|
280
|
+
return iter(self.__dict__.items())
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_duck_users = [_DuckModel(user="Fred", age=36), _DuckModel(user="Bob", age=40)]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_extracting_model_dump_object_single_field():
|
|
287
|
+
assert_that(_duck_users).extracting("user").contains("Fred", "Bob")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_extracting_model_dump_object_multiple_fields():
|
|
291
|
+
assert_that(_duck_users).extracting("user", "age").contains(("Fred", 36), ("Bob", 40))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_extracting_model_dump_object_missing_field_failure():
|
|
295
|
+
with pytest.raises(ValueError) as exc_info:
|
|
296
|
+
assert_that(_duck_users).extracting("foo")
|
|
297
|
+
assert_that(str(exc_info.value)).is_equal_to("item does not have property or zero-arg method <foo>")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_extracting_real_pydantic_model():
|
|
301
|
+
pytest.importorskip("pydantic", reason="pydantic not installed")
|
|
302
|
+
from pydantic import BaseModel
|
|
303
|
+
|
|
304
|
+
class User(BaseModel):
|
|
305
|
+
user: str
|
|
306
|
+
age: int
|
|
307
|
+
|
|
308
|
+
models = [User(user="Fred", age=36), User(user="Bob", age=40)]
|
|
309
|
+
assert_that(models).extracting("user").contains("Fred", "Bob")
|
|
310
|
+
assert_that(models).extracting("user", "age").contains(("Fred", 36), ("Bob", 40))
|
|
311
|
+
|
|
312
|
+
|
|
264
313
|
def test_extracting_iterable_multi_extracting():
|
|
265
314
|
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
|
|
266
315
|
assert_that(matrix).extracting(-1, 2).is_equal_to([(3, 3), (6, 6), (9, 9)])
|
|
@@ -141,6 +141,15 @@ def test_is_named_failure(tmpfile):
|
|
|
141
141
|
assert_that(str(exc_info.value)).matches("Expected filename <.*> to be equal to <foo.txt>, but was not.")
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def test_is_named_failure_name_sorts_after_basename(tmpfile):
|
|
145
|
+
# Guards the `!=` filename check against a `>` mutant: when the expected name sorts AFTER the actual
|
|
146
|
+
# basename, `>` would wrongly report a match. Appending a char makes the expected name a strict
|
|
147
|
+
# superstring of the basename, so it is guaranteed larger and unequal regardless of the basename.
|
|
148
|
+
larger_name = os.path.basename(tmpfile.name) + "x"
|
|
149
|
+
with pytest.raises(AssertionError):
|
|
150
|
+
assert_that(tmpfile.name).is_named(larger_name)
|
|
151
|
+
|
|
152
|
+
|
|
144
153
|
def test_is_named_bad_arg_type_failure(tmpfile):
|
|
145
154
|
with pytest.raises(TypeError) as exc_info:
|
|
146
155
|
assert_that(tmpfile.name).is_named(123)
|