assertpy2 2.20.0__tar.gz → 2.20.1__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 (192) hide show
  1. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/ci.yml +9 -1
  2. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/codeql.yml +2 -2
  3. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/codspeed.yml +1 -1
  4. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/publish.yml +1 -1
  5. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/scorecard.yml +1 -1
  6. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/zizmor.yml +1 -1
  7. {assertpy2-2.20.0 → assertpy2-2.20.1}/PKG-INFO +1 -1
  8. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_diff.py +29 -9
  9. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/assertpy.py +80 -46
  10. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/dynamic.py +16 -2
  11. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/errors.py +11 -0
  12. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/helpers.py +13 -9
  13. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/pytest_plugin.py +9 -3
  14. assertpy2-2.20.1/docs/concepts/stability.md +63 -0
  15. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/concepts/type-safety.md +35 -3
  16. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/extending/custom-assertions.md +9 -0
  17. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/getting-started/migration.md +2 -1
  18. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/assertions.md +13 -0
  19. {assertpy2-2.20.0 → assertpy2-2.20.1}/mkdocs.yml +1 -0
  20. {assertpy2-2.20.0 → assertpy2-2.20.1}/pyproject.toml +1 -1
  21. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/docs_fixtures.py +68 -0
  22. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/pyright_baseline.py +4 -4
  23. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_core.py +3 -1
  24. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_docs_examples.py +13 -14
  25. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_docs_typing.py +9 -16
  26. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_property_based.py +35 -2
  27. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_public_surface.py +36 -0
  28. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_rich_diff.py +28 -4
  29. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_typing.py +5 -0
  30. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_warn.py +6 -1
  31. {assertpy2-2.20.0 → assertpy2-2.20.1}/uv.lock +1 -1
  32. {assertpy2-2.20.0 → assertpy2-2.20.1}/.codecov.yml +0 -0
  33. {assertpy2-2.20.0 → assertpy2-2.20.1}/.gitattributes +0 -0
  34. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/dependabot.yml +0 -0
  35. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/docs.yml +0 -0
  36. {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/mutation.yml +0 -0
  37. {assertpy2-2.20.0 → assertpy2-2.20.1}/.gitignore +0 -0
  38. {assertpy2-2.20.0 → assertpy2-2.20.1}/CONTRIBUTING.md +0 -0
  39. {assertpy2-2.20.0 → assertpy2-2.20.1}/LICENSE +0 -0
  40. {assertpy2-2.20.0 → assertpy2-2.20.1}/README.md +0 -0
  41. {assertpy2-2.20.0 → assertpy2-2.20.1}/SECURITY.md +0 -0
  42. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/__init__.py +0 -0
  43. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/__init__.py +0 -0
  44. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_compare.py +0 -0
  45. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_compat.py +0 -0
  46. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_contract.py +0 -0
  47. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_introspection.py +0 -0
  48. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_mixin_base.py +0 -0
  49. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_path.py +0 -0
  50. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_typing.py +0 -0
  51. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_hints.py +0 -0
  52. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_inline.py +0 -0
  53. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_matcher_impls.py +0 -0
  54. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_satisfies.py +0 -0
  55. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_snapshot_codec.py +0 -0
  56. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/async_assertions.py +0 -0
  57. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/base.py +0 -0
  58. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/behave_matchers.py +0 -0
  59. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/bytes_mixin.py +0 -0
  60. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/collection.py +0 -0
  61. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/contains.py +0 -0
  62. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/dataframe.py +0 -0
  63. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/date.py +0 -0
  64. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/dict.py +0 -0
  65. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/exception.py +0 -0
  66. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/extracting.py +0 -0
  67. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/file.py +0 -0
  68. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/json_mixin.py +0 -0
  69. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/matchers.py +0 -0
  70. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/numeric.py +0 -0
  71. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/outcome.py +0 -0
  72. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/py.typed +0 -0
  73. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/snapshot.py +0 -0
  74. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/string.py +0 -0
  75. {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/warning.py +0 -0
  76. {assertpy2-2.20.0 → assertpy2-2.20.1}/benchmarks/test_perf.py +0 -0
  77. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-equal.png +0 -0
  78. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-equal.svg +0 -0
  79. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-gallery.png +0 -0
  80. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-match.svg +0 -0
  81. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-sequence.svg +0 -0
  82. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-set.svg +0 -0
  83. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-string.svg +0 -0
  84. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/extending/integrations.md +0 -0
  85. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/getting-started/comparison.md +0 -0
  86. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/getting-started/quickstart.md +0 -0
  87. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/data.md +0 -0
  88. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/errors.md +0 -0
  89. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/fluent.md +0 -0
  90. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/matchers.md +0 -0
  91. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/testing.md +0 -0
  92. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/index.md +0 -0
  93. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/logo-dark.svg +0 -0
  94. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/logo.svg +0 -0
  95. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/recipes.md +0 -0
  96. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/async.md +0 -0
  97. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/bytes.md +0 -0
  98. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/collections.md +0 -0
  99. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/containment.md +0 -0
  100. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/core.md +0 -0
  101. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dataframes.md +0 -0
  102. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dates.md +0 -0
  103. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dicts.md +0 -0
  104. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dynamic.md +0 -0
  105. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/entry-points.md +0 -0
  106. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/errors.md +0 -0
  107. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/exceptions.md +0 -0
  108. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/extracting.md +0 -0
  109. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/files.md +0 -0
  110. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/json.md +0 -0
  111. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/matchers.md +0 -0
  112. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/numbers.md +0 -0
  113. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/overview.md +0 -0
  114. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/snapshots.md +0 -0
  115. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/strings.md +0 -0
  116. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/warnings.md +0 -0
  117. {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/stylesheets/extra.css +0 -0
  118. {assertpy2-2.20.0 → assertpy2-2.20.1}/mkdocs_hooks.py +0 -0
  119. {assertpy2-2.20.0 → assertpy2-2.20.1}/scripts/mutation_report.py +0 -0
  120. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/conftest.py +0 -0
  121. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_api_vocabulary.py +0 -0
  122. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_async.py +0 -0
  123. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_attrs_support.py +0 -0
  124. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_behave_matchers.py +0 -0
  125. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_bool.py +0 -0
  126. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_boundary_cases.py +0 -0
  127. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_bytes.py +0 -0
  128. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_callable.py +0 -0
  129. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_chaining.py +0 -0
  130. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_check.py +0 -0
  131. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_class.py +0 -0
  132. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_collection.py +0 -0
  133. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_custom_dict.py +0 -0
  134. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_custom_list.py +0 -0
  135. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dataframe.py +0 -0
  136. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_datetime.py +0 -0
  137. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_description.py +0 -0
  138. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dict.py +0 -0
  139. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dict_compare.py +0 -0
  140. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dynamic.py +0 -0
  141. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_equals.py +0 -0
  142. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_errors.py +0 -0
  143. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_exception_context.py +0 -0
  144. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_expected_exception.py +0 -0
  145. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_expected_warning.py +0 -0
  146. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_extensions.py +0 -0
  147. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_extracting.py +0 -0
  148. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_fail.py +0 -0
  149. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_file.py +0 -0
  150. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_grouped_soft.py +0 -0
  151. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_hints.py +0 -0
  152. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_in.py +0 -0
  153. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_inline_record.py +0 -0
  154. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_inline_snapshot.py +0 -0
  155. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_iterable_cluster.py +0 -0
  156. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_json.py +0 -0
  157. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_list.py +0 -0
  158. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_match_result.py +0 -0
  159. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_matcher_parity.py +0 -0
  160. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_matcher_registry.py +0 -0
  161. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_matchers.py +0 -0
  162. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_message_elision.py +0 -0
  163. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_namedtuple.py +0 -0
  164. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_none.py +0 -0
  165. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_not.py +0 -0
  166. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_numbers.py +0 -0
  167. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_openapi_contract.py +0 -0
  168. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_optional_integration_contracts.py +0 -0
  169. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_overloads.py +0 -0
  170. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_pipeline.py +0 -0
  171. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_property_attrs.py +0 -0
  172. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_protocol_parity.py +0 -0
  173. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_pyright_baseline.py +0 -0
  174. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_pytest_plugin.py +0 -0
  175. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_assertion.py +0 -0
  176. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare.py +0 -0
  177. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare_attrs.py +0 -0
  178. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare_config.py +0 -0
  179. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare_pydantic.py +0 -0
  180. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_regex_groups.py +0 -0
  181. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_same_as.py +0 -0
  182. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_satisfy.py +0 -0
  183. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_snapshots.py +0 -0
  184. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_soft.py +0 -0
  185. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_soft_fail.py +0 -0
  186. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_string.py +0 -0
  187. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_structural.py +0 -0
  188. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_sync_eventually.py +0 -0
  189. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_traceback.py +0 -0
  190. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_type.py +0 -0
  191. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_typing_claims.py +0 -0
  192. {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_vacuity_contract.py +0 -0
@@ -17,6 +17,10 @@ jobs:
17
17
  test:
18
18
  timeout-minutes: 15
19
19
  runs-on: ${{ matrix.os }}
20
+ # mapped at job level so the upload step can ask whether it has a token before it runs: the secrets
21
+ # context is not readable from a step `if`, and step-level env is not in scope for its own condition
22
+ env:
23
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
20
24
  strategy:
21
25
  matrix:
22
26
  os: [ubuntu-latest]
@@ -75,7 +79,11 @@ jobs:
75
79
  # Only the full-deps cell. The bare 3.14/ubuntu cell skips every integration test and used
76
80
  # to upload too: Codecov merges reports so the number stayed right, but one of the two was
77
81
  # always a partial report sent at a 100% target.
78
- if: matrix.full-deps
82
+ # A run without repository secrets gets an empty token, and Codecov rejects an untokened upload
83
+ # to a protected branch, so the job went red on the upload while every test passed. The gate is
84
+ # the token rather than who opened the pull request: dependabot pushes its branches into this
85
+ # repository, so a fork test reads false for it and it still runs without secrets.
86
+ if: matrix.full-deps && env.CODECOV_TOKEN != ''
79
87
  uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
80
88
  with:
81
89
  token: ${{ secrets.CODECOV_TOKEN }}
@@ -21,7 +21,7 @@ jobs:
21
21
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
22
  with:
23
23
  persist-credentials: false
24
- - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
24
+ - uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
25
25
  with:
26
26
  languages: python
27
- - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
27
+ - uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
@@ -35,7 +35,7 @@ jobs:
35
35
  uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
36
36
 
37
37
  - name: Run benchmarks under CodSpeed
38
- uses: CodSpeedHQ/action@f22792bfac16f3e14eb9fbea76f4a48e9cc22b93 # v4.19.1
38
+ uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
39
39
  with:
40
40
  mode: simulation
41
41
  run: uv run --locked --group benchmark pytest benchmarks/ --codspeed --no-cov
@@ -66,7 +66,7 @@ jobs:
66
66
  run: syft scan dir:dist -o "cyclonedx-json=provenance/assertpy2.sbom.cdx.json"
67
67
 
68
68
  - name: Publish to PyPI
69
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
69
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
70
70
 
71
71
  - name: Upload to GitHub Release
72
72
  run: gh release upload "$TAG_NAME" dist/* provenance/*
@@ -30,6 +30,6 @@ jobs:
30
30
  name: SARIF file
31
31
  path: results.sarif
32
32
  retention-days: 5
33
- - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
33
+ - uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
34
34
  with:
35
35
  sarif_file: results.sarif
@@ -23,4 +23,4 @@ jobs:
23
23
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
24
  with:
25
25
  persist-credentials: false
26
- - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
26
+ - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.20.0
3
+ Version: 2.20.1
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
@@ -29,12 +29,18 @@ from __future__ import annotations
29
29
 
30
30
  import dataclasses
31
31
  import difflib
32
+ from typing import TYPE_CHECKING, TypeVar
32
33
 
33
34
  from ..errors import DiffEntry, DiffResult, _safe_repr
34
35
  from ._compare import _node_decision
35
36
  from ._introspection import is_attrs_instance, is_mapping_like, is_model_dump_object, is_namedtuple
36
37
  from ._path import _ROOT, _Path
37
38
 
39
+ if TYPE_CHECKING:
40
+ from collections.abc import Hashable, Iterable
41
+
42
+ _K = TypeVar("_K", bound="Hashable") # a mapping key or a field name, kept as itself through the walk
43
+
38
44
  __tracebackhide__ = True
39
45
 
40
46
 
@@ -254,20 +260,34 @@ def _element_entries(actual_item, expected_item, path: _Path, seen, config) -> l
254
260
  return _child_entries(actual_item, expected_item, path, descended_for=decision, _seen=seen, config=config)
255
261
 
256
262
 
263
+ def _ordered_keys(actual: Iterable[_K], expected: Iterable[_K]) -> list[_K]:
264
+ """Every key of both sides, in the order a reader wrote them.
265
+
266
+ A union of two sets loses insertion order, which is why this used to be sorted: without an order
267
+ imposed, the report varied with the hash seed. Sorting bought determinism at the price of the one
268
+ ordering that carries meaning - a JSON response reads in the order its fields arrived, and `k0, k1,
269
+ k10, k100` reads as no order at all. Walking the actual side and then the keys only the expected
270
+ side has is just as deterministic, and it is the order pytest shows.
271
+ """
272
+ seen = set(actual)
273
+ return [*actual, *(key for key in expected if key not in seen)]
274
+
275
+
257
276
  def _dataclass_diff_entries(actual, expected, prefix: _Path, seen, config=None) -> list[DiffEntry]:
258
- """Diff two dataclasses over the sorted union of field names, both directions, recursing.
277
+ """Diff two dataclasses over both sides' field names in declaration order, recursing.
259
278
 
260
279
  Reports fields present on only one side, and recurses into nested containers. ``seen`` must
261
280
  already include the ids of ``actual``/``expected``. Shared by the top-level and nested paths
262
281
  so both report dataclass fields identically.
263
282
  """
264
283
  entries: list[DiffEntry] = []
265
- actual_names = {field.name for field in dataclasses.fields(actual)}
266
- expected_names = {field.name for field in dataclasses.fields(expected)}
267
- for field in sorted(actual_names | expected_names):
268
- if field not in expected_names:
284
+ actual_names = [field.name for field in dataclasses.fields(actual)]
285
+ expected_names = [field.name for field in dataclasses.fields(expected)]
286
+ in_actual, in_expected = set(actual_names), set(expected_names)
287
+ for field in _ordered_keys(actual_names, expected_names):
288
+ if field not in in_expected:
269
289
  entries.append(prefix.attr(field).entry(actual=getattr(actual, field), expected=None, absent="expected"))
270
- elif field not in actual_names:
290
+ elif field not in in_actual:
271
291
  entries.append(prefix.attr(field).entry(actual=None, absent="actual", expected=getattr(expected, field)))
272
292
  else:
273
293
  actual_value = getattr(actual, field)
@@ -356,7 +376,7 @@ def _build_equality_diff(
356
376
  actual_dict = _field_dict(actual, both_model)
357
377
  expected_dict = _field_dict(expected, both_model)
358
378
  entries = []
359
- for key in sorted(set(actual_dict) | set(expected_dict)):
379
+ for key in _ordered_keys(actual_dict, expected_dict):
360
380
  if key not in expected_dict:
361
381
  entries.append(_prefix.attr(key).entry(actual=actual_dict[key], expected=None, absent="expected"))
362
382
  elif key not in actual_dict:
@@ -439,7 +459,7 @@ def _sub_diff_entries(
439
459
  entries: list[DiffEntry] = []
440
460
  actual_keys = set(actual)
441
461
  expected_keys = set(expected)
442
- for key in sorted(actual_keys | expected_keys, key=_safe_repr):
462
+ for key in _ordered_keys(actual, expected):
443
463
  if key not in expected_keys:
444
464
  entries.append(prefix.key(key).entry(actual=actual[key], expected=None, absent="expected"))
445
465
  elif key not in actual_keys:
@@ -504,7 +524,7 @@ def _sub_diff_entries(
504
524
  actual_dict = _field_dict(actual, both_model)
505
525
  expected_dict = _field_dict(expected, both_model)
506
526
  entries = []
507
- for key in sorted(set(actual_dict) | set(expected_dict)):
527
+ for key in _ordered_keys(actual_dict, expected_dict):
508
528
  if key not in expected_dict:
509
529
  entries.append(prefix.attr(key).entry(actual=actual_dict[key], expected=None, absent="expected"))
510
530
  elif key not in actual_dict:
@@ -2,9 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import contextlib
6
5
  import contextvars
7
- import inspect
8
6
  import logging
9
7
  import os
10
8
  import sys
@@ -16,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeVar, overloa
16
14
  if TYPE_CHECKING:
17
15
  import datetime
18
16
  import pathlib
19
- from collections.abc import Callable, Iterator
17
+ from collections.abc import Callable
20
18
 
21
19
  from typing_extensions import TypeIs
22
20
 
@@ -60,7 +58,7 @@ from .snapshot import SnapshotMixin
60
58
  from .string import StringMixin
61
59
  from .warning import WarningMixin
62
60
 
63
- __version__ = "2.20.0"
61
+ __version__ = "2.20.1"
64
62
 
65
63
  # the tracked value type of the generic AssertionBuilder fallback (_U appears only in narrowing stubs)
66
64
  _T = TypeVar("_T")
@@ -73,33 +71,44 @@ if TYPE_CHECKING:
73
71
  _V = TypeVar("_V") # dict value type
74
72
 
75
73
  __tracebackhide__ = True # clean tracebacks via py.test integration
76
- contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
77
74
 
78
75
  # assertpy2 source files, used to strip internal frames when locating the caller for warn-mode messages.
79
76
  # Derived from the package directory so new modules are covered automatically (no hand-maintained list).
80
- ASSERTPY_FILES: Final = [
81
- os.path.join("assertpy2", name) for name in os.listdir(os.path.dirname(__file__)) if name.endswith(".py")
82
- ]
77
+ # Absolute paths in a set, not suffixes in a list: membership is one hash lookup per frame where the
78
+ # suffix scan ran `endswith` once per module, thirty string comparisons a frame on a path taken for
79
+ # every collected soft failure. Exact paths also stop a user file of the same name from shadowing ours.
80
+ ASSERTPY_FILES: Final = frozenset(
81
+ os.path.join(os.path.dirname(__file__), name)
82
+ for name in os.listdir(os.path.dirname(__file__))
83
+ if name.endswith(".py")
84
+ )
83
85
 
84
86
 
85
87
  def _caller_location() -> tuple[str, int] | None:
86
88
  """The ``(filename, lineno)`` of the user frame that called into assertpy2, skipping internal frames.
87
89
 
88
- Used to locate a warn-mode warning and each collected soft-assertion failure. A user file living
89
- under a directory named ``assertpy2`` could shadow every frame, so ``None`` is returned rather than
90
- crashing.
90
+ Used to locate a warn-mode warning and each collected soft-assertion failure.
91
+
92
+ The answer is the frame just outside the *outermost* assertpy2 frame, which is why the walk cannot
93
+ stop at the first user frame it meets going inwards: a predicate passed to `satisfies()` or `each()`
94
+ runs inside our own call, and the line worth reporting is the assertion in the test rather than the
95
+ lambda we invoked. Walking outwards and keeping the last handover does the same in one pass,
96
+ without building a list of the whole stack.
97
+
98
+ ``None`` where no such handover exists, so a caller that cannot be located logs without the prefix
99
+ instead of crashing on unpacking.
91
100
  """
92
- frames = []
93
- frame = inspect.currentframe()
101
+ frame: types.FrameType | None = sys._getframe(1) # CPython accessor; the inspect equivalent is 10x slower here
102
+ location: tuple[str, int] | None = None
103
+ inner_is_internal = False
94
104
  while frame:
95
- frames.append((frame.f_code.co_filename, frame.f_lineno))
105
+ filename = frame.f_code.co_filename
106
+ is_internal = filename in ASSERTPY_FILES
107
+ if inner_is_internal and not is_internal:
108
+ location = (filename, frame.f_lineno)
109
+ inner_is_internal = is_internal
96
110
  frame = frame.f_back
97
- previous: tuple[str, int] | None = None
98
- for filename, lineno in reversed(frames):
99
- if any(filename.endswith(internal) for internal in ASSERTPY_FILES):
100
- return previous
101
- previous = (filename, lineno)
102
- return None # pragma: no cover - error() is always reached through an assertpy frame
111
+ return location
103
112
 
104
113
 
105
114
  # soft assertions (contextvars for thread/async safety)
@@ -110,12 +119,33 @@ _soft_err: contextvars.ContextVar[list[AssertionOutcome]] = contextvars.ContextV
110
119
  _soft_group: contextvars.ContextVar[str | None] = contextvars.ContextVar("assertpy2_soft_group", default=None)
111
120
 
112
121
 
122
+ class _Group:
123
+ """The context `group()` hands back.
124
+
125
+ A class rather than a `@contextlib.contextmanager` generator, and the same goes for the soft block
126
+ itself. A generator-based manager puts `contextlib.__exit__` on the stack, so a failure raised on
127
+ the way out is reported against `contextlib.py` instead of the `with` line in the test. The old
128
+ cure was setting `__tracebackhide__` on the `contextlib` module, which fixed our two managers by
129
+ changing how every third-party context manager in the process is reported.
130
+ """
131
+
132
+ __slots__ = ("_label", "_token")
133
+
134
+ def __init__(self, label: str) -> None:
135
+ self._label = label
136
+
137
+ def __enter__(self) -> None:
138
+ self._token = _soft_group.set(self._label)
139
+
140
+ def __exit__(self, *_exc: object) -> None:
141
+ _soft_group.reset(self._token)
142
+
143
+
113
144
  class SoftAssertionCollector:
114
145
  """Collector returned by [`soft_assertions()`][assertpy2.assertpy.soft_assertions] for grouping
115
146
  errors hierarchically."""
116
147
 
117
- @contextlib.contextmanager
118
- def group(self, label: str) -> Iterator[None]:
148
+ def group(self, label: str) -> _Group:
119
149
  """Group subsequent assertion failures under *label*.
120
150
 
121
151
  Examples:
@@ -127,11 +157,7 @@ class SoftAssertionCollector:
127
157
  with sa.group("Body"):
128
158
  assert_that(body["status"]).is_equal_to("ok")
129
159
  """
130
- token = _soft_group.set(label)
131
- try:
132
- yield
133
- finally:
134
- _soft_group.reset(token)
160
+ return _Group(label)
135
161
 
136
162
 
137
163
  def _located(location: tuple[str, int] | None, msg: str) -> str:
@@ -184,8 +210,32 @@ def _format_soft_errors(errs: list[AssertionOutcome]) -> str:
184
210
  return "\n".join(lines)
185
211
 
186
212
 
187
- @contextlib.contextmanager
188
- def soft_assertions() -> Iterator[SoftAssertionCollector]:
213
+ class _SoftAssertions:
214
+ """The context `soft_assertions()` hands back. See `_Group` for why this is a class."""
215
+
216
+ __slots__ = ()
217
+
218
+ def __enter__(self) -> SoftAssertionCollector:
219
+ ctx = _soft_ctx.get()
220
+ if ctx == 0:
221
+ _soft_err.set([])
222
+ _soft_ctx.set(ctx + 1)
223
+ return SoftAssertionCollector()
224
+
225
+ def __exit__(self, exc_type: type[BaseException] | None, *_exc: object) -> None:
226
+ _soft_ctx.set(_soft_ctx.get() - 1)
227
+ if exc_type is not None:
228
+ return # an error out of the block wins: it says more than the failures collected before it
229
+ errs = _soft_err.get([])
230
+ if errs and _soft_ctx.get() == 0:
231
+ out = _format_soft_errors(errs)
232
+ _soft_err.set([])
233
+ # the same class as a single failure, so one `except AssertionFailure` covers a soft block
234
+ # too, and it hands back what it aggregated rather than only the text it rendered
235
+ raise AssertionFailure(out, failures=tuple(errs))
236
+
237
+
238
+ def soft_assertions() -> _SoftAssertions:
189
239
  """Create a soft assertion context.
190
240
 
191
241
  Normally, any assertion failure will halt test execution immediately by raising an error.
@@ -232,23 +282,7 @@ def soft_assertions() -> Iterator[SoftAssertionCollector]:
232
282
  If you need more forgiving behavior, use [`soft_fail()`][assertpy2.assertpy.soft_fail] to add
233
283
  a failure message without halting test execution.
234
284
  """
235
- ctx = _soft_ctx.get()
236
- if ctx == 0:
237
- _soft_err.set([])
238
- _soft_ctx.set(ctx + 1)
239
-
240
- try:
241
- yield SoftAssertionCollector()
242
- finally:
243
- _soft_ctx.set(_soft_ctx.get() - 1)
244
-
245
- errs = _soft_err.get([])
246
- if errs and _soft_ctx.get() == 0:
247
- out = _format_soft_errors(errs)
248
- _soft_err.set([])
249
- # the same class as a single failure, so one `except AssertionFailure` covers a soft block too,
250
- # and it hands back what it aggregated rather than only the text it rendered
251
- raise AssertionFailure(out, failures=tuple(errs))
285
+ return _SoftAssertions()
252
286
 
253
287
 
254
288
  def assert_all(*callables: Callable[[], object]) -> None:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import collections.abc
4
4
  import inspect
5
+ from typing import Any
5
6
 
6
7
  from ._engine._introspection import is_namedtuple
7
8
  from ._engine._mixin_base import _MixinBase
@@ -43,9 +44,22 @@ class DynamicMixin(_MixinBase):
43
44
  assert_that(fred).has_shoe_size(12)
44
45
  """
45
46
 
46
- def __getattr__(self, attr):
47
+ def __getattr__(self, attr: str) -> Any:
47
48
  """Asserts that val has attribute attr and that its value is equal to other via a dynamic
48
- assertion of the form ``has_<attr>()``."""
49
+ assertion of the form ``has_<attr>()``.
50
+
51
+ The return stays ``Any`` on purpose, and the reason is written down because the annotation looks
52
+ like an oversight worth fixing. Two unrelated things resolve through this hook: a ``has_``
53
+ wrapper, which takes one argument and hands back the builder, and any name registered with
54
+ `add_extension`, which takes and returns whatever it declares. No single signature is true of
55
+ both. Pinning the argument list reported a correct zero-argument extension call as too few
56
+ arguments, and pinning the return type reported a correct extension result as the wrong type
57
+ where it was used. Both were measured against mypy, pyright and ty.
58
+
59
+ The narrower annotation also bought less than it looked like. It could not catch an assertion
60
+ chained onto a dynamic step, because that access resolves back through this same hook; the only
61
+ thing it added was a verdict in assignment position, which is where it was wrong for extensions.
62
+ """
49
63
  if not attr.startswith("has_"):
50
64
  raise AttributeError(f"assertpy has no assertion <{attr}()>")
51
65
 
@@ -198,6 +198,17 @@ def _diff_side(value: object, limit: int = 400) -> str:
198
198
  return _truncated(_safe_repr(value), limit)
199
199
 
200
200
 
201
+ def _diff_sides(actual: object, expected: object, limit: int = 400) -> tuple[str, str]:
202
+ """Both sides of one comparison, capped onto the difference rather than from the start.
203
+
204
+ `_diff_side` caps each side on its own, which is right for a row that stands alone. A pair read
205
+ together needs the pair's window: two 10 000-character strings differing in the middle were both
206
+ cut at character 400, so the section printed two identical-looking values under a heading saying
207
+ they were not equal, while the diff below it pointed straight at the change.
208
+ """
209
+ return _windowed(_safe_repr(actual), _safe_repr(expected), limit)
210
+
211
+
201
212
  def _windowed(actual: str, expected: str, width: int = 160) -> tuple[str, str]:
202
213
  """Both lines cut to a window around their first difference.
203
214
 
@@ -6,11 +6,6 @@ import math
6
6
  import numbers
7
7
  import re
8
8
 
9
- try:
10
- import attrs
11
- except ImportError: # pragma: no cover - optional dependency; the attrs branch runs only when present
12
- attrs = None # ty: ignore[invalid-assignment] # sentinel for the absent optional module
13
-
14
9
  from assertpy2.errors import DiffResult, _safe_repr, _truncated, _windowed
15
10
 
16
11
  from ._engine._compare import _CompareConfig, _config_note, _guarded_not_equal, _node_decision, _spec_matches
@@ -437,9 +432,10 @@ class HelpersMixin(_MixinBase):
437
432
  _seen = _seen | {id(mapping)}
438
433
  parts = []
439
434
  ellip = False
440
- # build items by iterating keys + [] (the dict-likeness gate guarantees those, not items())
441
- mapping_items = ((key, mapping[key]) for key in mapping)
442
- for key, value in sorted(mapping_items, key=lambda item: _safe_repr(item[0])):
435
+ # build items by iterating keys + [] (the dict-likeness gate guarantees those, not items()).
436
+ # left in the order the mapping holds them, which is what the diff under this line prints:
437
+ # sorting only here made the two halves of one message disagree about where a key sits
438
+ for key, value in ((key, mapping[key]) for key in mapping):
443
439
  if key not in counterpart:
444
440
  parts.append(f"{_safe_repr(key)}: {_safe_repr(value)}")
445
441
  else:
@@ -550,7 +546,15 @@ class HelpersMixin(_MixinBase):
550
546
  if is_model_dump_object(obj):
551
547
  return obj.model_dump()
552
548
  if is_attrs_instance(obj):
553
- # attrs is importable whenever an attrs instance exists, so the guarded import bound it;
549
+ # deferred, and the only import in the package that is: at module level it cost 8.5 ms and
550
+ # 22 modules of a 39.8 ms import, a fifth of it, paid by every pytest run in an environment
551
+ # where attrs happens to be installed. The auto-loaded plugin means that is every run, and
552
+ # it was paid whether or not any attrs instance ever reached a comparison. Here it is paid
553
+ # by the one branch that needs it, and the guard above already proved the library is present
554
+ # (nothing carries `__attrs_attrs__` unless attrs built it). Every other optional
555
+ # dependency in the package is imported inside its own branch for the same reason.
556
+ import attrs
557
+
554
558
  # asdict recurses like dataclasses.asdict, flattening nested attrs for ignore/include
555
559
  return attrs.asdict(obj)
556
560
  if hasattr(obj, "__dict__") and not isinstance(obj, type):
@@ -12,7 +12,7 @@ from . import _inline, _satisfies, async_assertions, errors
12
12
  from . import snapshot as _snapshot
13
13
  from ._engine._diff import _sub_diff_entries
14
14
  from ._engine._path import _ROOT
15
- from .errors import _diff_side, _json_safe, _render_diff
15
+ from .errors import _diff_side, _diff_sides, _json_safe, _render_diff
16
16
 
17
17
  try:
18
18
  import allure # ty: ignore[unresolved-import] # optional dependency
@@ -368,9 +368,15 @@ def pytest_runtest_makereport(item, call):
368
368
  # capped like the diff rows: this section is read on a terminal, and the untouched values stay
369
369
  # on the exception for anything that wants them
370
370
  lines = []
371
- if named_actual:
371
+ if named_actual and named_expected:
372
+ # windowed as a pair: capping each side on its own hides the difference when it sits past
373
+ # the cap, and prints two values that look identical under a heading saying they are not
374
+ left, right = _diff_sides(actual, expected)
375
+ lines.append(f" actual: {left}")
376
+ lines.append(f" expected: {right}")
377
+ elif named_actual:
372
378
  lines.append(f" actual: {_diff_side(actual)}")
373
- if named_expected:
379
+ else:
374
380
  lines.append(f" expected: {_diff_side(expected)}")
375
381
  report.sections.append(("AssertionFailure", "\n".join(lines)))
376
382
 
@@ -0,0 +1,63 @@
1
+ # Stability
2
+
3
+ What you can build on, and what enforces it. Every row below is held by a test that fails CI, not by
4
+ an intention.
5
+
6
+ ## The contract
7
+
8
+ | You can depend on | Enforced by |
9
+ |---|---|
10
+ | The 36 names `assertpy2` exports, and the fields of every record it hands you | [`test_public_surface.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_public_surface.py) pins both against a hand-written list |
11
+ | Every assertion the type checker offers you existing at runtime | [`test_protocol_parity.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_protocol_parity.py) walks all ten protocols |
12
+ | The type your chain has after each step | [`test_typing.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_typing.py), 123 `assert_type` checks under ty, mypy `--strict` and Pyright, zero suppressions |
13
+ | One relation keeping one name across the API | [`test_api_vocabulary.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_api_vocabulary.py) |
14
+ | The three-method `Matcher` protocol your custom matchers implement | [`test_matcher_parity.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_matcher_parity.py) |
15
+ | The Allure attachment schema | versioned in its own `format` field, so a consumer branches on a number rather than guessing |
16
+ | Your assertions keeping their verdict | 4100 tests at 100 % branch coverage, plus mutation testing |
17
+
18
+ The records in the first row are `AssertionOutcome`, `MatchResult`, `DiffEntry`, `DiffResult`, `Step`,
19
+ `PollSample` and `PollTrace`. Fields are added, never renamed or removed, inside a major version.
20
+
21
+ Documentation is held to the same bar: every example in these guides is executed and type-checked in
22
+ CI, so a snippet you copy is one that ran.
23
+
24
+ ## Reading a failure from code
25
+
26
+ Failures carry [structured data](../guides/errors.md#structured-errors) for exactly this. Reach for it
27
+ rather than for the rendered text:
28
+
29
+ | You want | Use |
30
+ |---|---|
31
+ | the values that were compared | `failure.actual`, `failure.expected` |
32
+ | where they differ | `failure.diff.entries`, each with a `path` |
33
+ | a location you can walk in code | [`entry.steps`](../guides/errors.md#paths-a-program-can-follow) |
34
+ | every failure a soft block collected | `failure.failures` |
35
+ | a verdict without an exception | [`check()`](../guides/errors.md#asking-instead-of-asserting) |
36
+
37
+ Those are covered by the table above. **Message wording is not**: it improves in minor releases, so a
38
+ `pytest.raises(match=...)` written against our phrasing, or a snapshot of a failing run, is the one
39
+ thing that predictably needs updating. Match on the exception type and read the fields.
40
+
41
+ ## Upgrading
42
+
43
+ Semantic versioning, read strictly: a new assertion is a minor, a patch carries fixes only.
44
+
45
+ Two kinds of change ship in a minor, and the release notes list each one under **Behaviour changes**:
46
+
47
+ - **an input that was silently wrong starts raising**, such as an empty prefix that no value could fail
48
+ - **a wrong verdict is corrected**, so an assertion that passed and should not have begins to fail
49
+
50
+ Both are the reason to read that section before upgrading. Nothing else in a minor is designed to
51
+ change what your suite reports.
52
+
53
+ ## Not part of the API
54
+
55
+ Anything whose name starts with `_`, and the module an assertion happens to live in. The reference
56
+ lists `matches_structure()` because you can call it from `assert_that()`, and its anchor reads
57
+ `assertpy2._satisfies.SatisfiesMixin.matches_structure` because that is where the code sits.
58
+ Importing from there is not supported.
59
+
60
+ Python 3.10 and up. Dropping a version that upstream still supports would be a major.
61
+
62
+ Code written against [assertpy](../getting-started/migration.md) keeps working, and that holds for
63
+ refactors: an internal change that would break a documented assertpy call is not internal.
@@ -94,6 +94,20 @@ This is the deliberate cost of keeping the original assertpy API working unchang
94
94
  mutually satisfiable: for a checker to accept `has_first_name` on a dict, `_DictAssertion` would need a
95
95
  `__getattr__`, and that same declaration would stop it reporting `contins_key` as a typo.
96
96
 
97
+ [Custom assertions](../extending/custom-assertions.md) registered with `add_extension()` sit on the same
98
+ line, for the same reason. The name is attached at runtime, so it reaches a checker through that one
99
+ `__getattr__` and nowhere else:
100
+
101
+ <!-- docs-guard: untyped -->
102
+ ```python
103
+ assert_that(order).is_paid() # clean: a user class falls to the generic builder
104
+ assert_that(5).is_5() # type error: _NumericAssertion has no attribute is_5
105
+ ```
106
+
107
+ Both lines run. If your extension targets a value with an overload of its own, the call needs
108
+ `# type: ignore[attr-defined]` at each site, and the return type a checker infers for it is `Any`
109
+ rather than whatever the extension actually returns.
110
+
97
111
  ## Typed narrowing with .value
98
112
 
99
113
  Assertions don't just check a value - they can hand it back, typed. The `value` property ends a chain
@@ -274,9 +288,27 @@ A few refinements keep it precise:
274
288
 
275
289
  ## Set up your type checker
276
290
 
277
- The narrowing works in any checker mode, but strict mode surfaces the most - a wrong method called on a
278
- narrowed value, a missing return annotation, a `.value` read where the type was never narrowed. Turn it
279
- on for your checker:
291
+ Under mypy, one setting decides whether any of this reaches your tests. mypy does not look inside a
292
+ function with no annotations at all, and a test written as `def test_orders():` is exactly that, so the
293
+ narrowing is silently skipped for the file where you wanted it most. The same three mistakes below are
294
+ reported six times by Pyright and `ty` out of the box, three times by mypy at its defaults, and six by
295
+ mypy once it is told to read those bodies:
296
+
297
+ <!-- docs-guard: skip -->
298
+
299
+ ```python
300
+ def test_unannotated(): # mypy default: not checked at all
301
+ assert_that("abc").is_greater_than(3)
302
+
303
+ def test_annotated() -> None: # mypy default: checked
304
+ assert_that("abc").is_greater_than(3)
305
+ ```
306
+
307
+ Either annotate every test with `-> None`, or set `check_untyped_defs = true`, which `strict = true`
308
+ already includes. Pyright and `ty` need neither.
309
+
310
+ Strict mode then surfaces the most - a wrong method called on a narrowed value, a missing return
311
+ annotation, a `.value` read where the type was never narrowed. Turn it on for your checker:
280
312
 
281
313
  ```toml
282
314
  # pyproject.toml - mypy
@@ -4,6 +4,8 @@ Add your own assertions to `assertpy2` with `add_extension()`.
4
4
 
5
5
  ## A custom assertion
6
6
 
7
+ <!-- docs-guard: untyped -->
8
+
7
9
  ```python
8
10
  from assertpy2 import add_extension, assert_that
9
11
 
@@ -18,6 +20,11 @@ assert_that(5).is_5()
18
20
  assert_that(6).is_5() # fails!
19
21
  ```
20
22
 
23
+ Both lines run, but a type checker rejects the first one: `5` resolves to the numeric protocol, which
24
+ declares no `is_5`. An extension is only visible to a checker on values that fall to the generic
25
+ builder, which means your own classes rather than `str`, `int`, `dict` and the rest. The reason and the
26
+ ways round it are in [Where the typed surface ends](../concepts/type-safety.md#where-the-typed-surface-ends).
27
+
21
28
  `remove_extension()` takes the same function and unregisters it, so a temporary assertion does not
22
29
  leak into other tests:
23
30
 
@@ -48,6 +55,8 @@ def my_extensions():
48
55
 
49
56
  Then request the fixture in any test that needs the extensions:
50
57
 
58
+ <!-- docs-guard: untyped -->
59
+
51
60
  ```python
52
61
  from assertpy2 import assert_that
53
62
 
@@ -106,4 +106,5 @@ New capabilities the original never had, ready whenever you want them:
106
106
  - [Async and blocking polling](../guides/testing.md#async-assertions) for eventual consistency.
107
107
  - [JSON Path / Schema](../guides/data.md), [regex group extraction](../guides/data.md#regex-group-extraction), and [bytes assertions](../guides/assertions.md#bytes--bytearray).
108
108
 
109
- See the [comparison](comparison.md) for the full feature delta.
109
+ See the [comparison](comparison.md) for the full feature delta, and [stability](../concepts/stability.md)
110
+ for what is covered by a compatibility promise and what is not.