assertpy2 2.7.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.7.0 → assertpy2-2.8.0}/PKG-INFO +38 -13
- {assertpy2-2.7.0 → assertpy2-2.8.0}/README.md +37 -12
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/base.py +24 -5
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/matchers.py +55 -9
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/pytest_plugin.py +5 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/comparison.md +36 -1
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/errors.md +18 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/getting-started.md +15 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/index.md +10 -1
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/matchers.md +19 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/pyproject.toml +1 -1
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_matchers.py +21 -1
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_recursive_compare_attrs.py +28 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_recursive_compare_pydantic.py +28 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_rich_diff.py +30 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_structural.py +69 -1
- {assertpy2-2.7.0 → assertpy2-2.8.0}/uv.lock +1 -1
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.codecov.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.gitattributes +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/.gitignore +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/LICENSE +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/SECURITY.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/_compat.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/_mixin_base.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/_typing.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/collection.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/contains.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/date.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/errors.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/extracting.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/file.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/helpers.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/string.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/assertions.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/data.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/extending.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/fluent.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/integrations.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/logo.svg +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/migration.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/docs/testing.md +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/mkdocs.yml +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_async.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_bool.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_class.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_collection.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_core.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_description.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_dict.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_dyn.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_equals.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_errors.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_extracting.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_fail.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_file.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_in.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_json.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_list.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_matchers_phase3.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_none.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_not.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_phase2.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_property_based.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_readme.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_snapshots.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_soft.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_string.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.7.0 → assertpy2-2.8.0}/tests/test_type.py +0 -0
- {assertpy2-2.7.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**
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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"]
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -80,6 +80,10 @@ assert 42 == (match.is_positive() & match.less_than(100))
|
|
|
80
80
|
AssertionError: assert -5 == a positive value
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
The `==` form hands rendering to pytest, so you get pytest's own message without a path-level
|
|
84
|
+
diff. For the rich `match` diff, use the fluent form (`satisfies()`, `matches_structure()`) -
|
|
85
|
+
see [Errors & Reporting](errors.md#rich-pytest-diffs).
|
|
86
|
+
|
|
83
87
|
!!! tip
|
|
84
88
|
This makes matchers a drop-in addition to an existing suite: add one import, use `match.*` in any
|
|
85
89
|
`==` comparison, no rewrite required.
|
|
@@ -171,6 +175,21 @@ assert_that({"id": "abc-123", "tags": ["python", "testing"]}).matches_structure(
|
|
|
171
175
|
Keys present in the value but absent from the spec are ignored, so a structure spec validates a
|
|
172
176
|
subset of fields rather than requiring an exact match.
|
|
173
177
|
|
|
178
|
+
### What you see on failure
|
|
179
|
+
|
|
180
|
+
When fields do not match, the pytest plugin prints the exact path and the predicate that failed - every
|
|
181
|
+
mismatch, not just the first:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
diff (match):
|
|
185
|
+
user.name: expected a non-empty string, but was ''
|
|
186
|
+
user.role: expected a value in <('admin', 'user')>, but was 'superadmin'
|
|
187
|
+
user.age: expected a value between <18> and <120>, but was 15
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The same `match` diff is produced by [`satisfies()`](#satisfies) and [`each()`](#each) whenever a
|
|
191
|
+
matcher fails inside an assertion.
|
|
192
|
+
|
|
174
193
|
## Custom matchers
|
|
175
194
|
|
|
176
195
|
`register_matcher()` adds your own matcher to the `match` namespace. Custom matchers compose with
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from assertpy2 import Matcher, assert_that, match
|
|
3
|
+
from assertpy2 import AssertionFailure, Matcher, assert_that, match
|
|
4
4
|
from assertpy2.matchers import BaseMatcher
|
|
5
5
|
|
|
6
6
|
|
|
@@ -459,6 +459,16 @@ class TestSatisfies:
|
|
|
459
459
|
def test_chaining(self):
|
|
460
460
|
assert_that(7).satisfies(match.greater_than(5)).is_less_than(10)
|
|
461
461
|
|
|
462
|
+
def test_failure_attaches_match_diff(self):
|
|
463
|
+
try:
|
|
464
|
+
assert_that(5).satisfies(match.greater_than(10))
|
|
465
|
+
except AssertionFailure as exc:
|
|
466
|
+
assert_that(exc.diff.kind).is_equal_to("match")
|
|
467
|
+
assert_that(exc.diff.entries[0].path).is_equal_to(".")
|
|
468
|
+
assert_that(exc.diff.entries[0].actual).is_equal_to(5)
|
|
469
|
+
else:
|
|
470
|
+
raise AssertionError("expected AssertionFailure") from None
|
|
471
|
+
|
|
462
472
|
|
|
463
473
|
class TestEach:
|
|
464
474
|
def test_with_matcher(self):
|
|
@@ -500,6 +510,16 @@ class TestEach:
|
|
|
500
510
|
users = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
|
|
501
511
|
assert_that(users).extracting("age").each(match.between(18, 120))
|
|
502
512
|
|
|
513
|
+
def test_failure_attaches_match_diff(self):
|
|
514
|
+
try:
|
|
515
|
+
assert_that([1, 2, -3]).each(match.is_positive())
|
|
516
|
+
except AssertionFailure as exc:
|
|
517
|
+
assert_that(exc.diff.kind).is_equal_to("match")
|
|
518
|
+
assert_that(exc.diff.entries[0].path).is_equal_to("[2]")
|
|
519
|
+
assert_that(exc.diff.entries[0].actual).is_equal_to(-3)
|
|
520
|
+
else:
|
|
521
|
+
raise AssertionError("expected AssertionFailure") from None
|
|
522
|
+
|
|
503
523
|
|
|
504
524
|
class TestContainsWithMatcher:
|
|
505
525
|
def test_single_matcher(self):
|
|
@@ -3,6 +3,8 @@ import pytest
|
|
|
3
3
|
pytest.importorskip("attrs", reason="attrs not installed")
|
|
4
4
|
|
|
5
5
|
import attrs
|
|
6
|
+
from hypothesis import given, settings
|
|
7
|
+
from hypothesis import strategies as st
|
|
6
8
|
|
|
7
9
|
from assertpy2 import assert_that
|
|
8
10
|
from assertpy2.helpers import HelpersMixin
|
|
@@ -51,3 +53,29 @@ class TestAttrsToComparableDict:
|
|
|
51
53
|
product = Product(sku="A", name="W", price=10.0)
|
|
52
54
|
result = HelpersMixin._to_comparable_dict(product)
|
|
53
55
|
assert_that(result).is_equal_to({"sku": "A", "name": "W", "price": 10.0})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_products = st.builds(Product, sku=st.text(max_size=5), name=st.text(max_size=5), price=st.floats(allow_nan=False))
|
|
59
|
+
_orders = st.builds(Order, id=st.integers(), product=_products, quantity=st.integers())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestAttrsProperties:
|
|
63
|
+
@settings(deadline=None)
|
|
64
|
+
@given(left=_orders, right=_orders)
|
|
65
|
+
def test_is_equal_to_consistent_with_eq(self, left, right):
|
|
66
|
+
if left == right:
|
|
67
|
+
assert_that(left).is_equal_to(right)
|
|
68
|
+
else:
|
|
69
|
+
with pytest.raises(AssertionError):
|
|
70
|
+
assert_that(left).is_equal_to(right)
|
|
71
|
+
|
|
72
|
+
@settings(deadline=None)
|
|
73
|
+
@given(value=_orders)
|
|
74
|
+
def test_is_equal_to_reflexive(self, value):
|
|
75
|
+
assert_that(value).is_equal_to(attrs.evolve(value))
|
|
76
|
+
|
|
77
|
+
@settings(deadline=None)
|
|
78
|
+
@given(value=_products, new_sku=st.text(max_size=5))
|
|
79
|
+
def test_ignore_removes_field_difference(self, value, new_sku):
|
|
80
|
+
other = attrs.evolve(value, sku=new_sku)
|
|
81
|
+
assert_that(value).is_equal_to(other, ignore="sku")
|
|
@@ -2,6 +2,8 @@ import pytest
|
|
|
2
2
|
|
|
3
3
|
pytest.importorskip("pydantic", reason="pydantic not installed")
|
|
4
4
|
|
|
5
|
+
from hypothesis import given, settings
|
|
6
|
+
from hypothesis import strategies as st
|
|
5
7
|
from pydantic import BaseModel
|
|
6
8
|
|
|
7
9
|
from assertpy2 import assert_that
|
|
@@ -53,3 +55,29 @@ class TestPydanticListOfObjects:
|
|
|
53
55
|
actual = [UserDto(id=1, name="Alice"), UserDto(id=2, name="Bob")]
|
|
54
56
|
expected = [UserDto(id=99, name="Alice"), UserDto(id=99, name="Bob")]
|
|
55
57
|
assert_that(actual).is_equal_to(expected, ignore="id")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_addresses = st.builds(AddressDto, city=st.text(max_size=5), zip_code=st.text(max_size=5))
|
|
61
|
+
_users = st.builds(UserDtoWithAddress, id=st.integers(), name=st.text(max_size=5), address=_addresses)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestPydanticProperties:
|
|
65
|
+
@settings(deadline=None)
|
|
66
|
+
@given(left=_users, right=_users)
|
|
67
|
+
def test_is_equal_to_consistent_with_eq(self, left, right):
|
|
68
|
+
if left == right:
|
|
69
|
+
assert_that(left).is_equal_to(right)
|
|
70
|
+
else:
|
|
71
|
+
with pytest.raises(AssertionError):
|
|
72
|
+
assert_that(left).is_equal_to(right)
|
|
73
|
+
|
|
74
|
+
@settings(deadline=None)
|
|
75
|
+
@given(value=_users)
|
|
76
|
+
def test_is_equal_to_reflexive(self, value):
|
|
77
|
+
assert_that(value).is_equal_to(value.model_copy(deep=True))
|
|
78
|
+
|
|
79
|
+
@settings(deadline=None)
|
|
80
|
+
@given(value=st.builds(UserDto, id=st.integers(), name=st.text(max_size=5)), new_id=st.integers())
|
|
81
|
+
def test_ignore_removes_field_difference(self, value, new_id):
|
|
82
|
+
other = value.model_copy(update={"id": new_id})
|
|
83
|
+
assert_that(value).is_equal_to(other, ignore="id")
|
|
@@ -406,6 +406,36 @@ class TestPytestPluginDiffRendering:
|
|
|
406
406
|
)
|
|
407
407
|
assert_that(result.stdout).does_not_contain("Structured Diff")
|
|
408
408
|
|
|
409
|
+
def test_matches_structure_diff_in_report(self, tmp_path):
|
|
410
|
+
test_file = tmp_path / "test_sample.py"
|
|
411
|
+
test_file.write_text(
|
|
412
|
+
"from assertpy2 import assert_that, match\n"
|
|
413
|
+
"def test_structure():\n"
|
|
414
|
+
" assert_that({'role': 'guest', 'address': {'city': 'LA'}}).matches_structure({\n"
|
|
415
|
+
" 'role': match.is_in('admin', 'user'),\n"
|
|
416
|
+
" 'address': match.structure({'city': match.equal_to('NYC')}),\n"
|
|
417
|
+
" })\n",
|
|
418
|
+
)
|
|
419
|
+
result = subprocess.run(
|
|
420
|
+
["uv", "run", "pytest", str(test_file), "-v", "--no-header", "--tb=short"],
|
|
421
|
+
capture_output=True,
|
|
422
|
+
text=True,
|
|
423
|
+
timeout=60,
|
|
424
|
+
)
|
|
425
|
+
assert_that(result.stdout).contains("diff (match)")
|
|
426
|
+
assert_that(result.stdout).contains("address.city")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TestFormatDiffMatchKind:
|
|
430
|
+
def test_renders_predicate_and_actual(self):
|
|
431
|
+
diff = DiffResult(
|
|
432
|
+
kind="match",
|
|
433
|
+
entries=[DiffEntry(path="role", actual="guest", expected="a value in <('admin', 'user')>")],
|
|
434
|
+
)
|
|
435
|
+
output = _format_diff(diff)
|
|
436
|
+
assert_that(output).contains("diff (match):")
|
|
437
|
+
assert_that(output).contains("role: expected a value in <('admin', 'user')>, but was 'guest'")
|
|
438
|
+
|
|
409
439
|
|
|
410
440
|
class TestFormatDiffTruncation:
|
|
411
441
|
def test_truncation_when_over_max(self):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from assertpy2 import assert_that, match
|
|
3
|
+
from assertpy2 import AssertionFailure, assert_that, match
|
|
4
4
|
from assertpy2.matchers import (
|
|
5
5
|
EachMatcher,
|
|
6
6
|
IgnoreMatcher,
|
|
@@ -243,6 +243,60 @@ class TestStructureMatcher:
|
|
|
243
243
|
assert_that(matcher.matches(value)).is_true()
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
class TestCollectMismatches:
|
|
247
|
+
def test_collects_all_failing_fields(self):
|
|
248
|
+
matcher = match.structure({"a": match.is_positive(), "b": match.is_positive(), "c": match.is_positive()})
|
|
249
|
+
result = matcher.collect_mismatches({"a": -1, "b": 5, "c": -3})
|
|
250
|
+
assert_that([entry[0] for entry in result]).is_equal_to(["a", "c"])
|
|
251
|
+
|
|
252
|
+
def test_empty_when_all_match(self):
|
|
253
|
+
matcher = match.structure({"a": match.is_positive(), "status": "active"})
|
|
254
|
+
assert_that(matcher.collect_mismatches({"a": 5, "status": "active"})).is_empty()
|
|
255
|
+
|
|
256
|
+
def test_nested_structure_matcher_joins_path(self):
|
|
257
|
+
matcher = match.structure({"address": match.structure({"city": match.equal_to("NYC")})})
|
|
258
|
+
result = matcher.collect_mismatches({"address": {"city": "LA"}})
|
|
259
|
+
assert_that(result[0][0]).is_equal_to("address.city")
|
|
260
|
+
|
|
261
|
+
def test_missing_key_recorded(self):
|
|
262
|
+
matcher = match.structure({"name": match.is_non_empty_string()})
|
|
263
|
+
path, actual, description = matcher.collect_mismatches({})[0]
|
|
264
|
+
assert_that(path).is_equal_to("name")
|
|
265
|
+
assert_that(repr(actual)).is_equal_to("<missing>")
|
|
266
|
+
assert_that(description).is_equal_to("a non-empty string")
|
|
267
|
+
|
|
268
|
+
def test_nested_structure_matcher_against_non_dict(self):
|
|
269
|
+
matcher = match.structure({"user": match.structure({"name": match.is_non_empty_string()})})
|
|
270
|
+
result = matcher.collect_mismatches({"user": "not a dict"})
|
|
271
|
+
assert_that(result[0][0]).is_equal_to("user")
|
|
272
|
+
|
|
273
|
+
def test_plain_nested_dict_against_non_dict(self):
|
|
274
|
+
matcher = match.structure({"user": {"name": match.is_non_empty_string()}})
|
|
275
|
+
path, _actual, description = matcher.collect_mismatches({"user": "not a dict"})[0]
|
|
276
|
+
assert_that(path).is_equal_to("user")
|
|
277
|
+
assert_that(description).is_equal_to("a dict")
|
|
278
|
+
|
|
279
|
+
def test_plain_nested_dict_recurses(self):
|
|
280
|
+
matcher = match.structure({"user": {"role": "admin"}})
|
|
281
|
+
result = matcher.collect_mismatches({"user": {"role": "guest"}})
|
|
282
|
+
assert_that(result[0][0]).is_equal_to("user.role")
|
|
283
|
+
|
|
284
|
+
def test_raw_value_mismatch(self):
|
|
285
|
+
matcher = match.structure({"status": "active"})
|
|
286
|
+
path, actual, description = matcher.collect_mismatches({"status": "inactive"})[0]
|
|
287
|
+
assert_that(path).is_equal_to("status")
|
|
288
|
+
assert_that(actual).is_equal_to("inactive")
|
|
289
|
+
assert_that(description).is_equal_to("<active>")
|
|
290
|
+
|
|
291
|
+
def test_circular_reference(self):
|
|
292
|
+
circular = {}
|
|
293
|
+
circular["self"] = circular
|
|
294
|
+
spec = {}
|
|
295
|
+
spec["self"] = spec
|
|
296
|
+
result = match.structure(spec).collect_mismatches(circular)
|
|
297
|
+
assert_that(result[0][1]).is_equal_to("<circular ref>")
|
|
298
|
+
|
|
299
|
+
|
|
246
300
|
class TestMatchesStructureMethod:
|
|
247
301
|
def test_basic(self):
|
|
248
302
|
user = {"name": "Alice", "age": 30}
|
|
@@ -293,3 +347,17 @@ class TestMatchesStructureMethod:
|
|
|
293
347
|
def test_error_message_contains_structure_description(self):
|
|
294
348
|
with pytest.raises(AssertionError, match="to match structure"):
|
|
295
349
|
assert_that({"x": 1}).matches_structure({"x": match.is_non_empty_string()})
|
|
350
|
+
|
|
351
|
+
def test_failure_attaches_structured_diff(self):
|
|
352
|
+
value = {"role": "guest", "address": {"city": "LA"}}
|
|
353
|
+
try:
|
|
354
|
+
assert_that(value).matches_structure(
|
|
355
|
+
{"role": match.is_in("admin", "user"), "address": match.structure({"city": match.equal_to("NYC")})}
|
|
356
|
+
)
|
|
357
|
+
except AssertionFailure as exc:
|
|
358
|
+
assert_that(exc.diff.kind).is_equal_to("match")
|
|
359
|
+
assert_that([entry.path for entry in exc.diff.entries]).contains("role", "address.city")
|
|
360
|
+
assert_that(exc.actual).is_equal_to(value)
|
|
361
|
+
assert_that(exc.expected).is_not_none()
|
|
362
|
+
else:
|
|
363
|
+
raise AssertionError("expected AssertionFailure") from None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|