assertpy2 2.21.0__tar.gz → 2.22.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 (203) hide show
  1. assertpy2-2.22.0/CONTRIBUTING.md +83 -0
  2. {assertpy2-2.21.0 → assertpy2-2.22.0}/PKG-INFO +1 -1
  3. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_typing.py +5 -3
  4. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/assertpy.py +60 -36
  5. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/getting-started/comparison.md +2 -2
  6. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/guides/assertions.md +9 -10
  7. {assertpy2-2.21.0 → assertpy2-2.22.0}/pyproject.toml +2 -2
  8. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_not.py +187 -2
  9. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/typing_cases.py +10 -3
  10. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/typing_negative_baseline.py +22 -6
  11. assertpy2-2.21.0/CONTRIBUTING.md +0 -61
  12. {assertpy2-2.21.0 → assertpy2-2.22.0}/.gitignore +0 -0
  13. {assertpy2-2.21.0 → assertpy2-2.22.0}/LICENSE +0 -0
  14. {assertpy2-2.21.0 → assertpy2-2.22.0}/README.md +0 -0
  15. {assertpy2-2.21.0 → assertpy2-2.22.0}/SECURITY.md +0 -0
  16. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/__init__.py +0 -0
  17. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_clustering.py +0 -0
  18. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_dangling.py +0 -0
  19. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/__init__.py +0 -0
  20. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_compare.py +0 -0
  21. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_compat.py +0 -0
  22. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_contract.py +0 -0
  23. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_diff.py +0 -0
  24. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_equality.py +0 -0
  25. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_introspection.py +0 -0
  26. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_membership.py +0 -0
  27. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_mixin_base.py +0 -0
  28. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_ordering.py +0 -0
  29. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_path.py +0 -0
  30. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_require.py +0 -0
  31. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_size.py +0 -0
  32. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_engine/_text.py +0 -0
  33. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_hints.py +0 -0
  34. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_inline.py +0 -0
  35. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_matcher_impls.py +0 -0
  36. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_satisfies.py +0 -0
  37. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/_snapshot_codec.py +0 -0
  38. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/async_assertions.py +0 -0
  39. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/base.py +0 -0
  40. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/behave_matchers.py +0 -0
  41. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/bytes_mixin.py +0 -0
  42. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/collection.py +0 -0
  43. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/contains.py +0 -0
  44. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/dataframe.py +0 -0
  45. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/date.py +0 -0
  46. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/dict.py +0 -0
  47. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/dynamic.py +0 -0
  48. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/errors.py +0 -0
  49. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/exception.py +0 -0
  50. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/extracting.py +0 -0
  51. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/file.py +0 -0
  52. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/helpers.py +0 -0
  53. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/http_mixin.py +0 -0
  54. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/json_mixin.py +0 -0
  55. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/matchers.py +0 -0
  56. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/numeric.py +0 -0
  57. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/outcome.py +0 -0
  58. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/py.typed +0 -0
  59. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/pytest_plugin.py +0 -0
  60. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/snapshot.py +0 -0
  61. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/string.py +0 -0
  62. {assertpy2-2.21.0 → assertpy2-2.22.0}/assertpy2/warning.py +0 -0
  63. {assertpy2-2.21.0 → assertpy2-2.22.0}/benchmarks/test_perf.py +0 -0
  64. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/assets/diff-equal.png +0 -0
  65. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/assets/diff-equal.svg +0 -0
  66. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/assets/diff-gallery.png +0 -0
  67. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/assets/diff-match.svg +0 -0
  68. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/assets/diff-sequence.svg +0 -0
  69. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/assets/diff-set.svg +0 -0
  70. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/assets/diff-string.svg +0 -0
  71. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/concepts/stability.md +0 -0
  72. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/concepts/type-safety.md +0 -0
  73. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/extending/custom-assertions.md +0 -0
  74. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/extending/integrations.md +0 -0
  75. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/getting-started/migration.md +0 -0
  76. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/getting-started/quickstart.md +0 -0
  77. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/guides/data.md +0 -0
  78. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/guides/errors.md +0 -0
  79. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/guides/fluent.md +0 -0
  80. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/guides/matchers.md +0 -0
  81. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/guides/testing.md +0 -0
  82. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/index.md +0 -0
  83. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/logo-dark.svg +0 -0
  84. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/logo.svg +0 -0
  85. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/recipes.md +0 -0
  86. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/async.md +0 -0
  87. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/bytes.md +0 -0
  88. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/collections.md +0 -0
  89. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/containment.md +0 -0
  90. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/core.md +0 -0
  91. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/dataframes.md +0 -0
  92. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/dates.md +0 -0
  93. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/dicts.md +0 -0
  94. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/dynamic.md +0 -0
  95. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/entry-points.md +0 -0
  96. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/errors.md +0 -0
  97. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/exceptions.md +0 -0
  98. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/extracting.md +0 -0
  99. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/files.md +0 -0
  100. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/http.md +0 -0
  101. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/json.md +0 -0
  102. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/matchers.md +0 -0
  103. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/numbers.md +0 -0
  104. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/overview.md +0 -0
  105. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/snapshots.md +0 -0
  106. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/strings.md +0 -0
  107. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/reference/warnings.md +0 -0
  108. {assertpy2-2.21.0 → assertpy2-2.22.0}/docs/stylesheets/extra.css +0 -0
  109. {assertpy2-2.21.0 → assertpy2-2.22.0}/mkdocs.yml +0 -0
  110. {assertpy2-2.21.0 → assertpy2-2.22.0}/mkdocs_hooks.py +0 -0
  111. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/api_snapshot.json +0 -0
  112. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/api_surface.py +0 -0
  113. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/conftest.py +0 -0
  114. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/docs_fixtures.py +0 -0
  115. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/group_compat.py +0 -0
  116. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/pyright_baseline.py +0 -0
  117. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_api_compatibility.py +0 -0
  118. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_api_vocabulary.py +0 -0
  119. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_async.py +0 -0
  120. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_attrs_support.py +0 -0
  121. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_behave_matchers.py +0 -0
  122. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_bool.py +0 -0
  123. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_boundary_cases.py +0 -0
  124. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_bytes.py +0 -0
  125. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_callable.py +0 -0
  126. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_chaining.py +0 -0
  127. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_check.py +0 -0
  128. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_class.py +0 -0
  129. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_clustering.py +0 -0
  130. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_collection.py +0 -0
  131. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_core.py +0 -0
  132. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_custom_dict.py +0 -0
  133. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_custom_list.py +0 -0
  134. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_dangling.py +0 -0
  135. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_dataframe.py +0 -0
  136. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_datetime.py +0 -0
  137. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_description.py +0 -0
  138. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_dict.py +0 -0
  139. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_dict_compare.py +0 -0
  140. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_docs_examples.py +0 -0
  141. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_docs_typing.py +0 -0
  142. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_dynamic.py +0 -0
  143. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_equals.py +0 -0
  144. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_errors.py +0 -0
  145. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_evaluation_core.py +0 -0
  146. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_exception_context.py +0 -0
  147. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_expected_exception.py +0 -0
  148. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_expected_warning.py +0 -0
  149. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_extensions.py +0 -0
  150. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_extracting.py +0 -0
  151. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_fail.py +0 -0
  152. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_file.py +0 -0
  153. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_grouped_soft.py +0 -0
  154. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_hints.py +0 -0
  155. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_http.py +0 -0
  156. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_in.py +0 -0
  157. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_inline_record.py +0 -0
  158. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_inline_snapshot.py +0 -0
  159. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_iterable_cluster.py +0 -0
  160. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_json.py +0 -0
  161. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_list.py +0 -0
  162. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_match_result.py +0 -0
  163. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_matcher_parity.py +0 -0
  164. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_matcher_registry.py +0 -0
  165. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_matchers.py +0 -0
  166. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_message_elision.py +0 -0
  167. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_namedtuple.py +0 -0
  168. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_none.py +0 -0
  169. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_numbers.py +0 -0
  170. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_openapi_contract.py +0 -0
  171. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_optional_integration_contracts.py +0 -0
  172. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_overloads.py +0 -0
  173. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_pipeline.py +0 -0
  174. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_property_attrs.py +0 -0
  175. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_property_based.py +0 -0
  176. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_protocol_parity.py +0 -0
  177. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_public_surface.py +0 -0
  178. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_pyright_baseline.py +0 -0
  179. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_pytest_plugin.py +0 -0
  180. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_recursive_assertion.py +0 -0
  181. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_recursive_compare.py +0 -0
  182. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_recursive_compare_attrs.py +0 -0
  183. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_recursive_compare_config.py +0 -0
  184. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_recursive_compare_pydantic.py +0 -0
  185. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_refusals.py +0 -0
  186. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_regex_groups.py +0 -0
  187. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_rich_diff.py +0 -0
  188. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_same_as.py +0 -0
  189. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_satisfy.py +0 -0
  190. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_snapshots.py +0 -0
  191. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_soft.py +0 -0
  192. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_soft_fail.py +0 -0
  193. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_string.py +0 -0
  194. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_structural.py +0 -0
  195. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_surface_conformance.py +0 -0
  196. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_sync_eventually.py +0 -0
  197. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_traceback.py +0 -0
  198. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_type.py +0 -0
  199. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_typing.py +0 -0
  200. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_typing_claims.py +0 -0
  201. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_typing_negative.py +0 -0
  202. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_vacuity_contract.py +0 -0
  203. {assertpy2-2.21.0 → assertpy2-2.22.0}/tests/test_warn.py +0 -0
@@ -0,0 +1,83 @@
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 with the [one sync command](#verification-pipeline) below, not a bare `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
+ Install exactly this set. A bare `uv sync` leaves out the optional libraries several tests import, and
31
+ those tests fail rather than skip:
32
+
33
+ ```bash
34
+ uv sync --extra json --extra data --extra inline --group integrations --group docs-examples
35
+ ```
36
+
37
+ ```bash
38
+ uv run ruff check .
39
+ uv run ruff format --check .
40
+ uv run ty check assertpy2/ tests/test_typing.py
41
+ uv run pytest --cov=assertpy2 --cov-fail-under=100 --ignore=tests/test_docs_examples.py tests
42
+ uv run pytest tests/test_docs_examples.py tests/test_typing_claims.py
43
+ uv run --group docs mkdocs build --strict
44
+ ```
45
+
46
+ The public typing surface is checked by three checkers. Run them after the block above, not before:
47
+ `uv run --group typecheck` adds the checkers to the same `.venv` and leaves them there, and a coverage
48
+ run with them installed collects a different set:
49
+
50
+ ```bash
51
+ uv run --group typecheck mypy --strict --follow-imports=silent tests/test_typing.py
52
+ uv run --group typecheck pyright tests/test_typing.py
53
+ uv run --group typecheck pytest tests/test_pyright_baseline.py
54
+ ```
55
+
56
+ Three details that cost time if you meet them the hard way:
57
+
58
+ - `ty check` is scoped on purpose. Run over the whole tree it reports hundreds of diagnostics from
59
+ `tests/`, which is full of deliberate negative typing cases: calls that must not type-check.
60
+ - `--all-extras` is not a shortcut for the sync line. Installing `allure` or `behave` makes 100%
61
+ coverage unreachable by construction, because the `except ImportError` branch guarding them stops
62
+ being executed. CI never installs them for the coverage job either.
63
+ - `tests/test_docs_examples.py` is run separately because it executes the snippets in `docs/`, which
64
+ needs the `docs-examples` group and a different collection.
65
+
66
+ CI requires 100% code coverage.
67
+
68
+ ## Commit style
69
+
70
+ Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, etc.
71
+
72
+ ## Tests
73
+
74
+ Write tests for every new feature or bug fix. Use `assertpy2` assertions in tests.
75
+
76
+ ## Documentation examples
77
+
78
+ Guide code blocks are executed and type-checked in CI, so an example you add has to run. A block that
79
+ cannot (pseudo-context, a deliberate failure, a rejected counter-example) is marked with an HTML comment
80
+ above the fence: `tests/test_docs_examples.py` lists the markers and what each one exempts.
81
+
82
+ Setup a page assumes (a domain class, a repository, an HTTP response) goes in
83
+ `tests/docs_fixtures.py`, not into an extra block on the page.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: assertpy2
3
- Version: 2.21.0
3
+ Version: 2.22.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
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
11
11
  from typing_extensions import TypeIs
12
12
 
13
13
  from .._engine._introspection import MappingLike
14
- from ..assertpy import AssertionBuilder, CheckBuilder, NegatedBuilder
14
+ from ..assertpy import AssertionBuilder, CheckBuilder
15
15
  from ..async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
16
16
  from ..matchers import Matcher
17
17
  from ._compat import Self
@@ -251,9 +251,11 @@ if TYPE_CHECKING:
251
251
  comparators: dict[object, Callable[[Any, Any], object]] | None = ...,
252
252
  placeholders: dict[Hashable, Matcher[Any] | Callable[[Any], object]] | None = ...,
253
253
  ) -> Self: ...
254
- # NegatedBuilder
254
+ # NegatedBuilder, which a checker sees as this very protocol: the proxy accepts what the
255
+ # value accepts and hands the chain back, so describing it any other way is what let
256
+ # `assert_that(1).not_.starts_with("x")` type-check
255
257
  @property
256
- def not_(self) -> NegatedBuilder[Self]: ...
258
+ def not_(self) -> Self: ...
257
259
  # CheckBuilder - run the next assertion for its verdict instead of for its failure
258
260
  def check(self) -> CheckBuilder: ...
259
261
  # AssertionBuilder - typed extract-and-continue
@@ -62,7 +62,7 @@ from .snapshot import SnapshotMixin
62
62
  from .string import StringMixin
63
63
  from .warning import WarningMixin
64
64
 
65
- __version__ = "2.21.0"
65
+ __version__ = "2.22.0"
66
66
 
67
67
  # the tracked value type of the generic AssertionBuilder fallback (_U appears only in narrowing stubs)
68
68
  _T = TypeVar("_T")
@@ -956,37 +956,65 @@ class NegatedBuilder(Generic[_S]):
956
956
  # unbound TypeVar has no attributes, so the code above could not read `kind` off it.
957
957
  return _negated # ty: ignore[invalid-return-type] # see above
958
958
 
959
- def _make_msg(self, name: str) -> str:
959
+ def _make_msg(self, name: str, *args: object, **kwargs: object) -> str:
960
+ """The negated failure, naming the call that held when it should not have.
961
+
962
+ The arguments are in it because without them the message says which relation was asked for and
963
+ not what it was asked about, which is the one thing the reader needs.
964
+
965
+ Rendered here rather than by `HelpersMixin._fmt_args_kwargs()`, which spells a keyword as
966
+ ``'key': value``: that is the shape `when_called_with()` has printed since assertpy and its
967
+ messages are pinned to it, but this line reads as a call and ``key=value`` is what a call looks
968
+ like. Keyword order is the caller's, which Python preserves, so it is stable without sorting.
969
+ """
960
970
  desc = f"[{self._builder.description}] " if self._builder.description else ""
961
- return f"{desc}Expected <{self._builder.val}> to NOT satisfy: {name}()"
971
+ rendered = ", ".join(
972
+ [_safe_repr(arg) for arg in args] + [f"{key}={_safe_repr(value)}" for key, value in kwargs.items()]
973
+ )
974
+ return f"{desc}Expected <{self._builder.val}> to NOT satisfy: {name}({rendered})"
975
+
976
+ def _verdict(self, attr: Callable[..., object], *args: object, **kwargs: object) -> AssertionOutcome | None:
977
+ """What the underlying assertion decided, or ``None`` when it held.
978
+
979
+ Run in check mode, the one mode that hands a verdict back instead of delivering it. That is
980
+ what separates a verdict from an accident: anything still raised by the call came out of the
981
+ value or out of an assertion nested inside it, and inverting *that* turns a break into a pass.
982
+
983
+ Catching an exception class cannot make the distinction, whatever class is chosen. A comparator
984
+ that asserts with this library raises this library's own failure, and it is still not the
985
+ verdict of the assertion being negated. `_negated_check()` has always read the sink instead;
986
+ the other three modes now do the same.
987
+ """
988
+ builder = self._builder
989
+ kind, sink = builder.kind, builder._check_sink
990
+ builder.kind = "check"
991
+ builder._check_sink = None
992
+ try:
993
+ attr(*args, **kwargs)
994
+ return builder._check_sink
995
+ finally:
996
+ builder.kind, builder._check_sink = kind, sink
962
997
 
963
998
  def _negated_strict(
964
999
  self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
965
1000
  ) -> AssertionBuilder:
966
- try:
967
- attr(*args, **kwargs)
968
- except (AssertionError, AssertionFailure):
1001
+ if self._verdict(attr, *args, **kwargs) is not None:
969
1002
  return self._builder
970
1003
  # the message is composed here rather than by `error()`, which would prefix the description a
971
1004
  # second time, but the exception is the builder's own: a negated failure is a failure
972
- raise AssertionBuilder._failure(AssertionOutcome(message=self._make_msg(name), actual=self._builder.val))
1005
+ raise AssertionBuilder._failure(
1006
+ AssertionOutcome(message=self._make_msg(name, *args, **kwargs), actual=self._builder.val)
1007
+ )
973
1008
 
974
1009
  def _negated_soft(
975
1010
  self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
976
1011
  ) -> AssertionBuilder:
1012
+ if self._verdict(attr, *args, **kwargs) is not None:
1013
+ return self._builder # the assertion failed, so the negation held
977
1014
  block = _collecting()
978
1015
  err_list = block.failures if block is not None else []
979
- before = len(err_list)
980
- taint_before = self._builder._value_taint_reason
981
- attr(*args, **kwargs)
982
- if len(err_list) > before:
983
- # underlying assertion failed, so the negation passed: roll back the collected soft
984
- # error and the taint that failure set, keeping any pre-existing taint
985
- del err_list[before:]
986
- self._builder._value_taint_reason = taint_before
987
- return self._builder
988
1016
  # underlying assertion passed, so the negation failed: collect it and taint .value
989
- msg = self._make_msg(name)
1017
+ msg = self._make_msg(name, *args, **kwargs)
990
1018
  if self._builder._value_taint_reason is None:
991
1019
  self._builder._value_taint_reason = msg
992
1020
  err_list.append(
@@ -1002,28 +1030,21 @@ class NegatedBuilder(Generic[_S]):
1002
1030
  def _negated_check(
1003
1031
  self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
1004
1032
  ) -> AssertionBuilder:
1005
- # the underlying assertion is in verdict mode too, so it lands in the sink rather than raising,
1006
- # and reading the sink is how this tells which way it went
1007
- self._builder._check_sink = None
1008
- attr(*args, **kwargs)
1009
- if self._builder._check_sink is not None:
1033
+ if self._verdict(attr, *args, **kwargs) is not None:
1010
1034
  self._builder._check_sink = None # it failed, so the negation held
1011
1035
  return self._builder
1012
- self._builder._check_sink = AssertionOutcome(message=self._make_msg(name), actual=self._builder.val)
1036
+ self._builder._check_sink = AssertionOutcome(
1037
+ message=self._make_msg(name, *args, **kwargs), actual=self._builder.val
1038
+ )
1013
1039
  return self._builder
1014
1040
 
1015
1041
  def _negated_warn(
1016
1042
  self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
1017
1043
  ) -> AssertionBuilder:
1018
- self._builder.kind = None
1019
- try:
1020
- attr(*args, **kwargs)
1021
- except (AssertionError, AssertionFailure):
1022
- return self._builder
1023
- finally:
1024
- self._builder.kind = "warn"
1044
+ if self._verdict(attr, *args, **kwargs) is not None:
1045
+ return self._builder # the assertion failed, so the negation held
1025
1046
  # underlying assertion passed, so the negation failed: taint .value like error() does
1026
- msg = self._make_msg(name)
1047
+ msg = self._make_msg(name, *args, **kwargs)
1027
1048
  if self._builder._value_taint_reason is None:
1028
1049
  self._builder._value_taint_reason = msg
1029
1050
  self._builder.logger.warning(msg)
@@ -1273,8 +1294,10 @@ class AssertionBuilder(
1273
1294
  If an error description is set by [`described_as()`][assertpy2.base.BaseMixin.described_as], then that
1274
1295
  description is prepended to the error message.
1275
1296
 
1276
- When structured data (``actual``, ``expected``, or ``diff``) is provided, raises
1277
- [`AssertionFailure`][assertpy2.errors.AssertionFailure] instead of plain ``AssertionError``.
1297
+ Always raises [`AssertionFailure`][assertpy2.errors.AssertionFailure], which is an
1298
+ ``AssertionError``. Structured data (``actual``, ``expected``, ``diff``) is carried on it when
1299
+ given, and the class is the same either way, which is why nothing reads the class to learn who
1300
+ raised it: `NegatedBuilder._verdict()` asks in check mode instead.
1278
1301
 
1279
1302
  Args:
1280
1303
  msg: the error message
@@ -1288,11 +1311,12 @@ class AssertionBuilder(
1288
1311
  it alone when the caught exception is the caller's, which is context they want.
1289
1312
 
1290
1313
  Raises:
1291
- AssertionError: always raised unless ``kind`` is ``warn`` or ``soft``.
1314
+ AssertionFailure: unless ``kind`` is ``warn``, ``soft`` or ``check``, which log it, collect
1315
+ it and record it respectively.
1292
1316
 
1293
1317
  Returns:
1294
- AssertionBuilder: returns this instance to chain to the next assertion, but only when
1295
- ``AssertionError`` is not raised, as is the case when ``kind`` is ``warn`` or ``soft``.
1318
+ AssertionBuilder: this instance, to chain the next assertion, whenever the failure was
1319
+ delivered some other way than by raising.
1296
1320
  """
1297
1321
  failure = self._deliver(self._compose(msg, actual=actual, expected=expected, diff=diff, trace=trace))
1298
1322
  if failure is None:
@@ -154,8 +154,8 @@ whole differing container. assertpy2 keeps a path-level diff on either.
154
154
  | | pytest assert | PyHamcrest | assertpy | dirty-equals | **assertpy2** |
155
155
  |---|:---:|:---:|:---:|:---:|:---:|
156
156
  | Paradigm | rewritten `assert` | matchers | fluent chain | `==` objects | **[fluent + matchers + `==`](#all-three-styles-one-import)** |
157
- | Mix styles in one suite | No | No | No | No | **[Yes](#all-three-styles-one-import)** |
158
- | Static typing (`py.typed`, overloads) | n/a | `py.typed` only | No | **Typed** | **[Yes](../concepts/type-safety.md)** |
157
+ | All three styles from one import | No | No | No | No | **[Yes](#all-three-styles-one-import)** |
158
+ | Static typing | n/a | **generic matchers** | No | **Typed** | **[Typed + per-type overloads](../concepts/type-safety.md)** |
159
159
  | Autocomplete filtered by value type | No | No | No | No | **[Yes](../concepts/type-safety.md#type-aware-autocomplete)** |
160
160
  | Typed narrowing (the assertion returns the value, narrowed) | No | No | No | No | **[Yes](../concepts/type-safety.md#typed-narrowing-with-value)** |
161
161
  | Contract testing (validate a payload and narrow to the model) | No | No | No | No | **[Yes](../concepts/type-safety.md#contract-narrowing-with-assert_conforms)** |
@@ -486,16 +486,15 @@ both sides is matched by identity and not walked again, exactly as Python does i
486
486
  a config object placed in two expected blocks stays cheap and a container holding the same `NaN` on
487
487
  both sides keeps comparing equal.
488
488
 
489
- Two limits.
490
-
491
- Anything matched **by hash** is outside it, which means dictionary keys and set elements. `1`, `1.0`
492
- and `True` hash alike and compare equal, so the pair is found before anything looks at its type and
493
- the walk never sees it. `{True: "a"}` against `{1: "a"}` and `{1}` against `{1.0}` both pass a strict
494
- comparison. Values, list elements and object fields are all covered normally.
495
-
496
- Strictness also turns off the fast path: a container's own `==` says nothing about the types inside
497
- it, so every comparison walks the whole structure in Python. On a list of 20 000 dicts that is about
498
- 0.3 ms against 29 ms, which matters only if you are comparing large dumps in a loop.
489
+ Anything matched **by hash** is covered too, which means dictionary keys and set members. `1`, `1.0`
490
+ and `True` hash alike and compare equal, so a mapping finds the pair before anything looks at its
491
+ type, and reaching it takes a second pass keyed on the type. `{True: "a"}` against `{1: "a"}` and
492
+ `{1}` against `{1.0}` both fail a strict comparison.
493
+
494
+ The one limit is cost. Strictness turns off the fast path: a container's own `==` says nothing about
495
+ the types inside it, so every comparison walks the whole structure in Python. On a list of 20 000
496
+ dicts that is about 0.3 ms against 41 ms, which matters only if you are comparing large dumps in a
497
+ loop.
499
498
 
500
499
  Inside a [structural spec](matchers.md#structural-matching) the same relation is spelled
501
500
  `match.equal_to(value, strict_types=True)`, one matcher covering value and type together:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "assertpy2"
3
- version = "2.21.0"
3
+ version = "2.22.0"
4
4
  description = "Fluent assertion library for Python with composable matchers, structural matching, and full type safety"
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -85,7 +85,7 @@ dev = [
85
85
  # real worker processes instead of a fake node
86
86
  "pytest-xdist>=3.8.0",
87
87
  "ruff>=0.16.1",
88
- "ty>=0.0.71",
88
+ "ty>=0.0.73",
89
89
  "typing_extensions>=4.0",
90
90
  ]
91
91
  docs = [
@@ -3,7 +3,16 @@ from io import StringIO
3
3
 
4
4
  import pytest
5
5
 
6
- from assertpy2 import WarningLoggingAdapter, assert_that, assert_warn, match, soft_assertions
6
+ from assertpy2 import (
7
+ AssertionFailure,
8
+ WarningLoggingAdapter,
9
+ add_extension,
10
+ assert_that,
11
+ assert_warn,
12
+ match,
13
+ remove_extension,
14
+ soft_assertions,
15
+ )
7
16
 
8
17
 
9
18
  class TestNotBasic:
@@ -170,6 +179,182 @@ class TestNotWarnMode:
170
179
  assert_that(out).is_empty()
171
180
 
172
181
 
182
+ class TestNotNamesWhatItWasAskedAbout:
183
+ """A negated failure carries the arguments, or it names the relation and not the subject.
184
+
185
+ ``starts_with()`` says which relation held; ``starts_with('a')`` says what held. The direct
186
+ spellings have always named the operand, and the proxy is the one place that did not.
187
+ """
188
+
189
+ def test_a_positional_argument(self):
190
+ with pytest.raises(AssertionError) as caught:
191
+ assert_that("abc").not_.starts_with("a")
192
+ assert_that(str(caught.value)).is_equal_to("Expected <abc> to NOT satisfy: starts_with('a')")
193
+
194
+ def test_several_arguments(self):
195
+ with pytest.raises(AssertionError) as caught:
196
+ assert_that(3).not_.is_between(1, 5)
197
+ assert_that(str(caught.value)).is_equal_to("Expected <3> to NOT satisfy: is_between(1, 5)")
198
+
199
+ def test_a_keyword_argument(self):
200
+ with pytest.raises(AssertionError) as caught:
201
+ assert_that({"n": 1}).not_.is_equal_to({"n": 1}, strict_types=True)
202
+ assert_that(str(caught.value)).is_equal_to(
203
+ "Expected <{'n': 1}> to NOT satisfy: is_equal_to({'n': 1}, strict_types=True)"
204
+ )
205
+
206
+ def test_an_assertion_that_takes_none_reads_as_before(self):
207
+ with pytest.raises(AssertionError) as caught:
208
+ assert_that(5).not_.is_positive()
209
+ assert_that(str(caught.value)).is_equal_to("Expected <5> to NOT satisfy: is_positive()")
210
+
211
+ def test_the_soft_block_collects_the_same_sentence(self):
212
+ with pytest.raises(AssertionFailure) as caught, soft_assertions():
213
+ assert_that("abc").not_.starts_with("a")
214
+ assert_that(str(caught.value)).contains("starts_with('a')")
215
+
216
+ def test_check_mode_records_the_same_sentence(self):
217
+ outcome = assert_that("abc").check().not_.starts_with("a")
218
+ assert_that(outcome.message).is_equal_to("Expected <abc> to NOT satisfy: starts_with('a')")
219
+
220
+
221
+ class TestNotDoesNotInvertAnErrorFromTheValue:
222
+ """Negation inverts this library's verdict, and an ``AssertionError`` from your code is not one.
223
+
224
+ The proxy used to catch every ``AssertionError``, so a comparison that broke while being negated
225
+ read as "the assertion failed, so the negation held" and the test went green over the break. The
226
+ four sources below are the four places user code runs inside an assertion.
227
+ """
228
+
229
+ @staticmethod
230
+ def _foreign(caught):
231
+ """The error travelled out as it was raised, rather than being read as a verdict."""
232
+ # spelled without `not_`, which is the thing under test here
233
+ assert_that(isinstance(caught.value, AssertionFailure)).is_false()
234
+ return assert_that(str(caught.value))
235
+
236
+ def test_an_equality_that_raises(self):
237
+ class Broken:
238
+ __hash__ = None
239
+
240
+ def __eq__(self, other):
241
+ raise AssertionError("comparison implementation broke")
242
+
243
+ with pytest.raises(AssertionError) as caught:
244
+ assert_that(Broken()).not_.is_equal_to(1)
245
+ self._foreign(caught).is_equal_to("comparison implementation broke")
246
+
247
+ def test_a_comparator_that_raises(self):
248
+ def broken(left, right):
249
+ raise AssertionError("comparator broke")
250
+
251
+ with pytest.raises(AssertionError) as caught:
252
+ assert_that({"n": 1}).not_.is_equal_to({"n": 2}, comparators={int: broken})
253
+ self._foreign(caught).is_equal_to("comparator broke")
254
+
255
+ def test_a_property_that_raises(self):
256
+ # this one never reached the catch even before it was narrowed: a dynamic `has_*` reads the
257
+ # property while the proxy is resolving the attribute, which is before there is anything to
258
+ # invert. Pinned so a refactor that moves resolution inside the try does not lose it silently
259
+ class Record:
260
+ @property
261
+ def name(self):
262
+ raise AssertionError("property broke")
263
+
264
+ with pytest.raises(AssertionError) as caught:
265
+ assert_that(Record()).not_.has_name("ada")
266
+ self._foreign(caught).is_equal_to("property broke")
267
+
268
+ def test_an_extension_that_raises_on_its_own(self, _broken_extension):
269
+ with pytest.raises(AssertionError) as caught:
270
+ assert_that(1).not_.breaks_outright()
271
+ self._foreign(caught).is_equal_to("extension broke")
272
+
273
+ def test_warn_mode_does_not_invert_it_either(self):
274
+ class Broken:
275
+ __hash__ = None
276
+
277
+ def __eq__(self, other):
278
+ raise AssertionError("comparison implementation broke")
279
+
280
+ with pytest.raises(AssertionError) as caught:
281
+ assert_warn(Broken()).not_.is_equal_to(1)
282
+ self._foreign(caught).is_equal_to("comparison implementation broke")
283
+
284
+ def test_a_nested_assertion_inside_a_comparator(self):
285
+ # the class of the exception cannot answer this one: a comparator that asserts with this
286
+ # library raises this library's own failure, and it is still not the verdict being negated
287
+ def asserts_inside(left, right):
288
+ assert_that(left).is_equal_to(right)
289
+ return True
290
+
291
+ with pytest.raises(AssertionFailure) as caught:
292
+ assert_that({"n": 1}).not_.is_equal_to({"n": 2}, comparators={int: asserts_inside})
293
+ assert_that(str(caught.value)).starts_with("Expected <1> to be equal to <2>")
294
+
295
+ def test_a_nested_assertion_inside_a_comparator_in_warn_mode(self):
296
+ def asserts_inside(left, right):
297
+ assert_that(left).is_equal_to(right)
298
+ return True
299
+
300
+ with pytest.raises(AssertionFailure) as caught:
301
+ assert_warn({"n": 1}).not_.is_equal_to({"n": 2}, comparators={int: asserts_inside})
302
+ assert_that(str(caught.value)).starts_with("Expected <1> to be equal to <2>")
303
+
304
+ def test_a_nested_assertion_in_a_soft_block_is_collected_rather_than_lost(self):
305
+ # soft collects rather than raises, so the honest outcome here is both failures, not silence
306
+ def asserts_inside(left, right):
307
+ assert_that(left).is_equal_to(right)
308
+ return True
309
+
310
+ with pytest.raises(AssertionFailure) as caught, soft_assertions():
311
+ assert_that({"n": 1}).not_.is_equal_to({"n": 2}, comparators={int: asserts_inside})
312
+ collected = [outcome.message.splitlines()[0] for outcome in caught.value.failures]
313
+ assert_that(collected).is_length(2)
314
+ assert_that(collected[0]).is_equal_to("Expected <1> to be equal to <2>, but was not.")
315
+
316
+ def test_the_library_own_failure_still_inverts(self):
317
+ # the other half of the same rule: a real verdict is what negation is for
318
+ assert_that(5).not_.is_equal_to(6)
319
+ assert_warn(5).not_.is_equal_to(6)
320
+
321
+ def test_an_extension_reporting_a_verdict_still_inverts(self, _five_extension):
322
+ # the documented way an extension fails is `self.error(...)`, and that is a verdict
323
+ assert_that(6).not_.is_five()
324
+ with pytest.raises(AssertionFailure, match=r"to NOT satisfy: is_five\(\)"):
325
+ assert_that(5).not_.is_five()
326
+
327
+
328
+ @pytest.fixture
329
+ def _five_extension():
330
+ """The extension from the guide, which reports its verdict with `self.error(...)`."""
331
+
332
+ def is_five(self):
333
+ if self.val != 5:
334
+ return self.error(f"{self.val} is NOT 5!")
335
+ return self
336
+
337
+ add_extension(is_five)
338
+ try:
339
+ yield
340
+ finally:
341
+ remove_extension(is_five)
342
+
343
+
344
+ @pytest.fixture
345
+ def _broken_extension():
346
+ """An extension that raises rather than reporting a verdict, taken back off the global registry."""
347
+
348
+ def breaks_outright(self):
349
+ raise AssertionError("extension broke")
350
+
351
+ add_extension(breaks_outright)
352
+ try:
353
+ yield
354
+ finally:
355
+ remove_extension(breaks_outright)
356
+
357
+
173
358
  class TestNotAttributes:
174
359
  def test_non_callable_attr_passthrough(self):
175
360
  builder = assert_that(5).described_as("test")
@@ -207,7 +392,7 @@ def test_not_rejects_described_as_with_clear_error():
207
392
 
208
393
 
209
394
  def test_described_as_before_not_keeps_working():
210
- with pytest.raises(AssertionError, match=r"\[desc\] Expected <1> to NOT satisfy: is_equal_to\(\)"):
395
+ with pytest.raises(AssertionError, match=r"\[desc\] Expected <1> to NOT satisfy: is_equal_to\(1\)"):
211
396
  assert_that(1).described_as("desc").not_.is_equal_to(1)
212
397
 
213
398
 
@@ -153,10 +153,17 @@ def _chaining_must_not_widen_what_the_value_offers() -> None:
153
153
  """
154
154
  assert_that(1 + 2j).is_not_zero().is_positive() # case: complex-widened-by-chaining
155
155
  assert_that(True).is_greater_than(0).is_even() # case: bool-widened-by-chaining
156
- # `.not_` is the exception, and it is a known one: the proxy resolves any name through
157
- # `__getattr__`, so the negated branch accepts what the protocol does not. Closing it means a
158
- # second protocol per type, which is a doubling of the typed surface for one inverted call
156
+ # `.not_` used to be the exception: the proxy resolves any name through `__getattr__`, so the
157
+ # negated branch accepted what the protocol does not. It is declared as the protocol it was
158
+ # reached from instead, which costs nothing and refuses the same calls the un-negated chain does
159
159
  assert_that(1 + 2j).not_.is_greater_than(0) # case: negation-widens-the-protocol
160
+ # what that declaration does allow and the runtime refuses: the fourteen names that transform or
161
+ # configure rather than assert. The runtime says so by name, which is the tolerable direction
162
+ assert_that(1).not_.described_as("x") # case: negation-allows-a-non-negatable-name
163
+ # an ordering matcher takes and judges anything its `<` accepts, and neither half is typed. A
164
+ # boundary of the wrong type is accepted, and so is a matcher built for another subject entirely
165
+ assert_that(1).satisfies(match.greater_than("x")) # case: ordering-matcher-takes-any-boundary
166
+ assert_that("x").satisfies(match.greater_than(0)) # case: ordering-matcher-judges-any-subject
160
167
 
161
168
 
162
169
  def _takes_repeats_of_derived(assertion: _RepeatableAssertion[_Derived]) -> None:
@@ -15,9 +15,13 @@ and the argument is missing. `ty` sometimes answers `no-matching-overload` wher
15
15
  argument: same family, different route through an overload set.
16
16
 
17
17
  **What stays uncaught, and why.** `assert_that(Person()).is_positive()` type-checks, because a value
18
- with no protocol of its own gets the generic builder, which carries every method there is. `.not_`
19
- returns a proxy resolving any name, so the negated branch accepts what the protocol refuses. Closing
20
- the second means a second protocol per type, doubling the typed surface for one inverted call.
18
+ with no protocol of its own gets the generic builder, which carries every method there is. The
19
+ ordering matchers take and judge anything, and the entries below say why every spelling that closes
20
+ that trades a correct call for an incorrect one.
21
+
22
+ `.not_` used to belong here for the same reason as the first: it returns a proxy resolving any name.
23
+ It is declared as the protocol it was reached from instead, which costs nothing, and what remains is
24
+ the other direction: the fourteen names the proxy refuses at runtime are still spelled as available.
21
25
 
22
26
  **The trap this file exists to catch.** A numeric comparison is bound to `SupportsFloat` rather than to
23
27
  a list of types: the first attempt named `float | Decimal | Fraction` and rejected `numpy.int64`, a
@@ -136,10 +140,22 @@ CAUGHT: dict[str, dict[str, frozenset[str]]] = {
136
140
  # --- a step hands back `Self`, so the narrowing holds for the whole chain ----------------------
137
141
  "complex-widened-by-chaining": _MISSING,
138
142
  "bool-widened-by-chaining": _MISSING,
139
- # --- still open, both for the same reason: a surface reached through `__getattr__` -------------
140
- # the generic builder carries every method there is, and `.not_` resolves any name through a proxy
143
+ # `.not_` is declared as the protocol it was reached from, so the proxy accepts what the value
144
+ # accepts. What it still allows and the runtime refuses is the handful of non-negatable names
145
+ "negation-widens-the-protocol": _MISSING,
146
+ # --- still open: the generic builder carries every method there is ------------------------------
141
147
  "numeric-assertion-on-an-object": {},
142
- "negation-widens-the-protocol": {},
148
+ # and the negation proxy, which is declared as the protocol and so cannot describe the fourteen
149
+ # names it refuses at runtime
150
+ "negation-allows-a-non-negatable-name": {},
151
+ # the ordering matchers, deliberately. `Matcher` is contravariant and `satisfies()` already refuses
152
+ # `match.starts_with("a")` over an `int`, so the shape works; what cannot be spelled is the ordering
153
+ # relation itself. Typing the boundary as a number refuses `match.greater_than("a")`, which orders
154
+ # strings at runtime, and typing it as a plain TypeVar refuses `match.greater_than(0)` over a
155
+ # `float`, since contravariance then asks for `float` to be a subtype of `int`. Both trade a
156
+ # correct call for an incorrect one, so the boundary stays `object` and the gap stays recorded
157
+ "ordering-matcher-takes-any-boundary": {},
158
+ "ordering-matcher-judges-any-subject": {},
143
159
  # --- and the third: `SupportsFloat` is wider than the runtime's `numbers.Number` ----------------
144
160
  # both are refused at runtime by name, which is the tolerable direction for an approximation to err
145
161
  "convertible-but-not-a-number": {},