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.
Files changed (129) hide show
  1. {assertpy2-2.9.1 → assertpy2-2.11.0}/PKG-INFO +32 -60
  2. {assertpy2-2.9.1 → assertpy2-2.11.0}/README.md +21 -59
  3. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_mixin_base.py +6 -1
  4. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/assertpy.py +3 -1
  5. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/base.py +132 -64
  6. assertpy2-2.11.0/assertpy2/dataframe.py +153 -0
  7. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/extracting.py +2 -2
  8. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/helpers.py +3 -54
  9. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/matchers.py +14 -2
  10. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assertions.md +1 -1
  11. assertpy2-2.11.0/docs/assets/diff-equal.png +0 -0
  12. assertpy2-2.11.0/docs/assets/diff-equal.svg +51 -0
  13. assertpy2-2.11.0/docs/assets/diff-gallery.png +0 -0
  14. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assets/diff-match.svg +1 -2
  15. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assets/diff-sequence.svg +1 -2
  16. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/assets/diff-set.svg +1 -2
  17. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/comparison.md +15 -1
  18. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/errors.md +2 -2
  19. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/getting-started.md +1 -1
  20. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/index.md +1 -1
  21. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/integrations.md +62 -0
  22. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/matchers.md +25 -1
  23. {assertpy2-2.9.1 → assertpy2-2.11.0}/pyproject.toml +6 -2
  24. assertpy2-2.11.0/tests/test_dataframe.py +163 -0
  25. assertpy2-2.11.0/tests/test_equals.py +138 -0
  26. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_extracting.py +49 -0
  27. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_file.py +9 -0
  28. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_rich_diff.py +293 -0
  29. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_snapshots.py +1 -1
  30. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_structural.py +81 -0
  31. {assertpy2-2.9.1 → assertpy2-2.11.0}/uv.lock +468 -28
  32. assertpy2-2.9.1/docs/assets/diff-equal.png +0 -0
  33. assertpy2-2.9.1/docs/assets/diff-equal.svg +0 -64
  34. assertpy2-2.9.1/tests/test_equals.py +0 -66
  35. {assertpy2-2.9.1 → assertpy2-2.11.0}/.codecov.yml +0 -0
  36. {assertpy2-2.9.1 → assertpy2-2.11.0}/.gitattributes +0 -0
  37. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/dependabot.yml +0 -0
  38. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/ci.yml +0 -0
  39. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/codeql.yml +0 -0
  40. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/docs.yml +0 -0
  41. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/mutation.yml +0 -0
  42. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/publish.yml +0 -0
  43. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/scorecard.yml +0 -0
  44. {assertpy2-2.9.1 → assertpy2-2.11.0}/.github/workflows/zizmor.yml +0 -0
  45. {assertpy2-2.9.1 → assertpy2-2.11.0}/.gitignore +0 -0
  46. {assertpy2-2.9.1 → assertpy2-2.11.0}/CONTRIBUTING.md +0 -0
  47. {assertpy2-2.9.1 → assertpy2-2.11.0}/LICENSE +0 -0
  48. {assertpy2-2.9.1 → assertpy2-2.11.0}/SECURITY.md +0 -0
  49. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/__init__.py +0 -0
  50. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_compat.py +0 -0
  51. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_introspection.py +0 -0
  52. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/_typing.py +0 -0
  53. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/async_assertions.py +0 -0
  54. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/behave_matchers.py +0 -0
  55. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/bytes_mixin.py +0 -0
  56. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/collection.py +0 -0
  57. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/contains.py +0 -0
  58. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/date.py +0 -0
  59. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/dict.py +0 -0
  60. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/dynamic.py +0 -0
  61. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/errors.py +0 -0
  62. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/exception.py +0 -0
  63. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/file.py +0 -0
  64. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/json_mixin.py +0 -0
  65. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/numeric.py +0 -0
  66. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/py.typed +0 -0
  67. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/pytest_plugin.py +0 -0
  68. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/snapshot.py +0 -0
  69. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/string.py +0 -0
  70. {assertpy2-2.9.1 → assertpy2-2.11.0}/assertpy2/warning.py +0 -0
  71. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/data.md +0 -0
  72. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/extending.md +0 -0
  73. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/fluent.md +0 -0
  74. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/logo-dark.svg +0 -0
  75. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/logo.svg +0 -0
  76. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/migration.md +0 -0
  77. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/testing.md +0 -0
  78. {assertpy2-2.9.1 → assertpy2-2.11.0}/docs/type-safety.md +0 -0
  79. {assertpy2-2.9.1 → assertpy2-2.11.0}/mkdocs.yml +0 -0
  80. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_async.py +0 -0
  81. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_behave_matchers.py +0 -0
  82. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_bool.py +0 -0
  83. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_bytes.py +0 -0
  84. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_callable.py +0 -0
  85. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_chaining.py +0 -0
  86. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_class.py +0 -0
  87. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_collection.py +0 -0
  88. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_core.py +0 -0
  89. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_custom_dict.py +0 -0
  90. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_custom_list.py +0 -0
  91. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_datetime.py +0 -0
  92. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_description.py +0 -0
  93. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_dict.py +0 -0
  94. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_dict_compare.py +0 -0
  95. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_dynamic.py +0 -0
  96. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_errors.py +0 -0
  97. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_expected_exception.py +0 -0
  98. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_expected_warning.py +0 -0
  99. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_extensions.py +0 -0
  100. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_fail.py +0 -0
  101. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_grouped_soft.py +0 -0
  102. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_in.py +0 -0
  103. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_json.py +0 -0
  104. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_list.py +0 -0
  105. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_matcher_registry.py +0 -0
  106. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_matchers.py +0 -0
  107. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_mutation_hardening.py +0 -0
  108. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_namedtuple.py +0 -0
  109. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_none.py +0 -0
  110. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_not.py +0 -0
  111. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_numbers.py +0 -0
  112. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_overloads.py +0 -0
  113. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_pipeline.py +0 -0
  114. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_property_based.py +0 -0
  115. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_pytest_plugin.py +0 -0
  116. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_readme.py +0 -0
  117. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_recursive_compare.py +0 -0
  118. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_recursive_compare_attrs.py +0 -0
  119. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_recursive_compare_pydantic.py +0 -0
  120. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_regex_groups.py +0 -0
  121. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_same_as.py +0 -0
  122. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_satisfy.py +0 -0
  123. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_soft.py +0 -0
  124. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_soft_fail.py +0 -0
  125. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_string.py +0 -0
  126. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_traceback.py +0 -0
  127. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_type.py +0 -0
  128. {assertpy2-2.9.1 → assertpy2-2.11.0}/tests/test_typing.py +0 -0
  129. {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.9.1
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
- ## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
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
- ## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
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
- <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
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
- ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
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
- ## Features
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**](#type-aware-autocomplete): 9 Protocols, IDE shows only relevant methods per type.
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
- ## [Integrations](https://solganis.github.io/assertpy2/integrations/)
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
- Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
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
- See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
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
- ## [Quick start](https://solganis.github.io/assertpy2/getting-started/)
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
- ## [Why fluent assertions?](https://solganis.github.io/assertpy2/comparison/)
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
- <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
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
- ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
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
- ## Features
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**](#type-aware-autocomplete): 9 Protocols, IDE shows only relevant methods per type.
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
- ## [Integrations](https://solganis.github.io/assertpy2/integrations/)
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
- Available types: `PositiveInt`, `NonNegativeInt`, `PositiveFloat`, `NonEmptyString`, `BoolLike`.
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
- See the [Integrations guide](https://solganis.github.io/assertpy2/integrations/) for attachment modes, configuration, and full examples.
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.9.1"
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
- entries = []
233
- actual_names = {field.name for field in dataclasses.fields(actual)}
234
- expected_names = {field.name for field in dataclasses.fields(expected)}
235
- for field in sorted(actual_names | expected_names):
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
- entries = []
266
- max_len = max(len(actual), len(expected))
267
- for i in range(max_len):
268
- path = f"{_prefix}[{i}]" if _prefix else f"[{i}]"
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 isinstance(actual, dict) and isinstance(expected, dict):
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
- for key in sorted(set(actual) | set(expected), key=repr):
317
- path = f"{prefix}.{key}"
318
- if key not in expected:
319
- entries.append(DiffEntry(path=path, actual=actual[key], expected=None)) # ty: ignore[invalid-argument-type]
320
- elif key not in actual:
321
- entries.append(DiffEntry(path=path, actual=None, expected=expected[key])) # ty: ignore[invalid-argument-type]
322
- elif actual[key] != expected[key]: # ty: ignore[invalid-argument-type]
323
- sub_entries = BaseMixin._sub_diff_entries(actual[key], expected[key], path, _seen=child_seen) # ty: ignore[invalid-argument-type]
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])) # ty: ignore[invalid-argument-type]
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
- entries = []
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 is a dict matching the given structure specification.
552
+ """Asserts that val matches the given structure specification.
492
553
 
493
- Each key in ``spec`` maps to either a :class:`~assertpy2.matchers.Matcher`, a raw value
494
- (checked via ``==``), or a nested ``dict`` for recursive matching. Extra keys in val
495
- that are absent from the spec are allowed.
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