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.
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/publish.yml +10 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/PKG-INFO +2 -2
- {assertpy2-2.16.0 → assertpy2-2.17.0}/README.md +1 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_compare.py +5 -3
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_diff.py +5 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/async_assertions.py +8 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/base.py +11 -2
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/contains.py +32 -19
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/errors.py +1 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/extracting.py +44 -7
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/helpers.py +104 -29
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/json_mixin.py +39 -15
- {assertpy2-2.16.0 → assertpy2-2.17.0}/benchmarks/test_perf.py +41 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/getting-started/migration.md +8 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/data.md +5 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/errors.md +35 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/testing.md +3 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/pyproject.toml +1 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_async.py +43 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_bytes.py +24 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_custom_dict.py +22 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dict.py +3 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dict_compare.py +10 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_equals.py +97 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_extracting.py +73 -3
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_list.py +3 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_namedtuple.py +3 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_openapi_contract.py +85 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_property_based.py +307 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare_config.py +154 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_rich_diff.py +54 -2
- assertpy2-2.17.0/tests/test_vacuity_contract.py +97 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/uv.lock +1 -1
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.codecov.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.gitattributes +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/codspeed.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/.gitignore +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/LICENSE +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/SECURITY.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_compat.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_contract.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_introspection.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_mixin_base.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_engine/_typing.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_inline.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_matcher_impls.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_satisfies.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/_snapshot_codec.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/collection.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/date.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/file.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/matchers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/string.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/concepts/type-safety.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/extending/custom-assertions.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/extending/integrations.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/getting-started/comparison.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/assertions.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/fluent.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/guides/matchers.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/index.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/logo.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/recipes.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/async.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/core.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/errors.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/files.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/json.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/mkdocs.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/mkdocs_hooks.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/conftest.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_attrs_support.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_bool.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_callable.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_class.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_collection.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_core.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dataframe.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_description.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_docs_examples.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_dynamic.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_errors.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_fail.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_file.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_in.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_inline_record.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_inline_snapshot.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_json.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_matchers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_none.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_not.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_property_attrs.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_protocol_parity.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_readme.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_satisfy.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_snapshots.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_soft.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_string.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_structural.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_sync_eventually.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_type.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.17.0}/tests/test_typing.py +0 -0
- {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:
|
|
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.
|
|
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.
|
|
95
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
190
|
+
try:
|
|
175
191
|
return _attr_value(item, name)
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
val[key],
|
|
207
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
49
|
+
# a non-boolean value is a property literally named that, not the OpenAPI nullable keyword
|
|
49
50
|
return rewritten
|
|
50
|
-
del rewritten[
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|