assertpy2 2.13.0__tar.gz → 2.15.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 (172) hide show
  1. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/publish.yml +2 -2
  2. {assertpy2-2.13.0 → assertpy2-2.15.0}/PKG-INFO +9 -7
  3. {assertpy2-2.13.0 → assertpy2-2.15.0}/README.md +8 -6
  4. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/__init__.py +10 -3
  5. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_compare.py +30 -10
  6. assertpy2-2.15.0/assertpy2/_compat.py +25 -0
  7. assertpy2-2.15.0/assertpy2/_contract.py +161 -0
  8. assertpy2-2.15.0/assertpy2/_diff.py +346 -0
  9. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_introspection.py +14 -1
  10. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_mixin_base.py +2 -11
  11. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_typing.py +117 -26
  12. assertpy2-2.15.0/assertpy2/assertpy.py +1091 -0
  13. assertpy2-2.15.0/assertpy2/async_assertions.py +338 -0
  14. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/base.py +285 -355
  15. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/collection.py +94 -0
  16. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/contains.py +107 -18
  17. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/dataframe.py +9 -4
  18. assertpy2-2.15.0/assertpy2/errors.py +178 -0
  19. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/exception.py +119 -2
  20. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/extracting.py +12 -9
  21. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/helpers.py +60 -14
  22. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/matchers.py +128 -8
  23. assertpy2-2.15.0/assertpy2/pytest_plugin.py +353 -0
  24. assertpy2-2.15.0/assertpy2/snapshot.py +753 -0
  25. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/string.py +102 -2
  26. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assertions.md +20 -1
  27. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/comparison.md +15 -0
  28. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/errors.md +63 -0
  29. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/fluent.md +12 -0
  30. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/integrations.md +36 -4
  31. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/matchers.md +8 -0
  32. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/async.md +10 -1
  33. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/entry-points.md +11 -0
  34. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/errors.md +12 -0
  35. assertpy2-2.15.0/docs/reference/snapshots.md +23 -0
  36. assertpy2-2.15.0/docs/testing.md +327 -0
  37. assertpy2-2.15.0/docs/type-safety.md +213 -0
  38. {assertpy2-2.13.0 → assertpy2-2.15.0}/pyproject.toml +1 -1
  39. assertpy2-2.15.0/tests/conftest.py +22 -0
  40. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_async.py +213 -2
  41. assertpy2-2.15.0/tests/test_boundary_cases.py +135 -0
  42. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_bytes.py +6 -6
  43. assertpy2-2.15.0/tests/test_core.py +168 -0
  44. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dataframe.py +22 -6
  45. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dict_compare.py +21 -0
  46. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dynamic.py +3 -5
  47. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_equals.py +16 -0
  48. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_errors.py +49 -0
  49. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_expected_exception.py +183 -0
  50. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_extensions.py +65 -0
  51. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_extracting.py +1 -4
  52. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_file.py +1 -3
  53. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_grouped_soft.py +21 -3
  54. assertpy2-2.15.0/tests/test_iterable_cluster.py +410 -0
  55. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_list.py +168 -1
  56. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_matchers.py +46 -0
  57. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_not.py +52 -0
  58. assertpy2-2.15.0/tests/test_property_based.py +704 -0
  59. assertpy2-2.15.0/tests/test_protocol_parity.py +86 -0
  60. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_pytest_plugin.py +336 -20
  61. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_assertion.py +30 -21
  62. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare_config.py +44 -3
  63. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_rich_diff.py +149 -83
  64. assertpy2-2.15.0/tests/test_snapshots.py +988 -0
  65. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_string.py +92 -0
  66. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_structural.py +381 -1
  67. assertpy2-2.15.0/tests/test_sync_eventually.py +216 -0
  68. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_type.py +61 -0
  69. assertpy2-2.15.0/tests/test_typing.py +197 -0
  70. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_warn.py +39 -23
  71. {assertpy2-2.13.0 → assertpy2-2.15.0}/uv.lock +9 -9
  72. assertpy2-2.13.0/assertpy2/_compat.py +0 -15
  73. assertpy2-2.13.0/assertpy2/assertpy.py +0 -698
  74. assertpy2-2.13.0/assertpy2/async_assertions.py +0 -113
  75. assertpy2-2.13.0/assertpy2/errors.py +0 -51
  76. assertpy2-2.13.0/assertpy2/pytest_plugin.py +0 -196
  77. assertpy2-2.13.0/assertpy2/snapshot.py +0 -291
  78. assertpy2-2.13.0/docs/reference/snapshots.md +0 -11
  79. assertpy2-2.13.0/docs/testing.md +0 -148
  80. assertpy2-2.13.0/docs/type-safety.md +0 -67
  81. assertpy2-2.13.0/tests/test_core.py +0 -82
  82. assertpy2-2.13.0/tests/test_iterable_cluster.py +0 -201
  83. assertpy2-2.13.0/tests/test_mutation_hardening.py +0 -138
  84. assertpy2-2.13.0/tests/test_property_based.py +0 -333
  85. assertpy2-2.13.0/tests/test_snapshots.py +0 -330
  86. assertpy2-2.13.0/tests/test_typing.py +0 -68
  87. {assertpy2-2.13.0 → assertpy2-2.15.0}/.codecov.yml +0 -0
  88. {assertpy2-2.13.0 → assertpy2-2.15.0}/.gitattributes +0 -0
  89. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/dependabot.yml +0 -0
  90. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/ci.yml +0 -0
  91. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/codeql.yml +0 -0
  92. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/docs.yml +0 -0
  93. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/mutation.yml +0 -0
  94. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/scorecard.yml +0 -0
  95. {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/zizmor.yml +0 -0
  96. {assertpy2-2.13.0 → assertpy2-2.15.0}/.gitignore +0 -0
  97. {assertpy2-2.13.0 → assertpy2-2.15.0}/CONTRIBUTING.md +0 -0
  98. {assertpy2-2.13.0 → assertpy2-2.15.0}/LICENSE +0 -0
  99. {assertpy2-2.13.0 → assertpy2-2.15.0}/SECURITY.md +0 -0
  100. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/behave_matchers.py +0 -0
  101. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/bytes_mixin.py +0 -0
  102. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/date.py +0 -0
  103. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/dict.py +0 -0
  104. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/dynamic.py +0 -0
  105. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/file.py +0 -0
  106. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/json_mixin.py +0 -0
  107. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/numeric.py +0 -0
  108. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/py.typed +0 -0
  109. {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/warning.py +0 -0
  110. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-equal.png +0 -0
  111. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-equal.svg +0 -0
  112. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-gallery.png +0 -0
  113. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-match.svg +0 -0
  114. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-sequence.svg +0 -0
  115. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-set.svg +0 -0
  116. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/data.md +0 -0
  117. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/extending.md +0 -0
  118. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/getting-started.md +0 -0
  119. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/index.md +0 -0
  120. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/logo-dark.svg +0 -0
  121. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/logo.svg +0 -0
  122. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/migration.md +0 -0
  123. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/bytes.md +0 -0
  124. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/collections.md +0 -0
  125. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/containment.md +0 -0
  126. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/core.md +0 -0
  127. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dataframes.md +0 -0
  128. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dates.md +0 -0
  129. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dicts.md +0 -0
  130. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dynamic.md +0 -0
  131. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/exceptions.md +0 -0
  132. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/extracting.md +0 -0
  133. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/files.md +0 -0
  134. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/json.md +0 -0
  135. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/matchers.md +0 -0
  136. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/numbers.md +0 -0
  137. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/overview.md +0 -0
  138. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/strings.md +0 -0
  139. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/warnings.md +0 -0
  140. {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/stylesheets/extra.css +0 -0
  141. {assertpy2-2.13.0 → assertpy2-2.15.0}/mkdocs.yml +0 -0
  142. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_behave_matchers.py +0 -0
  143. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_bool.py +0 -0
  144. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_callable.py +0 -0
  145. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_chaining.py +0 -0
  146. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_class.py +0 -0
  147. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_collection.py +0 -0
  148. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_custom_dict.py +0 -0
  149. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_custom_list.py +0 -0
  150. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_datetime.py +0 -0
  151. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_description.py +0 -0
  152. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dict.py +0 -0
  153. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_expected_warning.py +0 -0
  154. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_fail.py +0 -0
  155. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_in.py +0 -0
  156. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_json.py +0 -0
  157. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_matcher_registry.py +0 -0
  158. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_namedtuple.py +0 -0
  159. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_none.py +0 -0
  160. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_numbers.py +0 -0
  161. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_overloads.py +0 -0
  162. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_pipeline.py +0 -0
  163. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_readme.py +0 -0
  164. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare.py +0 -0
  165. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare_attrs.py +0 -0
  166. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare_pydantic.py +0 -0
  167. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_regex_groups.py +0 -0
  168. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_same_as.py +0 -0
  169. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_satisfy.py +0 -0
  170. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_soft.py +0 -0
  171. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_soft_fail.py +0 -0
  172. {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_traceback.py +0 -0
@@ -36,13 +36,13 @@ jobs:
36
36
 
37
37
  - name: Attest wheel
38
38
  id: attest-wheel
39
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
39
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
40
40
  with:
41
41
  subject-path: dist/*.whl
42
42
 
43
43
  - name: Attest sdist
44
44
  id: attest-sdist
45
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
45
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
46
46
  with:
47
47
  subject-path: dist/*.tar.gz
48
48
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.13.0
3
+ Version: 2.15.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
@@ -169,7 +169,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
169
169
  - [**Recursive field assertions**](https://solganis.github.io/assertpy2/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph, reporting the exact path.
170
170
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
171
171
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
172
- - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`.
172
+ - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
173
173
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
174
174
 
175
175
  **Built-in types**
@@ -178,21 +178,23 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
178
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`.
179
179
  - [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
180
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), including by regex or type.
181
- - [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth) or `comparators` (per-type / per-field predicates) for nested structures.
181
+ - [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth), `comparators` (per-type / per-field predicates), or `ignore_null` (skip fields the expected template leaves `None`) for nested structures.
182
182
  - [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
183
183
 
184
184
  **Testing**
185
185
 
186
- - [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
187
- - [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
186
+ - [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`; each collected failure is reported with its `file:line`. Group errors with `sa.group()`, or use `assert_all()`.
187
+ - [**Polling assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` (async) / `eventually_sync()` (blocking) retry for eventual consistency, with a convergence trace pinpointing why a timeout never settled.
188
+ - [**Expected exceptions**](https://solganis.github.io/assertpy2/errors/#expected-exceptions): `raises().when_called_with()` then assert on the message, walk the cause chain (`caused_by()`, `has_root_cause()`), match an `ExceptionGroup` (`contains_error()`), or pivot to the exception object (`raised()`).
188
189
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
189
190
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
190
- - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
191
- - **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
191
+ - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format; update via `--assertpy2-snapshot-update`. [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/testing/#contract-snapshots) catches structural regressions, value-tolerant.
192
192
 
193
193
  **Type safety**
194
194
 
195
195
  - [**Type-aware autocomplete**](https://solganis.github.io/assertpy2/type-safety/): 9 Protocols, IDE shows only relevant methods per type.
196
+ - [**Typed narrowing**](https://solganis.github.io/assertpy2/type-safety/#typed-narrowing-with-value): `.value` hands the checked value back; `is_not_none()`, `is_instance_of()`, and a [`satisfies()` `TypeIs` predicate](https://solganis.github.io/assertpy2/type-safety/#refinement-narrowing-with-a-typeis-predicate-advanced) narrow its static type - no casts.
197
+ - [**Contract testing**](https://solganis.github.io/assertpy2/type-safety/#contract-narrowing-with-assert_conforms): `assert_conforms()` validates a raw payload against a Pydantic v2 model and narrows the chain to it - the capstone for API-response tests; [`exact=True`](https://solganis.github.io/assertpy2/type-safety/#contract-drift-with-exacttrue) catches silent contract drift (undeclared fields), `each=True` validates list endpoints.
196
198
  - **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
197
199
 
198
200
  **Extensibility**
@@ -121,7 +121,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
121
121
  - [**Recursive field assertions**](https://solganis.github.io/assertpy2/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph, reporting the exact path.
122
122
  - [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
123
123
  - [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
124
- - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`.
124
+ - [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
125
125
  - [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
126
126
 
127
127
  **Built-in types**
@@ -130,21 +130,23 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
130
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`.
131
131
  - [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
132
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), including by regex or type.
133
- - [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth) or `comparators` (per-type / per-field predicates) for nested structures.
133
+ - [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth), `comparators` (per-type / per-field predicates), or `ignore_null` (skip fields the expected template leaves `None`) for nested structures.
134
134
  - [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
135
135
 
136
136
  **Testing**
137
137
 
138
- - [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
139
- - [**Async assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` with polling/retry for eventual consistency.
138
+ - [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`; each collected failure is reported with its `file:line`. Group errors with `sa.group()`, or use `assert_all()`.
139
+ - [**Polling assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` (async) / `eventually_sync()` (blocking) retry for eventual consistency, with a convergence trace pinpointing why a timeout never settled.
140
+ - [**Expected exceptions**](https://solganis.github.io/assertpy2/errors/#expected-exceptions): `raises().when_called_with()` then assert on the message, walk the cause chain (`caused_by()`, `has_root_cause()`), match an `ExceptionGroup` (`contains_error()`), or pivot to the exception object (`raised()`).
140
141
  - [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
141
142
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
142
- - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
143
- - **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
143
+ - [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format; update via `--assertpy2-snapshot-update`. [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/testing/#contract-snapshots) catches structural regressions, value-tolerant.
144
144
 
145
145
  **Type safety**
146
146
 
147
147
  - [**Type-aware autocomplete**](https://solganis.github.io/assertpy2/type-safety/): 9 Protocols, IDE shows only relevant methods per type.
148
+ - [**Typed narrowing**](https://solganis.github.io/assertpy2/type-safety/#typed-narrowing-with-value): `.value` hands the checked value back; `is_not_none()`, `is_instance_of()`, and a [`satisfies()` `TypeIs` predicate](https://solganis.github.io/assertpy2/type-safety/#refinement-narrowing-with-a-typeis-predicate-advanced) narrow its static type - no casts.
149
+ - [**Contract testing**](https://solganis.github.io/assertpy2/type-safety/#contract-narrowing-with-assert_conforms): `assert_conforms()` validates a raw payload against a Pydantic v2 model and narrows the chain to it - the capstone for API-response tests; [`exact=True`](https://solganis.github.io/assertpy2/type-safety/#contract-drift-with-exacttrue) catches silent contract drift (undeclared fields), `each=True` validates list endpoints.
148
150
  - **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
149
151
 
150
152
  **Extensibility**
@@ -5,6 +5,7 @@ from .assertpy import (
5
5
  __version__,
6
6
  add_extension,
7
7
  assert_all,
8
+ assert_conforms,
8
9
  assert_that,
9
10
  assert_warn,
10
11
  fail,
@@ -12,11 +13,11 @@ from .assertpy import (
12
13
  soft_assertions,
13
14
  soft_fail,
14
15
  )
15
- from .async_assertions import AsyncAssertionBuilder
16
- from .errors import AssertionFailure, DiffEntry, DiffResult
16
+ from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
17
+ from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace
17
18
  from .file import contents_of
18
19
  from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
19
- from .snapshot import SnapshotCreatedWarning
20
+ from .snapshot import SnapshotCreatedWarning, SnapshotUpdatedWarning, register_snapshot_serializer
20
21
 
21
22
  __all__ = [
22
23
  "AssertionFailure",
@@ -25,12 +26,17 @@ __all__ = [
25
26
  "DiffResult",
26
27
  "Matcher",
27
28
  "NegatedBuilder",
29
+ "PollSample",
30
+ "PollTrace",
28
31
  "SnapshotCreatedWarning",
32
+ "SnapshotUpdatedWarning",
29
33
  "SoftAssertionCollector",
34
+ "SyncAssertionBuilder",
30
35
  "WarningLoggingAdapter",
31
36
  "__version__",
32
37
  "add_extension",
33
38
  "assert_all",
39
+ "assert_conforms",
34
40
  "assert_that",
35
41
  "assert_warn",
36
42
  "clear_custom_matchers",
@@ -38,6 +44,7 @@ __all__ = [
38
44
  "fail",
39
45
  "match",
40
46
  "register_matcher",
47
+ "register_snapshot_serializer",
41
48
  "remove_extension",
42
49
  "soft_assertions",
43
50
  "soft_fail",
@@ -2,7 +2,7 @@
2
2
 
3
3
  ``is_equal_to`` builds a `_CompareConfig` from its ``tolerance``/``comparators`` kwargs and threads it
4
4
  through both the boolean comparison (`HelpersMixin._dict_not_equal()`) and the diff/message rendering
5
- (`BaseMixin._sub_diff_entries()`, `HelpersMixin._dict_err()`). `_node_decision()` is the single
5
+ (`assertpy2._diff._sub_diff_entries()`, `HelpersMixin._dict_err()`). `_node_decision()` is the single
6
6
  switch both sides consult, so a tolerated or comparator-equal leaf is reported in neither. With ``config is
7
7
  None`` every helper reproduces the engine's historical ``actual != expected`` behavior exactly.
8
8
 
@@ -31,20 +31,25 @@ class _CompareConfig:
31
31
  """Tolerance and custom comparators for a single ``is_equal_to`` call.
32
32
 
33
33
  ``tolerance`` is an absolute tolerance applied to real-number leaves; ``comparators`` maps a ``type`` or
34
- an immediate field name to a ``(actual, expected) -> bool`` predicate that owns matching leaves.
34
+ an immediate field name to a ``(actual, expected) -> bool`` predicate that owns matching leaves;
35
+ ``ignore_null`` skips a named field whenever the *expected* side leaves it ``None``.
35
36
  """
36
37
 
37
38
  tolerance: float | None = None
38
39
  comparators: dict[object, Callable[[object, object], bool]] | None = None
40
+ ignore_null: bool = False
39
41
 
40
42
 
41
- def _build_compare_config(tolerance, comparators) -> _CompareConfig | None:
42
- """Validate the ``is_equal_to`` ``tolerance``/``comparators`` kwargs and build a config (``None`` if neither).
43
+ def _build_compare_config(tolerance, comparators, ignore_null=False) -> _CompareConfig | None:
44
+ """Validate the ``is_equal_to`` ``tolerance``/``comparators``/``ignore_null`` kwargs and build a config.
43
45
 
44
- ``tolerance`` must be a non-negative real number (not ``bool``/``complex``/``NaN``); ``comparators`` must be a
45
- dict of ``(actual, expected) -> bool`` callables keyed by ``type`` or field name.
46
+ Returns ``None`` when none are set. ``tolerance`` must be a non-negative real number (not
47
+ ``bool``/``complex``/``NaN``); ``comparators`` must be a dict of ``(actual, expected) -> bool`` callables
48
+ keyed by ``type`` or field name; ``ignore_null`` must be a bool.
46
49
  """
47
- if tolerance is None and comparators is None:
50
+ if ignore_null is not False and ignore_null is not True:
51
+ raise TypeError("given ignore_null arg must be a bool")
52
+ if tolerance is None and comparators is None and not ignore_null:
48
53
  return None
49
54
  if tolerance is not None:
50
55
  if isinstance(tolerance, bool) or not isinstance(tolerance, numbers.Number) or isinstance(tolerance, complex):
@@ -59,7 +64,7 @@ def _build_compare_config(tolerance, comparators) -> _CompareConfig | None:
59
64
  for comparator in comparators.values():
60
65
  if not callable(comparator):
61
66
  raise TypeError("each comparator must be callable")
62
- return _CompareConfig(tolerance=tolerance, comparators=comparators)
67
+ return _CompareConfig(tolerance=tolerance, comparators=comparators, ignore_null=ignore_null)
63
68
 
64
69
 
65
70
  def _ambiguous_array_operand(value: object, other: object) -> object | None:
@@ -71,6 +76,8 @@ def _ambiguous_array_operand(value: object, other: object) -> object | None:
71
76
  truth test is actually attempted, so 0-d / scalar array values (which *are* truth-testable) pass
72
77
  through unchanged.
73
78
  """
79
+ if not hasattr(value, "__array__") and not hasattr(other, "__array__"):
80
+ return None # fast path: no array-like operand, skip the tuple/loop on every is_equal_to
74
81
  for candidate, counterpart in ((value, other), (other, value)):
75
82
  if hasattr(candidate, "__array__"):
76
83
  try:
@@ -133,7 +140,7 @@ def _find_ambiguous_operand(actual, expected, _seen=None):
133
140
  return None
134
141
 
135
142
 
136
- def _guarded_not_equal(actual, expected) -> bool:
143
+ def _guarded_not_equal(actual, expected, *, method="is_equal_to") -> bool:
137
144
  """``bool(actual != expected)``, converting the ambiguity raised from *inside* a container's ``==``
138
145
  (where the top-level operand gate cannot see the array member) into the actionable ``TypeError``."""
139
146
  try:
@@ -142,7 +149,18 @@ def _guarded_not_equal(actual, expected) -> bool:
142
149
  operand = _find_ambiguous_operand(actual, expected)
143
150
  if operand is None:
144
151
  raise
145
- raise _array_equality_error("is_equal_to", operand) from error
152
+ raise _array_equality_error(method, operand) from error
153
+
154
+
155
+ def _guarded_equal(actual, expected, *, method) -> bool:
156
+ """``bool(actual == expected)`` with the same nested array/frame-like guard as `_guarded_not_equal`."""
157
+ try:
158
+ return bool(actual == expected)
159
+ except (ValueError, TypeError) as error:
160
+ operand = _find_ambiguous_operand(actual, expected)
161
+ if operand is None:
162
+ raise
163
+ raise _array_equality_error(method, operand) from error
146
164
 
147
165
 
148
166
  def _is_real_number(value) -> bool:
@@ -195,6 +213,8 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
195
213
  never recursed into.
196
214
  """
197
215
  if config is not None:
216
+ if config.ignore_null and field is not None and expected is None:
217
+ return "equal" # a named field the expected side leaves None is not compared
198
218
  comparator = _resolve_comparator(actual, config, field=field)
199
219
  if comparator is not None:
200
220
  return "equal" if comparator(actual, expected) else "leaf"
@@ -0,0 +1,25 @@
1
+ """Internal compatibility shims.
2
+
3
+ ``Self`` entered the standard library's `typing` in Python 3.11; on 3.10 it is provided by
4
+ ``typing_extensions``. Re-exporting it from one place lets every mixin import ``Self`` without repeating
5
+ the version gate, and lets ``typing_extensions`` be dropped as a runtime dependency on Python 3.11+.
6
+
7
+ ``BaseExceptionGroup`` is a builtin from Python 3.11; on 3.10 it comes from the ``exceptiongroup``
8
+ backport when installed, and otherwise degrades to an empty tuple so ``isinstance`` is simply always
9
+ ``False`` (a 3.10 interpreter without the backport cannot have produced a group anyway).
10
+ """
11
+
12
+ import sys
13
+
14
+ if sys.version_info >= (3, 11):
15
+ from builtins import BaseExceptionGroup
16
+ from typing import Self
17
+ else: # pragma: no cover - exercised only on Python 3.10
18
+ from typing_extensions import Self
19
+
20
+ try:
21
+ from exceptiongroup import BaseExceptionGroup # ty: ignore[unresolved-import] # optional 3.10 backport
22
+ except ImportError:
23
+ BaseExceptionGroup = ()
24
+
25
+ __all__ = ["BaseExceptionGroup", "Self"]
@@ -0,0 +1,161 @@
1
+ """Contract-drift detection for `assert_conforms(..., exact=True)`.
2
+
3
+ Reports fields a raw payload carries that its pydantic v2 model does **not** declare - the silent API
4
+ growth that `model_validate` drops by default. Duck-typed on ``model_fields`` (no pydantic import),
5
+ alias-aware, and recursive into nested sub-models and ``list``/``tuple`` of sub-models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import types
11
+ import typing
12
+ from functools import reduce
13
+ from typing import Any
14
+
15
+
16
+ def _submodel(annotation: object) -> type | None:
17
+ """The nested model class for an annotation, peeling ``Optional`` / ``list`` / ``tuple``; else ``None``."""
18
+ origin = typing.get_origin(annotation)
19
+ args = typing.get_args(annotation)
20
+ if origin is typing.Union or origin is types.UnionType:
21
+ non_none = [arg for arg in args if arg is not type(None)]
22
+ return _submodel(non_none[0]) if len(non_none) == 1 else None
23
+ if origin in (list, tuple, set, frozenset) and args:
24
+ return _submodel(args[0])
25
+ if isinstance(annotation, type) and hasattr(annotation, "model_fields"):
26
+ return annotation
27
+ return None
28
+
29
+
30
+ def _collect_aliases(keys: list[str], alias: object) -> None:
31
+ """Add every top-level payload key an alias spec maps to (str, ``AliasChoices``, ``AliasPath``)."""
32
+ if isinstance(alias, str):
33
+ keys.append(alias)
34
+ elif alias is not None:
35
+ for choice in getattr(alias, "choices", ()): # AliasChoices
36
+ _collect_aliases(keys, choice)
37
+ path = getattr(alias, "path", None) # AliasPath: its first segment is the consumed top-level key
38
+ if path and isinstance(path[0], str):
39
+ keys.append(path[0])
40
+
41
+
42
+ def _alias_keys(info: Any) -> list[str]:
43
+ """Every top-level key a field may appear under, across ``alias`` and ``validation_alias``."""
44
+ keys: list[str] = []
45
+ _collect_aliases(keys, getattr(info, "alias", None))
46
+ _collect_aliases(keys, getattr(info, "validation_alias", None))
47
+ return keys
48
+
49
+
50
+ def _declared_keys(model: Any) -> set[str]:
51
+ """Field names plus all their aliases, so an aliased payload key is not mistaken for drift."""
52
+ keys: set[str] = set()
53
+ for name, info in model.model_fields.items():
54
+ keys.add(name)
55
+ keys.update(_alias_keys(info))
56
+ return keys
57
+
58
+
59
+ def contract_drift(payload: object, model: Any, path: str = "") -> list[str]:
60
+ """Paths of fields ``payload`` carries that ``model`` does not declare, recursively.
61
+
62
+ A model whose config opts into extras (``extra="allow"``) keeps them intentionally, so its level is
63
+ skipped. Non-dict payloads (already validated) contribute nothing.
64
+ """
65
+ if not isinstance(payload, dict):
66
+ return []
67
+ drift: list[str] = []
68
+ if getattr(model, "model_config", {}).get("extra") != "allow":
69
+ declared = _declared_keys(model)
70
+ drift += [f"{path}{key}" for key in payload if key not in declared]
71
+ for name, info in model.model_fields.items():
72
+ submodel = _submodel(info.annotation)
73
+ if submodel is None:
74
+ continue
75
+ value = payload.get(name)
76
+ if value is None:
77
+ for alias in _alias_keys(info):
78
+ if alias in payload:
79
+ value = payload.get(alias)
80
+ break
81
+ if isinstance(value, (list, tuple)):
82
+ for index, element in enumerate(value):
83
+ drift += contract_drift(element, submodel, f"{path}{name}[{index}].")
84
+ elif isinstance(value, dict):
85
+ drift += contract_drift(value, submodel, f"{path}{name}.")
86
+ return drift
87
+
88
+
89
+ def shape(value: object) -> object:
90
+ """The structural shape of a value: paths and type *categories*, never values.
91
+
92
+ Numbers collapse to one category (so ``5`` and ``5.0`` do not read as drift) and ``None`` becomes
93
+ ``"null"`` (a nullable wildcard). A list becomes a single merged element shape. This is what a
94
+ contract snapshot stores, so later runs pass when values change but fail on structural drift.
95
+ """
96
+ if value is None:
97
+ return "null"
98
+ if isinstance(value, bool):
99
+ return "bool"
100
+ if isinstance(value, (int, float)):
101
+ return "number"
102
+ if isinstance(value, str):
103
+ return "str"
104
+ if isinstance(value, dict):
105
+ return {key: shape(item) for key, item in value.items()}
106
+ if isinstance(value, (list, tuple)):
107
+ element_shapes = [shape(item) for item in value]
108
+ return [reduce(_merge, element_shapes)] if element_shapes else []
109
+ return type(value).__name__
110
+
111
+
112
+ def _merge(left: Any, right: Any) -> Any:
113
+ """Merge two element shapes into one representative shape (``null`` yields to a concrete type)."""
114
+ if left == right:
115
+ return left
116
+ if left == "null":
117
+ return right
118
+ if right == "null":
119
+ return left
120
+ if isinstance(left, dict) and isinstance(right, dict):
121
+ return {key: _merge(left.get(key, "null"), right.get(key, "null")) for key in set(left) | set(right)}
122
+ if isinstance(left, list) and isinstance(right, list):
123
+ if not left:
124
+ return right
125
+ if not right:
126
+ return left
127
+ return [_merge(left[0], right[0])]
128
+ return "mixed"
129
+
130
+
131
+ def _shape_name(part: object) -> str:
132
+ if isinstance(part, str):
133
+ return part
134
+ return "object" if isinstance(part, dict) else "list"
135
+
136
+
137
+ def _join(path: str, key: str) -> str:
138
+ return f"{path}.{key}" if path else key
139
+
140
+
141
+ def shape_diff(old: Any, new: Any, path: str = "") -> list[tuple[str, str, str]]:
142
+ """Structural drift between two shapes: ``(kind, path, detail)`` for added / removed / retyped leaves.
143
+
144
+ A ``null`` on either side is a nullable wildcard and never counts as drift.
145
+ """
146
+ if old == new or old == "null" or new == "null":
147
+ return []
148
+ if isinstance(old, dict) and isinstance(new, dict):
149
+ drift: list[tuple[str, str, str]] = [("added", _join(path, key), "") for key in new if key not in old]
150
+ for key in old:
151
+ child = _join(path, key)
152
+ if key not in new:
153
+ drift.append(("removed", child, ""))
154
+ else:
155
+ drift += shape_diff(old[key], new[key], child)
156
+ return drift
157
+ if isinstance(old, list) and isinstance(new, list):
158
+ if not old or not new:
159
+ return []
160
+ return shape_diff(old[0], new[0], f"{path}[*]")
161
+ return [("retyped", path, f"{_shape_name(old)} -> {_shape_name(new)}")]