assertpy2 2.6.0__tar.gz → 2.8.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.6.0 → assertpy2-2.8.0}/.gitignore +1 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/PKG-INFO +38 -13
- {assertpy2-2.6.0 → assertpy2-2.8.0}/README.md +37 -12
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/_mixin_base.py +2 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/_typing.py +13 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/assertpy.py +3 -2
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/base.py +24 -5
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/exception.py +29 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/matchers.py +55 -9
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/pytest_plugin.py +5 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/warning.py +6 -3
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/comparison.md +36 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/errors.md +29 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/getting-started.md +15 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/index.md +10 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/matchers.md +19 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/pyproject.toml +2 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_expected_exception.py +14 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_expected_warning.py +26 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_matchers.py +21 -1
- assertpy2-2.8.0/tests/test_property_based.py +333 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_recursive_compare_attrs.py +28 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_recursive_compare_pydantic.py +28 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_rich_diff.py +30 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_structural.py +69 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/uv.lock +25 -1
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.codecov.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.gitattributes +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/LICENSE +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/SECURITY.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/_compat.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/collection.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/contains.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/date.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/errors.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/extracting.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/file.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/helpers.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/assertpy2/string.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/assertions.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/data.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/extending.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/fluent.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/integrations.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/logo.svg +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/migration.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/docs/testing.md +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/mkdocs.yml +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_async.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_bool.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_class.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_collection.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_core.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_description.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_dict.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_dyn.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_equals.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_errors.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_extracting.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_fail.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_file.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_in.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_json.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_list.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_none.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_not.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_phase2.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_readme.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_snapshots.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_soft.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_string.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.0}/tests/test_type.py +0 -0
- {assertpy2-2.6.0 → assertpy2-2.8.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.8.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
|
|
@@ -86,24 +86,49 @@ def test_user():
|
|
|
86
86
|
|
|
87
87
|
## Why fluent assertions?
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
assert user["age"] >= 18
|
|
92
|
-
# AssertionError
|
|
93
|
-
|
|
94
|
-
# assertpy2 - same check, clear failure message
|
|
95
|
-
assert_that(user["age"]).is_greater_than_or_equal_to(18)
|
|
96
|
-
# AssertionError: Expected <16> to be greater than or equal to <18>, but was not.
|
|
89
|
+
A fluent chain reads as one intent and replaces several bare asserts - and your IDE
|
|
90
|
+
offers only the methods that fit the value's type:
|
|
97
91
|
|
|
98
|
-
|
|
92
|
+
```py
|
|
93
|
+
# bare - three statements, no autocomplete help
|
|
99
94
|
assert isinstance(items, list)
|
|
100
95
|
assert len(items) == 3
|
|
101
96
|
assert "admin" in items
|
|
102
97
|
|
|
103
|
-
# assertpy2 - one
|
|
98
|
+
# assertpy2 - one chain, type-aware autocomplete
|
|
104
99
|
assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
105
100
|
```
|
|
106
101
|
|
|
102
|
+
The real difference shows up when a test fails. Here a nested response has two wrong
|
|
103
|
+
fields. Plain `assert` dumps both structures and leaves you to find them:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
assert response == expected
|
|
107
|
+
E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
|
|
108
|
+
E Omitting 1 identical items, use -vv to show
|
|
109
|
+
E Differing items:
|
|
110
|
+
E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
|
|
111
|
+
E {'status': 'active'} != {'status': 'disabled'}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
assertpy2 reports the exact path to every difference:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
assert_that(response).is_equal_to(expected)
|
|
118
|
+
--- Structured Diff ---
|
|
119
|
+
diff (dict):
|
|
120
|
+
status:
|
|
121
|
+
- 'active'
|
|
122
|
+
+ 'disabled'
|
|
123
|
+
user.role:
|
|
124
|
+
- 'superadmin'
|
|
125
|
+
+ 'admin'
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
129
|
+
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
130
|
+
`matches_structure()` instead of exact equality.
|
|
131
|
+
|
|
107
132
|
## Type-aware autocomplete
|
|
108
133
|
|
|
109
134
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
@@ -124,7 +149,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
124
149
|
**Fluent API**
|
|
125
150
|
|
|
126
151
|
- [**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.
|
|
152
|
+
- [**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
153
|
- [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
129
154
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
130
155
|
- [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
|
|
@@ -143,7 +168,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
143
168
|
- [**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
169
|
- [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
|
|
145
170
|
- [**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.
|
|
171
|
+
- [**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
172
|
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
148
173
|
|
|
149
174
|
**Type safety**
|
|
@@ -48,24 +48,49 @@ def test_user():
|
|
|
48
48
|
|
|
49
49
|
## Why fluent assertions?
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
assert user["age"] >= 18
|
|
54
|
-
# AssertionError
|
|
55
|
-
|
|
56
|
-
# assertpy2 - same check, clear failure message
|
|
57
|
-
assert_that(user["age"]).is_greater_than_or_equal_to(18)
|
|
58
|
-
# AssertionError: Expected <16> to be greater than or equal to <18>, but was not.
|
|
51
|
+
A fluent chain reads as one intent and replaces several bare asserts - and your IDE
|
|
52
|
+
offers only the methods that fit the value's type:
|
|
59
53
|
|
|
60
|
-
|
|
54
|
+
```py
|
|
55
|
+
# bare - three statements, no autocomplete help
|
|
61
56
|
assert isinstance(items, list)
|
|
62
57
|
assert len(items) == 3
|
|
63
58
|
assert "admin" in items
|
|
64
59
|
|
|
65
|
-
# assertpy2 - one
|
|
60
|
+
# assertpy2 - one chain, type-aware autocomplete
|
|
66
61
|
assert_that(items).is_type_of(list).is_length(3).contains("admin")
|
|
67
62
|
```
|
|
68
63
|
|
|
64
|
+
The real difference shows up when a test fails. Here a nested response has two wrong
|
|
65
|
+
fields. Plain `assert` dumps both structures and leaves you to find them:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
assert response == expected
|
|
69
|
+
E AssertionError: assert {'id': 1, ...} == {'id': 1, ...}
|
|
70
|
+
E Omitting 1 identical items, use -vv to show
|
|
71
|
+
E Differing items:
|
|
72
|
+
E {'user': {'name': 'Alice', 'role': 'superadmin'}} != {'user': {'name': 'Alice', 'role': 'admin'}}
|
|
73
|
+
E {'status': 'active'} != {'status': 'disabled'}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
assertpy2 reports the exact path to every difference:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
assert_that(response).is_equal_to(expected)
|
|
80
|
+
--- Structured Diff ---
|
|
81
|
+
diff (dict):
|
|
82
|
+
status:
|
|
83
|
+
- 'active'
|
|
84
|
+
+ 'disabled'
|
|
85
|
+
user.role:
|
|
86
|
+
- 'superadmin'
|
|
87
|
+
+ 'admin'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
91
|
+
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
92
|
+
`matches_structure()` instead of exact equality.
|
|
93
|
+
|
|
69
94
|
## Type-aware autocomplete
|
|
70
95
|
|
|
71
96
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
@@ -86,7 +111,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
86
111
|
**Fluent API**
|
|
87
112
|
|
|
88
113
|
- [**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.
|
|
114
|
+
- [**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
115
|
- [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
91
116
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
92
117
|
- [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
|
|
@@ -105,7 +130,7 @@ Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
|
105
130
|
- [**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
131
|
- [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
|
|
107
132
|
- [**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.
|
|
133
|
+
- [**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
134
|
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
110
135
|
|
|
111
136
|
**Type safety**
|
|
@@ -17,6 +17,7 @@ class _MixinBase:
|
|
|
17
17
|
logger: logging.LoggerAdapter
|
|
18
18
|
_not_expected: bool
|
|
19
19
|
_expected_warning: type[Warning] | None
|
|
20
|
+
_return_value: object
|
|
20
21
|
|
|
21
22
|
def error(
|
|
22
23
|
self,
|
|
@@ -32,7 +33,7 @@ class _MixinBase:
|
|
|
32
33
|
val: object,
|
|
33
34
|
description: str = ...,
|
|
34
35
|
kind: str | None = ...,
|
|
35
|
-
expected: BaseException | None = ...,
|
|
36
|
+
expected: type[BaseException] | None = ...,
|
|
36
37
|
logger: logging.LoggerAdapter | None = ...,
|
|
37
38
|
) -> Self: ...
|
|
38
39
|
|
|
@@ -215,6 +215,17 @@ if TYPE_CHECKING:
|
|
|
215
215
|
def is_hex_equal_to(self, expected_hex: str) -> Self: ...
|
|
216
216
|
def decoded_as(self, encoding: str = ...) -> _StringAssertion: ...
|
|
217
217
|
|
|
218
|
+
class _InvokedAssertion(_StringAssertion, Protocol):
|
|
219
|
+
"""Assertions available after ``when_called_with()`` captured an exception/warning message.
|
|
220
|
+
|
|
221
|
+
The captured message is a ``str`` (hence the string assertions); :meth:`returned` pivots to
|
|
222
|
+
the value the callable returned. Its static type is unknown, so ``returned()`` exposes the
|
|
223
|
+
type-agnostic core assertions (``is_equal_to``, ``is_instance_of``, ``satisfies``, ...) -
|
|
224
|
+
type-safe by construction, never advertising methods that may not apply.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def returned(self) -> _CoreAssertion: ...
|
|
228
|
+
|
|
218
229
|
class _CallableAssertion(_CoreAssertion, Protocol):
|
|
219
230
|
"""Assertions available for callable values."""
|
|
220
231
|
|
|
@@ -222,5 +233,6 @@ if TYPE_CHECKING:
|
|
|
222
233
|
def does_not_raise(self, ex: type) -> Self: ...
|
|
223
234
|
def warns(self, warning: type[Warning] = ...) -> Self: ...
|
|
224
235
|
def does_not_warn(self, warning: type[Warning] = ...) -> Self: ...
|
|
225
|
-
|
|
236
|
+
# captured value (exception or warning message) is a str; returned() pivots to the call result
|
|
237
|
+
def when_called_with(self, *some_args: object, **some_kwargs: object) -> _InvokedAssertion: ...
|
|
226
238
|
def eventually(self, *, timeout: float = ..., interval: float = ...) -> AsyncAssertionBuilder: ...
|
|
@@ -37,7 +37,7 @@ from .date import DateMixin
|
|
|
37
37
|
from .dict import DictMixin
|
|
38
38
|
from .dynamic import DynamicMixin
|
|
39
39
|
from .errors import AssertionFailure
|
|
40
|
-
from .exception import ExceptionMixin
|
|
40
|
+
from .exception import _UNSET, ExceptionMixin
|
|
41
41
|
from .extracting import ExtractingMixin
|
|
42
42
|
from .file import FileMixin
|
|
43
43
|
from .helpers import HelpersMixin
|
|
@@ -47,7 +47,7 @@ from .snapshot import SnapshotMixin
|
|
|
47
47
|
from .string import StringMixin
|
|
48
48
|
from .warning import WarningMixin
|
|
49
49
|
|
|
50
|
-
__version__ = "2.
|
|
50
|
+
__version__ = "2.8.0"
|
|
51
51
|
|
|
52
52
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
53
53
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -589,6 +589,7 @@ class AssertionBuilder(
|
|
|
589
589
|
self.logger = logger if logger else _default_logger
|
|
590
590
|
self._not_expected = False
|
|
591
591
|
self._expected_warning = None
|
|
592
|
+
self._return_value = _UNSET
|
|
592
593
|
|
|
593
594
|
@property
|
|
594
595
|
def not_(self) -> NegatedBuilder:
|
|
@@ -430,7 +430,13 @@ class BaseMixin(_MixinBase):
|
|
|
430
430
|
"""
|
|
431
431
|
if isinstance(matcher, Matcher):
|
|
432
432
|
if not matcher.matches(self.val):
|
|
433
|
-
|
|
433
|
+
description = matcher.describe()
|
|
434
|
+
return self.error(
|
|
435
|
+
f"Expected {description}, but {matcher.describe_mismatch(self.val)}.",
|
|
436
|
+
actual=self.val,
|
|
437
|
+
expected=description,
|
|
438
|
+
diff=DiffResult(kind="match", entries=[DiffEntry(path=".", actual=self.val, expected=description)]),
|
|
439
|
+
)
|
|
434
440
|
elif callable(matcher):
|
|
435
441
|
if not matcher(self.val):
|
|
436
442
|
return self.error(f"Expected <{self.val}> to satisfy <{matcher}>, but did not.")
|
|
@@ -466,11 +472,17 @@ class BaseMixin(_MixinBase):
|
|
|
466
472
|
if not isinstance(self.val, collections.abc.Iterable):
|
|
467
473
|
raise TypeError("val is not iterable")
|
|
468
474
|
if isinstance(matcher, Matcher):
|
|
475
|
+
description = matcher.describe()
|
|
469
476
|
for i, item in enumerate(self.val):
|
|
470
477
|
if not matcher.matches(item):
|
|
471
478
|
return self.error(
|
|
472
|
-
f"Expected all items to satisfy {
|
|
473
|
-
f" {matcher.describe_mismatch(item)}."
|
|
479
|
+
f"Expected all items to satisfy {description}, but item at index {i} <{item}> did not:"
|
|
480
|
+
f" {matcher.describe_mismatch(item)}.",
|
|
481
|
+
actual=item,
|
|
482
|
+
expected=description,
|
|
483
|
+
diff=DiffResult(
|
|
484
|
+
kind="match", entries=[DiffEntry(path=f"[{i}]", actual=item, expected=description)]
|
|
485
|
+
),
|
|
474
486
|
)
|
|
475
487
|
elif callable(matcher):
|
|
476
488
|
for i, item in enumerate(self.val):
|
|
@@ -515,10 +527,17 @@ class BaseMixin(_MixinBase):
|
|
|
515
527
|
if not isinstance(spec, dict):
|
|
516
528
|
raise TypeError("given arg must be a dict")
|
|
517
529
|
matcher = StructureMatcher(spec)
|
|
518
|
-
|
|
530
|
+
mismatches = matcher.collect_mismatches(self.val)
|
|
531
|
+
if mismatches:
|
|
532
|
+
entries = [
|
|
533
|
+
DiffEntry(path=path, actual=actual, expected=description) for path, actual, description in mismatches
|
|
534
|
+
]
|
|
519
535
|
return self.error(
|
|
520
536
|
f"Expected <{self.val}> to match structure {matcher.describe()}, but"
|
|
521
|
-
f" {matcher.describe_mismatch(self.val)}."
|
|
537
|
+
f" {matcher.describe_mismatch(self.val)}.",
|
|
538
|
+
actual=self.val,
|
|
539
|
+
expected=spec,
|
|
540
|
+
diff=DiffResult(kind="match", entries=entries),
|
|
522
541
|
)
|
|
523
542
|
return self
|
|
524
543
|
|
|
@@ -9,6 +9,8 @@ if TYPE_CHECKING:
|
|
|
9
9
|
|
|
10
10
|
__tracebackhide__ = True
|
|
11
11
|
|
|
12
|
+
_UNSET = object() # sentinel: no return value captured yet
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
class _InertBuilder:
|
|
14
16
|
"""No-op builder returned after a failed raises/when_called_with in soft mode.
|
|
@@ -105,10 +107,34 @@ class ExceptionMixin(_MixinBase):
|
|
|
105
107
|
)
|
|
106
108
|
return cast("Self", _InertBuilder())
|
|
107
109
|
|
|
110
|
+
def returned(self) -> Self:
|
|
111
|
+
"""Pivots the chain to the value ``val()`` returned during :meth:`when_called_with`.
|
|
112
|
+
|
|
113
|
+
Use after a call that completed normally (:meth:`~assertpy2.warning.WarningMixin.warns`,
|
|
114
|
+
:meth:`~assertpy2.warning.WarningMixin.does_not_warn`, or :meth:`does_not_raise`) to assert
|
|
115
|
+
on the return value in the same chain.
|
|
116
|
+
|
|
117
|
+
Examples:
|
|
118
|
+
Usage::
|
|
119
|
+
|
|
120
|
+
assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client)
|
|
121
|
+
assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2).returned().is_equal_to(3)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
AssertionBuilder: a new instance wrapping the captured return value
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
TypeError: if no return value was captured (the call raised, or :meth:`when_called_with`
|
|
128
|
+
was not invoked first)
|
|
129
|
+
"""
|
|
130
|
+
if self._return_value is _UNSET:
|
|
131
|
+
raise TypeError("no return value captured; returned() is only valid after a call that completed normally")
|
|
132
|
+
return self.builder(self._return_value, self.description, self.kind, logger=self.logger)
|
|
133
|
+
|
|
108
134
|
def _when_called_with_not_expected(self, *some_args, **some_kwargs) -> Self:
|
|
109
135
|
assert self.expected is not None
|
|
110
136
|
try:
|
|
111
|
-
self.val(*some_args, **some_kwargs)
|
|
137
|
+
result = self.val(*some_args, **some_kwargs)
|
|
112
138
|
except BaseException as e:
|
|
113
139
|
if issubclass(type(e), self.expected):
|
|
114
140
|
self.error(
|
|
@@ -117,6 +143,8 @@ class ExceptionMixin(_MixinBase):
|
|
|
117
143
|
f" but did raise <{type(e).__name__}>."
|
|
118
144
|
)
|
|
119
145
|
return cast("Self", _InertBuilder())
|
|
146
|
+
return self
|
|
147
|
+
self._return_value = result
|
|
120
148
|
return self
|
|
121
149
|
|
|
122
150
|
def does_not_raise(self, ex) -> Self:
|
|
@@ -512,6 +512,26 @@ class EachMatcher(BaseMatcher):
|
|
|
512
512
|
return f"was <{value}>"
|
|
513
513
|
|
|
514
514
|
|
|
515
|
+
class _MissingSentinel:
|
|
516
|
+
"""Placeholder recorded for a spec key absent from the value during structural matching."""
|
|
517
|
+
|
|
518
|
+
def __repr__(self) -> str:
|
|
519
|
+
return "<missing>"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
_MISSING = _MissingSentinel()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _describe_spec_value(value: object) -> str:
|
|
526
|
+
"""Describe a single structure-spec value (matcher, nested dict spec, or raw value)."""
|
|
527
|
+
if isinstance(value, Matcher):
|
|
528
|
+
return value.describe()
|
|
529
|
+
if isinstance(value, dict):
|
|
530
|
+
parts = [f"{key}: {_describe_spec_value(sub_value)}" for key, sub_value in value.items()]
|
|
531
|
+
return f"{{{', '.join(parts)}}}"
|
|
532
|
+
return f"<{value}>"
|
|
533
|
+
|
|
534
|
+
|
|
515
535
|
class StructureMatcher(BaseMatcher):
|
|
516
536
|
"""Matches dicts against a structure spec where values are matchers, raw values, or nested dicts."""
|
|
517
537
|
|
|
@@ -559,16 +579,42 @@ class StructureMatcher(BaseMatcher):
|
|
|
559
579
|
return f"at <{current_path}>: expected <{expected}>, but was <{actual}>"
|
|
560
580
|
return None
|
|
561
581
|
|
|
582
|
+
def collect_mismatches(self, value: dict) -> list[tuple[str, object, str]]:
|
|
583
|
+
"""Collect every structural mismatch as ``(path, actual, expected_description)``.
|
|
584
|
+
|
|
585
|
+
Unlike :meth:`describe_mismatch`, this does not stop at the first failure and joins nested
|
|
586
|
+
paths, so callers can build a path-level :class:`~assertpy2.errors.DiffResult`.
|
|
587
|
+
"""
|
|
588
|
+
return self._collect(value, self._spec, "", set())
|
|
589
|
+
|
|
590
|
+
def _collect(self, value: dict, spec: dict, path: str, seen: set[tuple[int, int]]) -> list[tuple[str, object, str]]:
|
|
591
|
+
pair_id = (id(value), id(spec))
|
|
592
|
+
if pair_id in seen:
|
|
593
|
+
return [(path or "root", "<circular ref>", "<circular ref>")]
|
|
594
|
+
seen = seen | {pair_id}
|
|
595
|
+
mismatches: list[tuple[str, object, str]] = []
|
|
596
|
+
for key, expected in spec.items():
|
|
597
|
+
current_path = f"{path}.{key}" if path else str(key)
|
|
598
|
+
if key not in value:
|
|
599
|
+
mismatches.append((current_path, _MISSING, _describe_spec_value(expected)))
|
|
600
|
+
continue
|
|
601
|
+
actual = value[key]
|
|
602
|
+
if isinstance(expected, StructureMatcher) and isinstance(actual, dict):
|
|
603
|
+
mismatches.extend(self._collect(actual, expected._spec, current_path, seen))
|
|
604
|
+
elif isinstance(expected, Matcher):
|
|
605
|
+
if not expected.matches(actual):
|
|
606
|
+
mismatches.append((current_path, actual, expected.describe()))
|
|
607
|
+
elif isinstance(expected, dict):
|
|
608
|
+
if isinstance(actual, dict):
|
|
609
|
+
mismatches.extend(self._collect(actual, expected, current_path, seen))
|
|
610
|
+
else:
|
|
611
|
+
mismatches.append((current_path, actual, "a dict"))
|
|
612
|
+
elif actual != expected:
|
|
613
|
+
mismatches.append((current_path, actual, f"<{expected}>"))
|
|
614
|
+
return mismatches
|
|
615
|
+
|
|
562
616
|
def _describe_spec(self, spec: dict) -> str:
|
|
563
|
-
|
|
564
|
-
for key, value in spec.items():
|
|
565
|
-
if isinstance(value, Matcher):
|
|
566
|
-
parts.append(f"{key}: {value.describe()}")
|
|
567
|
-
elif isinstance(value, dict):
|
|
568
|
-
parts.append(f"{key}: {self._describe_spec(value)}")
|
|
569
|
-
else:
|
|
570
|
-
parts.append(f"{key}: <{value}>")
|
|
571
|
-
return f"{{{', '.join(parts)}}}"
|
|
617
|
+
return _describe_spec_value(spec)
|
|
572
618
|
|
|
573
619
|
|
|
574
620
|
# --- Custom matcher registry ---
|
|
@@ -121,6 +121,11 @@ def _format_diff(diff, *, color: bool = False, max_entries: int = 50) -> str:
|
|
|
121
121
|
lines.append(f" {path}:")
|
|
122
122
|
lines.append(f" {red}- {entry.actual!r}{reset}")
|
|
123
123
|
lines.append(f" {green}+ {entry.expected!r}{reset}")
|
|
124
|
+
elif kind == "match":
|
|
125
|
+
for entry in visible:
|
|
126
|
+
lines.append(
|
|
127
|
+
f" {cyan}{entry.path}{reset}: expected {entry.expected}, but was {red}{entry.actual!r}{reset}"
|
|
128
|
+
)
|
|
124
129
|
elif kind in {"set", "contains"}:
|
|
125
130
|
extra = [e for e in visible if e.path == "extra"]
|
|
126
131
|
missing = [e for e in visible if e.path == "missing"]
|
|
@@ -89,10 +89,12 @@ class WarningMixin(_MixinBase):
|
|
|
89
89
|
def _when_called_with_warning(self, expected: type[Warning], *some_args, **some_kwargs) -> Self:
|
|
90
90
|
with warnings.catch_warnings(record=True) as caught:
|
|
91
91
|
warnings.simplefilter("always") # bypass __warningregistry__ "show once" dedup and filterwarnings=error
|
|
92
|
-
self.val(*some_args, **some_kwargs)
|
|
92
|
+
result = self.val(*some_args, **some_kwargs)
|
|
93
93
|
matched = [w for w in caught if issubclass(w.category, expected)]
|
|
94
94
|
if matched:
|
|
95
|
-
|
|
95
|
+
captured = self.builder(str(matched[0].message), self.description, self.kind, logger=self.logger)
|
|
96
|
+
captured._return_value = result
|
|
97
|
+
return captured
|
|
96
98
|
if caught:
|
|
97
99
|
seen = ", ".join(sorted({w.category.__name__ for w in caught}))
|
|
98
100
|
self.error(
|
|
@@ -110,7 +112,7 @@ class WarningMixin(_MixinBase):
|
|
|
110
112
|
def _when_called_with_not_warning(self, expected: type[Warning], *some_args, **some_kwargs) -> Self:
|
|
111
113
|
with warnings.catch_warnings(record=True) as caught:
|
|
112
114
|
warnings.simplefilter("always") # bypass __warningregistry__ "show once" dedup and filterwarnings=error
|
|
113
|
-
self.val(*some_args, **some_kwargs)
|
|
115
|
+
result = self.val(*some_args, **some_kwargs)
|
|
114
116
|
matched = [w for w in caught if issubclass(w.category, expected)]
|
|
115
117
|
if matched:
|
|
116
118
|
seen = ", ".join(sorted({w.category.__name__ for w in matched}))
|
|
@@ -120,4 +122,5 @@ class WarningMixin(_MixinBase):
|
|
|
120
122
|
f" but did warn <{seen}>."
|
|
121
123
|
)
|
|
122
124
|
return cast("Self", _InertBuilder())
|
|
125
|
+
self._return_value = result
|
|
123
126
|
return self
|
|
@@ -82,6 +82,41 @@ The same check - `id` is a positive integer and `name` is a non-empty string - i
|
|
|
82
82
|
|
|
83
83
|
Only assertpy2 offers both the typed structural form and the `==` form from a single import.
|
|
84
84
|
|
|
85
|
+
## When it fails
|
|
86
|
+
|
|
87
|
+
A nested response, after `role` comes back as `"superadmin"`. What each library prints on failure:
|
|
88
|
+
|
|
89
|
+
=== "assertpy2"
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
assert_that(response).matches_structure({...})
|
|
93
|
+
--- Structured Diff ---
|
|
94
|
+
diff (match):
|
|
95
|
+
user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
=== "pytest assert"
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
assert response == expected
|
|
102
|
+
E Differing items:
|
|
103
|
+
E {'user': {'name': 'Alice', 'role': 'superadmin', 'age': 30}} !=
|
|
104
|
+
E {'user': {'name': 'Alice', 'role': 'admin', 'age': 30}}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
=== "dirty-equals"
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
assert response == {"user": {"role": IsOneOf("admin", "user"), ...}}
|
|
111
|
+
E Differing items:
|
|
112
|
+
E {'user': {'name': 'Alice', 'role': 'superadmin', 'age': 30}} !=
|
|
113
|
+
E {'user': {'name': IsStr, 'role': IsOneOf('admin', 'user'), 'age': IsInt}}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Only assertpy2 prints the path (`user.role`) and the exact predicate that failed. dirty-equals and the
|
|
117
|
+
assertpy2 `==` form both hand rendering to pytest, which dumps the whole differing container for you to
|
|
118
|
+
scan. The fluent form trades the zero-import convenience of `==` for a path-level diff.
|
|
119
|
+
|
|
85
120
|
## Style and typing
|
|
86
121
|
|
|
87
122
|
| | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
|
|
@@ -126,7 +161,7 @@ Only assertpy2 offers both the typed structural form and the `==` form from a si
|
|
|
126
161
|
|
|
127
162
|
| | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
|
|
128
163
|
|---|:---:|:---:|:---:|:---:|:---:|
|
|
129
|
-
| Latest release | built-in | 2.1.0 | 1.1 (2020) | 0.9.0 | **2.
|
|
164
|
+
| Latest release | built-in | 2.1.0 | 1.1 (2020) | 0.9.0 | **2.8.0** |
|
|
130
165
|
| Runtime dependencies | none | none | none | none | **none on 3.11+** |
|
|
131
166
|
| License | MIT | BSD | BSD | MIT | BSD-3 |
|
|
132
167
|
|
|
@@ -25,6 +25,9 @@ except AssertionError as e:
|
|
|
25
25
|
# DiffResult(kind='dict', entries=[DiffEntry(path='b', actual=2, expected=99)])
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`) attach a `DiffResult` with
|
|
29
|
+
`kind='match'`, where each entry's `expected` holds the failed predicate's description.
|
|
30
|
+
|
|
28
31
|
When the pytest plugin is active (auto-registered via the `pytest11` entry point, no configuration
|
|
29
32
|
needed), this data is rendered as extra report sections. See [Rich pytest diffs](#rich-pytest-diffs)
|
|
30
33
|
for supported types and configuration.
|
|
@@ -45,6 +48,7 @@ rendered by the plugin as colored diff sections.
|
|
|
45
48
|
| Pydantic model | `model` | Field-by-field via `model_dump()`, recursive into nested models |
|
|
46
49
|
| other | `scalar` | Single actual-vs-expected entry |
|
|
47
50
|
| `contains` family | `contains` | Missing and extra items |
|
|
51
|
+
| matcher mismatch | `match` | `matches_structure()` / `satisfies()` / `each()`: path + failed predicate |
|
|
48
52
|
|
|
49
53
|
```
|
|
50
54
|
--- AssertionFailure ---
|
|
@@ -61,6 +65,20 @@ Nested structures are diffed recursively and report the exact path to the differ
|
|
|
61
65
|
`[1].name`). Circular references are detected and shown as `<circular ref>` rather than recursing
|
|
62
66
|
forever.
|
|
63
67
|
|
|
68
|
+
Matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`) emit a `match` diff that
|
|
69
|
+
shows the path and the predicate that failed, for every field, not just the first:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
diff (match):
|
|
73
|
+
user.name: expected a non-empty string, but was ''
|
|
74
|
+
user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
|
|
75
|
+
user.age: expected a value between <18> and <120>, but was 15
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The rich `match`/`dict` diff comes from the fluent form. The `==` drop-in for matchers (for example
|
|
79
|
+
`assert response == {"id": match.is_positive()}`) hands rendering to pytest instead, which prints its
|
|
80
|
+
own dict comparison without the path.
|
|
81
|
+
|
|
64
82
|
### Configuration
|
|
65
83
|
|
|
66
84
|
```toml
|
|
@@ -119,6 +137,17 @@ The category defaults to `Warning` (matches any warning) and matches subclasses.
|
|
|
119
137
|
assert_that(safe_func).does_not_warn(DeprecationWarning).when_called_with("foo")
|
|
120
138
|
```
|
|
121
139
|
|
|
140
|
+
To also assert on the value the call returned (alongside the warning, or after `does_not_warn` /
|
|
141
|
+
`does_not_raise`), pivot with `returned()`:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client)
|
|
145
|
+
assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2).returned().is_equal_to(3)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`returned()` exposes the type-agnostic core assertions (`is_equal_to`, `is_instance_of`, `satisfies`,
|
|
149
|
+
...); it raises `TypeError` if the call raised (there is no return value to inspect).
|
|
150
|
+
|
|
122
151
|
!!! warning "Not thread-safe"
|
|
123
152
|
`warns()` / `does_not_warn()` rely on `warnings.catch_warnings()`, which mutates process-global
|
|
124
153
|
state. They are safe within a single thread (including multiple `asyncio` tasks on one event
|
|
@@ -35,6 +35,21 @@ assert_that(5).is_greater_than(10)
|
|
|
35
35
|
# AssertionError: Expected <5> to be greater than <10>, but was not.
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
For dicts, dataclasses, and other structures, the pytest plugin renders a path-level diff that points
|
|
39
|
+
straight at the differing field instead of dumping the whole value:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
assert_that(actual).is_equal_to(expected)
|
|
43
|
+
--- Structured Diff ---
|
|
44
|
+
diff (dict):
|
|
45
|
+
user.role:
|
|
46
|
+
- 'superadmin'
|
|
47
|
+
+ 'admin'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The same path-level diff backs `matches_structure()`, `satisfies()`, and `each()`. See
|
|
51
|
+
[Errors & Reporting](errors.md) for the full diff format and configuration.
|
|
52
|
+
|
|
38
53
|
## Collect multiple failures
|
|
39
54
|
|
|
40
55
|
Use soft assertions to report every failure at once instead of stopping at the first:
|
|
@@ -12,7 +12,7 @@ rich structural diffs on failure.
|
|
|
12
12
|
accurate autocomplete and type checkers catch misuse. This is the core advantage over `assertpy` and
|
|
13
13
|
most alternatives.
|
|
14
14
|
- **Composable matchers.** `match.greater_than(5)`, `match.is_uuid()`, combined with `&`, `|`, `~`.
|
|
15
|
-
- **Structural matching.** Declarative validation of dicts and API responses.
|
|
15
|
+
- **Structural matching.** Declarative validation of dicts 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.
|
|
@@ -37,5 +37,14 @@ assert_that({"id": 1, "name": "Alice"}).matches_structure(
|
|
|
37
37
|
)
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
When a check fails, the pytest plugin points at the exact field instead of dumping both structures:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
assert_that(response).matches_structure({...})
|
|
44
|
+
--- Structured Diff ---
|
|
45
|
+
diff (match):
|
|
46
|
+
user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
|
|
47
|
+
```
|
|
48
|
+
|
|
40
49
|
See [Getting Started](getting-started.md) to dive in, or browse [Type Assertions](assertions.md),
|
|
41
50
|
[Matchers](matchers.md), and the rest of the navigation for the full set of assertions and integrations.
|