assertpy2 2.9.0__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 (128) hide show
  1. {assertpy2-2.9.0 → assertpy2-2.10.0}/PKG-INFO +8 -2
  2. {assertpy2-2.9.0 → assertpy2-2.10.0}/README.md +7 -1
  3. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/assertpy.py +1 -1
  4. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/base.py +70 -52
  5. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/extracting.py +2 -2
  6. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/matchers.py +55 -53
  7. {assertpy2-2.9.0 → 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.0 → assertpy2-2.10.0}/docs/assets/diff-match.svg +1 -2
  12. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/assets/diff-sequence.svg +1 -2
  13. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/assets/diff-set.svg +1 -2
  14. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/comparison.md +14 -1
  15. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/errors.md +1 -1
  16. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/getting-started.md +1 -1
  17. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/index.md +1 -1
  18. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/matchers.md +25 -1
  19. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/type-safety.md +8 -3
  20. {assertpy2-2.9.0 → assertpy2-2.10.0}/pyproject.toml +2 -2
  21. assertpy2-2.10.0/tests/test_callable.py +48 -0
  22. assertpy2-2.10.0/tests/test_chaining.py +22 -0
  23. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_datetime.py +71 -0
  24. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_expected_exception.py +50 -1
  25. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_extracting.py +49 -0
  26. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_file.py +9 -0
  27. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_list.py +70 -0
  28. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_matchers.py +268 -0
  29. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_rich_diff.py +172 -0
  30. assertpy2-2.10.0/tests/test_satisfy.py +93 -0
  31. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_structural.py +91 -0
  32. {assertpy2-2.9.0 → assertpy2-2.10.0}/uv.lock +23 -23
  33. assertpy2-2.9.0/docs/assets/diff-equal.png +0 -0
  34. assertpy2-2.9.0/docs/assets/diff-equal.svg +0 -64
  35. assertpy2-2.9.0/tests/test_matchers_phase3.py +0 -269
  36. assertpy2-2.9.0/tests/test_phase2.py +0 -349
  37. {assertpy2-2.9.0 → assertpy2-2.10.0}/.codecov.yml +0 -0
  38. {assertpy2-2.9.0 → assertpy2-2.10.0}/.gitattributes +0 -0
  39. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/dependabot.yml +0 -0
  40. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/ci.yml +0 -0
  41. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/codeql.yml +0 -0
  42. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/docs.yml +0 -0
  43. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/mutation.yml +0 -0
  44. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/publish.yml +0 -0
  45. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/scorecard.yml +0 -0
  46. {assertpy2-2.9.0 → assertpy2-2.10.0}/.github/workflows/zizmor.yml +0 -0
  47. {assertpy2-2.9.0 → assertpy2-2.10.0}/.gitignore +0 -0
  48. {assertpy2-2.9.0 → assertpy2-2.10.0}/CONTRIBUTING.md +0 -0
  49. {assertpy2-2.9.0 → assertpy2-2.10.0}/LICENSE +0 -0
  50. {assertpy2-2.9.0 → assertpy2-2.10.0}/SECURITY.md +0 -0
  51. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/__init__.py +0 -0
  52. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_compat.py +0 -0
  53. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_introspection.py +0 -0
  54. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_mixin_base.py +0 -0
  55. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/_typing.py +0 -0
  56. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/async_assertions.py +0 -0
  57. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/behave_matchers.py +0 -0
  58. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/bytes_mixin.py +0 -0
  59. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/collection.py +0 -0
  60. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/contains.py +0 -0
  61. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/date.py +0 -0
  62. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/dict.py +0 -0
  63. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/dynamic.py +0 -0
  64. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/errors.py +0 -0
  65. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/exception.py +0 -0
  66. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/file.py +0 -0
  67. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/helpers.py +0 -0
  68. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/json_mixin.py +0 -0
  69. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/numeric.py +0 -0
  70. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/py.typed +0 -0
  71. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/pytest_plugin.py +0 -0
  72. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/snapshot.py +0 -0
  73. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/string.py +0 -0
  74. {assertpy2-2.9.0 → assertpy2-2.10.0}/assertpy2/warning.py +0 -0
  75. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/data.md +0 -0
  76. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/extending.md +0 -0
  77. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/fluent.md +0 -0
  78. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/integrations.md +0 -0
  79. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/logo-dark.svg +0 -0
  80. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/logo.svg +0 -0
  81. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/migration.md +0 -0
  82. {assertpy2-2.9.0 → assertpy2-2.10.0}/docs/testing.md +0 -0
  83. {assertpy2-2.9.0 → assertpy2-2.10.0}/mkdocs.yml +0 -0
  84. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_async.py +0 -0
  85. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_behave_matchers.py +0 -0
  86. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_bool.py +0 -0
  87. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_bytes.py +0 -0
  88. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_class.py +0 -0
  89. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_collection.py +0 -0
  90. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_core.py +0 -0
  91. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_custom_dict.py +0 -0
  92. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_custom_list.py +0 -0
  93. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_description.py +0 -0
  94. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_dict.py +0 -0
  95. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_dict_compare.py +0 -0
  96. /assertpy2-2.9.0/tests/test_dyn.py → /assertpy2-2.10.0/tests/test_dynamic.py +0 -0
  97. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_equals.py +0 -0
  98. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_errors.py +0 -0
  99. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_expected_warning.py +0 -0
  100. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_extensions.py +0 -0
  101. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_fail.py +0 -0
  102. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_grouped_soft.py +0 -0
  103. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_in.py +0 -0
  104. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_json.py +0 -0
  105. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_matcher_registry.py +0 -0
  106. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_mutation_hardening.py +0 -0
  107. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_namedtuple.py +0 -0
  108. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_none.py +0 -0
  109. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_not.py +0 -0
  110. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_numbers.py +0 -0
  111. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_overloads.py +0 -0
  112. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_pipeline.py +0 -0
  113. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_property_based.py +0 -0
  114. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_pytest_plugin.py +0 -0
  115. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_readme.py +0 -0
  116. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_recursive_compare.py +0 -0
  117. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_recursive_compare_attrs.py +0 -0
  118. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_recursive_compare_pydantic.py +0 -0
  119. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_regex_groups.py +0 -0
  120. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_same_as.py +0 -0
  121. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_snapshots.py +0 -0
  122. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_soft.py +0 -0
  123. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_soft_fail.py +0 -0
  124. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_string.py +0 -0
  125. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_traceback.py +0 -0
  126. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_type.py +0 -0
  127. {assertpy2-2.9.0 → assertpy2-2.10.0}/tests/test_typing.py +0 -0
  128. {assertpy2-2.9.0 → 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.0
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
@@ -62,6 +62,8 @@ Description-Content-Type: text/markdown
62
62
  <a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
63
63
  <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/assertpy2/badge" alt="OpenSSF Scorecard"></a>
64
64
  <a href="https://www.bestpractices.dev/projects/12990"><img src="https://www.bestpractices.dev/projects/12990/badge" alt="OpenSSF Best Practices"></a>
65
+ <br>
66
+ <a href="https://solganis.github.io/assertpy2/type-safety/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="public overloads type-checked by ty, mypy --strict, and pyright with zero suppressions"></a>
65
67
  </p>
66
68
 
67
69
  ---
@@ -119,12 +121,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
119
121
  assert_that(response).is_equal_to(expected)
120
122
  ```
121
123
 
122
- <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">
123
125
 
124
126
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
125
127
  For responses with dynamic fields (IDs, timestamps), validate a subset with
126
128
  [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
127
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
+
128
134
  ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
129
135
 
130
136
  `assert_that()` uses `@overload` to return type-specific Protocols.
@@ -24,6 +24,8 @@
24
24
  <a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
25
25
  <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/assertpy2/badge" alt="OpenSSF Scorecard"></a>
26
26
  <a href="https://www.bestpractices.dev/projects/12990"><img src="https://www.bestpractices.dev/projects/12990/badge" alt="OpenSSF Best Practices"></a>
27
+ <br>
28
+ <a href="https://solganis.github.io/assertpy2/type-safety/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="public overloads type-checked by ty, mypy --strict, and pyright with zero suppressions"></a>
27
29
  </p>
28
30
 
29
31
  ---
@@ -81,12 +83,16 @@ assertpy2 reports the [exact path to every difference](https://solganis.github.i
81
83
  assert_that(response).is_equal_to(expected)
82
84
  ```
83
85
 
84
- <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">
85
87
 
86
88
  Recursive diffs work for dicts, dataclasses, namedtuples, attrs, and Pydantic models.
87
89
  For responses with dynamic fields (IDs, timestamps), validate a subset with
88
90
  [`matches_structure()`](https://solganis.github.io/assertpy2/matchers/#structural-matching) instead of exact equality.
89
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
+
90
96
  ## [Type-aware autocomplete](https://solganis.github.io/assertpy2/type-safety/)
91
97
 
92
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.0"
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):
@@ -3,7 +3,9 @@ from __future__ import annotations
3
3
  import re
4
4
  import threading
5
5
  import uuid as _uuid_mod
6
- from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable
6
+ from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, runtime_checkable
7
+
8
+ from ._introspection import is_model_dump_object
7
9
 
8
10
  if TYPE_CHECKING:
9
11
  from collections.abc import Callable
@@ -548,95 +550,95 @@ def _describe_spec_value(value: object) -> str:
548
550
  return f"<{value}>"
549
551
 
550
552
 
553
+ class _SpecMismatch(NamedTuple):
554
+ """One structural mismatch produced by a single spec walk.
555
+
556
+ ``expected_desc`` is the public "expected" half (as exposed by ``collect_mismatches``); ``detail``
557
+ carries a matcher's ``describe_mismatch(actual)`` so fail-fast failure text keeps its per-value
558
+ detail without a second traversal.
559
+ """
560
+
561
+ path: str
562
+ actual: object
563
+ expected_desc: str
564
+ detail: str | None
565
+
566
+
551
567
  class StructureMatcher(BaseMatcher):
552
- """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."""
553
570
 
554
571
  def __init__(self, spec: dict[Any, Any]):
555
572
  self._spec = spec
556
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
+
557
580
  def matches(self, value: Any) -> bool:
581
+ value = self._as_mapping(value)
558
582
  if not isinstance(value, dict):
559
583
  return False
560
- return self._match_recursive(value, self._spec, "", set()) is None
584
+ return not self._walk(value, self._spec, "", set())
561
585
 
562
586
  def describe(self) -> str:
563
587
  return f"a dict matching structure {_describe_spec_value(self._spec)}"
564
588
 
565
589
  def describe_mismatch(self, value: Any) -> str:
590
+ value = self._as_mapping(value)
566
591
  if not isinstance(value, dict):
567
592
  return f"was not a dict: <{value}>"
568
- error = self._match_recursive(value, self._spec, "", set())
569
- if error:
570
- return error
571
- return f"was <{value}>"
572
-
573
- def _match_recursive(
574
- self, value: dict[Any, Any], spec: dict[Any, Any], path: str, seen: set[tuple[int, int]]
575
- ) -> str | None:
576
- pair_id = (id(value), id(spec))
577
- if pair_id in seen:
578
- return f"circular reference detected at <{path or 'root'}>"
579
- seen.add(pair_id)
580
- for key, expected in spec.items():
581
- current_path = f"{path}.{key}" if path else str(key)
582
- if key not in value:
583
- return f"missing key <{current_path}>"
584
- actual = value[key]
585
- if isinstance(expected, StructureMatcher):
586
- if not isinstance(actual, dict):
587
- return f"at <{current_path}>: expected a dict, but was <{actual}>"
588
- error = self._match_recursive(actual, expected._spec, current_path, seen)
589
- if error:
590
- return error
591
- elif isinstance(expected, Matcher):
592
- if not expected.matches(actual):
593
- return (
594
- f"at <{current_path}>: expected {expected.describe()}, but {expected.describe_mismatch(actual)}"
595
- )
596
- elif isinstance(expected, dict):
597
- if not isinstance(actual, dict):
598
- return f"at <{current_path}>: expected a dict, but was <{actual}>"
599
- error = self._match_recursive(actual, expected, current_path, seen)
600
- if error:
601
- return error
602
- elif actual != expected:
603
- return f"at <{current_path}>: expected <{expected}>, but was <{actual}>"
604
- return None
605
-
606
- def collect_mismatches(self, value: dict[Any, Any]) -> list[tuple[str, object, str]]:
593
+ mismatches = self._walk(value, self._spec, "", set())
594
+ if not mismatches:
595
+ return f"was <{value}>"
596
+ first = mismatches[0]
597
+ if first.actual is _MISSING:
598
+ return f"missing key <{first.path}>"
599
+ if first.expected_desc == "<circular ref>": # cycle sentinel emitted by _walk
600
+ return f"circular reference detected at <{first.path}>"
601
+ if first.detail is not None:
602
+ return f"at <{first.path}>: expected {first.expected_desc}, but {first.detail}"
603
+ return f"at <{first.path}>: expected {first.expected_desc}, but was <{first.actual}>"
604
+
605
+ def collect_mismatches(self, value: Any) -> list[tuple[str, object, str]]:
607
606
  """Collect every structural mismatch as ``(path, actual, expected_description)``.
608
607
 
609
608
  Unlike :meth:`describe_mismatch`, this does not stop at the first failure and joins nested
610
609
  paths, so callers can build a path-level :class:`~assertpy2.errors.DiffResult`.
611
610
  """
612
- return self._collect(value, self._spec, "", set())
611
+ value = self._as_mapping(value)
612
+ return [(m.path, m.actual, m.expected_desc) for m in self._walk(value, self._spec, "", set())]
613
613
 
614
- def _collect(
614
+ def _walk(
615
615
  self, value: dict[Any, Any], spec: dict[Any, Any], path: str, seen: set[tuple[int, int]]
616
- ) -> list[tuple[str, object, str]]:
616
+ ) -> list[_SpecMismatch]:
617
617
  pair_id = (id(value), id(spec))
618
618
  if pair_id in seen:
619
- return [(path or "root", "<circular ref>", "<circular ref>")]
619
+ return [_SpecMismatch(path or "root", "<circular ref>", "<circular ref>", None)]
620
620
  seen = seen | {pair_id}
621
- mismatches: list[tuple[str, object, str]] = []
621
+ mismatches: list[_SpecMismatch] = []
622
622
  for key, expected in spec.items():
623
623
  current_path = f"{path}.{key}" if path else str(key)
624
624
  if key not in value:
625
- mismatches.append((current_path, _MISSING, _describe_spec_value(expected)))
625
+ mismatches.append(_SpecMismatch(current_path, _MISSING, _describe_spec_value(expected), None))
626
626
  continue
627
627
  actual = value[key]
628
628
  if isinstance(expected, StructureMatcher) and isinstance(actual, dict):
629
- mismatches.extend(self._collect(actual, expected._spec, current_path, seen))
629
+ mismatches.extend(self._walk(actual, expected._spec, current_path, seen))
630
630
  elif isinstance(expected, Matcher):
631
631
  if not expected.matches(actual):
632
- mismatches.append((current_path, actual, expected.describe()))
632
+ mismatches.append(
633
+ _SpecMismatch(current_path, actual, expected.describe(), expected.describe_mismatch(actual))
634
+ )
633
635
  elif isinstance(expected, dict):
634
636
  if isinstance(actual, dict):
635
- mismatches.extend(self._collect(actual, expected, current_path, seen))
637
+ mismatches.extend(self._walk(actual, expected, current_path, seen))
636
638
  else:
637
- mismatches.append((current_path, actual, "a dict"))
639
+ mismatches.append(_SpecMismatch(current_path, actual, "a dict", None))
638
640
  elif actual != expected:
639
- mismatches.append((current_path, actual, f"<{expected}>"))
641
+ mismatches.append(_SpecMismatch(current_path, actual, f"<{expected}>", None))
640
642
  return mismatches
641
643
 
642
644
 
@@ -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.0** |
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:
@@ -42,9 +42,14 @@ assert_that("foo").is_positive() # type error: is_positive is not a strin
42
42
  assert_that(42).is_instance_of("int") # type error: expected `type`, got `str`
43
43
  ```
44
44
 
45
- [ty](https://github.com/astral-sh/ty), [Pyright](https://github.com/microsoft/pyright), and
46
- [Mypy](https://github.com/python/mypy) all report these in the editor and in CI, turning a class of test
47
- bugs into errors you see while typing.
45
+ [ty](https://github.com/astral-sh/ty), [mypy `--strict`](https://github.com/python/mypy), and
46
+ [Pyright](https://github.com/microsoft/pyright) all report these in the editor and in CI, turning a class
47
+ of test bugs into errors you see while typing.
48
+
49
+ Every public `assert_that` overload is pinned by an `assert_type` check in
50
+ [`tests/test_typing.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_typing.py); CI runs all
51
+ three checkers against that file on every push, with **zero suppressions**, so a regression that broadens or
52
+ changes a return type fails the build. `ty` additionally type-checks the whole package.
48
53
 
49
54
  !!! note "Callables and captured values stay typed too"
50
55
  `assert_that(func).raises(...).when_called_with(...)` exposes string assertions on the captured