assertpy2 2.9.1__tar.gz → 2.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {assertpy2-2.9.1 → assertpy2-2.10.0}/PKG-INFO +6 -2
  2. {assertpy2-2.9.1 → assertpy2-2.10.0}/README.md +5 -1
  3. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/assertpy.py +1 -1
  4. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/base.py +70 -52
  5. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/extracting.py +2 -2
  6. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/matchers.py +14 -2
  7. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/assertions.md +1 -1
  8. assertpy2-2.10.0/docs/assets/diff-equal.png +0 -0
  9. assertpy2-2.10.0/docs/assets/diff-equal.svg +51 -0
  10. assertpy2-2.10.0/docs/assets/diff-gallery.png +0 -0
  11. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/assets/diff-match.svg +1 -2
  12. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/assets/diff-sequence.svg +1 -2
  13. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/assets/diff-set.svg +1 -2
  14. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/comparison.md +14 -1
  15. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/errors.md +1 -1
  16. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/getting-started.md +1 -1
  17. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/index.md +1 -1
  18. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/matchers.md +25 -1
  19. {assertpy2-2.9.1 → assertpy2-2.10.0}/pyproject.toml +2 -2
  20. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_extracting.py +49 -0
  21. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_file.py +9 -0
  22. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_rich_diff.py +172 -0
  23. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_structural.py +81 -0
  24. {assertpy2-2.9.1 → assertpy2-2.10.0}/uv.lock +23 -23
  25. assertpy2-2.9.1/docs/assets/diff-equal.png +0 -0
  26. assertpy2-2.9.1/docs/assets/diff-equal.svg +0 -64
  27. {assertpy2-2.9.1 → assertpy2-2.10.0}/.codecov.yml +0 -0
  28. {assertpy2-2.9.1 → assertpy2-2.10.0}/.gitattributes +0 -0
  29. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/dependabot.yml +0 -0
  30. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/ci.yml +0 -0
  31. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/codeql.yml +0 -0
  32. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/docs.yml +0 -0
  33. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/mutation.yml +0 -0
  34. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/publish.yml +0 -0
  35. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/scorecard.yml +0 -0
  36. {assertpy2-2.9.1 → assertpy2-2.10.0}/.github/workflows/zizmor.yml +0 -0
  37. {assertpy2-2.9.1 → assertpy2-2.10.0}/.gitignore +0 -0
  38. {assertpy2-2.9.1 → assertpy2-2.10.0}/CONTRIBUTING.md +0 -0
  39. {assertpy2-2.9.1 → assertpy2-2.10.0}/LICENSE +0 -0
  40. {assertpy2-2.9.1 → assertpy2-2.10.0}/SECURITY.md +0 -0
  41. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/__init__.py +0 -0
  42. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_compat.py +0 -0
  43. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_introspection.py +0 -0
  44. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_mixin_base.py +0 -0
  45. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/_typing.py +0 -0
  46. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/async_assertions.py +0 -0
  47. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/behave_matchers.py +0 -0
  48. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/bytes_mixin.py +0 -0
  49. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/collection.py +0 -0
  50. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/contains.py +0 -0
  51. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/date.py +0 -0
  52. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/dict.py +0 -0
  53. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/dynamic.py +0 -0
  54. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/errors.py +0 -0
  55. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/exception.py +0 -0
  56. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/file.py +0 -0
  57. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/helpers.py +0 -0
  58. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/json_mixin.py +0 -0
  59. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/numeric.py +0 -0
  60. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/py.typed +0 -0
  61. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/pytest_plugin.py +0 -0
  62. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/snapshot.py +0 -0
  63. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/string.py +0 -0
  64. {assertpy2-2.9.1 → assertpy2-2.10.0}/assertpy2/warning.py +0 -0
  65. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/data.md +0 -0
  66. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/extending.md +0 -0
  67. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/fluent.md +0 -0
  68. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/integrations.md +0 -0
  69. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/logo-dark.svg +0 -0
  70. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/logo.svg +0 -0
  71. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/migration.md +0 -0
  72. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/testing.md +0 -0
  73. {assertpy2-2.9.1 → assertpy2-2.10.0}/docs/type-safety.md +0 -0
  74. {assertpy2-2.9.1 → assertpy2-2.10.0}/mkdocs.yml +0 -0
  75. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_async.py +0 -0
  76. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_behave_matchers.py +0 -0
  77. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_bool.py +0 -0
  78. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_bytes.py +0 -0
  79. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_callable.py +0 -0
  80. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_chaining.py +0 -0
  81. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_class.py +0 -0
  82. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_collection.py +0 -0
  83. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_core.py +0 -0
  84. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_custom_dict.py +0 -0
  85. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_custom_list.py +0 -0
  86. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_datetime.py +0 -0
  87. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_description.py +0 -0
  88. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_dict.py +0 -0
  89. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_dict_compare.py +0 -0
  90. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_dynamic.py +0 -0
  91. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_equals.py +0 -0
  92. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_errors.py +0 -0
  93. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_expected_exception.py +0 -0
  94. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_expected_warning.py +0 -0
  95. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_extensions.py +0 -0
  96. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_fail.py +0 -0
  97. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_grouped_soft.py +0 -0
  98. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_in.py +0 -0
  99. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_json.py +0 -0
  100. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_list.py +0 -0
  101. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_matcher_registry.py +0 -0
  102. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_matchers.py +0 -0
  103. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_mutation_hardening.py +0 -0
  104. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_namedtuple.py +0 -0
  105. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_none.py +0 -0
  106. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_not.py +0 -0
  107. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_numbers.py +0 -0
  108. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_overloads.py +0 -0
  109. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_pipeline.py +0 -0
  110. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_property_based.py +0 -0
  111. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_pytest_plugin.py +0 -0
  112. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_readme.py +0 -0
  113. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_recursive_compare.py +0 -0
  114. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_recursive_compare_attrs.py +0 -0
  115. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_recursive_compare_pydantic.py +0 -0
  116. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_regex_groups.py +0 -0
  117. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_same_as.py +0 -0
  118. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_satisfy.py +0 -0
  119. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_snapshots.py +0 -0
  120. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_soft.py +0 -0
  121. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_soft_fail.py +0 -0
  122. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_string.py +0 -0
  123. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_traceback.py +0 -0
  124. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_type.py +0 -0
  125. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_typing.py +0 -0
  126. {assertpy2-2.9.1 → assertpy2-2.10.0}/tests/test_warn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.9.1
3
+ Version: 2.10.0
4
4
  Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
5
5
  Project-URL: Homepage, https://github.com/Solganis/assertpy2
6
6
  Project-URL: Repository, https://github.com/Solganis/assertpy2
@@ -121,12 +121,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
121
121
  assert_that(response).is_equal_to(expected)
122
122
  ```
123
123
 
124
- <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
124
+ <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: user.role shown with its path, removal in red and addition in green">
125
125
 
126
126
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
127
127
  For responses with dynamic fields (IDs, timestamps), validate a subset with
128
128
  [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
129
129
 
130
+ The same path-level treatment for dicts, lists, sets, and matcher predicates:
131
+
132
+ <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
133
+
130
134
  ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
131
135
 
132
136
  `assert_that()` uses `@overload` to return type-specific Protocols.
@@ -83,12 +83,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
83
83
  assert_that(response).is_equal_to(expected)
84
84
  ```
85
85
 
86
- <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: status and user.role shown with their paths, removals in red and additions in green">
86
+ <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-equal.png" width="300" alt="Structured diff in the terminal: user.role shown with its path, removal in red and addition in green">
87
87
 
88
88
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
89
89
  For responses with dynamic fields (IDs, timestamps), validate a subset with
90
90
  [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
91
91
 
92
+ The same path-level treatment for dicts, lists, sets, and matcher predicates:
93
+
94
+ <img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
95
+
92
96
  ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
93
97
 
94
98
  `assert_that()` uses `@overload` to return type-specific Protocols.
@@ -48,7 +48,7 @@ from .snapshot import SnapshotMixin
48
48
  from .string import StringMixin
49
49
  from .warning import WarningMixin
50
50
 
51
- __version__ = "2.9.1"
51
+ __version__ = "2.10.0"
52
52
 
53
53
  __tracebackhide__ = True # clean tracebacks via py.test integration
54
54
  contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
@@ -187,6 +187,58 @@ class BaseMixin(_MixinBase):
187
187
  expected=expected_item,
188
188
  )
189
189
 
190
+ @staticmethod
191
+ def _sequence_diff_entries(actual, expected, prefix, seen) -> list[DiffEntry]:
192
+ """Diff two sequences element-by-element, recursing into nested containers.
193
+
194
+ ``seen`` must already include the ids of ``actual``/``expected`` so a self-referential element
195
+ is caught. Shared by the top-level (:meth:`_build_equality_diff`) and nested
196
+ (:meth:`_sub_diff_entries`) paths so both decompose sequences identically.
197
+ """
198
+ entries: list[DiffEntry] = []
199
+ max_len = max(len(actual), len(expected))
200
+ for i in range(max_len):
201
+ path = f"{prefix}[{i}]" if prefix else f"[{i}]"
202
+ if i >= len(actual):
203
+ entries.append(DiffEntry(path=path, actual=None, expected=expected[i]))
204
+ elif i >= len(expected):
205
+ entries.append(DiffEntry(path=path, actual=actual[i], expected=None))
206
+ elif actual[i] != expected[i]:
207
+ sub_entries = BaseMixin._sub_diff_entries(actual[i], expected[i], path, _seen=seen)
208
+ if sub_entries:
209
+ entries.extend(sub_entries)
210
+ else:
211
+ entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
212
+ return entries
213
+
214
+ @staticmethod
215
+ def _dataclass_diff_entries(actual, expected, prefix, seen) -> list[DiffEntry]:
216
+ """Diff two dataclasses over the sorted union of field names, both directions, recursing.
217
+
218
+ Reports fields present on only one side, and recurses into nested containers. ``seen`` must
219
+ already include the ids of ``actual``/``expected``. Shared by the top-level and nested paths
220
+ so both report dataclass fields identically.
221
+ """
222
+ entries: list[DiffEntry] = []
223
+ actual_names = {field.name for field in dataclasses.fields(actual)}
224
+ expected_names = {field.name for field in dataclasses.fields(expected)}
225
+ for field in sorted(actual_names | expected_names):
226
+ path = f"{prefix}.{field}"
227
+ if field not in expected_names:
228
+ entries.append(DiffEntry(path=path, actual=getattr(actual, field), expected=None))
229
+ elif field not in actual_names:
230
+ entries.append(DiffEntry(path=path, actual=None, expected=getattr(expected, field)))
231
+ else:
232
+ actual_value = getattr(actual, field)
233
+ expected_value = getattr(expected, field)
234
+ if actual_value != expected_value:
235
+ sub_entries = BaseMixin._sub_diff_entries(actual_value, expected_value, path, _seen=seen)
236
+ if sub_entries is not None:
237
+ entries.extend(sub_entries)
238
+ else:
239
+ entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
240
+ return entries
241
+
190
242
  @staticmethod
191
243
  def _build_equality_diff(
192
244
  actual: object, expected: object, *, _prefix: str = "", _seen: set[int] | None = None
@@ -229,21 +281,10 @@ class BaseMixin(_MixinBase):
229
281
  and dataclasses.is_dataclass(expected)
230
282
  and not isinstance(expected, type)
231
283
  ):
232
- 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)
284
+ return DiffResult(
285
+ kind="dataclass",
286
+ entries=BaseMixin._dataclass_diff_entries(actual, expected, _prefix, _seen),
287
+ )
247
288
  if is_model_dump_object(actual) and is_model_dump_object(expected):
248
289
  actual_dict = actual.model_dump()
249
290
  expected_dict = expected.model_dump()
@@ -262,21 +303,10 @@ class BaseMixin(_MixinBase):
262
303
  entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
263
304
  return DiffResult(kind="model", entries=entries)
264
305
  if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
265
- 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)
306
+ return DiffResult(
307
+ kind="sequence",
308
+ entries=BaseMixin._sequence_diff_entries(actual, expected, _prefix, _seen),
309
+ )
280
310
  if isinstance(actual, (set, frozenset)) and isinstance(expected, (set, frozenset)):
281
311
  entries = []
282
312
  for item in sorted(actual - expected, key=repr):
@@ -333,23 +363,7 @@ class BaseMixin(_MixinBase):
333
363
  and not isinstance(expected, type)
334
364
  ):
335
365
  child_seen = _seen | {id(actual), id(expected)}
336
- 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
366
+ return BaseMixin._dataclass_diff_entries(actual, expected, prefix, child_seen) or None
353
367
  if is_namedtuple(actual) and is_namedtuple(expected):
354
368
  child_seen = _seen | {id(actual), id(expected)}
355
369
  entries = []
@@ -394,6 +408,9 @@ class BaseMixin(_MixinBase):
394
408
  else:
395
409
  entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
396
410
  return entries or None
411
+ if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
412
+ child_seen = _seen | {id(actual), id(expected)}
413
+ return BaseMixin._sequence_diff_entries(actual, expected, prefix, child_seen) or None
397
414
  return None
398
415
 
399
416
  def satisfies(self, matcher) -> Self:
@@ -488,11 +505,12 @@ class BaseMixin(_MixinBase):
488
505
  return self
489
506
 
490
507
  def matches_structure(self, spec: dict[Any, Any]) -> Self:
491
- """Asserts that val is a dict matching the given structure specification.
508
+ """Asserts that val matches the given structure specification.
492
509
 
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.
510
+ ``val`` may be a dict or a pydantic-style model (anything exposing ``model_dump()``), which is
511
+ normalized to its dict before matching. Each key in ``spec`` maps to either a
512
+ :class:`~assertpy2.matchers.Matcher`, a raw value (checked via ``==``), or a nested ``dict``
513
+ for recursive matching. Extra keys in val that are absent from the spec are allowed.
496
514
 
497
515
  Args:
498
516
  spec: a dict where values can be Matcher instances, raw values, or nested dicts
@@ -515,7 +533,7 @@ class BaseMixin(_MixinBase):
515
533
  Raises:
516
534
  AssertionError: if val does **not** match the structure spec
517
535
  """
518
- if not isinstance(self.val, dict):
536
+ if not isinstance(self.val, dict) and not is_model_dump_object(self.val):
519
537
  raise TypeError("val must be a dict")
520
538
  if not isinstance(spec, dict):
521
539
  raise TypeError("given arg must be a dict")
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import collections.abc
4
4
  from typing import TYPE_CHECKING
5
5
 
6
- from ._introspection import is_namedtuple
6
+ from ._introspection import is_model_dump_object, is_namedtuple
7
7
  from ._mixin_base import _MixinBase
8
8
 
9
9
  if TYPE_CHECKING:
@@ -155,7 +155,7 @@ class ExtractingMixin(_MixinBase):
155
155
  return getattr(item, name)
156
156
  else: # val has no attribute <foo>
157
157
  raise ValueError(f"item attributes {item._fields} did not contain attribute <{name}>")
158
- elif isinstance(item, collections.abc.Iterable):
158
+ elif isinstance(item, collections.abc.Iterable) and not is_model_dump_object(item):
159
159
  self._check_iterable(item, name="item")
160
160
  return item[name]
161
161
  elif hasattr(item, name):
@@ -5,6 +5,8 @@ import threading
5
5
  import uuid as _uuid_mod
6
6
  from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, runtime_checkable
7
7
 
8
+ from ._introspection import is_model_dump_object
9
+
8
10
  if TYPE_CHECKING:
9
11
  from collections.abc import Callable
10
12
 
@@ -563,12 +565,20 @@ class _SpecMismatch(NamedTuple):
563
565
 
564
566
 
565
567
  class StructureMatcher(BaseMatcher):
566
- """Matches dicts against a structure spec where values are matchers, raw values, or nested dicts."""
568
+ """Matches dicts (or pydantic-style models via ``model_dump()``) against a structure spec whose
569
+ values are matchers, raw values, or nested dicts."""
567
570
 
568
571
  def __init__(self, spec: dict[Any, Any]):
569
572
  self._spec = spec
570
573
 
574
+ @staticmethod
575
+ def _as_mapping(value: Any) -> Any:
576
+ """Normalize a pydantic-style model (anything exposing ``model_dump()``) to its dict, so a
577
+ model can be matched structurally just like a plain dict; pass other values through."""
578
+ return value.model_dump() if is_model_dump_object(value) else value
579
+
571
580
  def matches(self, value: Any) -> bool:
581
+ value = self._as_mapping(value)
572
582
  if not isinstance(value, dict):
573
583
  return False
574
584
  return not self._walk(value, self._spec, "", set())
@@ -577,6 +587,7 @@ class StructureMatcher(BaseMatcher):
577
587
  return f"a dict matching structure {_describe_spec_value(self._spec)}"
578
588
 
579
589
  def describe_mismatch(self, value: Any) -> str:
590
+ value = self._as_mapping(value)
580
591
  if not isinstance(value, dict):
581
592
  return f"was not a dict: <{value}>"
582
593
  mismatches = self._walk(value, self._spec, "", set())
@@ -591,12 +602,13 @@ class StructureMatcher(BaseMatcher):
591
602
  return f"at <{first.path}>: expected {first.expected_desc}, but {first.detail}"
592
603
  return f"at <{first.path}>: expected {first.expected_desc}, but was <{first.actual}>"
593
604
 
594
- def collect_mismatches(self, value: dict[Any, Any]) -> list[tuple[str, object, str]]:
605
+ def collect_mismatches(self, value: Any) -> list[tuple[str, object, str]]:
595
606
  """Collect every structural mismatch as ``(path, actual, expected_description)``.
596
607
 
597
608
  Unlike :meth:`describe_mismatch`, this does not stop at the first failure and joins nested
598
609
  paths, so callers can build a path-level :class:`~assertpy2.errors.DiffResult`.
599
610
  """
611
+ value = self._as_mapping(value)
600
612
  return [(m.path, m.actual, m.expected_desc) for m in self._walk(value, self._spec, "", set())]
601
613
 
602
614
  def _walk(
@@ -335,7 +335,7 @@ assert_that(people).extracting("name").contains("Fred Smith", "Bob Barr")
335
335
  assert_that(people).extracting("say_hello").contains("Hello, Fred!", "Hello, Bob!") # method
336
336
  ```
337
337
 
338
- It also works on collections of dicts (extracting by key) and across subclasses in a mixed collection.
338
+ It also works on collections of dicts (extracting by key), Pydantic models, and across subclasses in a mixed collection.
339
339
 
340
340
  #### Filtering
341
341
 
@@ -0,0 +1,51 @@
1
+ <svg width="250" height="120" viewBox="0 0 250 119.6" xmlns="http://www.w3.org/2000/svg">
2
+ <style>
3
+
4
+ .terminal-346716669-matrix {
5
+ font-family: Fira Code, monospace;
6
+ font-size: 20px;
7
+ line-height: 24.4px;
8
+ font-variant-east-asian: full-width;
9
+ }
10
+
11
+ .terminal-346716669-title {
12
+ font-size: 18px;
13
+ font-weight: bold;
14
+ font-family: arial;
15
+ }
16
+
17
+ .terminal-346716669-r1 { fill: #68a0b3 }
18
+ .terminal-346716669-r2 { fill: #c5c8c6 }
19
+ .terminal-346716669-r3 { fill: #cc555a }
20
+ .terminal-346716669-r4 { fill: #98a84b }
21
+ </style>
22
+
23
+ <defs>
24
+ <clipPath id="terminal-346716669-clip-terminal">
25
+ <rect x="0" y="0" width="230.79999999999998" height="96.6" />
26
+ </clipPath>
27
+ <clipPath id="terminal-346716669-line-0">
28
+ <rect x="0" y="1.5" width="231.8" height="24.65"/>
29
+ </clipPath>
30
+ <clipPath id="terminal-346716669-line-1">
31
+ <rect x="0" y="25.9" width="231.8" height="24.65"/>
32
+ </clipPath>
33
+ <clipPath id="terminal-346716669-line-2">
34
+ <rect x="0" y="50.3" width="231.8" height="24.65"/>
35
+ </clipPath>
36
+ </defs>
37
+
38
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="248" height="117.6" rx="8"/>
39
+
40
+
41
+ <g transform="translate(9, 13)" clip-path="url(#terminal-346716669-clip-terminal)">
42
+
43
+ <g class="terminal-346716669-matrix">
44
+ <text class="terminal-346716669-r1" x="0" y="20" textLength="146.4" clip-path="url(#terminal-346716669-line-0)">diff&#160;(dict):</text><text class="terminal-346716669-r2" x="231.8" y="20" textLength="12.2" clip-path="url(#terminal-346716669-line-0)">
45
+ </text><text class="terminal-346716669-r2" x="0" y="44.4" textLength="146.4" clip-path="url(#terminal-346716669-line-1)">&#160;&#160;user.role:</text><text class="terminal-346716669-r2" x="231.8" y="44.4" textLength="12.2" clip-path="url(#terminal-346716669-line-1)">
46
+ </text><text class="terminal-346716669-r3" x="48.8" y="68.8" textLength="170.8" clip-path="url(#terminal-346716669-line-2)">-&#160;&#x27;superadmin&#x27;</text><text class="terminal-346716669-r2" x="231.8" y="68.8" textLength="12.2" clip-path="url(#terminal-346716669-line-2)">
47
+ </text><text class="terminal-346716669-r4" x="48.8" y="93.2" textLength="109.8" clip-path="url(#terminal-346716669-line-3)">+&#160;&#x27;admin&#x27;</text><text class="terminal-346716669-r2" x="231.8" y="93.2" textLength="12.2" clip-path="url(#terminal-346716669-line-3)">
48
+ </text>
49
+ </g>
50
+ </g>
51
+ </svg>
@@ -1,5 +1,4 @@
1
- <svg class="rich-terminal" width="933" height="120" viewBox="0 0 933 119.6" xmlns="http://www.w3.org/2000/svg">
2
- <!-- Generated with Rich https://www.textualize.io -->
1
+ <svg width="933" height="120" viewBox="0 0 933 119.6" xmlns="http://www.w3.org/2000/svg">
3
2
  <style>
4
3
 
5
4
  .terminal-4172749894-matrix {
@@ -1,5 +1,4 @@
1
- <svg class="rich-terminal" width="226" height="120" viewBox="0 0 226 119.6" xmlns="http://www.w3.org/2000/svg">
2
- <!-- Generated with Rich https://www.textualize.io -->
1
+ <svg width="226" height="120" viewBox="0 0 226 119.6" xmlns="http://www.w3.org/2000/svg">
3
2
  <style>
4
3
 
5
4
  .terminal-4133779467-matrix {
@@ -1,5 +1,4 @@
1
- <svg class="rich-terminal" width="275" height="95" viewBox="0 0 275 95.19999999999999" xmlns="http://www.w3.org/2000/svg">
2
- <!-- Generated with Rich https://www.textualize.io -->
1
+ <svg width="275" height="95" viewBox="0 0 275 95.19999999999999" xmlns="http://www.w3.org/2000/svg">
3
2
  <style>
4
3
 
5
4
  .terminal-3363794957-matrix {
@@ -117,6 +117,19 @@ Only assertpy2 prints the path (`user.role`) and the exact predicate that failed
117
117
  assertpy2 `==` form both hand rendering to pytest, which dumps the whole differing container for you to
118
118
  scan. The fluent form trades the zero-import convenience of `==` for a path-level diff.
119
119
 
120
+ ### With a Pydantic model
121
+
122
+ When the value is a Pydantic model, `matches_structure()` accepts it directly, with no `.model_dump()`
123
+ step, and prints a path-level diff just like the one above:
124
+
125
+ ```python
126
+ assert_that(user).matches_structure({"role": match.is_in("admin", "user")})
127
+ ```
128
+
129
+ dirty-equals cannot compare a model against a spec dict, because Pydantic's `__eq__` only matches another
130
+ model. You dump it first (`assert user.model_dump() == {"role": IsOneOf("admin", "user")}`), and pytest
131
+ again dumps the whole differing container. assertpy2 keeps a path-level diff on a model.
132
+
120
133
  ## Style and typing
121
134
 
122
135
  | | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
@@ -161,7 +174,7 @@ scan. The fluent form trades the zero-import convenience of `==` for a path-leve
161
174
 
162
175
  | | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
163
176
  |---|:---:|:---:|:---:|:---:|:---:|
164
- | Latest release | built-in | 2.1.0 | 1.1 (2020) | 0.9.0 | **2.9.1** |
177
+ | Maintained | built-in | Yes | No (since 2020) | Yes | **Yes** |
165
178
  | Property-based tests | n/a | No | No | No | **Yes** |
166
179
  | Runtime dependencies | **none** | **none** | **none** | **none** | **none on 3.11+** |
167
180
  | License | MIT | BSD | BSD | MIT | BSD-3 |
@@ -39,7 +39,7 @@ rendered by the plugin as colored diff sections.
39
39
 
40
40
  | Type | Diff kind | How it works |
41
41
  |---|---|---|
42
- | `list`, `tuple` | `sequence` | Element-by-element, recursive into nested dicts/dataclasses/models |
42
+ | `list`, `tuple` | `sequence` | Element-by-element, recursive into nested dicts, lists, dataclasses, and models |
43
43
  | `set`, `frozenset` | `set` | Extra and missing items |
44
44
  | `str` | `string` | Line-by-line comparison |
45
45
  | `dict` | `dict` | Key-by-key, recursive into nested dicts and lists |
@@ -43,7 +43,7 @@ straight at the differing field instead of dumping the whole value:
43
43
  assert_that(actual).is_equal_to(expected)
44
44
  ```
45
45
 
46
- ![Colored dict diff: status and user.role shown with their paths, removals in red and additions in green](assets/diff-equal.svg)
46
+ ![Colored dict diff: user.role shown with its path, removal in red and addition in green](assets/diff-equal.svg)
47
47
 
48
48
  The same path-level diff backs `matches_structure()`, `satisfies()`, and `each()`. See
49
49
  [Errors & Reporting](errors.md) for the full diff format and configuration.
@@ -12,7 +12,7 @@ rich structural diffs on failure.
12
12
  ones, and a type checker rejects `assert_that("foo").is_positive()` before the test runs. The core
13
13
  advantage over `assertpy` and most alternatives - [see how it works](type-safety.md).
14
14
  - **Composable matchers.** `match.greater_than(5)`, `match.is_uuid()`, combined with `&`, `|`, `~`.
15
- - **Structural matching.** Declarative validation of dicts and API responses, with the exact path to each mismatch on failure.
15
+ - **Structural matching.** Declarative validation of dicts, Pydantic models, and API responses, with the exact path to each mismatch on failure.
16
16
  - **Soft and async assertions.** Collect multiple failures; poll for eventual consistency with `eventually()`.
17
17
  - **Structured failures.** `AssertionFailure` exposes `.actual`, `.expected`, and `.diff`; the pytest plugin
18
18
  renders recursive diffs for lists, dicts, dataclasses, namedtuples, and Pydantic models.
@@ -124,7 +124,7 @@ assert 42 == (match.is_positive() & match.less_than(100))
124
124
  | `match.is_non_empty_string()` | a non-empty string |
125
125
  | `match.ignore()` | anything (placeholder for structural matching) |
126
126
  | `match.each_item(matcher)` | an iterable whose every item matches `matcher` |
127
- | `match.structure(spec)` | a dict matching a nested `spec` |
127
+ | `match.structure(spec)` | a dict or model matching a nested `spec` |
128
128
  | `match.all_of(*matchers)` | a value matching all of `matchers` |
129
129
  | `match.any_of(*matchers)` | a value matching any of `matchers` |
130
130
  | `match.not_(matcher)` | a value not matching `matcher` |
@@ -153,6 +153,30 @@ assert_that(response).matches_structure({
153
153
  })
154
154
  ```
155
155
 
156
+ The value under test can be a plain dict or a Pydantic model (anything exposing `model_dump()`); a
157
+ model is normalized to its dict before matching, so the same spec works either way, including inside
158
+ `satisfies()` and the `==` form:
159
+
160
+ ```python
161
+ from pydantic import BaseModel
162
+
163
+ class User(BaseModel):
164
+ id: str
165
+ name: str
166
+
167
+ user = User(id="550e8400-e29b-41d4-a716-446655440000", name="Alice")
168
+
169
+ assert_that(user).matches_structure({"id": match.is_uuid(), "name": match.equal_to("Alice")})
170
+ assert_that(user).satisfies(match.structure({"id": match.is_uuid()}))
171
+ assert user == match.structure({"id": match.is_uuid()})
172
+ ```
173
+
174
+ !!! note
175
+ A model is matched in its `model_dump()` form: nested models become dicts, `@field_serializer` and
176
+ `@computed_field` outputs are applied, and spec keys are the model's field names (not aliases). The
177
+ spec is matched against this serialized shape, not the live attributes. This is a runtime structural
178
+ check; the spec keys and values are not type-checked against the model's schema.
179
+
156
180
  ### Nested structures
157
181
 
158
182
  Use `match.structure()` for nested dicts:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "assertpy2"
3
- version = "2.9.1"
3
+ version = "2.10.0"
4
4
  description = "Fluent assertion library for Python with composable matchers, structural matching, and full type safety"
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -57,7 +57,7 @@ dev = [
57
57
  "pytest>=9.1.1",
58
58
  "pytest-cov>=6.1",
59
59
  "ruff>=0.15.19",
60
- "ty>=0.0.53",
60
+ "ty>=0.0.54",
61
61
  "typing_extensions>=4.0",
62
62
  ]
63
63
  docs = [
@@ -261,6 +261,55 @@ def test_extracting_iterable_of_lists():
261
261
  assert_that(matrix).extracting(-1, -2).extracting(0).is_equal_to([3, 6, 9])
262
262
 
263
263
 
264
+ class _DuckModel:
265
+ """Duck-types a pydantic v2 model for the dependency-free path: iterable over (field, value)
266
+ pairs and NOT subscriptable, with fields exposed as attributes and a model_dump() method.
267
+
268
+ This is the exact shape that regressed extracting(): the object is iterable, so without the
269
+ model-dump guard it fell into the index branch and item[name] raised (no [] accessor). It must
270
+ stay iterable and non-subscriptable to keep exercising that guard.
271
+ """
272
+
273
+ def __init__(self, **fields):
274
+ self.__dict__.update(fields)
275
+
276
+ def model_dump(self):
277
+ return dict(self.__dict__)
278
+
279
+ def __iter__(self):
280
+ return iter(self.__dict__.items())
281
+
282
+
283
+ _duck_users = [_DuckModel(user="Fred", age=36), _DuckModel(user="Bob", age=40)]
284
+
285
+
286
+ def test_extracting_model_dump_object_single_field():
287
+ assert_that(_duck_users).extracting("user").contains("Fred", "Bob")
288
+
289
+
290
+ def test_extracting_model_dump_object_multiple_fields():
291
+ assert_that(_duck_users).extracting("user", "age").contains(("Fred", 36), ("Bob", 40))
292
+
293
+
294
+ def test_extracting_model_dump_object_missing_field_failure():
295
+ with pytest.raises(ValueError) as exc_info:
296
+ assert_that(_duck_users).extracting("foo")
297
+ assert_that(str(exc_info.value)).is_equal_to("item does not have property or zero-arg method <foo>")
298
+
299
+
300
+ def test_extracting_real_pydantic_model():
301
+ pytest.importorskip("pydantic", reason="pydantic not installed")
302
+ from pydantic import BaseModel
303
+
304
+ class User(BaseModel):
305
+ user: str
306
+ age: int
307
+
308
+ models = [User(user="Fred", age=36), User(user="Bob", age=40)]
309
+ assert_that(models).extracting("user").contains("Fred", "Bob")
310
+ assert_that(models).extracting("user", "age").contains(("Fred", 36), ("Bob", 40))
311
+
312
+
264
313
  def test_extracting_iterable_multi_extracting():
265
314
  matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
266
315
  assert_that(matrix).extracting(-1, 2).is_equal_to([(3, 3), (6, 6), (9, 9)])
@@ -141,6 +141,15 @@ def test_is_named_failure(tmpfile):
141
141
  assert_that(str(exc_info.value)).matches("Expected filename <.*> to be equal to <foo.txt>, but was not.")
142
142
 
143
143
 
144
+ def test_is_named_failure_name_sorts_after_basename(tmpfile):
145
+ # Guards the `!=` filename check against a `>` mutant: when the expected name sorts AFTER the actual
146
+ # basename, `>` would wrongly report a match. Appending a char makes the expected name a strict
147
+ # superstring of the basename, so it is guaranteed larger and unequal regardless of the basename.
148
+ larger_name = os.path.basename(tmpfile.name) + "x"
149
+ with pytest.raises(AssertionError):
150
+ assert_that(tmpfile.name).is_named(larger_name)
151
+
152
+
144
153
  def test_is_named_bad_arg_type_failure(tmpfile):
145
154
  with pytest.raises(TypeError) as exc_info:
146
155
  assert_that(tmpfile.name).is_named(123)