assertpy2 2.16.0__tar.gz → 2.17.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 (174) hide show
  1. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/publish.yml +10 -1
  2. {assertpy2-2.16.0 → assertpy2-2.17.0}/PKG-INFO +2 -2
  3. {assertpy2-2.16.0 → assertpy2-2.17.0}/README.md +1 -1
  4. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_compare.py +5 -3
  5. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_diff.py +5 -1
  6. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/assertpy.py +1 -1
  7. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/async_assertions.py +8 -0
  8. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/base.py +11 -2
  9. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/contains.py +32 -19
  10. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/errors.py +1 -1
  11. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/extracting.py +44 -7
  12. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/helpers.py +104 -29
  13. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/json_mixin.py +39 -15
  14. {assertpy2-2.16.0 → assertpy2-2.17.0}/benchmarks/test_perf.py +41 -0
  15. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/getting-started/migration.md +8 -1
  16. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/data.md +5 -1
  17. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/errors.md +35 -1
  18. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/testing.md +3 -1
  19. {assertpy2-2.16.0 → assertpy2-2.17.0}/pyproject.toml +1 -1
  20. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_async.py +43 -0
  21. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_bytes.py +24 -0
  22. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_custom_dict.py +22 -0
  23. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dict.py +3 -1
  24. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dict_compare.py +10 -0
  25. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_equals.py +97 -0
  26. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_extracting.py +73 -3
  27. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_list.py +3 -1
  28. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_namedtuple.py +3 -1
  29. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_openapi_contract.py +85 -0
  30. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_property_based.py +307 -1
  31. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare_config.py +154 -0
  32. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_rich_diff.py +54 -2
  33. assertpy2-2.17.0/tests/test_vacuity_contract.py +97 -0
  34. {assertpy2-2.16.0 → assertpy2-2.17.0}/uv.lock +1 -1
  35. {assertpy2-2.16.0 → assertpy2-2.17.0}/.codecov.yml +0 -0
  36. {assertpy2-2.16.0 → assertpy2-2.17.0}/.gitattributes +0 -0
  37. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/dependabot.yml +0 -0
  38. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/ci.yml +0 -0
  39. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/codeql.yml +0 -0
  40. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/codspeed.yml +0 -0
  41. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/docs.yml +0 -0
  42. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/mutation.yml +0 -0
  43. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/scorecard.yml +0 -0
  44. {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/zizmor.yml +0 -0
  45. {assertpy2-2.16.0 → assertpy2-2.17.0}/.gitignore +0 -0
  46. {assertpy2-2.16.0 → assertpy2-2.17.0}/CONTRIBUTING.md +0 -0
  47. {assertpy2-2.16.0 → assertpy2-2.17.0}/LICENSE +0 -0
  48. {assertpy2-2.16.0 → assertpy2-2.17.0}/SECURITY.md +0 -0
  49. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/__init__.py +0 -0
  50. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/__init__.py +0 -0
  51. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_compat.py +0 -0
  52. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_contract.py +0 -0
  53. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_introspection.py +0 -0
  54. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_mixin_base.py +0 -0
  55. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_typing.py +0 -0
  56. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_inline.py +0 -0
  57. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_matcher_impls.py +0 -0
  58. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_satisfies.py +0 -0
  59. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_snapshot_codec.py +0 -0
  60. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/behave_matchers.py +0 -0
  61. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/bytes_mixin.py +0 -0
  62. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/collection.py +0 -0
  63. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/dataframe.py +0 -0
  64. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/date.py +0 -0
  65. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/dict.py +0 -0
  66. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/dynamic.py +0 -0
  67. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/exception.py +0 -0
  68. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/file.py +0 -0
  69. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/matchers.py +0 -0
  70. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/numeric.py +0 -0
  71. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/py.typed +0 -0
  72. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/pytest_plugin.py +0 -0
  73. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/snapshot.py +0 -0
  74. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/string.py +0 -0
  75. {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/warning.py +0 -0
  76. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-equal.png +0 -0
  77. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-equal.svg +0 -0
  78. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-gallery.png +0 -0
  79. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-match.svg +0 -0
  80. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-sequence.svg +0 -0
  81. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-set.svg +0 -0
  82. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-string.svg +0 -0
  83. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/concepts/type-safety.md +0 -0
  84. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/extending/custom-assertions.md +0 -0
  85. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/extending/integrations.md +0 -0
  86. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/getting-started/comparison.md +0 -0
  87. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/getting-started/quickstart.md +0 -0
  88. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/assertions.md +0 -0
  89. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/fluent.md +0 -0
  90. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/matchers.md +0 -0
  91. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/index.md +0 -0
  92. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/logo-dark.svg +0 -0
  93. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/logo.svg +0 -0
  94. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/recipes.md +0 -0
  95. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/async.md +0 -0
  96. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/bytes.md +0 -0
  97. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/collections.md +0 -0
  98. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/containment.md +0 -0
  99. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/core.md +0 -0
  100. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dataframes.md +0 -0
  101. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dates.md +0 -0
  102. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dicts.md +0 -0
  103. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dynamic.md +0 -0
  104. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/entry-points.md +0 -0
  105. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/errors.md +0 -0
  106. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/exceptions.md +0 -0
  107. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/extracting.md +0 -0
  108. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/files.md +0 -0
  109. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/json.md +0 -0
  110. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/matchers.md +0 -0
  111. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/numbers.md +0 -0
  112. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/overview.md +0 -0
  113. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/snapshots.md +0 -0
  114. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/strings.md +0 -0
  115. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/warnings.md +0 -0
  116. {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/stylesheets/extra.css +0 -0
  117. {assertpy2-2.16.0 → assertpy2-2.17.0}/mkdocs.yml +0 -0
  118. {assertpy2-2.16.0 → assertpy2-2.17.0}/mkdocs_hooks.py +0 -0
  119. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/conftest.py +0 -0
  120. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_attrs_support.py +0 -0
  121. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_behave_matchers.py +0 -0
  122. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_bool.py +0 -0
  123. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_boundary_cases.py +0 -0
  124. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_callable.py +0 -0
  125. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_chaining.py +0 -0
  126. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_class.py +0 -0
  127. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_collection.py +0 -0
  128. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_core.py +0 -0
  129. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_custom_list.py +0 -0
  130. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dataframe.py +0 -0
  131. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_datetime.py +0 -0
  132. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_description.py +0 -0
  133. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_docs_examples.py +0 -0
  134. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dynamic.py +0 -0
  135. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_errors.py +0 -0
  136. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_expected_exception.py +0 -0
  137. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_expected_warning.py +0 -0
  138. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_extensions.py +0 -0
  139. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_fail.py +0 -0
  140. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_file.py +0 -0
  141. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_grouped_soft.py +0 -0
  142. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_in.py +0 -0
  143. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_inline_record.py +0 -0
  144. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_inline_snapshot.py +0 -0
  145. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_iterable_cluster.py +0 -0
  146. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_json.py +0 -0
  147. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_matcher_registry.py +0 -0
  148. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_matchers.py +0 -0
  149. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_none.py +0 -0
  150. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_not.py +0 -0
  151. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_numbers.py +0 -0
  152. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_overloads.py +0 -0
  153. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_pipeline.py +0 -0
  154. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_property_attrs.py +0 -0
  155. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_protocol_parity.py +0 -0
  156. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_pytest_plugin.py +0 -0
  157. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_readme.py +0 -0
  158. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_assertion.py +0 -0
  159. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare.py +0 -0
  160. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare_attrs.py +0 -0
  161. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare_pydantic.py +0 -0
  162. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_regex_groups.py +0 -0
  163. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_same_as.py +0 -0
  164. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_satisfy.py +0 -0
  165. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_snapshots.py +0 -0
  166. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_soft.py +0 -0
  167. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_soft_fail.py +0 -0
  168. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_string.py +0 -0
  169. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_structural.py +0 -0
  170. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_sync_eventually.py +0 -0
  171. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_traceback.py +0 -0
  172. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_type.py +0 -0
  173. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_typing.py +0 -0
  174. {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_warn.py +0 -0
@@ -46,15 +46,24 @@ jobs:
46
46
  with:
47
47
  subject-path: dist/*.tar.gz
48
48
 
49
- - name: Prepare provenance bundles
49
+ - name: Collect provenance (sigstore bundles + in-toto)
50
50
  run: |
51
51
  mkdir -p provenance
52
52
  cp "$WHEEL_BUNDLE" "provenance/$(basename dist/*.whl).sigstore.json"
53
53
  cp "$SDIST_BUNDLE" "provenance/$(basename dist/*.tar.gz).sigstore.json"
54
+ jq -c '.dsseEnvelope' "$WHEEL_BUNDLE" > provenance/provenance.intoto.jsonl
55
+ jq -c '.dsseEnvelope' "$SDIST_BUNDLE" >> provenance/provenance.intoto.jsonl
56
+ test -s provenance/provenance.intoto.jsonl
54
57
  env:
55
58
  WHEEL_BUNDLE: ${{ steps.attest-wheel.outputs.bundle-path }}
56
59
  SDIST_BUNDLE: ${{ steps.attest-sdist.outputs.bundle-path }}
57
60
 
61
+ - name: Install syft
62
+ uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
63
+
64
+ - name: Generate SBOM (CycloneDX)
65
+ run: syft scan dir:dist -o "cyclonedx-json=provenance/assertpy2.sbom.cdx.json"
66
+
58
67
  - name: Publish to PyPI
59
68
  uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
60
69
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertpy2
3
- Version: 2.16.0
3
+ Version: 2.17.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
@@ -203,7 +203,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
203
203
  - [**Structured errors**](https://solganis.github.io/assertpy2/guides/errors/#structured-errors): `AssertionFailure` carries `.actual`, `.expected`, `.diff`, and the diff renders into the message itself, so it shows off pytest too.
204
204
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs): recursive structural diffs across containers, dataclasses, attrs, and Pydantic models, with intra-line carets for strings and circular-reference protection.
205
205
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/guides/testing/#snapshot-testing): three modes under one typed API, all updated with `--assertpy2-snapshot-update`: `snapshot()` (external JSON file), [`matches_inline()`](https://solganis.github.io/assertpy2/guides/testing/#inline-snapshots) (recorded into the test source), and [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/guides/testing/#contract-snapshots) (value-tolerant structural regressions).
206
- - [**OpenAPI response contracts**](https://solganis.github.io/assertpy2/reference/json/#assertpy2.json_mixin.JsonMixin.conforms_to_openapi): `conforms_to_openapi(spec, path, method)` validates a JSON response body against an operation's response schema (OpenAPI 3.0/3.1), reporting every violation with its JSON path.
206
+ - [**OpenAPI response contracts**](https://solganis.github.io/assertpy2/reference/json/#assertpy2.json_mixin.JsonMixin.conforms_to_openapi): `conforms_to_openapi(spec, path, method)` validates a JSON response body against an operation's response schema (OpenAPI 3.0/3.1 and Swagger 2.0), reporting every violation with its JSON path.
207
207
 
208
208
  **Extensibility**
209
209
 
@@ -151,7 +151,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
151
151
  - [**Structured errors**](https://solganis.github.io/assertpy2/guides/errors/#structured-errors): `AssertionFailure` carries `.actual`, `.expected`, `.diff`, and the diff renders into the message itself, so it shows off pytest too.
152
152
  - [**Rich pytest diffs**](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs): recursive structural diffs across containers, dataclasses, attrs, and Pydantic models, with intra-line carets for strings and circular-reference protection.
153
153
  - [**Snapshot testing**](https://solganis.github.io/assertpy2/guides/testing/#snapshot-testing): three modes under one typed API, all updated with `--assertpy2-snapshot-update`: `snapshot()` (external JSON file), [`matches_inline()`](https://solganis.github.io/assertpy2/guides/testing/#inline-snapshots) (recorded into the test source), and [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/guides/testing/#contract-snapshots) (value-tolerant structural regressions).
154
- - [**OpenAPI response contracts**](https://solganis.github.io/assertpy2/reference/json/#assertpy2.json_mixin.JsonMixin.conforms_to_openapi): `conforms_to_openapi(spec, path, method)` validates a JSON response body against an operation's response schema (OpenAPI 3.0/3.1), reporting every violation with its JSON path.
154
+ - [**OpenAPI response contracts**](https://solganis.github.io/assertpy2/reference/json/#assertpy2.json_mixin.JsonMixin.conforms_to_openapi): `conforms_to_openapi(spec, path, method)` validates a JSON response body against an operation's response schema (OpenAPI 3.0/3.1 and Swagger 2.0), reporting every violation with its JSON path.
155
155
 
156
156
  **Extensibility**
157
157
 
@@ -89,11 +89,13 @@ def _ambiguous_array_operand(value: object, other: object) -> object | None:
89
89
 
90
90
  def _array_equality_error(method: str, operand: object) -> TypeError:
91
91
  """Build the actionable error raised when ``method`` is given an element-wise array/frame-like."""
92
+ # point at the assertion that delegates to the library's own comparison: it reports the differing
93
+ # column or index, where wrapping `.equals()` in is_true() would report a bare False
94
+ dedicated = "is_frame_equal(expected)" if hasattr(operand, "equals") else "is_array_equal(expected)"
92
95
  return TypeError(
93
96
  f"{method}() cannot directly compare <{type(operand).__name__}>: its '==' is element-wise and has"
94
- " no single truth value. Compare the value's own equality (e.g."
95
- " assert_that(actual.equals(expected)).is_true()), assert on extracted scalars (columns, shape,"
96
- " length), or use satisfies(...) with an explicit predicate."
97
+ f" no single truth value. Use {dedicated}, assert on extracted scalars (columns, shape, length),"
98
+ " or use satisfies(...) with an explicit predicate."
97
99
  )
98
100
 
99
101
 
@@ -183,7 +183,11 @@ def _build_equality_diff(
183
183
  for item in sorted(expected - actual, key=_safe_repr):
184
184
  entries.append(DiffEntry(path="missing", actual=None, expected=item))
185
185
  return DiffResult(kind="set", entries=entries)
186
- if isinstance(actual, str) and isinstance(expected, str):
186
+ # bytes render as their b'...' literal, which difflib can point into exactly like text, and both
187
+ # kinds expose splitlines(), so one branch serves them
188
+ both_text = isinstance(actual, str) and isinstance(expected, str)
189
+ both_bytes = isinstance(actual, (bytes, bytearray)) and isinstance(expected, (bytes, bytearray))
190
+ if both_text or both_bytes:
187
191
  entries = []
188
192
  actual_lines = actual.splitlines()
189
193
  expected_lines = expected.splitlines()
@@ -53,7 +53,7 @@ from .snapshot import SnapshotMixin
53
53
  from .string import StringMixin
54
54
  from .warning import WarningMixin
55
55
 
56
- __version__ = "2.16.0"
56
+ __version__ = "2.17.0"
57
57
 
58
58
  # the tracked value type of the generic AssertionBuilder fallback (_U appears only in narrowing stubs)
59
59
  _T = TypeVar("_T")
@@ -84,6 +84,14 @@ def _summarize(samples, total_polls, elapsed, fail_polls, error_polls) -> str:
84
84
  return f"probe recovered after {error_polls} raising {poll_word}; {trend}"
85
85
  if not changes:
86
86
  return f"value unchanged across {total_polls} polls"
87
+ distinct: list[object] = []
88
+ for sample in fails:
89
+ if not any(sample.value == seen for seen in distinct):
90
+ distinct.append(sample.value)
91
+ # a simple path through k values takes k-1 changes, so any surplus means the probe came back to a
92
+ # value it already reported: a cycle, which reads nothing like steady progress that ran out of time
93
+ if len(changes) >= len(distinct):
94
+ return f"value cycles between {len(distinct)} states across {total_polls} polls"
87
95
  last_change = elapsed - changes[-1].elapsed
88
96
  return f"value changed {len(changes)} {change_word}; last change {last_change:.1f}s before the deadline"
89
97
 
@@ -14,6 +14,7 @@ from ._engine._diff import _build_equality_diff
14
14
  from ._engine._introspection import is_namedtuple
15
15
  from ._satisfies import SatisfiesMixin
16
16
  from .errors import _disambiguated, _truncated
17
+ from .helpers import _both_list_like, _elided_seq_repr, _elided_text_repr
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  from ._engine._compat import Self
@@ -172,7 +173,11 @@ class BaseMixin(SatisfiesMixin):
172
173
  if type(self.val) in _EQ_ATOMIC and type(other) in _EQ_ATOMIC:
173
174
  # atomic scalars: no array/dict-likeness, == yields a real bool - skip config/guards entirely
174
175
  if self.val != other:
175
- actual_repr, expected_repr = _disambiguated(self.val, other)
176
+ if isinstance(self.val, str) and isinstance(other, str):
177
+ actual_repr = _truncated(_elided_text_repr(self.val, other))
178
+ expected_repr = _truncated(_elided_text_repr(other, self.val))
179
+ else:
180
+ actual_repr, expected_repr = _disambiguated(self.val, other)
176
181
  return self.error(
177
182
  f"Expected <{actual_repr}> to be equal to <{expected_repr}>, but was not.",
178
183
  actual=self.val,
@@ -218,7 +223,11 @@ class BaseMixin(SatisfiesMixin):
218
223
  )
219
224
  else:
220
225
  if _guarded_not_equal(self.val, other):
221
- actual_repr, expected_repr = _disambiguated(self.val, other)
226
+ if _both_list_like(self.val, other):
227
+ actual_repr = _truncated(_elided_seq_repr(self.val, other))
228
+ expected_repr = _truncated(_elided_seq_repr(other, self.val))
229
+ else:
230
+ actual_repr, expected_repr = _disambiguated(self.val, other)
222
231
  diff = _build_equality_diff(self.val, other)
223
232
  return self.error(
224
233
  f"Expected <{actual_repr}> to be equal to <{expected_repr}>, but was not.",
@@ -236,20 +236,23 @@ class ContainsMixin(_MixinBase):
236
236
  """
237
237
  if len(items) == 0:
238
238
  raise ValueError("one or more args must be given")
239
- else:
240
- extra = [item for item in self.val if item not in items]
239
+ extra = [item for item in self.val if item not in items]
240
+ missing = [item for item in items if item not in self.val]
241
+ if extra or missing:
242
+ # both halves at once: reporting only the extras sends the reader to fix one problem and
243
+ # rerun into the other, and the message wording of each half alone is unchanged
244
+ faults = []
245
+ entries = []
241
246
  if extra:
242
- return self.error(
243
- f"Expected <{self.val}> to contain only {self._fmt_items(items)},"
244
- f" but did contain {self._fmt_items(extra)}."
245
- )
246
-
247
- missing = [item for item in items if item not in self.val]
247
+ faults.append(f"did contain {self._fmt_items(extra)}")
248
+ entries += [DiffEntry(path="extra", actual=item, expected=None) for item in extra]
248
249
  if missing:
249
- return self.error(
250
- f"Expected <{self.val}> to contain only {self._fmt_items(items)},"
251
- f" but did not contain {self._fmt_items(missing)}."
252
- )
250
+ faults.append(f"did not contain {self._fmt_items(missing)}")
251
+ entries += [DiffEntry(path="missing", actual=None, expected=item) for item in missing]
252
+ return self.error(
253
+ f"Expected <{self.val}> to contain only {self._fmt_items(items)}, but {' and '.join(faults)}.",
254
+ diff=DiffResult(kind="contains", entries=entries),
255
+ )
253
256
  return self
254
257
 
255
258
  def contains_sequence(self, *items: object) -> Self:
@@ -425,13 +428,23 @@ class ContainsMixin(_MixinBase):
425
428
  val_list = list(self.val)
426
429
  except TypeError:
427
430
  raise TypeError("val is not iterable") from None
428
- if val_list != list(items):
429
- entries = _multiset_diff_entries(val_list, list(items))
430
- diff = DiffResult(kind="contains", entries=entries) if entries else None
431
- return self.error(
432
- f"Expected <{self.val}> to contain exactly {self._fmt_items(items)}, but did not.",
433
- diff=diff,
434
- )
431
+ expected_list = list(items)
432
+ if val_list != expected_list:
433
+ message = f"Expected <{self.val}> to contain exactly {self._fmt_items(items)}, but did not."
434
+ entries = _multiset_diff_entries(val_list, expected_list)
435
+ if entries:
436
+ diff = DiffResult(kind="contains", entries=entries)
437
+ else:
438
+ # equal multisets, so only the order differs: name the first position that disagrees,
439
+ # which is the one the reader has to look at anyway
440
+ pairs = enumerate(zip(val_list, expected_list, strict=True)) # equal multisets, equal lengths
441
+ index = next(i for i, (found, wanted) in pairs if found != wanted)
442
+ message += f" Same items, but the order differs at index {index}."
443
+ diff = DiffResult(
444
+ kind="sequence",
445
+ entries=[DiffEntry(path=f"[{index}]", actual=val_list[index], expected=expected_list[index])],
446
+ )
447
+ return self.error(message, diff=diff)
435
448
  return self
436
449
 
437
450
  def contains_exactly_in_any_order(self, *items: object) -> Self:
@@ -138,7 +138,7 @@ def _append_string_entry(lines: list[str], entry: DiffEntry, *, red: str, green:
138
138
  return
139
139
  actual_line, expected_line = _safe_str(entry.actual), _safe_str(entry.expected)
140
140
  lines.append(f" {entry.path}:")
141
- # ndiff is O(len_a * len_b); on long lines skip the carets and show a plain pair instead
141
+ # ndiff costs ~175x a plain pair even at the cutoff; on long lines skip the carets instead
142
142
  if len(actual_line) <= 200 and len(expected_line) <= 200:
143
143
  for guide in difflib.ndiff([actual_line], [expected_line]):
144
144
  text = guide.rstrip("\n")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import collections.abc
4
+ import difflib
4
5
  import inspect
5
6
  from typing import TYPE_CHECKING
6
7
 
@@ -13,6 +14,21 @@ if TYPE_CHECKING:
13
14
  __tracebackhide__ = True
14
15
 
15
16
 
17
+ _OURS = "_assertpy2_extracting"
18
+
19
+
20
+ def _extraction_error(message: str) -> ValueError:
21
+ """A ``ValueError`` tagged as this module's own.
22
+
23
+ The localization wrapper re-raises only tagged errors, so a ``ValueError`` raised by user code (a
24
+ property that fails, say) keeps its own traceback instead of being swallowed. The visible type
25
+ stays ``ValueError``, exactly as before.
26
+ """
27
+ error = ValueError(message)
28
+ setattr(error, _OURS, True)
29
+ return error
30
+
31
+
16
32
  class ExtractingMixin(_MixinBase):
17
33
  """Collection flattening mixin.
18
34
 
@@ -152,7 +168,7 @@ class ExtractingMixin(_MixinBase):
152
168
  try:
153
169
  inspect.signature(attr).bind()
154
170
  except TypeError: # the callable needs arguments, so it is not a zero-arg method
155
- raise ValueError(f"item method <{name}()> exists, but is not zero-arg method") from None
171
+ raise _extraction_error(f"item method <{name}()> exists, but is not zero-arg method") from None
156
172
  except ValueError: # some builtins expose no introspectable signature; fall back to calling
157
173
  pass
158
174
  return attr() # a TypeError from here comes from the method body, not an arity mismatch
@@ -161,19 +177,34 @@ class ExtractingMixin(_MixinBase):
161
177
  if self._is_dict_like(item, check_values=False):
162
178
  if name in item:
163
179
  return item[name]
164
- raise ValueError(f"item keys {list(item.keys())} did not contain key <{name}>")
180
+ raise _extraction_error(f"item keys {list(item.keys())} did not contain key <{name}>")
165
181
  if is_namedtuple(item) and type(name) is str:
166
182
  if name in item._fields:
167
183
  return getattr(item, name)
168
184
  if hasattr(item, name): # a property or zero-arg method on the NamedTuple subclass
169
185
  return _attr_value(item, name)
170
- raise ValueError(f"item attributes {item._fields} did not contain attribute <{name}>")
186
+ raise _extraction_error(f"item attributes {item._fields} did not contain attribute <{name}>")
171
187
  if isinstance(item, collections.abc.Iterable) and not is_model_dump_object(item):
172
188
  self._check_iterable(item, name="item")
173
189
  return item[name]
174
- if hasattr(item, name):
190
+ try:
175
191
  return _attr_value(item, name)
176
- raise ValueError(f"item does not have property or zero-arg method <{name}>")
192
+ except AttributeError as exc:
193
+ # hasattr() reports a raising accessor as a missing one, so telling the two apart is the
194
+ # difference between "you typed the wrong name" and "your property is broken"
195
+ if hasattr(type(item), name):
196
+ raise _extraction_error(
197
+ f"item has property or zero-arg method <{name}>, but reading it raised AttributeError: {exc}"
198
+ ) from exc
199
+ try:
200
+ available = sorted(attr for attr in dir(item) if not attr.startswith("_"))
201
+ except Exception: # a broken __dir__ must not replace the real diagnostic with its own
202
+ available = []
203
+ # one suggestion, not a list: measured typos score ~0.9 while wrong neighbours sit at
204
+ # ~0.65, so extra candidates are noise that costs the hint its credibility
205
+ close = difflib.get_close_matches(str(name), available, n=1)
206
+ hint = f"; did you mean {close[0]!r}?" if close else ""
207
+ raise _extraction_error(f"item does not have property or zero-arg method <{name}>{hint}") from None
177
208
 
178
209
  def _filter(item):
179
210
  if "filter" in kwargs:
@@ -208,9 +239,15 @@ class ExtractingMixin(_MixinBase):
208
239
  # only pay the sort when a sort key was actually requested; otherwise iteration order is unchanged
209
240
  source = sorted(self.val, key=_sort) if "sort" in kwargs else self.val
210
241
  extracted = []
211
- for item in source:
242
+ for index, item in enumerate(source):
212
243
  if _filter(item):
213
- extracted_values = [_extract(item, name) for name in names]
244
+ try:
245
+ extracted_values = [_extract(item, name) for name in names]
246
+ except ValueError as exc:
247
+ if not getattr(exc, _OURS, False):
248
+ raise
249
+ localized = f"{exc} (at index {index}, item is <{type(item).__name__}>)"
250
+ raise _extraction_error(localized) from exc.__cause__
214
251
  extracted.append(tuple(extracted_values) if len(extracted_values) > 1 else extracted_values[0])
215
252
 
216
253
  # chain on with _extracted_ list (don't chain to self!)
@@ -20,6 +20,96 @@ from ._engine._mixin_base import _MixinBase
20
20
  __tracebackhide__ = True
21
21
 
22
22
 
23
+ def _values_not_equal(value: object, other_value: object, config: _CompareConfig | None) -> bool:
24
+ """Whether two non-mapping values differ, delegating to the shared structural walker.
25
+
26
+ Asking `_sub_diff_entries` keeps the equality *decision* and the rendered *diff* in agreement: every
27
+ shape it can decompose (dataclass, attrs, namedtuple, model, sequence) is then compared under the
28
+ compare config, instead of falling back to plain equality and silently dropping that config.
29
+ Without a config there is nothing to honour, so the plain check is kept exactly as it was.
30
+ """
31
+ if value is other_value:
32
+ return False # identity first, the way Python's own container comparison short-circuits
33
+ if config is None:
34
+ return _guarded_not_equal(value, other_value)
35
+ entries = _sub_diff_entries(value, other_value, "", config=config)
36
+ if entries is None: # a leaf the walker does not decompose
37
+ return _guarded_not_equal(value, other_value)
38
+ return bool(entries)
39
+
40
+
41
+ def _both_list_like(left: object, right: object) -> bool:
42
+ """Whether both values are plain sequences, so they compare element-by-element.
43
+
44
+ Namedtuples are excluded on purpose: they carry field names and are compared field-wise.
45
+ """
46
+ return (
47
+ isinstance(left, (list, tuple))
48
+ and isinstance(right, (list, tuple))
49
+ and not is_namedtuple(left)
50
+ and not is_namedtuple(right)
51
+ )
52
+
53
+
54
+ def _joined_parts(parts: list[str], *, elided: bool, opener: str = "", closer: str = "") -> str:
55
+ """Assemble a collapsed repr, capping how many differing parts are spelled out.
56
+
57
+ Collapsing only removes what matched, so a value where nearly everything differs still prints in
58
+ full. The cap is what keeps that case from becoming a wall of text on one line.
59
+ """
60
+ hidden = len(parts) - 5
61
+ if hidden > 0:
62
+ parts = [*parts[:5], f"... and {hidden} more"]
63
+ prefix = ".." if elided and not parts else ".., " if elided else ""
64
+ return f"{opener}{prefix}{', '.join(parts)}{closer}"
65
+
66
+
67
+ def _elided_text_repr(text: str, counterpart: str) -> str:
68
+ """Collapse lines equal to their counterpart into ``..`` so only the changed ones are printed.
69
+
70
+ Mirrors `_elided_seq_repr` for multi-line values: a one-line change in a long block should not put
71
+ the whole block into the message twice.
72
+ """
73
+ # the cost of a multi-line value is vertical: every line takes a terminal row, and the message
74
+ # prints the value twice. Character budgets miss that, so this one counts rows.
75
+ if len(text.splitlines()) <= 3:
76
+ return text
77
+ other_lines = counterpart.splitlines()
78
+ parts = []
79
+ elided = False
80
+ for index, line in enumerate(text.splitlines()):
81
+ if index < len(other_lines) and line == other_lines[index]:
82
+ elided = True
83
+ continue
84
+ parts.append(f"line {index + 1}: {line}")
85
+ return _joined_parts(parts, elided=elided)
86
+
87
+
88
+ def _elided_seq_repr(seq, counterpart) -> str:
89
+ """Collapse elements equal to their counterpart into ``..`` so only the differing ones are printed.
90
+
91
+ A one-element change in a forty-element list reads as ``[.., 999]`` instead of dumping the list
92
+ twice into a message the reader then has to diff by eye.
93
+ """
94
+ # past 20 elements the rendering is over budget by construction (one char each plus separators), so
95
+ # the value is never rendered just to be measured: on the failure path that render is the whole value
96
+ if len(seq) <= 20:
97
+ rendered = _safe_repr(seq)
98
+ if len(rendered) <= 60:
99
+ # short enough to read whole: collapsing it would hide context to save a few characters, and
100
+ # on a two-element list the ".." form is actually the longer of the two
101
+ return rendered
102
+ parts = []
103
+ elided = False
104
+ for index, value in enumerate(seq):
105
+ if index < len(counterpart) and not _guarded_not_equal(value, counterpart[index]):
106
+ elided = True
107
+ continue
108
+ parts.append(_safe_repr(value))
109
+ opener, closer = ("(", ")") if isinstance(seq, tuple) else ("[", "]")
110
+ return _joined_parts(parts, elided=elided, opener=opener, closer=closer)
111
+
112
+
23
113
  class HelpersMixin(_MixinBase):
24
114
  """Helpers mixin. For internal use only."""
25
115
 
@@ -201,24 +291,20 @@ class HelpersMixin(_MixinBase):
201
291
  continue
202
292
  if decision == "leaf":
203
293
  return True
294
+ nested_ignore = (
295
+ [entry[1:] for entry in ignores if type(entry) is tuple and entry[0] == key] if ignore else None
296
+ )
297
+ nested_include = (
298
+ [entry[1:] for entry in self._dict_ignore(include) if type(entry) is tuple and entry[0] == key]
299
+ if include
300
+ else None
301
+ )
204
302
  if self._is_dict_like(val[key], check_values=False) and self._is_dict_like(other[key], check_values=False):
205
- subdicts_not_equal = self._dict_not_equal(
206
- val[key],
207
- other[key],
208
- ignore=[entry[1:] for entry in ignores if type(entry) is tuple and entry[0] == key]
209
- if ignore
210
- else None,
211
- include=[
212
- entry[1:] for entry in self._dict_ignore(include) if type(entry) is tuple and entry[0] == key
213
- ]
214
- if include
215
- else None,
216
- config=config,
217
- _seen=_seen,
218
- )
219
- if subdicts_not_equal:
303
+ if self._dict_not_equal(
304
+ val[key], other[key], ignore=nested_ignore, include=nested_include, config=config, _seen=_seen
305
+ ):
220
306
  return True
221
- elif _guarded_not_equal(val[key], other[key]):
307
+ elif _values_not_equal(val[key], other[key], config):
222
308
  return True
223
309
  return False
224
310
 
@@ -301,17 +387,7 @@ class HelpersMixin(_MixinBase):
301
387
  else:
302
388
  value_repr = _safe_repr(value)
303
389
  parts.append(f"{_safe_repr(key)}: {value_repr}")
304
- out = ", ".join(parts)
305
- ellip_prefix = ".." if ellip and not parts else ".., " if ellip else ""
306
- return f"{{{ellip_prefix}{out}}}"
307
-
308
- def _both_list_like(left, right):
309
- return (
310
- isinstance(left, (list, tuple))
311
- and isinstance(right, (list, tuple))
312
- and not is_namedtuple(left)
313
- and not is_namedtuple(right)
314
- )
390
+ return _joined_parts(parts, elided=ellip, opener="{", closer="}")
315
391
 
316
392
  def _list_repr(seq, counterpart, _seen):
317
393
  """List counterpart of ``_dict_repr``: collapse equal elements to ``..`` and drill only into
@@ -342,8 +418,7 @@ class HelpersMixin(_MixinBase):
342
418
  else:
343
419
  parts.append(_safe_repr(value))
344
420
  opener, closer = ("(", ")") if isinstance(seq, tuple) else ("[", "]") # keep tuples looking like tuples
345
- ellip_prefix = ".." if ellip and not parts else ".., " if ellip else ""
346
- return f"{opener}{ellip_prefix}{', '.join(parts)}{closer}"
421
+ return _joined_parts(parts, elided=ellip, opener=opener, closer=closer)
347
422
 
348
423
  ignore_err = include_err = ""
349
424
  if ignore:
@@ -33,21 +33,22 @@ def _ensure_jsonschema():
33
33
  return jsonschema
34
34
 
35
35
 
36
- def _openapi_nullable_to_null(node: Any) -> Any:
36
+ def _openapi_nullable_to_null(node: Any, keyword: str = "nullable") -> Any:
37
37
  """Rewrite OpenAPI 3.0 ``nullable: true`` into standard JSON Schema (jsonschema ignores it).
38
38
 
39
39
  For the common scalar-typed field, ``"null"`` is added to ``type`` (and to ``enum`` if present),
40
40
  which keeps per-keyword error paths precise - a bad ``format``/``type`` on a nullable field still
41
41
  reports ``format``/``type``, not a vague union. Only a nullable schema with no scalar ``type``
42
- (nullable beside ``$ref``/``oneOf``) falls back to an ``anyOf`` null union.
42
+ (nullable beside ``$ref``/``oneOf``) falls back to an ``anyOf`` null union. Swagger 2.0 spells the
43
+ same idea ``x-nullable``, so it is handled by passing ``keyword="x-nullable"``.
43
44
  """
44
45
  if isinstance(node, dict):
45
- rewritten = {key: _openapi_nullable_to_null(value) for key, value in node.items()}
46
- nullable = rewritten.get("nullable")
46
+ rewritten = {key: _openapi_nullable_to_null(value, keyword) for key, value in node.items()}
47
+ nullable = rewritten.get(keyword)
47
48
  if not isinstance(nullable, bool):
48
- # a non-boolean "nullable" is a property literally named "nullable", not the OpenAPI keyword
49
+ # a non-boolean value is a property literally named that, not the OpenAPI nullable keyword
49
50
  return rewritten
50
- del rewritten["nullable"]
51
+ del rewritten[keyword]
51
52
  if not nullable: # nullable: false - drop the keyword, add no null
52
53
  return rewritten
53
54
  node_type = rewritten.get("type")
@@ -59,7 +60,7 @@ def _openapi_nullable_to_null(node: Any) -> Any:
59
60
  return rewritten
60
61
  return {"anyOf": [rewritten, {"type": "null"}]}
61
62
  if isinstance(node, list):
62
- return [_openapi_nullable_to_null(item) for item in node]
63
+ return [_openapi_nullable_to_null(item, keyword) for item in node]
63
64
  return node
64
65
 
65
66
 
@@ -98,7 +99,8 @@ def _openapi_resolve(spec: dict[str, Any], path: str, method: str, status: str |
98
99
  """
99
100
  method_key = method.lower()
100
101
  try:
101
- responses = spec["paths"][path][method_key]["responses"]
102
+ operation = spec["paths"][path][method_key]
103
+ responses = operation["responses"]
102
104
  except (KeyError, TypeError):
103
105
  raise ValueError(f"OpenAPI spec has no operation <{method.upper()} {path}>.") from None
104
106
  if status is not None:
@@ -116,10 +118,25 @@ def _openapi_resolve(spec: dict[str, Any], path: str, method: str, status: str |
116
118
  response, response_segments = _resolve_local_ref(spec, response["$ref"])
117
119
  if response is None:
118
120
  raise ValueError(f"Response <{status_key}> of <{method.upper()} {path}> has an unresolvable $ref.")
119
- content = response.get("content", {}) if isinstance(response, dict) else {}
120
- if content_type not in content or "schema" not in content[content_type]:
121
- raise ValueError(f"Response <{status_key}> of <{method.upper()} {path}> declares no <{content_type}> schema.")
122
- segments = [*response_segments, "content", content_type, "schema"]
121
+ if str(spec.get("swagger", "")).startswith("2"):
122
+ # Swagger 2.0 puts the response-body schema directly under the response, with no content-type
123
+ # layer, and lists the media types in `produces` instead. Check that list so a content_type the
124
+ # operation does not produce is rejected here, exactly as the 3.x content lookup rejects it.
125
+ produces = operation.get("produces") or spec.get("produces")
126
+ if produces and content_type not in produces:
127
+ raise ValueError(
128
+ f"Response <{status_key}> of <{method.upper()} {path}> declares no <{content_type}> schema."
129
+ )
130
+ if not (isinstance(response, dict) and "schema" in response):
131
+ raise ValueError(f"Response <{status_key}> of <{method.upper()} {path}> declares no schema.")
132
+ segments = [*response_segments, "schema"]
133
+ else:
134
+ content = response.get("content", {}) if isinstance(response, dict) else {}
135
+ if content_type not in content or "schema" not in content[content_type]:
136
+ raise ValueError(
137
+ f"Response <{status_key}> of <{method.upper()} {path}> declares no <{content_type}> schema."
138
+ )
139
+ segments = [*response_segments, "content", content_type, "schema"]
123
140
  pointer = "#/" + "/".join(segment.replace("~", "~0").replace("/", "~1") for segment in segments)
124
141
  return status_key, pointer
125
142
 
@@ -287,7 +304,8 @@ class JsonMixin(_MixinBase):
287
304
  ``method``/``path`` operation in ``spec``. This checks only the response body of that one
288
305
  operation - not request bodies, parameters, headers, or the spec as a whole.
289
306
 
290
- Both OpenAPI 3.0 (its ``nullable`` keyword is honoured) and 3.1 are supported. ``$ref``,
307
+ OpenAPI 3.0 (its ``nullable`` keyword is honoured), 3.1, and Swagger 2.0 (schema declared directly
308
+ on the response, its ``x-nullable`` extension honoured) are all supported. ``$ref``,
291
309
  ``oneOf``/``allOf``/``anyOf``, ``enum``, and ``format`` all validate with full JSON-Schema
292
310
  semantics, and every violation is reported with its JSON path.
293
311
 
@@ -297,7 +315,9 @@ class JsonMixin(_MixinBase):
297
315
  method: the HTTP method, e.g. ``"get"`` (case-insensitive).
298
316
  status: response status to validate against; defaults to ``200``, then ``201``, then
299
317
  ``default``.
300
- content_type: response content type; defaults to ``"application/json"``.
318
+ content_type: response content type; defaults to ``"application/json"``. Swagger 2.0 has no
319
+ content-type layer, so it is checked against the operation's ``produces`` list instead
320
+ (and skipped when the spec declares none).
301
321
 
302
322
  Examples:
303
323
  Usage:
@@ -318,8 +338,12 @@ class JsonMixin(_MixinBase):
318
338
 
319
339
  spec = _stringify_keys(spec) # YAML may parse numeric-looking keys (e.g. status 200) as ints
320
340
  is_openapi_31 = str(spec.get("openapi", "")).startswith("3.1")
341
+ is_swagger_2 = str(spec.get("swagger", "")).startswith("2")
321
342
  status_key, pointer = _openapi_resolve(spec, path, method, status, content_type)
322
- document = spec if is_openapi_31 else _openapi_nullable_to_null(spec)
343
+ if is_openapi_31:
344
+ document = spec # 3.1 is JSON Schema 2020-12 already, no nullable rewrite
345
+ else:
346
+ document = _openapi_nullable_to_null(spec, "x-nullable" if is_swagger_2 else "nullable")
323
347
  specification = DRAFT202012 if is_openapi_31 else DRAFT4
324
348
  validator_cls = jsonschema_mod.Draft202012Validator if is_openapi_31 else jsonschema_mod.Draft4Validator
325
349
 
@@ -80,3 +80,44 @@ def test_contains_exactly_large(benchmark):
80
80
  # the contains engine: exact membership over a sizeable list
81
81
  items = list(range(300))
82
82
  benchmark(lambda: assert_that(items).contains_exactly(*items))
83
+
84
+
85
+ def test_string_diff_with_carets(benchmark):
86
+ # difflib.ndiff costs ~175x a plain pair of prints, and is guarded by a length cutoff. Rendering is
87
+ # lazy, so str() has to be called or the carets never run and the benchmark guards nothing.
88
+ left = "the quick brown fox jumps over the lazy dog " * 3
89
+ right = left.replace("brown", "brawn", 1)
90
+
91
+ def run():
92
+ try:
93
+ assert_that(left).is_equal_to(right)
94
+ except AssertionFailure as failure:
95
+ return str(failure)
96
+ return None
97
+
98
+ benchmark(run)
99
+
100
+
101
+ def test_wide_dict_diff(benchmark):
102
+ # _dict_repr over a wide mapping where nearly every key differs, so there is little to collapse and
103
+ # every entry is rendered. (The cap on how many are named is free at runtime, so nothing guards it.)
104
+ left = {f"k{i}": i for i in range(200)}
105
+ right = {f"k{i}": -i for i in range(200)}
106
+
107
+ def run():
108
+ with contextlib.suppress(AssertionFailure):
109
+ assert_that(left).is_equal_to(right)
110
+
111
+ benchmark(run)
112
+
113
+
114
+ def test_extracting_large(benchmark):
115
+ # the success path of the collection pipeline, the common shape of an API assertion
116
+ records = _records(300)
117
+ benchmark(lambda: assert_that(records).extracting("id", "name").is_not_empty())
118
+
119
+
120
+ def test_contains_only_large(benchmark):
121
+ # membership both ways over a sizeable list, the multiset engine rather than the ordered one
122
+ items = list(range(300))
123
+ benchmark(lambda: assert_that(items).contains_only(*items))