assertpy2 2.18.0__tar.gz → 2.20.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 (202) hide show
  1. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/workflows/ci.yml +60 -9
  2. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/workflows/codeql.yml +4 -3
  3. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/workflows/codspeed.yml +4 -3
  4. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/workflows/docs.yml +5 -3
  5. assertpy2-2.20.0/.github/workflows/mutation.yml +78 -0
  6. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/workflows/publish.yml +5 -4
  7. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/workflows/scorecard.yml +4 -3
  8. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/workflows/zizmor.yml +3 -2
  9. assertpy2-2.20.0/CONTRIBUTING.md +67 -0
  10. {assertpy2-2.18.0 → assertpy2-2.20.0}/PKG-INFO +25 -3
  11. {assertpy2-2.18.0 → assertpy2-2.20.0}/README.md +24 -2
  12. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/__init__.py +32 -3
  13. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_engine/_compare.py +121 -7
  14. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_engine/_contract.py +2 -0
  15. assertpy2-2.20.0/assertpy2/_engine/_diff.py +584 -0
  16. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_engine/_introspection.py +51 -2
  17. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_engine/_mixin_base.py +6 -0
  18. assertpy2-2.20.0/assertpy2/_engine/_path.py +100 -0
  19. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_engine/_typing.py +36 -5
  20. assertpy2-2.20.0/assertpy2/_hints.py +325 -0
  21. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_inline.py +7 -1
  22. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_matcher_impls.py +221 -38
  23. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_satisfies.py +58 -26
  24. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/assertpy.py +303 -50
  25. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/async_assertions.py +15 -5
  26. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/base.py +59 -14
  27. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/bytes_mixin.py +15 -6
  28. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/collection.py +7 -3
  29. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/contains.py +92 -49
  30. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/dataframe.py +5 -3
  31. assertpy2-2.20.0/assertpy2/errors.py +502 -0
  32. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/extracting.py +4 -0
  33. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/file.py +1 -3
  34. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/helpers.py +85 -10
  35. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/json_mixin.py +26 -7
  36. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/matchers.py +94 -7
  37. assertpy2-2.20.0/assertpy2/outcome.py +118 -0
  38. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/pytest_plugin.py +152 -22
  39. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/snapshot.py +187 -32
  40. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/string.py +23 -2
  41. {assertpy2-2.18.0 → assertpy2-2.20.0}/benchmarks/test_perf.py +107 -0
  42. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/concepts/type-safety.md +40 -1
  43. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/extending/custom-assertions.md +23 -0
  44. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/extending/integrations.md +43 -3
  45. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/getting-started/comparison.md +4 -2
  46. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/getting-started/quickstart.md +6 -2
  47. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/guides/assertions.md +96 -2
  48. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/guides/errors.md +275 -9
  49. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/guides/fluent.md +17 -1
  50. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/guides/matchers.md +78 -4
  51. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/guides/testing.md +47 -3
  52. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/core.md +5 -0
  53. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/entry-points.md +18 -0
  54. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/errors.md +6 -0
  55. assertpy2-2.20.0/docs/reference/matchers.md +21 -0
  56. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/snapshots.md +6 -0
  57. {assertpy2-2.18.0 → assertpy2-2.20.0}/pyproject.toml +80 -6
  58. assertpy2-2.20.0/scripts/mutation_report.py +142 -0
  59. assertpy2-2.20.0/tests/docs_fixtures.py +150 -0
  60. assertpy2-2.20.0/tests/pyright_baseline.py +69 -0
  61. assertpy2-2.20.0/tests/test_api_vocabulary.py +153 -0
  62. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_async.py +45 -0
  63. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_attrs_support.py +1 -1
  64. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_bytes.py +54 -3
  65. assertpy2-2.20.0/tests/test_check.py +121 -0
  66. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_collection.py +71 -1
  67. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_dataframe.py +21 -0
  68. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_datetime.py +25 -0
  69. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_dict_compare.py +100 -10
  70. assertpy2-2.20.0/tests/test_docs_examples.py +92 -0
  71. assertpy2-2.20.0/tests/test_docs_typing.py +105 -0
  72. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_equals.py +88 -0
  73. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_errors.py +146 -9
  74. assertpy2-2.20.0/tests/test_exception_context.py +73 -0
  75. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_extensions.py +90 -13
  76. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_extracting.py +21 -0
  77. assertpy2-2.20.0/tests/test_hints.py +417 -0
  78. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_inline_record.py +58 -0
  79. assertpy2-2.20.0/tests/test_inline_snapshot.py +199 -0
  80. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_json.py +27 -1
  81. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_list.py +114 -0
  82. assertpy2-2.20.0/tests/test_match_result.py +134 -0
  83. assertpy2-2.20.0/tests/test_matcher_parity.py +157 -0
  84. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_matcher_registry.py +90 -1
  85. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_matchers.py +212 -0
  86. assertpy2-2.20.0/tests/test_message_elision.py +118 -0
  87. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_openapi_contract.py +136 -1
  88. assertpy2-2.20.0/tests/test_optional_integration_contracts.py +87 -0
  89. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_pipeline.py +15 -0
  90. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_property_based.py +282 -15
  91. assertpy2-2.20.0/tests/test_protocol_parity.py +219 -0
  92. assertpy2-2.20.0/tests/test_public_surface.py +132 -0
  93. assertpy2-2.20.0/tests/test_pyright_baseline.py +54 -0
  94. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_pytest_plugin.py +471 -8
  95. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_recursive_assertion.py +26 -17
  96. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_recursive_compare_config.py +378 -2
  97. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_rich_diff.py +431 -27
  98. assertpy2-2.20.0/tests/test_satisfy.py +288 -0
  99. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_snapshots.py +39 -1
  100. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_soft.py +64 -1
  101. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_structural.py +276 -13
  102. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_sync_eventually.py +12 -0
  103. assertpy2-2.20.0/tests/test_traceback.py +86 -0
  104. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_typing.py +58 -1
  105. assertpy2-2.20.0/tests/test_typing_claims.py +81 -0
  106. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_vacuity_contract.py +11 -4
  107. {assertpy2-2.18.0 → assertpy2-2.20.0}/uv.lock +561 -1226
  108. assertpy2-2.18.0/.github/workflows/mutation.yml +0 -66
  109. assertpy2-2.18.0/CONTRIBUTING.md +0 -50
  110. assertpy2-2.18.0/assertpy2/_engine/_diff.py +0 -366
  111. assertpy2-2.18.0/assertpy2/errors.py +0 -299
  112. assertpy2-2.18.0/docs/reference/matchers.md +0 -8
  113. assertpy2-2.18.0/tests/test_docs_examples.py +0 -60
  114. assertpy2-2.18.0/tests/test_inline_snapshot.py +0 -98
  115. assertpy2-2.18.0/tests/test_protocol_parity.py +0 -86
  116. assertpy2-2.18.0/tests/test_readme.py +0 -472
  117. assertpy2-2.18.0/tests/test_satisfy.py +0 -115
  118. assertpy2-2.18.0/tests/test_traceback.py +0 -38
  119. {assertpy2-2.18.0 → assertpy2-2.20.0}/.codecov.yml +0 -0
  120. {assertpy2-2.18.0 → assertpy2-2.20.0}/.gitattributes +0 -0
  121. {assertpy2-2.18.0 → assertpy2-2.20.0}/.github/dependabot.yml +0 -0
  122. {assertpy2-2.18.0 → assertpy2-2.20.0}/.gitignore +0 -0
  123. {assertpy2-2.18.0 → assertpy2-2.20.0}/LICENSE +0 -0
  124. {assertpy2-2.18.0 → assertpy2-2.20.0}/SECURITY.md +0 -0
  125. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_engine/__init__.py +0 -0
  126. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_engine/_compat.py +0 -0
  127. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/_snapshot_codec.py +0 -0
  128. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/behave_matchers.py +0 -0
  129. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/date.py +0 -0
  130. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/dict.py +0 -0
  131. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/dynamic.py +0 -0
  132. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/exception.py +0 -0
  133. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/numeric.py +0 -0
  134. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/py.typed +0 -0
  135. {assertpy2-2.18.0 → assertpy2-2.20.0}/assertpy2/warning.py +0 -0
  136. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/assets/diff-equal.png +0 -0
  137. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/assets/diff-equal.svg +0 -0
  138. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/assets/diff-gallery.png +0 -0
  139. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/assets/diff-match.svg +0 -0
  140. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/assets/diff-sequence.svg +0 -0
  141. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/assets/diff-set.svg +0 -0
  142. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/assets/diff-string.svg +0 -0
  143. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/getting-started/migration.md +0 -0
  144. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/guides/data.md +0 -0
  145. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/index.md +0 -0
  146. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/logo-dark.svg +0 -0
  147. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/logo.svg +0 -0
  148. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/recipes.md +0 -0
  149. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/async.md +0 -0
  150. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/bytes.md +0 -0
  151. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/collections.md +0 -0
  152. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/containment.md +0 -0
  153. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/dataframes.md +0 -0
  154. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/dates.md +0 -0
  155. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/dicts.md +0 -0
  156. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/dynamic.md +0 -0
  157. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/exceptions.md +0 -0
  158. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/extracting.md +0 -0
  159. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/files.md +0 -0
  160. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/json.md +0 -0
  161. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/numbers.md +0 -0
  162. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/overview.md +0 -0
  163. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/strings.md +0 -0
  164. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/reference/warnings.md +0 -0
  165. {assertpy2-2.18.0 → assertpy2-2.20.0}/docs/stylesheets/extra.css +0 -0
  166. {assertpy2-2.18.0 → assertpy2-2.20.0}/mkdocs.yml +0 -0
  167. {assertpy2-2.18.0 → assertpy2-2.20.0}/mkdocs_hooks.py +0 -0
  168. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/conftest.py +0 -0
  169. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_behave_matchers.py +0 -0
  170. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_bool.py +0 -0
  171. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_boundary_cases.py +0 -0
  172. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_callable.py +0 -0
  173. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_chaining.py +0 -0
  174. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_class.py +0 -0
  175. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_core.py +0 -0
  176. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_custom_dict.py +0 -0
  177. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_custom_list.py +0 -0
  178. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_description.py +0 -0
  179. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_dict.py +0 -0
  180. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_dynamic.py +0 -0
  181. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_expected_exception.py +0 -0
  182. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_expected_warning.py +0 -0
  183. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_fail.py +0 -0
  184. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_file.py +0 -0
  185. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_grouped_soft.py +0 -0
  186. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_in.py +0 -0
  187. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_iterable_cluster.py +0 -0
  188. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_namedtuple.py +0 -0
  189. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_none.py +0 -0
  190. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_not.py +0 -0
  191. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_numbers.py +0 -0
  192. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_overloads.py +0 -0
  193. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_property_attrs.py +0 -0
  194. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_recursive_compare.py +0 -0
  195. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_recursive_compare_attrs.py +0 -0
  196. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_recursive_compare_pydantic.py +0 -0
  197. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_regex_groups.py +0 -0
  198. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_same_as.py +0 -0
  199. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_soft_fail.py +0 -0
  200. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_string.py +0 -0
  201. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_type.py +0 -0
  202. {assertpy2-2.18.0 → assertpy2-2.20.0}/tests/test_warn.py +0 -0
@@ -15,6 +15,7 @@ permissions:
15
15
 
16
16
  jobs:
17
17
  test:
18
+ timeout-minutes: 15
18
19
  runs-on: ${{ matrix.os }}
19
20
  strategy:
20
21
  matrix:
@@ -31,15 +32,15 @@ jobs:
31
32
  - os: macos-latest
32
33
  python-version: "3.14"
33
34
  steps:
34
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35
36
  with:
36
37
  persist-credentials: false
37
38
 
38
39
  - name: Install uv
39
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
40
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
40
41
 
41
42
  - name: Set up Python ${{ matrix.python-version }}
42
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
43
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
43
44
  with:
44
45
  python-version: ${{ matrix.python-version }}
45
46
  allow-prereleases: true
@@ -71,24 +72,63 @@ jobs:
71
72
  run: uv run pytest tests/test_docs_examples.py
72
73
 
73
74
  - name: Upload coverage to Codecov
74
- if: matrix.python-version == '3.14' && matrix.os == 'ubuntu-latest'
75
+ # Only the full-deps cell. The bare 3.14/ubuntu cell skips every integration test and used
76
+ # to upload too: Codecov merges reports so the number stayed right, but one of the two was
77
+ # always a partial report sent at a 100% target.
78
+ if: matrix.full-deps
75
79
  uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
76
80
  with:
77
81
  token: ${{ secrets.CODECOV_TOKEN }}
78
82
  files: coverage.xml
83
+ # A failed upload is a broken report, not a passing build. The 100% gate above is the hard
84
+ # guard either way, so this cannot turn a real coverage drop into a green run.
85
+ fail_ci_if_error: true
86
+
87
+ optional-integrations:
88
+ # allure and behave get their own job because installing them is incompatible with the coverage
89
+ # gate: with allure present the `except ImportError` fallback in pytest_plugin.py never runs, and
90
+ # without it the import itself never runs. The gating cell keeps the second state, this one holds
91
+ # the first. Everywhere else the two are tested against mocks, which accept any call and would
92
+ # wave through the renamed parameter or moved attribute a Dependabot bump can bring.
93
+ timeout-minutes: 10
94
+ runs-on: ubuntu-latest
95
+ steps:
96
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
97
+ with:
98
+ persist-credentials: false
99
+
100
+ - name: Install uv
101
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
102
+
103
+ - name: Set up Python
104
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
105
+ with:
106
+ python-version: "3.14"
107
+
108
+ - name: Install dependencies
109
+ run: uv sync --extra json --extra allure --extra behave
110
+
111
+ - name: Contract tests against the real libraries
112
+ run: uv run pytest -v tests/test_optional_integration_contracts.py
113
+
114
+ - name: The rest of the suite must also pass with both installed
115
+ # Installing allure flips `_HAS_ALLURE`, so every failing assertion in the suite takes the
116
+ # real attach path. That must not change any other test's outcome.
117
+ run: uv run pytest -q --ignore=tests/test_docs_examples.py tests
79
118
 
80
119
  lint:
120
+ timeout-minutes: 10
81
121
  runs-on: ubuntu-latest
82
122
  steps:
83
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
123
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
84
124
  with:
85
125
  persist-credentials: false
86
126
 
87
127
  - name: Install uv
88
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
128
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
89
129
 
90
130
  - name: Set up Python
91
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
131
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
92
132
  with:
93
133
  python-version: "3.14"
94
134
 
@@ -110,13 +150,24 @@ jobs:
110
150
  - name: Type check (pyright, public typing surface)
111
151
  run: uv run pyright tests/test_typing.py
112
152
 
153
+ # the package itself carries diagnostics that are not defects (values typed `object`, guarded
154
+ # optional imports, deliberate overloads). They are recorded per rule with a reason, so a new
155
+ # one fails here instead of hiding in a count nobody reads.
156
+ - name: Type check (pyright, package baseline)
157
+ run: uv run pytest tests/test_pyright_baseline.py -q
158
+
113
159
  ci-ok:
114
- needs: [test, lint]
160
+ timeout-minutes: 5
161
+ needs: [test, optional-integrations, lint]
115
162
  if: always()
116
163
  runs-on: ubuntu-latest
117
164
  steps:
118
165
  - name: Verify required jobs succeeded
119
166
  env:
120
167
  TEST_RESULT: ${{ needs.test.result }}
168
+ INTEGRATIONS_RESULT: ${{ needs.optional-integrations.result }}
121
169
  LINT_RESULT: ${{ needs.lint.result }}
122
- run: '[ "$TEST_RESULT" = "success" ] && [ "$LINT_RESULT" = "success" ]'
170
+ run: |
171
+ [ "$TEST_RESULT" = "success" ] &&
172
+ [ "$INTEGRATIONS_RESULT" = "success" ] &&
173
+ [ "$LINT_RESULT" = "success" ]
@@ -13,14 +13,15 @@ permissions:
13
13
 
14
14
  jobs:
15
15
  analyze:
16
+ timeout-minutes: 15
16
17
  runs-on: ubuntu-latest
17
18
  permissions:
18
19
  security-events: write
19
20
  steps:
20
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
22
  with:
22
23
  persist-credentials: false
23
- - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
24
+ - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
24
25
  with:
25
26
  languages: python
26
- - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
27
+ - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
@@ -21,20 +21,21 @@ concurrency:
21
21
 
22
22
  jobs:
23
23
  benchmarks:
24
+ timeout-minutes: 20
24
25
  runs-on: ubuntu-latest
25
26
  permissions:
26
27
  contents: read
27
28
  id-token: write
28
29
  steps:
29
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
30
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
30
31
  with:
31
32
  persist-credentials: false
32
33
 
33
34
  - name: Install uv
34
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
35
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
35
36
 
36
37
  - name: Run benchmarks under CodSpeed
37
- uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
38
+ uses: CodSpeedHQ/action@f22792bfac16f3e14eb9fbea76f4a48e9cc22b93 # v4.19.1
38
39
  with:
39
40
  mode: simulation
40
41
  run: uv run --locked --group benchmark pytest benchmarks/ --codspeed --no-cov
@@ -15,17 +15,18 @@ concurrency:
15
15
 
16
16
  jobs:
17
17
  build:
18
+ timeout-minutes: 10
18
19
  runs-on: ubuntu-latest
19
20
  steps:
20
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
22
  with:
22
23
  persist-credentials: false
23
24
 
24
25
  - name: Install uv
25
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
26
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
26
27
 
27
28
  - name: Set up Python
28
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
29
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
29
30
  with:
30
31
  python-version: "3.14"
31
32
 
@@ -42,6 +43,7 @@ jobs:
42
43
  path: site
43
44
 
44
45
  deploy:
46
+ timeout-minutes: 10
45
47
  if: github.ref == 'refs/heads/main'
46
48
  needs: build
47
49
  runs-on: ubuntu-latest
@@ -0,0 +1,78 @@
1
+ name: Mutation
2
+
3
+ # Coverage says a line ran; mutation testing asks whether anything would have NOTICED had it been
4
+ # wrong. It is a review tool, not a gate: most survivors are equivalent mutants (message text no test
5
+ # asserts on, formatting, `__repr__`), so the job stays green on survivors and publishes the list to
6
+ # triage. Weekly, not nightly - the score only moves when assertpy2/ or tests/ move, and a recurring
7
+ # red that means nothing is a red people learn to ignore.
8
+ #
9
+ # It does go red when the RUN itself is broken. mutmut runs the unmutated suite once to learn which
10
+ # tests cover which function, and exits 1 if that fails, so a test the trampoline breaks stops the
11
+ # whole run instead of quietly leaving mutants unjudged. That red is never routine: it means the
12
+ # config rotted, which is the failure the previous cosmic-ray setup hid for five weeks behind a
13
+ # stale module path.
14
+ #
15
+ # Linux only: mutmut forks and calls os.wait, and does not run on Windows.
16
+
17
+ on:
18
+ schedule:
19
+ - cron: "0 4 * * 1" # weekly, Monday 04:00 UTC
20
+ workflow_dispatch: {}
21
+
22
+ permissions:
23
+ contents: read
24
+
25
+ concurrency:
26
+ group: ${{ github.workflow }}
27
+ cancel-in-progress: true
28
+
29
+ jobs:
30
+ mutation:
31
+ runs-on: ubuntu-latest
32
+ # 10 060 mutants over the whole package. Measured on four pinned cores with `--max-children 4`,
33
+ # which is this runner's shape: 41m15s. Slower runner cores put the real figure somewhere
34
+ # around an hour, so this leaves comfortable headroom without letting a hang burn two hours.
35
+ timeout-minutes: 120
36
+ steps:
37
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
38
+ with:
39
+ persist-credentials: false
40
+
41
+ - name: Install uv
42
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
43
+
44
+ - name: Set up Python
45
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
46
+ with:
47
+ python-version: "3.14"
48
+
49
+ - name: Install dependencies
50
+ # Every optional integration, matching the full-deps cell of ci.yml. A skipped test cannot
51
+ # kill a mutant, so mutating dataframe.py or json_mixin.py without pandas/polars/jsonschema
52
+ # installed reports its whole surface as untested.
53
+ run: uv sync --extra json --extra data --extra inline --group integrations --group mutation
54
+
55
+ - name: Run the mutants
56
+ # Exits non-zero when it cannot collect stats, which is what a baseline failure looks like.
57
+ # Survivors alone do not fail it.
58
+ #
59
+ # `--no-sync` on every `uv run` below: without it uv re-resolves the environment to the
60
+ # project's default groups and uninstalls the extras the step above just installed, so the
61
+ # mutants would be judged by a suite that skips pandas, polars and jsonschema.
62
+ run: uv run --no-sync mutmut run
63
+
64
+ - name: Report
65
+ if: always()
66
+ run: |
67
+ uv run --no-sync python scripts/mutation_report.py >> "$GITHUB_STEP_SUMMARY"
68
+ uv run --no-sync mutmut results > not-killed.txt
69
+
70
+ - name: Upload the mutants that lived
71
+ # `mutmut results` lists every mutant that was not killed, which is survivors plus timeouts
72
+ # plus the ones no test covered. Each line carries its own status, so the file is named for
73
+ # what it holds rather than for survivors alone - reading it as a survivor list overcounts.
74
+ if: always()
75
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
76
+ with:
77
+ name: mutants-not-killed
78
+ path: not-killed.txt
@@ -9,6 +9,7 @@ permissions:
9
9
 
10
10
  jobs:
11
11
  publish:
12
+ timeout-minutes: 20
12
13
  runs-on: ubuntu-latest
13
14
  environment: pypi
14
15
  permissions:
@@ -16,17 +17,17 @@ jobs:
16
17
  contents: write
17
18
  attestations: write
18
19
  steps:
19
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
21
  with:
21
22
  persist-credentials: false
22
23
 
23
24
  - name: Install uv
24
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
25
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
25
26
  with:
26
27
  enable-cache: false
27
28
 
28
29
  - name: Set up Python
29
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
30
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
30
31
  with:
31
32
  python-version: "3.15"
32
33
  allow-prereleases: true
@@ -65,7 +66,7 @@ jobs:
65
66
  run: syft scan dir:dist -o "cyclonedx-json=provenance/assertpy2.sbom.cdx.json"
66
67
 
67
68
  - name: Publish to PyPI
68
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
69
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
69
70
 
70
71
  - name: Upload to GitHub Release
71
72
  run: gh release upload "$TAG_NAME" dist/* provenance/*
@@ -11,15 +11,16 @@ permissions: read-all
11
11
 
12
12
  jobs:
13
13
  analysis:
14
+ timeout-minutes: 10
14
15
  runs-on: ubuntu-latest
15
16
  permissions:
16
17
  security-events: write
17
18
  id-token: write
18
19
  steps:
19
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
21
  with:
21
22
  persist-credentials: false
22
- - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
23
+ - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
23
24
  with:
24
25
  results_file: results.sarif
25
26
  results_format: sarif
@@ -29,6 +30,6 @@ jobs:
29
30
  name: SARIF file
30
31
  path: results.sarif
31
32
  retention-days: 5
32
- - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
33
+ - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
33
34
  with:
34
35
  sarif_file: results.sarif
@@ -15,11 +15,12 @@ permissions:
15
15
 
16
16
  jobs:
17
17
  zizmor:
18
+ timeout-minutes: 10
18
19
  runs-on: ubuntu-latest
19
20
  permissions:
20
21
  security-events: write
21
22
  steps:
22
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
24
  with:
24
25
  persist-credentials: false
25
- - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
26
+ - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
@@ -0,0 +1,67 @@
1
+ # Contributing
2
+
3
+ Contributions of docs, tests, or code are welcome.
4
+
5
+ ## Workflow
6
+
7
+ 1. For a new assertion or matcher, open an issue first. The API grows from demand, and a working
8
+ implementation on its own is not enough to land one
9
+ 2. Fork the repo
10
+ 3. Clone your fork (`git clone <your_fork_url>`)
11
+ 4. Create a branch (`git checkout -b my_branch`)
12
+ 5. Install dependencies: `uv sync`
13
+ 6. Make your changes
14
+ 7. Run the [verification pipeline](#verification-pipeline) and fix any issues
15
+ 8. Commit using [Conventional Commits](#commit-style)
16
+ 9. Push your branch (`git push origin my_branch`)
17
+ 10. Open a [Pull Request](http://github.com/Solganis/assertpy2/pulls)
18
+
19
+ Read more about how pulls work on GitHub's [About pull requests](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) page.
20
+
21
+ ## Requirements
22
+
23
+ - Python 3.10+
24
+ - [uv](https://docs.astral.sh/uv/) as the package manager
25
+
26
+ ## Verification pipeline
27
+
28
+ Run all checks before submitting a PR. Every step must pass.
29
+
30
+ ```bash
31
+ # lint
32
+ uv run ruff check assertpy2/ tests/
33
+
34
+ # format
35
+ uv run ruff format --check assertpy2/ tests/
36
+
37
+ # type check
38
+ uv run ty check
39
+
40
+ # tests with coverage (must be 100%)
41
+ uv run pytest tests/ -v --cov=assertpy2 --cov-report=term-missing
42
+ ```
43
+
44
+ CI requires 100% code coverage.
45
+
46
+ ## Commit style
47
+
48
+ Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, etc.
49
+
50
+ ## Tests
51
+
52
+ Write tests for every new feature or bug fix. Use `assertpy2` assertions in tests.
53
+
54
+ ## Documentation examples
55
+
56
+ Guide code blocks are executed (`tests/test_docs_examples.py`) and type-checked
57
+ (`tests/test_docs_typing.py`). To skip one, put an HTML comment above the fence:
58
+
59
+ | Marker | Executed | Type-checked | Use it for |
60
+ |---|---|---|---|
61
+ | `<!-- docs-guard: skip -->` | no | no | pseudo-context, neither runnable nor checkable |
62
+ | `<!-- docs-guard: untyped -->` | yes | no | a dynamic assertion (`has_<attr>()`) |
63
+ | `<!-- docs-guard: raises -->` | no | yes | a block showing what a failure looks like |
64
+ | `<!-- docs-guard: type-error -->` | no | yes, and it **must** fail | a counter-example the page presents as rejected |
65
+
66
+ Setup a page assumes (a domain class, a repository, an HTTP response) goes in
67
+ `tests/docs_fixtures.py`, not into an extra block on the page.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.18.0
3
+ Version: 2.20.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
@@ -88,9 +88,9 @@ def test_user():
88
88
  user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}
89
89
 
90
90
  assert_that(user).contains_key("name", "age")
91
+ assert_that(user).contains_entry({"name": "Alice"})
91
92
  assert_that(user["age"]).is_between(18, 120)
92
93
  assert_that(user["roles"]).contains("viewer").does_not_contain("admin")
93
- assert_that(user).has_name("Alice")
94
94
  ```
95
95
 
96
96
  The [full documentation](https://solganis.github.io/assertpy2/) covers every assertion, matcher, and integration.
@@ -100,6 +100,7 @@ The [full documentation](https://solganis.github.io/assertpy2/) covers every ass
100
100
  A fluent chain reads as one intent and replaces several bare asserts -<br>
101
101
  and your IDE offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/concepts/type-safety/):
102
102
 
103
+ <!-- docs-guard: skip -->
103
104
  ```python
104
105
  # bare - three statements, no autocomplete help
105
106
  assert isinstance(items, list)
@@ -123,6 +124,7 @@ E {'status': 'active'} != {'status': 'disabled'}
123
124
 
124
125
  assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs), in color:
125
126
 
127
+ <!-- docs-guard: skip -->
126
128
  ```python
127
129
  assert_that(response).is_equal_to(expected)
128
130
  ```
@@ -133,6 +135,23 @@ assert_that(response).is_equal_to(expected)
133
135
 
134
136
  The diff recurses through nested containers, and matcher predicates get the same path-level treatment. For dynamic fields like IDs or timestamps, assert a subset with [`matches_structure()`](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching).
135
137
 
138
+ Matchers are ordinary values, so they also compose inside the expected structure itself, at any depth,
139
+ with or without the fluent chain:
140
+
141
+ ```python
142
+ response = {"id": 7, "user": {"name": "Alice", "age": 30}, "tags": ["a", "b"]}
143
+
144
+ assert_that(response).is_equal_to(
145
+ {"id": match.greater_than(0), "user": {"name": "Alice", "age": match.between(18, 120)}, "tags": ["a", "b"]}
146
+ )
147
+
148
+ # or keep the bare `assert`, and pytest's own rewriting reports it
149
+ assert response == {"id": match.greater_than(0), "user": match.ignore(), "tags": ["a", "b"]}
150
+ ```
151
+
152
+ The fluent form keeps the path-level diff, the bare form keeps pytest's. There are
153
+ [39 matchers](https://solganis.github.io/assertpy2/guides/matchers/), and they combine with `&`, `|` and `~`.
154
+
136
155
  <p align="center">
137
156
  <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">
138
157
  </p>
@@ -156,6 +175,7 @@ Works in PyCharm, VS Code, and any LSP-compatible editor.
156
175
  An assertion hands the value back, statically narrowed. `is_not_none()` strips `None`,
157
176
  `is_instance_of()` narrows to the class, and `.value` returns it with no `cast` and no bare `assert`:
158
177
 
178
+ <!-- docs-guard: skip -->
159
179
  ```python
160
180
  order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
161
181
  order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
@@ -164,6 +184,7 @@ order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
164
184
  For API tests, [`assert_conforms()`](https://solganis.github.io/assertpy2/concepts/type-safety/#contract-narrowing-with-assert_conforms) validates a raw payload against a Pydantic model and narrows the chain to it,<br>
165
185
  with `exact=True` catching silent contract drift:
166
186
 
187
+ <!-- docs-guard: skip -->
167
188
  ```python
168
189
  data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
169
190
  ```
@@ -202,13 +223,14 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
202
223
  - [**Polling assertions**](https://solganis.github.io/assertpy2/guides/testing/#async-assertions): `eventually()` (async) / `eventually_sync()` (blocking) retry for eventual consistency, with a convergence trace on timeout.
203
224
  - [**Expected exceptions**](https://solganis.github.io/assertpy2/guides/errors/#expected-exceptions): `raises().when_called_with()`, walk the cause chain (`caused_by()`, `has_root_cause()`), match `ExceptionGroup` (`contains_error()`), or pivot to the object (`raised()`).
204
225
  - [**Structured errors**](https://solganis.github.io/assertpy2/guides/errors/#structured-errors): `AssertionFailure` carries `.actual`, `.expected`, `.diff`, and the diff renders into the message itself, so it shows off pytest too.
226
+ - [**Assertions as values**](https://solganis.github.io/assertpy2/guides/errors/#asking-instead-of-asserting): `check()` runs the next assertion for its verdict instead of raising, returning an `AssertionOutcome` that is truthy when it held and carries the message, values and diff when it did not.
205
227
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs): recursive structural diffs across containers, dataclasses, attrs, and Pydantic models, with intra-line carets for strings and circular-reference protection.
206
228
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/guides/testing/#snapshot-testing): three modes under one typed API, all updated with `--assertpy2-snapshot-update`: `snapshot()` (external JSON file), [`matches_inline()`](https://solganis.github.io/assertpy2/guides/testing/#inline-snapshots) (recorded into the test source), and [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/guides/testing/#contract-snapshots) (value-tolerant structural regressions).
207
229
  - [**OpenAPI response contracts**](https://solganis.github.io/assertpy2/reference/json/#assertpy2.json_mixin.JsonMixin.conforms_to_openapi): `conforms_to_openapi(spec, path, method)` validates a JSON response body against an operation's response schema (OpenAPI 3.0/3.1 and Swagger 2.0), reporting every violation with its JSON path.
208
230
 
209
231
  **Extensibility**
210
232
 
211
- - [**Custom matchers**](https://solganis.github.io/assertpy2/guides/matchers/#custom-matchers): `register_matcher()` for domain-specific matchers, composable with `&`, `|`, `~`.
233
+ - [**Custom matchers**](https://solganis.github.io/assertpy2/guides/matchers/#custom-matchers): `register_matcher()` to compose existing ones, or subclass `BaseMatcher` for a rule that needs its own predicate. Both compose with `&`, `|`, `~`.
212
234
  - [**Regex group extraction**](https://solganis.github.io/assertpy2/guides/data/#regex-group-extraction): `extracting_group()` and `matches_with_groups()` for regex captures.
213
235
  - [**Extensions**](https://solganis.github.io/assertpy2/extending/custom-assertions/): `add_extension()` for custom assertion methods.
214
236
 
@@ -36,9 +36,9 @@ def test_user():
36
36
  user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}
37
37
 
38
38
  assert_that(user).contains_key("name", "age")
39
+ assert_that(user).contains_entry({"name": "Alice"})
39
40
  assert_that(user["age"]).is_between(18, 120)
40
41
  assert_that(user["roles"]).contains("viewer").does_not_contain("admin")
41
- assert_that(user).has_name("Alice")
42
42
  ```
43
43
 
44
44
  The [full documentation](https://solganis.github.io/assertpy2/) covers every assertion, matcher, and integration.
@@ -48,6 +48,7 @@ The [full documentation](https://solganis.github.io/assertpy2/) covers every ass
48
48
  A fluent chain reads as one intent and replaces several bare asserts -<br>
49
49
  and your IDE offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/concepts/type-safety/):
50
50
 
51
+ <!-- docs-guard: skip -->
51
52
  ```python
52
53
  # bare - three statements, no autocomplete help
53
54
  assert isinstance(items, list)
@@ -71,6 +72,7 @@ E {'status': 'active'} != {'status': 'disabled'}
71
72
 
72
73
  assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs), in color:
73
74
 
75
+ <!-- docs-guard: skip -->
74
76
  ```python
75
77
  assert_that(response).is_equal_to(expected)
76
78
  ```
@@ -81,6 +83,23 @@ assert_that(response).is_equal_to(expected)
81
83
 
82
84
  The diff recurses through nested containers, and matcher predicates get the same path-level treatment. For dynamic fields like IDs or timestamps, assert a subset with [`matches_structure()`](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching).
83
85
 
86
+ Matchers are ordinary values, so they also compose inside the expected structure itself, at any depth,
87
+ with or without the fluent chain:
88
+
89
+ ```python
90
+ response = {"id": 7, "user": {"name": "Alice", "age": 30}, "tags": ["a", "b"]}
91
+
92
+ assert_that(response).is_equal_to(
93
+ {"id": match.greater_than(0), "user": {"name": "Alice", "age": match.between(18, 120)}, "tags": ["a", "b"]}
94
+ )
95
+
96
+ # or keep the bare `assert`, and pytest's own rewriting reports it
97
+ assert response == {"id": match.greater_than(0), "user": match.ignore(), "tags": ["a", "b"]}
98
+ ```
99
+
100
+ The fluent form keeps the path-level diff, the bare form keeps pytest's. There are
101
+ [39 matchers](https://solganis.github.io/assertpy2/guides/matchers/), and they combine with `&`, `|` and `~`.
102
+
84
103
  <p align="center">
85
104
  <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">
86
105
  </p>
@@ -104,6 +123,7 @@ Works in PyCharm, VS Code, and any LSP-compatible editor.
104
123
  An assertion hands the value back, statically narrowed. `is_not_none()` strips `None`,
105
124
  `is_instance_of()` narrows to the class, and `.value` returns it with no `cast` and no bare `assert`:
106
125
 
126
+ <!-- docs-guard: skip -->
107
127
  ```python
108
128
  order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
109
129
  order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
@@ -112,6 +132,7 @@ order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
112
132
  For API tests, [`assert_conforms()`](https://solganis.github.io/assertpy2/concepts/type-safety/#contract-narrowing-with-assert_conforms) validates a raw payload against a Pydantic model and narrows the chain to it,<br>
113
133
  with `exact=True` catching silent contract drift:
114
134
 
135
+ <!-- docs-guard: skip -->
115
136
  ```python
116
137
  data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
117
138
  ```
@@ -150,13 +171,14 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
150
171
  - [**Polling assertions**](https://solganis.github.io/assertpy2/guides/testing/#async-assertions): `eventually()` (async) / `eventually_sync()` (blocking) retry for eventual consistency, with a convergence trace on timeout.
151
172
  - [**Expected exceptions**](https://solganis.github.io/assertpy2/guides/errors/#expected-exceptions): `raises().when_called_with()`, walk the cause chain (`caused_by()`, `has_root_cause()`), match `ExceptionGroup` (`contains_error()`), or pivot to the object (`raised()`).
152
173
  - [**Structured errors**](https://solganis.github.io/assertpy2/guides/errors/#structured-errors): `AssertionFailure` carries `.actual`, `.expected`, `.diff`, and the diff renders into the message itself, so it shows off pytest too.
174
+ - [**Assertions as values**](https://solganis.github.io/assertpy2/guides/errors/#asking-instead-of-asserting): `check()` runs the next assertion for its verdict instead of raising, returning an `AssertionOutcome` that is truthy when it held and carries the message, values and diff when it did not.
153
175
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs): recursive structural diffs across containers, dataclasses, attrs, and Pydantic models, with intra-line carets for strings and circular-reference protection.
154
176
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/guides/testing/#snapshot-testing): three modes under one typed API, all updated with `--assertpy2-snapshot-update`: `snapshot()` (external JSON file), [`matches_inline()`](https://solganis.github.io/assertpy2/guides/testing/#inline-snapshots) (recorded into the test source), and [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/guides/testing/#contract-snapshots) (value-tolerant structural regressions).
155
177
  - [**OpenAPI response contracts**](https://solganis.github.io/assertpy2/reference/json/#assertpy2.json_mixin.JsonMixin.conforms_to_openapi): `conforms_to_openapi(spec, path, method)` validates a JSON response body against an operation's response schema (OpenAPI 3.0/3.1 and Swagger 2.0), reporting every violation with its JSON path.
156
178
 
157
179
  **Extensibility**
158
180
 
159
- - [**Custom matchers**](https://solganis.github.io/assertpy2/guides/matchers/#custom-matchers): `register_matcher()` for domain-specific matchers, composable with `&`, `|`, `~`.
181
+ - [**Custom matchers**](https://solganis.github.io/assertpy2/guides/matchers/#custom-matchers): `register_matcher()` to compose existing ones, or subclass `BaseMatcher` for a rule that needs its own predicate. Both compose with `&`, `|`, `~`.
160
182
  - [**Regex group extraction**](https://solganis.github.io/assertpy2/guides/data/#regex-group-extraction): `extracting_group()` and `matches_with_groups()` for regex captures.
161
183
  - [**Extensions**](https://solganis.github.io/assertpy2/extending/custom-assertions/): `add_extension()` for custom assertion methods.
162
184