assertpy2 2.19.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 (199) hide show
  1. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/workflows/ci.yml +51 -3
  2. assertpy2-2.20.0/.github/workflows/mutation.yml +78 -0
  3. assertpy2-2.20.0/CONTRIBUTING.md +67 -0
  4. {assertpy2-2.19.0 → assertpy2-2.20.0}/PKG-INFO +3 -2
  5. {assertpy2-2.19.0 → assertpy2-2.20.0}/README.md +2 -1
  6. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/__init__.py +25 -2
  7. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/_compare.py +27 -0
  8. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/_contract.py +2 -0
  9. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/_diff.py +92 -75
  10. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/_introspection.py +51 -2
  11. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/_mixin_base.py +5 -0
  12. assertpy2-2.20.0/assertpy2/_engine/_path.py +100 -0
  13. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/_typing.py +25 -3
  14. assertpy2-2.20.0/assertpy2/_hints.py +325 -0
  15. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_inline.py +7 -1
  16. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_matcher_impls.py +173 -37
  17. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_satisfies.py +58 -26
  18. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/assertpy.py +271 -48
  19. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/async_assertions.py +10 -4
  20. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/base.py +15 -6
  21. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/bytes_mixin.py +15 -6
  22. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/collection.py +7 -3
  23. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/contains.py +92 -49
  24. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/errors.py +168 -9
  25. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/extracting.py +4 -0
  26. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/helpers.py +71 -8
  27. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/matchers.py +80 -5
  28. assertpy2-2.20.0/assertpy2/outcome.py +118 -0
  29. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/pytest_plugin.py +114 -37
  30. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/snapshot.py +70 -32
  31. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/string.py +19 -0
  32. {assertpy2-2.19.0 → assertpy2-2.20.0}/benchmarks/test_perf.py +42 -0
  33. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/concepts/type-safety.md +14 -1
  34. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/extending/integrations.md +43 -3
  35. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/getting-started/comparison.md +4 -2
  36. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/getting-started/quickstart.md +6 -2
  37. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/guides/assertions.md +13 -2
  38. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/guides/errors.md +253 -9
  39. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/guides/fluent.md +8 -3
  40. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/guides/matchers.md +78 -4
  41. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/guides/testing.md +12 -3
  42. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/core.md +5 -0
  43. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/entry-points.md +18 -0
  44. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/errors.md +6 -0
  45. assertpy2-2.20.0/docs/reference/matchers.md +21 -0
  46. {assertpy2-2.19.0 → assertpy2-2.20.0}/pyproject.toml +63 -2
  47. assertpy2-2.20.0/scripts/mutation_report.py +142 -0
  48. assertpy2-2.20.0/tests/docs_fixtures.py +150 -0
  49. assertpy2-2.20.0/tests/pyright_baseline.py +69 -0
  50. assertpy2-2.20.0/tests/test_api_vocabulary.py +153 -0
  51. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_attrs_support.py +1 -1
  52. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_bytes.py +54 -3
  53. assertpy2-2.20.0/tests/test_check.py +121 -0
  54. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_collection.py +71 -1
  55. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_datetime.py +25 -0
  56. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_dict_compare.py +100 -10
  57. assertpy2-2.20.0/tests/test_docs_examples.py +92 -0
  58. assertpy2-2.20.0/tests/test_docs_typing.py +105 -0
  59. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_equals.py +62 -0
  60. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_errors.py +146 -9
  61. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_extensions.py +90 -13
  62. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_extracting.py +21 -0
  63. assertpy2-2.20.0/tests/test_hints.py +417 -0
  64. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_inline_record.py +58 -0
  65. assertpy2-2.20.0/tests/test_inline_snapshot.py +199 -0
  66. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_list.py +114 -0
  67. assertpy2-2.20.0/tests/test_match_result.py +134 -0
  68. assertpy2-2.20.0/tests/test_matcher_parity.py +157 -0
  69. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_matcher_registry.py +90 -1
  70. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_matchers.py +136 -0
  71. assertpy2-2.20.0/tests/test_message_elision.py +118 -0
  72. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_openapi_contract.py +136 -1
  73. assertpy2-2.20.0/tests/test_optional_integration_contracts.py +87 -0
  74. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_pipeline.py +15 -0
  75. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_property_based.py +150 -15
  76. assertpy2-2.20.0/tests/test_public_surface.py +132 -0
  77. assertpy2-2.20.0/tests/test_pyright_baseline.py +54 -0
  78. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_pytest_plugin.py +308 -17
  79. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_recursive_assertion.py +26 -17
  80. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_recursive_compare_config.py +92 -0
  81. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_rich_diff.py +183 -27
  82. assertpy2-2.20.0/tests/test_satisfy.py +288 -0
  83. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_soft.py +64 -1
  84. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_structural.py +260 -13
  85. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_sync_eventually.py +12 -0
  86. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_typing.py +39 -2
  87. assertpy2-2.20.0/tests/test_typing_claims.py +81 -0
  88. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_vacuity_contract.py +11 -4
  89. {assertpy2-2.19.0 → assertpy2-2.20.0}/uv.lock +271 -934
  90. assertpy2-2.19.0/.github/workflows/mutation.yml +0 -66
  91. assertpy2-2.19.0/CONTRIBUTING.md +0 -50
  92. assertpy2-2.19.0/docs/reference/matchers.md +0 -8
  93. assertpy2-2.19.0/tests/test_docs_examples.py +0 -60
  94. assertpy2-2.19.0/tests/test_docs_typing.py +0 -77
  95. assertpy2-2.19.0/tests/test_inline_snapshot.py +0 -98
  96. assertpy2-2.19.0/tests/test_readme.py +0 -472
  97. assertpy2-2.19.0/tests/test_satisfy.py +0 -115
  98. {assertpy2-2.19.0 → assertpy2-2.20.0}/.codecov.yml +0 -0
  99. {assertpy2-2.19.0 → assertpy2-2.20.0}/.gitattributes +0 -0
  100. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/dependabot.yml +0 -0
  101. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/workflows/codeql.yml +0 -0
  102. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/workflows/codspeed.yml +0 -0
  103. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/workflows/docs.yml +0 -0
  104. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/workflows/publish.yml +0 -0
  105. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/workflows/scorecard.yml +0 -0
  106. {assertpy2-2.19.0 → assertpy2-2.20.0}/.github/workflows/zizmor.yml +0 -0
  107. {assertpy2-2.19.0 → assertpy2-2.20.0}/.gitignore +0 -0
  108. {assertpy2-2.19.0 → assertpy2-2.20.0}/LICENSE +0 -0
  109. {assertpy2-2.19.0 → assertpy2-2.20.0}/SECURITY.md +0 -0
  110. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/__init__.py +0 -0
  111. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_engine/_compat.py +0 -0
  112. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/_snapshot_codec.py +0 -0
  113. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/behave_matchers.py +0 -0
  114. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/dataframe.py +0 -0
  115. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/date.py +0 -0
  116. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/dict.py +0 -0
  117. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/dynamic.py +0 -0
  118. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/exception.py +0 -0
  119. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/file.py +0 -0
  120. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/json_mixin.py +0 -0
  121. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/numeric.py +0 -0
  122. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/py.typed +0 -0
  123. {assertpy2-2.19.0 → assertpy2-2.20.0}/assertpy2/warning.py +0 -0
  124. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/assets/diff-equal.png +0 -0
  125. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/assets/diff-equal.svg +0 -0
  126. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/assets/diff-gallery.png +0 -0
  127. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/assets/diff-match.svg +0 -0
  128. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/assets/diff-sequence.svg +0 -0
  129. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/assets/diff-set.svg +0 -0
  130. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/assets/diff-string.svg +0 -0
  131. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/extending/custom-assertions.md +0 -0
  132. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/getting-started/migration.md +0 -0
  133. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/guides/data.md +0 -0
  134. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/index.md +0 -0
  135. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/logo-dark.svg +0 -0
  136. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/logo.svg +0 -0
  137. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/recipes.md +0 -0
  138. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/async.md +0 -0
  139. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/bytes.md +0 -0
  140. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/collections.md +0 -0
  141. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/containment.md +0 -0
  142. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/dataframes.md +0 -0
  143. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/dates.md +0 -0
  144. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/dicts.md +0 -0
  145. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/dynamic.md +0 -0
  146. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/exceptions.md +0 -0
  147. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/extracting.md +0 -0
  148. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/files.md +0 -0
  149. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/json.md +0 -0
  150. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/numbers.md +0 -0
  151. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/overview.md +0 -0
  152. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/snapshots.md +0 -0
  153. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/strings.md +0 -0
  154. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/reference/warnings.md +0 -0
  155. {assertpy2-2.19.0 → assertpy2-2.20.0}/docs/stylesheets/extra.css +0 -0
  156. {assertpy2-2.19.0 → assertpy2-2.20.0}/mkdocs.yml +0 -0
  157. {assertpy2-2.19.0 → assertpy2-2.20.0}/mkdocs_hooks.py +0 -0
  158. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/conftest.py +0 -0
  159. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_async.py +0 -0
  160. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_behave_matchers.py +0 -0
  161. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_bool.py +0 -0
  162. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_boundary_cases.py +0 -0
  163. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_callable.py +0 -0
  164. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_chaining.py +0 -0
  165. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_class.py +0 -0
  166. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_core.py +0 -0
  167. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_custom_dict.py +0 -0
  168. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_custom_list.py +0 -0
  169. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_dataframe.py +0 -0
  170. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_description.py +0 -0
  171. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_dict.py +0 -0
  172. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_dynamic.py +0 -0
  173. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_exception_context.py +0 -0
  174. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_expected_exception.py +0 -0
  175. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_expected_warning.py +0 -0
  176. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_fail.py +0 -0
  177. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_file.py +0 -0
  178. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_grouped_soft.py +0 -0
  179. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_in.py +0 -0
  180. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_iterable_cluster.py +0 -0
  181. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_json.py +0 -0
  182. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_namedtuple.py +0 -0
  183. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_none.py +0 -0
  184. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_not.py +0 -0
  185. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_numbers.py +0 -0
  186. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_overloads.py +0 -0
  187. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_property_attrs.py +0 -0
  188. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_protocol_parity.py +0 -0
  189. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_recursive_compare.py +0 -0
  190. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_recursive_compare_attrs.py +0 -0
  191. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_recursive_compare_pydantic.py +0 -0
  192. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_regex_groups.py +0 -0
  193. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_same_as.py +0 -0
  194. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_snapshots.py +0 -0
  195. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_soft_fail.py +0 -0
  196. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_string.py +0 -0
  197. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_traceback.py +0 -0
  198. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_type.py +0 -0
  199. {assertpy2-2.19.0 → assertpy2-2.20.0}/tests/test_warn.py +0 -0
@@ -72,11 +72,49 @@ jobs:
72
72
  run: uv run pytest tests/test_docs_examples.py
73
73
 
74
74
  - name: Upload coverage to Codecov
75
- 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
76
79
  uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
77
80
  with:
78
81
  token: ${{ secrets.CODECOV_TOKEN }}
79
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
80
118
 
81
119
  lint:
82
120
  timeout-minutes: 10
@@ -112,14 +150,24 @@ jobs:
112
150
  - name: Type check (pyright, public typing surface)
113
151
  run: uv run pyright tests/test_typing.py
114
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
+
115
159
  ci-ok:
116
160
  timeout-minutes: 5
117
- needs: [test, lint]
161
+ needs: [test, optional-integrations, lint]
118
162
  if: always()
119
163
  runs-on: ubuntu-latest
120
164
  steps:
121
165
  - name: Verify required jobs succeeded
122
166
  env:
123
167
  TEST_RESULT: ${{ needs.test.result }}
168
+ INTEGRATIONS_RESULT: ${{ needs.optional-integrations.result }}
124
169
  LINT_RESULT: ${{ needs.lint.result }}
125
- run: '[ "$TEST_RESULT" = "success" ] && [ "$LINT_RESULT" = "success" ]'
170
+ run: |
171
+ [ "$TEST_RESULT" = "success" ] &&
172
+ [ "$INTEGRATIONS_RESULT" = "success" ] &&
173
+ [ "$LINT_RESULT" = "success" ]
@@ -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
@@ -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.19.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
@@ -223,13 +223,14 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
223
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.
224
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()`).
225
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.
226
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.
227
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).
228
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.
229
230
 
230
231
  **Extensibility**
231
232
 
232
- - [**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 `&`, `|`, `~`.
233
234
  - [**Regex group extraction**](https://solganis.github.io/assertpy2/guides/data/#regex-group-extraction): `extracting_group()` and `matches_with_groups()` for regex captures.
234
235
  - [**Extensions**](https://solganis.github.io/assertpy2/extending/custom-assertions/): `add_extension()` for custom assertion methods.
235
236
 
@@ -171,13 +171,14 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
171
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.
172
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()`).
173
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.
174
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.
175
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).
176
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.
177
178
 
178
179
  **Extensibility**
179
180
 
180
- - [**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 `&`, `|`, `~`.
181
182
  - [**Regex group extraction**](https://solganis.github.io/assertpy2/guides/data/#regex-group-extraction): `extracting_group()` and `matches_with_groups()` for regex captures.
182
183
  - [**Extensions**](https://solganis.github.io/assertpy2/extending/custom-assertions/): `add_extension()` for custom assertion methods.
183
184
 
@@ -1,4 +1,5 @@
1
1
  from .assertpy import (
2
+ CheckBuilder,
2
3
  NegatedBuilder,
3
4
  SoftAssertionCollector,
4
5
  WarningLoggingAdapter,
@@ -14,9 +15,26 @@ from .assertpy import (
14
15
  soft_fail,
15
16
  )
16
17
  from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
17
- from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace, VacuousAssertionWarning
18
+ from .errors import (
19
+ AssertionFailure,
20
+ DiffEntry,
21
+ DiffResult,
22
+ PollSample,
23
+ PollTrace,
24
+ Step,
25
+ VacuousAssertionWarning,
26
+ )
18
27
  from .file import contents_of
19
- from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
28
+ from .matchers import (
29
+ BaseMatcher,
30
+ Matcher,
31
+ MatchResult,
32
+ clear_custom_matchers,
33
+ match,
34
+ register_matcher,
35
+ unregister_matcher,
36
+ )
37
+ from .outcome import AssertionOutcome
20
38
  from .snapshot import (
21
39
  SnapshotCreatedWarning,
22
40
  SnapshotKeyReusedWarning,
@@ -26,9 +44,13 @@ from .snapshot import (
26
44
 
27
45
  __all__ = [
28
46
  "AssertionFailure",
47
+ "AssertionOutcome",
29
48
  "AsyncAssertionBuilder",
49
+ "BaseMatcher",
50
+ "CheckBuilder",
30
51
  "DiffEntry",
31
52
  "DiffResult",
53
+ "MatchResult",
32
54
  "Matcher",
33
55
  "NegatedBuilder",
34
56
  "PollSample",
@@ -37,6 +59,7 @@ __all__ = [
37
59
  "SnapshotKeyReusedWarning",
38
60
  "SnapshotUpdatedWarning",
39
61
  "SoftAssertionCollector",
62
+ "Step",
40
63
  "SyncAssertionBuilder",
41
64
  "VacuousAssertionWarning",
42
65
  "WarningLoggingAdapter",
@@ -331,3 +331,30 @@ def _spec_matches(key, value, specs) -> bool:
331
331
  elif spec == key:
332
332
  return True
333
333
  return False
334
+
335
+
336
+ def _config_note(config: _CompareConfig | None) -> str:
337
+ """A newline plus an echo of the comparison settings that were in force, or ``""`` when none were.
338
+
339
+ ``is_equal_to`` already names ``ignore`` and ``include`` inside its sentence, but nothing reports
340
+ the rest, and they are what a reader is questioning when a field they thought was tolerated still
341
+ failed. Rendered only for a non-default config: `_build_compare_config()` returns ``None`` when
342
+ every setting is at its default, so the check costs nothing and the line never appears on the
343
+ ordinary failure.
344
+
345
+ It goes on its own line rather than into the sentence, so the original message stays a prefix and
346
+ a `match=` or ``startswith`` written against it keeps working.
347
+ """
348
+ if config is None:
349
+ return ""
350
+ parts = []
351
+ if config.tolerance is not None:
352
+ parts.append(f"tolerance={config.tolerance!r}")
353
+ if config.comparators:
354
+ keys = ", ".join(sorted(key.__name__ if isinstance(key, type) else str(key) for key in config.comparators))
355
+ parts.append(f"comparators for {keys}")
356
+ if config.ignore_null:
357
+ parts.append("ignore_null=True")
358
+ if config.strict_types:
359
+ parts.append("strict_types=True")
360
+ return "\ncompared with " + ", ".join(parts) if parts else ""
@@ -17,6 +17,8 @@ def _submodel(annotation: object) -> type | None:
17
17
  """The nested model class for an annotation, peeling ``Optional`` / ``list`` / ``tuple``; else ``None``."""
18
18
  origin = typing.get_origin(annotation)
19
19
  args = typing.get_args(annotation)
20
+ # both names are needed below 3.14, where `Optional[X]` and `X | None` had distinct origins; from
21
+ # 3.14 they are the same object and the second test is redundant on the interpreters we gate on
20
22
  if origin is typing.Union or origin is types.UnionType:
21
23
  non_none = [arg for arg in args if arg is not type(None)]
22
24
  return _submodel(non_none[0]) if len(non_none) == 1 else None