assertpy2 2.9.1__tar.gz → 2.11.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.11.0}/PKG-INFO +32 -60
- {assertpy2-2.9.1 → assertpy2-2.11.0}/README.md +21 -59
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_mixin_base.py +6 -1
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/assertpy.py +3 -1
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/base.py +132 -64
- assertpy2-2.11.0/assertpy2/dataframe.py +153 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/extracting.py +2 -2
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/helpers.py +3 -54
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/matchers.py +14 -2
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assertions.md +1 -1
- assertpy2-2.11.0/docs/assets/diff-equal.png +0 -0
- assertpy2-2.11.0/docs/assets/diff-equal.svg +51 -0
- assertpy2-2.11.0/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assets/diff-match.svg +1 -2
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assets/diff-sequence.svg +1 -2
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assets/diff-set.svg +1 -2
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/comparison.md +15 -1
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/errors.md +2 -2
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/getting-started.md +1 -1
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/index.md +1 -1
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/integrations.md +62 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/matchers.md +25 -1
- {assertpy2-2.9.1 → assertpy2-2.11.0}/pyproject.toml +6 -2
- assertpy2-2.11.0/tests/test_dataframe.py +163 -0
- assertpy2-2.11.0/tests/test_equals.py +138 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_extracting.py +49 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_file.py +9 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_rich_diff.py +293 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_snapshots.py +1 -1
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_structural.py +81 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/uv.lock +468 -28
- 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/tests/test_equals.py +0 -66
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.codecov.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.gitattributes +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/.gitignore +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/LICENSE +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/SECURITY.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_compat.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_introspection.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_typing.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/collection.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/contains.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/date.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/errors.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/file.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/string.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/data.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/extending.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/fluent.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/logo.svg +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/migration.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/testing.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/type-safety.md +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/mkdocs.yml +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_async.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_bool.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_callable.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_class.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_collection.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_core.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_description.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_dict.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_dynamic.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_errors.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_fail.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_in.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_json.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_list.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_matchers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_mutation_hardening.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_none.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_not.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_property_based.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_readme.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_satisfy.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_soft.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_string.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_type.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_typing.py +0 -0
- {assertpy2-2.9.1 → assertpy2-2.11.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.11.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
|
|
@@ -31,9 +31,19 @@ Provides-Extra: allure
|
|
|
31
31
|
Requires-Dist: allure-pytest>=2.13; extra == 'allure'
|
|
32
32
|
Provides-Extra: behave
|
|
33
33
|
Requires-Dist: behave>=1.2.6; extra == 'behave'
|
|
34
|
+
Provides-Extra: data
|
|
35
|
+
Requires-Dist: numpy>=1.26; extra == 'data'
|
|
36
|
+
Requires-Dist: pandas>=2.0; extra == 'data'
|
|
37
|
+
Requires-Dist: polars>=1.0; extra == 'data'
|
|
34
38
|
Provides-Extra: json
|
|
35
39
|
Requires-Dist: jsonpath-ng>=1.8; extra == 'json'
|
|
36
40
|
Requires-Dist: jsonschema>=4.0; extra == 'json'
|
|
41
|
+
Provides-Extra: numpy
|
|
42
|
+
Requires-Dist: numpy>=1.26; extra == 'numpy'
|
|
43
|
+
Provides-Extra: pandas
|
|
44
|
+
Requires-Dist: pandas>=2.0; extra == 'pandas'
|
|
45
|
+
Provides-Extra: polars
|
|
46
|
+
Requires-Dist: polars>=1.0; extra == 'polars'
|
|
37
47
|
Description-Content-Type: text/markdown
|
|
38
48
|
|
|
39
49
|
<p align="center">
|
|
@@ -68,7 +78,7 @@ Description-Content-Type: text/markdown
|
|
|
68
78
|
|
|
69
79
|
---
|
|
70
80
|
|
|
71
|
-
|
|
81
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/getting-started/">Quick start</a></h2>
|
|
72
82
|
|
|
73
83
|
```bash
|
|
74
84
|
pip install assertpy2 # drop-in replacement for assertpy, just change the import
|
|
@@ -88,7 +98,7 @@ def test_user():
|
|
|
88
98
|
|
|
89
99
|
Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
|
|
90
100
|
|
|
91
|
-
|
|
101
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/comparison/">Why fluent assertions?</a></h2>
|
|
92
102
|
|
|
93
103
|
A fluent chain reads as one intent and replaces several bare asserts - and your IDE
|
|
94
104
|
offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
|
|
@@ -121,13 +131,21 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
|
|
|
121
131
|
assert_that(response).is_equal_to(expected)
|
|
122
132
|
```
|
|
123
133
|
|
|
124
|
-
<
|
|
134
|
+
<p align="center">
|
|
135
|
+
<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">
|
|
136
|
+
</p>
|
|
125
137
|
|
|
126
138
|
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
127
139
|
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
128
140
|
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
129
141
|
|
|
130
|
-
|
|
142
|
+
The same path-level treatment for dicts, lists, sets, and matcher predicates:
|
|
143
|
+
|
|
144
|
+
<p align="center">
|
|
145
|
+
<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">
|
|
146
|
+
</p>
|
|
147
|
+
|
|
148
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/type-safety/">Type-aware autocomplete</a></h2>
|
|
131
149
|
|
|
132
150
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
133
151
|
Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
@@ -144,7 +162,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
144
162
|
|
|
145
163
|
---
|
|
146
164
|
|
|
147
|
-
|
|
165
|
+
<h2 align="center">Features</h2>
|
|
148
166
|
|
|
149
167
|
**Fluent API**
|
|
150
168
|
|
|
@@ -158,7 +176,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
158
176
|
|
|
159
177
|
- [Strings](https://solganis.github.io/assertpy2/assertions/#strings), [numbers](https://solganis.github.io/assertpy2/assertions/#numbers), [lists](https://solganis.github.io/assertpy2/assertions/#lists), [tuples](https://solganis.github.io/assertpy2/assertions/#tuples), [sets](https://solganis.github.io/assertpy2/assertions/#sets), [dicts](https://solganis.github.io/assertpy2/assertions/#dicts), [dates](https://solganis.github.io/assertpy2/assertions/#dates), [booleans](https://solganis.github.io/assertpy2/assertions/#booleans), [objects](https://solganis.github.io/assertpy2/assertions/#objects), [bytes](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray), [files](https://solganis.github.io/assertpy2/assertions/#files), [exceptions](https://solganis.github.io/assertpy2/errors/#expected-exceptions).
|
|
160
178
|
- [**Bytes assertions**](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray): `is_valid_utf8()`, `starts_with_bytes()`, `is_hex_equal_to()`, `decoded_as()` for `bytes`/`bytearray`.
|
|
161
|
-
- [**JSON assertions**](https://solganis.github.io/assertpy2/data/#json-path--schema): JSONPath navigation and JSON Schema validation. `pip install assertpy2[json]`.
|
|
162
179
|
- [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
|
|
163
180
|
- [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects).
|
|
164
181
|
- [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
|
|
@@ -174,7 +191,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
174
191
|
|
|
175
192
|
**Type safety**
|
|
176
193
|
|
|
177
|
-
- [**Type-aware autocomplete**](
|
|
194
|
+
- [**Type-aware autocomplete**](https://solganis.github.io/assertpy2/type-safety/): 9 Protocols, IDE shows only relevant methods per type.
|
|
178
195
|
- **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
179
196
|
|
|
180
197
|
**Extensibility**
|
|
@@ -183,64 +200,19 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
183
200
|
- [**Regex group extraction**](https://solganis.github.io/assertpy2/data/#regex-group-extraction): `extracting_group()` and `matches_with_groups()` for regex captures.
|
|
184
201
|
- [**Extensions**](https://solganis.github.io/assertpy2/extending/): `add_extension()` for custom assertion methods.
|
|
185
202
|
|
|
186
|
-
**Integrations**
|
|
187
|
-
|
|
188
|
-
- [**Allure**](https://solganis.github.io/assertpy2/integrations/#allure): auto-attach structured diff and actual/expected data to reports. `pip install assertpy2[allure]`.
|
|
189
|
-
- [**Behave**](https://solganis.github.io/assertpy2/integrations/#behave): ready-made parameter types for step definitions. `pip install assertpy2[behave]`.
|
|
190
|
-
|
|
191
203
|
See the [full documentation](https://solganis.github.io/assertpy2/) for all assertion methods, examples, and advanced features.
|
|
192
204
|
|
|
193
205
|
---
|
|
194
206
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
### Allure
|
|
198
|
-
|
|
199
|
-
When `allure-pytest` is installed, the pytest plugin auto-attaches structured failure data to Allure reports as JSON attachments.
|
|
200
|
-
|
|
201
|
-
```bash
|
|
202
|
-
pip install assertpy2[allure]
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
Three modes controlled via `pytest.ini` (or `pyproject.toml`):
|
|
206
|
-
|
|
207
|
-
| Mode | What is attached |
|
|
208
|
-
|---|---|
|
|
209
|
-
| `diff` (default) | Structured Diff JSON (path-level breakdown) |
|
|
210
|
-
| `full` | Structured Diff + actual/expected JSON |
|
|
211
|
-
| `off` | Nothing |
|
|
212
|
-
|
|
213
|
-
```toml
|
|
214
|
-
# pyproject.toml
|
|
215
|
-
[tool.pytest.ini_options]
|
|
216
|
-
assertpy2_allure = "full"
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
### Behave
|
|
220
|
-
|
|
221
|
-
Ready-made parameter types for Behave step definitions:
|
|
222
|
-
|
|
223
|
-
```bash
|
|
224
|
-
pip install assertpy2[behave]
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
```py
|
|
228
|
-
# in environment.py or steps/conftest.py
|
|
229
|
-
from assertpy2.behave_matchers import register_assertpy_types
|
|
230
|
-
register_assertpy_types()
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
Then use in step definitions:
|
|
234
|
-
|
|
235
|
-
```py
|
|
236
|
-
@given('a user aged {age:PositiveInt}')
|
|
237
|
-
def step_impl(context, age):
|
|
238
|
-
context.age = age # already validated as int > 0
|
|
239
|
-
```
|
|
207
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/integrations/">Integrations</a></h2>
|
|
240
208
|
|
|
241
|
-
|
|
209
|
+
Optional adapters, each its own extra; full configuration and examples are in the
|
|
210
|
+
[Integrations guide](https://solganis.github.io/assertpy2/integrations/).
|
|
242
211
|
|
|
243
|
-
|
|
212
|
+
- [**Allure**](https://solganis.github.io/assertpy2/integrations/#allure) (`pip install assertpy2[allure]`): the pytest plugin auto-attaches structured diff and actual/expected data to Allure reports, in three configurable modes.
|
|
213
|
+
- [**Behave**](https://solganis.github.io/assertpy2/integrations/#behave) (`pip install assertpy2[behave]`): ready-made parameter types (`PositiveInt`, `NonEmptyString`, ...) for step definitions like `{age:PositiveInt}`.
|
|
214
|
+
- [**JSON**](https://solganis.github.io/assertpy2/data/#json-path--schema) (`pip install assertpy2[json]`): JSONPath navigation (`at_json_path()`, `has_json_path()`) and JSON Schema validation (`matches_json_schema()`).
|
|
215
|
+
- [**Data frames**](https://solganis.github.io/assertpy2/integrations/#data-frames-and-arrays) (`pip install assertpy2[pandas]` / `[polars]` / `[numpy]`): fluent equality for pandas/polars frames and numpy arrays, carrying each library's own diff.
|
|
244
216
|
|
|
245
217
|
---
|
|
246
218
|
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
---
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/getting-started/">Quick start</a></h2>
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
pip install assertpy2 # drop-in replacement for assertpy, just change the import
|
|
@@ -50,7 +50,7 @@ def test_user():
|
|
|
50
50
|
|
|
51
51
|
Browse the [full documentation](https://solganis.github.io/assertpy2/) for every assertion, matcher, and integration.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/comparison/">Why fluent assertions?</a></h2>
|
|
54
54
|
|
|
55
55
|
A fluent chain reads as one intent and replaces several bare asserts - and your IDE
|
|
56
56
|
offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/type-safety/):
|
|
@@ -83,13 +83,21 @@ 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
|
-
<
|
|
86
|
+
<p align="center">
|
|
87
|
+
<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">
|
|
88
|
+
</p>
|
|
87
89
|
|
|
88
90
|
Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
|
|
89
91
|
For responses with dynamic fields (IDs, timestamps), validate a subset with
|
|
90
92
|
[`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
|
|
91
93
|
|
|
92
|
-
|
|
94
|
+
The same path-level treatment for dicts, lists, sets, and matcher predicates:
|
|
95
|
+
|
|
96
|
+
<p align="center">
|
|
97
|
+
<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">
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/type-safety/">Type-aware autocomplete</a></h2>
|
|
93
101
|
|
|
94
102
|
`assert_that()` uses `@overload` to return type-specific Protocols.
|
|
95
103
|
Your IDE shows only methods relevant to the value you're testing, not all 100+:
|
|
@@ -106,7 +114,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
106
114
|
|
|
107
115
|
---
|
|
108
116
|
|
|
109
|
-
|
|
117
|
+
<h2 align="center">Features</h2>
|
|
110
118
|
|
|
111
119
|
**Fluent API**
|
|
112
120
|
|
|
@@ -120,7 +128,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
120
128
|
|
|
121
129
|
- [Strings](https://solganis.github.io/assertpy2/assertions/#strings), [numbers](https://solganis.github.io/assertpy2/assertions/#numbers), [lists](https://solganis.github.io/assertpy2/assertions/#lists), [tuples](https://solganis.github.io/assertpy2/assertions/#tuples), [sets](https://solganis.github.io/assertpy2/assertions/#sets), [dicts](https://solganis.github.io/assertpy2/assertions/#dicts), [dates](https://solganis.github.io/assertpy2/assertions/#dates), [booleans](https://solganis.github.io/assertpy2/assertions/#booleans), [objects](https://solganis.github.io/assertpy2/assertions/#objects), [bytes](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray), [files](https://solganis.github.io/assertpy2/assertions/#files), [exceptions](https://solganis.github.io/assertpy2/errors/#expected-exceptions).
|
|
122
130
|
- [**Bytes assertions**](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray): `is_valid_utf8()`, `starts_with_bytes()`, `is_hex_equal_to()`, `decoded_as()` for `bytes`/`bytearray`.
|
|
123
|
-
- [**JSON assertions**](https://solganis.github.io/assertpy2/data/#json-path--schema): JSONPath navigation and JSON Schema validation. `pip install assertpy2[json]`.
|
|
124
131
|
- [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
|
|
125
132
|
- [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects).
|
|
126
133
|
- [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
|
|
@@ -136,7 +143,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
136
143
|
|
|
137
144
|
**Type safety**
|
|
138
145
|
|
|
139
|
-
- [**Type-aware autocomplete**](
|
|
146
|
+
- [**Type-aware autocomplete**](https://solganis.github.io/assertpy2/type-safety/): 9 Protocols, IDE shows only relevant methods per type.
|
|
140
147
|
- **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
141
148
|
|
|
142
149
|
**Extensibility**
|
|
@@ -145,64 +152,19 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
145
152
|
- [**Regex group extraction**](https://solganis.github.io/assertpy2/data/#regex-group-extraction): `extracting_group()` and `matches_with_groups()` for regex captures.
|
|
146
153
|
- [**Extensions**](https://solganis.github.io/assertpy2/extending/): `add_extension()` for custom assertion methods.
|
|
147
154
|
|
|
148
|
-
**Integrations**
|
|
149
|
-
|
|
150
|
-
- [**Allure**](https://solganis.github.io/assertpy2/integrations/#allure): auto-attach structured diff and actual/expected data to reports. `pip install assertpy2[allure]`.
|
|
151
|
-
- [**Behave**](https://solganis.github.io/assertpy2/integrations/#behave): ready-made parameter types for step definitions. `pip install assertpy2[behave]`.
|
|
152
|
-
|
|
153
155
|
See the [full documentation](https://solganis.github.io/assertpy2/) for all assertion methods, examples, and advanced features.
|
|
154
156
|
|
|
155
157
|
---
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
### Allure
|
|
160
|
-
|
|
161
|
-
When `allure-pytest` is installed, the pytest plugin auto-attaches structured failure data to Allure reports as JSON attachments.
|
|
162
|
-
|
|
163
|
-
```bash
|
|
164
|
-
pip install assertpy2[allure]
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Three modes controlled via `pytest.ini` (or `pyproject.toml`):
|
|
168
|
-
|
|
169
|
-
| Mode | What is attached |
|
|
170
|
-
|---|---|
|
|
171
|
-
| `diff` (default) | Structured Diff JSON (path-level breakdown) |
|
|
172
|
-
| `full` | Structured Diff + actual/expected JSON |
|
|
173
|
-
| `off` | Nothing |
|
|
174
|
-
|
|
175
|
-
```toml
|
|
176
|
-
# pyproject.toml
|
|
177
|
-
[tool.pytest.ini_options]
|
|
178
|
-
assertpy2_allure = "full"
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
### Behave
|
|
182
|
-
|
|
183
|
-
Ready-made parameter types for Behave step definitions:
|
|
184
|
-
|
|
185
|
-
```bash
|
|
186
|
-
pip install assertpy2[behave]
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
```py
|
|
190
|
-
# in environment.py or steps/conftest.py
|
|
191
|
-
from assertpy2.behave_matchers import register_assertpy_types
|
|
192
|
-
register_assertpy_types()
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
Then use in step definitions:
|
|
196
|
-
|
|
197
|
-
```py
|
|
198
|
-
@given('a user aged {age:PositiveInt}')
|
|
199
|
-
def step_impl(context, age):
|
|
200
|
-
context.age = age # already validated as int > 0
|
|
201
|
-
```
|
|
159
|
+
<h2 align="center"><a href="https://solganis.github.io/assertpy2/integrations/">Integrations</a></h2>
|
|
202
160
|
|
|
203
|
-
|
|
161
|
+
Optional adapters, each its own extra; full configuration and examples are in the
|
|
162
|
+
[Integrations guide](https://solganis.github.io/assertpy2/integrations/).
|
|
204
163
|
|
|
205
|
-
|
|
164
|
+
- [**Allure**](https://solganis.github.io/assertpy2/integrations/#allure) (`pip install assertpy2[allure]`): the pytest plugin auto-attaches structured diff and actual/expected data to Allure reports, in three configurable modes.
|
|
165
|
+
- [**Behave**](https://solganis.github.io/assertpy2/integrations/#behave) (`pip install assertpy2[behave]`): ready-made parameter types (`PositiveInt`, `NonEmptyString`, ...) for step definitions like `{age:PositiveInt}`.
|
|
166
|
+
- [**JSON**](https://solganis.github.io/assertpy2/data/#json-path--schema) (`pip install assertpy2[json]`): JSONPath navigation (`at_json_path()`, `has_json_path()`) and JSON Schema validation (`matches_json_schema()`).
|
|
167
|
+
- [**Data frames**](https://solganis.github.io/assertpy2/integrations/#data-frames-and-arrays) (`pip install assertpy2[pandas]` / `[polars]` / `[numpy]`): fluent equality for pandas/polars frames and numpy arrays, carrying each library's own diff.
|
|
206
168
|
|
|
207
169
|
---
|
|
208
170
|
|
|
@@ -6,7 +6,7 @@ if TYPE_CHECKING:
|
|
|
6
6
|
import logging
|
|
7
7
|
|
|
8
8
|
from ._compat import Self
|
|
9
|
-
from .errors import DiffResult
|
|
9
|
+
from .errors import DiffEntry, DiffResult
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class _MixinBase:
|
|
@@ -96,6 +96,11 @@ class _MixinBase:
|
|
|
96
96
|
@staticmethod
|
|
97
97
|
def _to_comparable_dict(obj: object) -> dict[str, object] | None: ...
|
|
98
98
|
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _sub_diff_entries(
|
|
101
|
+
actual: object, expected: object, prefix: str, *, _seen: set[int] | None = ...
|
|
102
|
+
) -> list[DiffEntry] | None: ...
|
|
103
|
+
|
|
99
104
|
# NumericMixin class attrs used by HelpersMixin._validate_between_args
|
|
100
105
|
_NUMERIC_COMPAREABLE: frozenset[type]
|
|
101
106
|
_NUMERIC_NON_COMPAREABLE: frozenset[type]
|
|
@@ -34,6 +34,7 @@ from .base import BaseMixin
|
|
|
34
34
|
from .bytes_mixin import BytesMixin
|
|
35
35
|
from .collection import CollectionMixin
|
|
36
36
|
from .contains import ContainsMixin
|
|
37
|
+
from .dataframe import DataFrameMixin
|
|
37
38
|
from .date import DateMixin
|
|
38
39
|
from .dict import DictMixin
|
|
39
40
|
from .dynamic import DynamicMixin
|
|
@@ -48,7 +49,7 @@ from .snapshot import SnapshotMixin
|
|
|
48
49
|
from .string import StringMixin
|
|
49
50
|
from .warning import WarningMixin
|
|
50
51
|
|
|
51
|
-
__version__ = "2.
|
|
52
|
+
__version__ = "2.11.0"
|
|
52
53
|
|
|
53
54
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
54
55
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -548,6 +549,7 @@ class AssertionBuilder(
|
|
|
548
549
|
ContainsMixin,
|
|
549
550
|
CollectionMixin,
|
|
550
551
|
BytesMixin,
|
|
552
|
+
DataFrameMixin,
|
|
551
553
|
BaseMixin,
|
|
552
554
|
):
|
|
553
555
|
"""The main assertion class. Never call the constructor directly, always use the
|
|
@@ -4,7 +4,7 @@ import collections.abc
|
|
|
4
4
|
import dataclasses
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Final
|
|
6
6
|
|
|
7
|
-
from ._introspection import is_model_dump_object, is_namedtuple
|
|
7
|
+
from ._introspection import is_mapping_like, is_model_dump_object, is_namedtuple
|
|
8
8
|
from ._mixin_base import _MixinBase
|
|
9
9
|
from .errors import DiffEntry, DiffResult
|
|
10
10
|
from .matchers import Matcher, StructureMatcher
|
|
@@ -17,6 +17,34 @@ __tracebackhide__ = True
|
|
|
17
17
|
_SENTINEL: Final = object()
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _ambiguous_array_operand(value: object, other: object) -> object | None:
|
|
21
|
+
"""Return the array/frame-like operand whose ``==`` has no single truth value, else ``None``.
|
|
22
|
+
|
|
23
|
+
numpy/pandas/polars containers expose ``__array__`` and compare element-wise, so ``bool(a == b)``
|
|
24
|
+
raises rather than yielding one bool (and a ``DataFrame`` also quacks dict-like, which would otherwise
|
|
25
|
+
mis-dispatch the comparison). The ``__array__`` gate keeps the extra comparison off the hot path; the
|
|
26
|
+
truth test is actually attempted, so 0-d / scalar array values (which *are* truth-testable) pass
|
|
27
|
+
through unchanged.
|
|
28
|
+
"""
|
|
29
|
+
for candidate, counterpart in ((value, other), (other, value)):
|
|
30
|
+
if hasattr(candidate, "__array__"):
|
|
31
|
+
try:
|
|
32
|
+
bool(candidate == counterpart)
|
|
33
|
+
except (ValueError, TypeError):
|
|
34
|
+
return candidate
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _array_equality_error(method: str, operand: object) -> TypeError:
|
|
39
|
+
"""Build the actionable error raised when ``method`` is given an element-wise array/frame-like."""
|
|
40
|
+
return TypeError(
|
|
41
|
+
f"{method}() cannot directly compare <{type(operand).__name__}>: its '==' is element-wise and has"
|
|
42
|
+
" no single truth value. Compare the value's own equality (e.g."
|
|
43
|
+
" assert_that(actual.equals(expected)).is_true()), assert on extracted scalars (columns, shape,"
|
|
44
|
+
" length), or use satisfies(...) with an explicit predicate."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
20
48
|
class BaseMixin(_MixinBase):
|
|
21
49
|
"""Base mixin."""
|
|
22
50
|
|
|
@@ -115,7 +143,9 @@ class BaseMixin(_MixinBase):
|
|
|
115
143
|
Raises:
|
|
116
144
|
AssertionError: if actual is **not** equal to expected
|
|
117
145
|
TypeError: if ``ignore``/``include`` is a one-shot or otherwise unsupported iterable, or is
|
|
118
|
-
used on a value that is neither dict-like nor has introspectable fields
|
|
146
|
+
used on a value that is neither dict-like nor has introspectable fields; or if val or
|
|
147
|
+
other is an element-wise array/frame-like (numpy/pandas/polars) whose ``==`` has no single
|
|
148
|
+
truth value (compare the value's own equality, e.g. ``actual.equals(expected)``, instead)
|
|
119
149
|
|
|
120
150
|
Tip:
|
|
121
151
|
Using :meth:`is_equal_to` with a ``float`` val is just asking for trouble. Instead, you'll
|
|
@@ -128,6 +158,10 @@ class BaseMixin(_MixinBase):
|
|
|
128
158
|
ignore = kwargs.get("ignore")
|
|
129
159
|
include = kwargs.get("include")
|
|
130
160
|
|
|
161
|
+
operand = _ambiguous_array_operand(self.val, other)
|
|
162
|
+
if operand is not None:
|
|
163
|
+
raise _array_equality_error("is_equal_to", operand)
|
|
164
|
+
|
|
131
165
|
if self._is_dict_like(self.val, check_values=False) and self._is_dict_like(other, check_values=False):
|
|
132
166
|
if self._dict_not_equal(self.val, other, ignore=ignore, include=include):
|
|
133
167
|
self._dict_err(self.val, other, ignore=ignore, include=include)
|
|
@@ -187,6 +221,58 @@ class BaseMixin(_MixinBase):
|
|
|
187
221
|
expected=expected_item,
|
|
188
222
|
)
|
|
189
223
|
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _sequence_diff_entries(actual, expected, prefix, seen) -> list[DiffEntry]:
|
|
226
|
+
"""Diff two sequences element-by-element, recursing into nested containers.
|
|
227
|
+
|
|
228
|
+
``seen`` must already include the ids of ``actual``/``expected`` so a self-referential element
|
|
229
|
+
is caught. Shared by the top-level (:meth:`_build_equality_diff`) and nested
|
|
230
|
+
(:meth:`_sub_diff_entries`) paths so both decompose sequences identically.
|
|
231
|
+
"""
|
|
232
|
+
entries: list[DiffEntry] = []
|
|
233
|
+
max_len = max(len(actual), len(expected))
|
|
234
|
+
for i in range(max_len):
|
|
235
|
+
path = f"{prefix}[{i}]" if prefix else f"[{i}]"
|
|
236
|
+
if i >= len(actual):
|
|
237
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected[i]))
|
|
238
|
+
elif i >= len(expected):
|
|
239
|
+
entries.append(DiffEntry(path=path, actual=actual[i], expected=None))
|
|
240
|
+
elif actual[i] != expected[i]:
|
|
241
|
+
sub_entries = BaseMixin._sub_diff_entries(actual[i], expected[i], path, _seen=seen)
|
|
242
|
+
if sub_entries:
|
|
243
|
+
entries.extend(sub_entries)
|
|
244
|
+
else:
|
|
245
|
+
entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
|
|
246
|
+
return entries
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _dataclass_diff_entries(actual, expected, prefix, seen) -> list[DiffEntry]:
|
|
250
|
+
"""Diff two dataclasses over the sorted union of field names, both directions, recursing.
|
|
251
|
+
|
|
252
|
+
Reports fields present on only one side, and recurses into nested containers. ``seen`` must
|
|
253
|
+
already include the ids of ``actual``/``expected``. Shared by the top-level and nested paths
|
|
254
|
+
so both report dataclass fields identically.
|
|
255
|
+
"""
|
|
256
|
+
entries: list[DiffEntry] = []
|
|
257
|
+
actual_names = {field.name for field in dataclasses.fields(actual)}
|
|
258
|
+
expected_names = {field.name for field in dataclasses.fields(expected)}
|
|
259
|
+
for field in sorted(actual_names | expected_names):
|
|
260
|
+
path = f"{prefix}.{field}"
|
|
261
|
+
if field not in expected_names:
|
|
262
|
+
entries.append(DiffEntry(path=path, actual=getattr(actual, field), expected=None))
|
|
263
|
+
elif field not in actual_names:
|
|
264
|
+
entries.append(DiffEntry(path=path, actual=None, expected=getattr(expected, field)))
|
|
265
|
+
else:
|
|
266
|
+
actual_value = getattr(actual, field)
|
|
267
|
+
expected_value = getattr(expected, field)
|
|
268
|
+
if actual_value != expected_value:
|
|
269
|
+
sub_entries = BaseMixin._sub_diff_entries(actual_value, expected_value, path, _seen=seen)
|
|
270
|
+
if sub_entries is not None:
|
|
271
|
+
entries.extend(sub_entries)
|
|
272
|
+
else:
|
|
273
|
+
entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
|
|
274
|
+
return entries
|
|
275
|
+
|
|
190
276
|
@staticmethod
|
|
191
277
|
def _build_equality_diff(
|
|
192
278
|
actual: object, expected: object, *, _prefix: str = "", _seen: set[int] | None = None
|
|
@@ -229,21 +315,10 @@ class BaseMixin(_MixinBase):
|
|
|
229
315
|
and dataclasses.is_dataclass(expected)
|
|
230
316
|
and not isinstance(expected, type)
|
|
231
317
|
):
|
|
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)
|
|
318
|
+
return DiffResult(
|
|
319
|
+
kind="dataclass",
|
|
320
|
+
entries=BaseMixin._dataclass_diff_entries(actual, expected, _prefix, _seen),
|
|
321
|
+
)
|
|
247
322
|
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
248
323
|
actual_dict = actual.model_dump()
|
|
249
324
|
expected_dict = expected.model_dump()
|
|
@@ -262,21 +337,10 @@ class BaseMixin(_MixinBase):
|
|
|
262
337
|
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
263
338
|
return DiffResult(kind="model", entries=entries)
|
|
264
339
|
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)
|
|
340
|
+
return DiffResult(
|
|
341
|
+
kind="sequence",
|
|
342
|
+
entries=BaseMixin._sequence_diff_entries(actual, expected, _prefix, _seen),
|
|
343
|
+
)
|
|
280
344
|
if isinstance(actual, (set, frozenset)) and isinstance(expected, (set, frozenset)):
|
|
281
345
|
entries = []
|
|
282
346
|
for item in sorted(actual - expected, key=repr):
|
|
@@ -305,26 +369,36 @@ class BaseMixin(_MixinBase):
|
|
|
305
369
|
def _sub_diff_entries(
|
|
306
370
|
actual: object, expected: object, prefix: str, *, _seen: set[int] | None = None
|
|
307
371
|
) -> list[DiffEntry] | None:
|
|
372
|
+
"""Canonical recursive diff for a value, returning path-level entries (or ``None`` for a leaf).
|
|
373
|
+
|
|
374
|
+
Recurses into mappings, dataclasses, namedtuples, model-dump objects and sequences; returns
|
|
375
|
+
``None`` for anything else so the caller renders a single leaf entry. This is the single nested
|
|
376
|
+
engine shared by the top-level paths: :meth:`_build_equality_diff` (lists, dataclasses, ...) and
|
|
377
|
+
the dict path (:meth:`HelpersMixin._dict_err`), which calls it with an empty ``prefix`` so the
|
|
378
|
+
top-level dict keys render bare (``b``) and nested keys render dotted (``u.b``).
|
|
379
|
+
"""
|
|
308
380
|
if _seen is None:
|
|
309
381
|
_seen = set()
|
|
310
382
|
if id(actual) in _seen or id(expected) in _seen:
|
|
311
383
|
return [DiffEntry(path=prefix, actual="<circular ref>", expected="<circular ref>")]
|
|
312
384
|
|
|
313
|
-
if
|
|
385
|
+
if is_mapping_like(actual) and is_mapping_like(expected):
|
|
314
386
|
child_seen = _seen | {id(actual), id(expected)}
|
|
315
387
|
entries: list[DiffEntry] = []
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
entries.append(DiffEntry(path=path, actual=
|
|
322
|
-
elif
|
|
323
|
-
|
|
388
|
+
actual_keys = set(actual)
|
|
389
|
+
expected_keys = set(expected)
|
|
390
|
+
for key in sorted(actual_keys | expected_keys, key=repr):
|
|
391
|
+
path = f"{prefix}.{key}" if prefix else str(key)
|
|
392
|
+
if key not in expected_keys:
|
|
393
|
+
entries.append(DiffEntry(path=path, actual=actual[key], expected=None))
|
|
394
|
+
elif key not in actual_keys:
|
|
395
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected[key]))
|
|
396
|
+
elif actual[key] != expected[key]:
|
|
397
|
+
sub_entries = BaseMixin._sub_diff_entries(actual[key], expected[key], path, _seen=child_seen)
|
|
324
398
|
if sub_entries is not None:
|
|
325
399
|
entries.extend(sub_entries)
|
|
326
400
|
else:
|
|
327
|
-
entries.append(DiffEntry(path=path, actual=actual[key], expected=expected[key]))
|
|
401
|
+
entries.append(DiffEntry(path=path, actual=actual[key], expected=expected[key]))
|
|
328
402
|
return entries or None
|
|
329
403
|
if (
|
|
330
404
|
dataclasses.is_dataclass(actual)
|
|
@@ -333,23 +407,7 @@ class BaseMixin(_MixinBase):
|
|
|
333
407
|
and not isinstance(expected, type)
|
|
334
408
|
):
|
|
335
409
|
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
|
|
410
|
+
return BaseMixin._dataclass_diff_entries(actual, expected, prefix, child_seen) or None
|
|
353
411
|
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
354
412
|
child_seen = _seen | {id(actual), id(expected)}
|
|
355
413
|
entries = []
|
|
@@ -394,6 +452,9 @@ class BaseMixin(_MixinBase):
|
|
|
394
452
|
else:
|
|
395
453
|
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
396
454
|
return entries or None
|
|
455
|
+
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
456
|
+
child_seen = _seen | {id(actual), id(expected)}
|
|
457
|
+
return BaseMixin._sequence_diff_entries(actual, expected, prefix, child_seen) or None
|
|
397
458
|
return None
|
|
398
459
|
|
|
399
460
|
def satisfies(self, matcher) -> Self:
|
|
@@ -488,11 +549,12 @@ class BaseMixin(_MixinBase):
|
|
|
488
549
|
return self
|
|
489
550
|
|
|
490
551
|
def matches_structure(self, spec: dict[Any, Any]) -> Self:
|
|
491
|
-
"""Asserts that val
|
|
552
|
+
"""Asserts that val matches the given structure specification.
|
|
492
553
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
554
|
+
``val`` may be a dict or a pydantic-style model (anything exposing ``model_dump()``), which is
|
|
555
|
+
normalized to its dict before matching. Each key in ``spec`` maps to either a
|
|
556
|
+
:class:`~assertpy2.matchers.Matcher`, a raw value (checked via ``==``), or a nested ``dict``
|
|
557
|
+
for recursive matching. Extra keys in val that are absent from the spec are allowed.
|
|
496
558
|
|
|
497
559
|
Args:
|
|
498
560
|
spec: a dict where values can be Matcher instances, raw values, or nested dicts
|
|
@@ -515,7 +577,7 @@ class BaseMixin(_MixinBase):
|
|
|
515
577
|
Raises:
|
|
516
578
|
AssertionError: if val does **not** match the structure spec
|
|
517
579
|
"""
|
|
518
|
-
if not isinstance(self.val, dict):
|
|
580
|
+
if not isinstance(self.val, dict) and not is_model_dump_object(self.val):
|
|
519
581
|
raise TypeError("val must be a dict")
|
|
520
582
|
if not isinstance(spec, dict):
|
|
521
583
|
raise TypeError("given arg must be a dict")
|
|
@@ -697,7 +759,13 @@ class BaseMixin(_MixinBase):
|
|
|
697
759
|
|
|
698
760
|
Raises:
|
|
699
761
|
AssertionError: if actual **is** equal to expected
|
|
762
|
+
TypeError: if val or other is an element-wise array/frame-like (numpy/pandas/polars) whose
|
|
763
|
+
``==`` has no single truth value; compare the value's own equality instead
|
|
700
764
|
"""
|
|
765
|
+
operand = _ambiguous_array_operand(self.val, other)
|
|
766
|
+
if operand is not None:
|
|
767
|
+
raise _array_equality_error("is_not_equal_to", operand)
|
|
768
|
+
|
|
701
769
|
if self.val == other:
|
|
702
770
|
return self.error(f"Expected <{self.val}> to be not equal to <{other}>, but was.")
|
|
703
771
|
return self
|