assertpy2 2.14.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 (167) hide show
  1. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/publish.yml +2 -2
  2. {assertpy2-2.14.0 → assertpy2-2.15.0}/PKG-INFO +9 -6
  3. {assertpy2-2.14.0 → assertpy2-2.15.0}/README.md +8 -5
  4. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/__init__.py +7 -2
  5. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_compare.py +16 -7
  6. assertpy2-2.15.0/assertpy2/_compat.py +25 -0
  7. assertpy2-2.15.0/assertpy2/_contract.py +161 -0
  8. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_mixin_base.py +1 -0
  9. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_typing.py +100 -26
  10. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/assertpy.py +416 -54
  11. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/async_assertions.py +144 -14
  12. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/base.py +271 -15
  13. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/collection.py +94 -0
  14. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/contains.py +107 -18
  15. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/errors.py +12 -0
  16. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/exception.py +119 -2
  17. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/extracting.py +12 -9
  18. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/helpers.py +51 -6
  19. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/matchers.py +120 -6
  20. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/pytest_plugin.py +92 -0
  21. assertpy2-2.15.0/assertpy2/snapshot.py +753 -0
  22. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/string.py +102 -2
  23. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assertions.md +20 -1
  24. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/comparison.md +15 -0
  25. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/errors.md +31 -0
  26. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/fluent.md +12 -0
  27. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/matchers.md +3 -0
  28. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/async.md +10 -1
  29. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/entry-points.md +11 -0
  30. assertpy2-2.15.0/docs/reference/snapshots.md +23 -0
  31. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/testing.md +146 -12
  32. assertpy2-2.15.0/docs/type-safety.md +213 -0
  33. {assertpy2-2.14.0 → assertpy2-2.15.0}/pyproject.toml +1 -1
  34. assertpy2-2.15.0/tests/conftest.py +22 -0
  35. assertpy2-2.15.0/tests/test_boundary_cases.py +135 -0
  36. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_bytes.py +6 -6
  37. assertpy2-2.15.0/tests/test_core.py +168 -0
  38. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dataframe.py +1 -2
  39. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dynamic.py +3 -5
  40. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_equals.py +7 -0
  41. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_expected_exception.py +183 -0
  42. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_extracting.py +1 -4
  43. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_file.py +1 -3
  44. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_grouped_soft.py +21 -3
  45. assertpy2-2.15.0/tests/test_iterable_cluster.py +410 -0
  46. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_list.py +168 -1
  47. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_matchers.py +46 -0
  48. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_not.py +47 -0
  49. assertpy2-2.15.0/tests/test_property_based.py +704 -0
  50. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_pytest_plugin.py +166 -6
  51. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_assertion.py +10 -1
  52. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare_config.py +44 -3
  53. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_rich_diff.py +71 -5
  54. assertpy2-2.15.0/tests/test_snapshots.py +988 -0
  55. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_string.py +92 -0
  56. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_structural.py +350 -1
  57. assertpy2-2.15.0/tests/test_sync_eventually.py +216 -0
  58. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_type.py +61 -0
  59. assertpy2-2.15.0/tests/test_typing.py +197 -0
  60. {assertpy2-2.14.0 → assertpy2-2.15.0}/uv.lock +9 -9
  61. assertpy2-2.14.0/assertpy2/_compat.py +0 -15
  62. assertpy2-2.14.0/assertpy2/snapshot.py +0 -296
  63. assertpy2-2.14.0/docs/reference/snapshots.md +0 -11
  64. assertpy2-2.14.0/docs/type-safety.md +0 -67
  65. assertpy2-2.14.0/tests/test_core.py +0 -82
  66. assertpy2-2.14.0/tests/test_iterable_cluster.py +0 -201
  67. assertpy2-2.14.0/tests/test_mutation_hardening.py +0 -138
  68. assertpy2-2.14.0/tests/test_property_based.py +0 -333
  69. assertpy2-2.14.0/tests/test_snapshots.py +0 -349
  70. assertpy2-2.14.0/tests/test_typing.py +0 -94
  71. {assertpy2-2.14.0 → assertpy2-2.15.0}/.codecov.yml +0 -0
  72. {assertpy2-2.14.0 → assertpy2-2.15.0}/.gitattributes +0 -0
  73. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/dependabot.yml +0 -0
  74. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/ci.yml +0 -0
  75. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/codeql.yml +0 -0
  76. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/docs.yml +0 -0
  77. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/mutation.yml +0 -0
  78. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/scorecard.yml +0 -0
  79. {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/zizmor.yml +0 -0
  80. {assertpy2-2.14.0 → assertpy2-2.15.0}/.gitignore +0 -0
  81. {assertpy2-2.14.0 → assertpy2-2.15.0}/CONTRIBUTING.md +0 -0
  82. {assertpy2-2.14.0 → assertpy2-2.15.0}/LICENSE +0 -0
  83. {assertpy2-2.14.0 → assertpy2-2.15.0}/SECURITY.md +0 -0
  84. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_diff.py +0 -0
  85. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_introspection.py +0 -0
  86. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/behave_matchers.py +0 -0
  87. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/bytes_mixin.py +0 -0
  88. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/dataframe.py +0 -0
  89. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/date.py +0 -0
  90. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/dict.py +0 -0
  91. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/dynamic.py +0 -0
  92. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/file.py +0 -0
  93. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/json_mixin.py +0 -0
  94. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/numeric.py +0 -0
  95. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/py.typed +0 -0
  96. {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/warning.py +0 -0
  97. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-equal.png +0 -0
  98. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-equal.svg +0 -0
  99. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-gallery.png +0 -0
  100. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-match.svg +0 -0
  101. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-sequence.svg +0 -0
  102. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-set.svg +0 -0
  103. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/data.md +0 -0
  104. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/extending.md +0 -0
  105. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/getting-started.md +0 -0
  106. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/index.md +0 -0
  107. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/integrations.md +0 -0
  108. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/logo-dark.svg +0 -0
  109. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/logo.svg +0 -0
  110. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/migration.md +0 -0
  111. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/bytes.md +0 -0
  112. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/collections.md +0 -0
  113. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/containment.md +0 -0
  114. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/core.md +0 -0
  115. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dataframes.md +0 -0
  116. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dates.md +0 -0
  117. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dicts.md +0 -0
  118. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dynamic.md +0 -0
  119. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/errors.md +0 -0
  120. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/exceptions.md +0 -0
  121. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/extracting.md +0 -0
  122. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/files.md +0 -0
  123. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/json.md +0 -0
  124. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/matchers.md +0 -0
  125. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/numbers.md +0 -0
  126. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/overview.md +0 -0
  127. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/strings.md +0 -0
  128. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/warnings.md +0 -0
  129. {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/stylesheets/extra.css +0 -0
  130. {assertpy2-2.14.0 → assertpy2-2.15.0}/mkdocs.yml +0 -0
  131. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_async.py +0 -0
  132. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_behave_matchers.py +0 -0
  133. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_bool.py +0 -0
  134. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_callable.py +0 -0
  135. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_chaining.py +0 -0
  136. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_class.py +0 -0
  137. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_collection.py +0 -0
  138. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_custom_dict.py +0 -0
  139. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_custom_list.py +0 -0
  140. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_datetime.py +0 -0
  141. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_description.py +0 -0
  142. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dict.py +0 -0
  143. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dict_compare.py +0 -0
  144. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_errors.py +0 -0
  145. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_expected_warning.py +0 -0
  146. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_extensions.py +0 -0
  147. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_fail.py +0 -0
  148. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_in.py +0 -0
  149. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_json.py +0 -0
  150. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_matcher_registry.py +0 -0
  151. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_namedtuple.py +0 -0
  152. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_none.py +0 -0
  153. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_numbers.py +0 -0
  154. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_overloads.py +0 -0
  155. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_pipeline.py +0 -0
  156. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_protocol_parity.py +0 -0
  157. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_readme.py +0 -0
  158. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare.py +0 -0
  159. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare_attrs.py +0 -0
  160. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare_pydantic.py +0 -0
  161. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_regex_groups.py +0 -0
  162. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_same_as.py +0 -0
  163. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_satisfy.py +0 -0
  164. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_soft.py +0 -0
  165. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_soft_fail.py +0 -0
  166. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_traceback.py +0 -0
  167. {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_warn.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.14.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,20 +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
+ - [**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.
191
192
 
192
193
  **Type safety**
193
194
 
194
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.
195
198
  - **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
196
199
 
197
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,20 +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
+ - [**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.
143
144
 
144
145
  **Type safety**
145
146
 
146
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.
147
150
  - **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
148
151
 
149
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 .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
16
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",
@@ -28,11 +29,14 @@ __all__ = [
28
29
  "PollSample",
29
30
  "PollTrace",
30
31
  "SnapshotCreatedWarning",
32
+ "SnapshotUpdatedWarning",
31
33
  "SoftAssertionCollector",
34
+ "SyncAssertionBuilder",
32
35
  "WarningLoggingAdapter",
33
36
  "__version__",
34
37
  "add_extension",
35
38
  "assert_all",
39
+ "assert_conforms",
36
40
  "assert_that",
37
41
  "assert_warn",
38
42
  "clear_custom_matchers",
@@ -40,6 +44,7 @@ __all__ = [
40
44
  "fail",
41
45
  "match",
42
46
  "register_matcher",
47
+ "register_snapshot_serializer",
43
48
  "remove_extension",
44
49
  "soft_assertions",
45
50
  "soft_fail",
@@ -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:
@@ -206,6 +213,8 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
206
213
  never recursed into.
207
214
  """
208
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
209
218
  comparator = _resolve_comparator(actual, config, field=field)
210
219
  if comparator is not None:
211
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)}")]
@@ -20,6 +20,7 @@ class _MixinBase:
20
20
  _not_expected: bool
21
21
  _expected_warning: type[Warning] | None
22
22
  _return_value: object
23
+ _raised_exception: object
23
24
 
24
25
  def error(
25
26
  self,