assertpy2 2.16.0__tar.gz → 2.18.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.18.0}/.github/workflows/ci.yml +2 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/workflows/codeql.yml +2 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/workflows/codspeed.yml +1 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/workflows/docs.yml +1 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/workflows/mutation.yml +1 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/workflows/publish.yml +11 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/workflows/scorecard.yml +1 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/PKG-INFO +3 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/README.md +2 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/__init__.py +2 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/_compare.py +5 -3
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/_contract.py +10 -5
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/_diff.py +5 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/_mixin_base.py +1 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/_typing.py +13 -9
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_matcher_impls.py +12 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_satisfies.py +78 -14
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_snapshot_codec.py +16 -8
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/assertpy.py +65 -20
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/async_assertions.py +31 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/base.py +11 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/collection.py +23 -10
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/contains.py +71 -23
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/date.py +9 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/errors.py +11 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/extracting.py +58 -11
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/file.py +3 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/helpers.py +104 -29
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/json_mixin.py +39 -15
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/pytest_plugin.py +86 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/snapshot.py +43 -9
- {assertpy2-2.16.0 → assertpy2-2.18.0}/benchmarks/test_perf.py +41 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/getting-started/comparison.md +32 -21
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/getting-started/migration.md +21 -10
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/guides/assertions.md +67 -4
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/guides/data.md +5 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/guides/errors.md +116 -32
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/guides/testing.md +135 -67
- {assertpy2-2.16.0 → assertpy2-2.18.0}/pyproject.toml +2 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_async.py +89 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_bytes.py +24 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_collection.py +33 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_custom_dict.py +22 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_datetime.py +31 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_dict.py +3 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_dict_compare.py +10 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_equals.py +97 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_extracting.py +82 -5
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_inline_snapshot.py +16 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_list.py +60 -8
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_matchers.py +16 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_namedtuple.py +3 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_openapi_contract.py +85 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_property_based.py +340 -6
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_pytest_plugin.py +164 -4
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_recursive_compare_config.py +154 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_rich_diff.py +54 -2
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_satisfy.py +22 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_snapshots.py +90 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_soft.py +39 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_structural.py +41 -0
- assertpy2-2.18.0/tests/test_vacuity_contract.py +173 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_warn.py +24 -1
- {assertpy2-2.16.0 → assertpy2-2.18.0}/uv.lock +23 -23
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.codecov.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.gitattributes +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/.gitignore +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/LICENSE +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/SECURITY.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/_compat.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_engine/_introspection.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/_inline.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/matchers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/string.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/concepts/type-safety.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/extending/custom-assertions.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/extending/integrations.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/guides/fluent.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/guides/matchers.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/index.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/logo.svg +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/recipes.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/async.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/core.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/errors.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/files.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/json.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/mkdocs.yml +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/mkdocs_hooks.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/conftest.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_attrs_support.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_bool.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_callable.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_class.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_core.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_dataframe.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_description.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_docs_examples.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_dynamic.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_errors.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_fail.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_file.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_in.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_inline_record.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_json.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_none.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_not.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_property_attrs.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_protocol_parity.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_readme.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_string.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_sync_eventually.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_type.py +0 -0
- {assertpy2-2.16.0 → assertpy2-2.18.0}/tests/test_typing.py +0 -0
|
@@ -36,7 +36,7 @@ jobs:
|
|
|
36
36
|
persist-credentials: false
|
|
37
37
|
|
|
38
38
|
- name: Install uv
|
|
39
|
-
uses: astral-sh/setup-uv@
|
|
39
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
40
40
|
|
|
41
41
|
- name: Set up Python ${{ matrix.python-version }}
|
|
42
42
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
@@ -85,7 +85,7 @@ jobs:
|
|
|
85
85
|
persist-credentials: false
|
|
86
86
|
|
|
87
87
|
- name: Install uv
|
|
88
|
-
uses: astral-sh/setup-uv@
|
|
88
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
89
89
|
|
|
90
90
|
- name: Set up Python
|
|
91
91
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
@@ -20,7 +20,7 @@ jobs:
|
|
|
20
20
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
21
21
|
with:
|
|
22
22
|
persist-credentials: false
|
|
23
|
-
- uses: github/codeql-action/init@
|
|
23
|
+
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
|
24
24
|
with:
|
|
25
25
|
languages: python
|
|
26
|
-
- uses: github/codeql-action/analyze@
|
|
26
|
+
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
|
@@ -31,7 +31,7 @@ jobs:
|
|
|
31
31
|
persist-credentials: false
|
|
32
32
|
|
|
33
33
|
- name: Install uv
|
|
34
|
-
uses: astral-sh/setup-uv@
|
|
34
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
35
35
|
|
|
36
36
|
- name: Run benchmarks under CodSpeed
|
|
37
37
|
uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
|
|
@@ -22,7 +22,7 @@ jobs:
|
|
|
22
22
|
persist-credentials: false
|
|
23
23
|
|
|
24
24
|
- name: Install uv
|
|
25
|
-
uses: astral-sh/setup-uv@
|
|
25
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
26
26
|
|
|
27
27
|
- name: Set up Python
|
|
28
28
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
@@ -26,7 +26,7 @@ jobs:
|
|
|
26
26
|
persist-credentials: false
|
|
27
27
|
|
|
28
28
|
- name: Install uv
|
|
29
|
-
uses: astral-sh/setup-uv@
|
|
29
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
30
30
|
|
|
31
31
|
- name: Set up Python
|
|
32
32
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
persist-credentials: false
|
|
22
22
|
|
|
23
23
|
- name: Install uv
|
|
24
|
-
uses: astral-sh/setup-uv@
|
|
24
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
25
25
|
with:
|
|
26
26
|
enable-cache: false
|
|
27
27
|
|
|
@@ -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
|
|
|
@@ -29,6 +29,6 @@ jobs:
|
|
|
29
29
|
name: SARIF file
|
|
30
30
|
path: results.sarif
|
|
31
31
|
retention-days: 5
|
|
32
|
-
- uses: github/codeql-action/upload-sarif@
|
|
32
|
+
- uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
|
33
33
|
with:
|
|
34
34
|
sarif_file: results.sarif
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.18.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
|
|
@@ -175,6 +175,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
|
175
175
|
- [**Composable matchers**](https://solganis.github.io/assertpy2/guides/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`, usable in plain `assert ==`.
|
|
176
176
|
- [**Structural matching**](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching): `matches_structure()` for declarative dict/API-response validation.
|
|
177
177
|
- [**Recursive field assertions**](https://solganis.github.io/assertpy2/guides/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph.
|
|
178
|
+
- [**Vacuous-assertion guard**](https://solganis.github.io/assertpy2/guides/assertions/#assertions-that-checked-nothing): `--assertpy2-vacuous` warns when a universal assertion passes over an empty collection, having checked nothing.
|
|
178
179
|
- [**Universal negation**](https://solganis.github.io/assertpy2/guides/fluent/#universal-negation): `.not_` inverts any assertion, no dedicated `is_not_*` methods.
|
|
179
180
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/guides/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
180
181
|
- [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/guides/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
|
|
@@ -203,7 +204,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
|
203
204
|
- [**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
205
|
- [**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
206
|
- [**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.
|
|
207
|
+
- [**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
208
|
|
|
208
209
|
**Extensibility**
|
|
209
210
|
|
|
@@ -123,6 +123,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
|
123
123
|
- [**Composable matchers**](https://solganis.github.io/assertpy2/guides/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`, usable in plain `assert ==`.
|
|
124
124
|
- [**Structural matching**](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching): `matches_structure()` for declarative dict/API-response validation.
|
|
125
125
|
- [**Recursive field assertions**](https://solganis.github.io/assertpy2/guides/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph.
|
|
126
|
+
- [**Vacuous-assertion guard**](https://solganis.github.io/assertpy2/guides/assertions/#assertions-that-checked-nothing): `--assertpy2-vacuous` warns when a universal assertion passes over an empty collection, having checked nothing.
|
|
126
127
|
- [**Universal negation**](https://solganis.github.io/assertpy2/guides/fluent/#universal-negation): `.not_` inverts any assertion, no dedicated `is_not_*` methods.
|
|
127
128
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/guides/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
128
129
|
- [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/guides/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
|
|
@@ -151,7 +152,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
|
151
152
|
- [**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
153
|
- [**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
154
|
- [**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.
|
|
155
|
+
- [**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
156
|
|
|
156
157
|
**Extensibility**
|
|
157
158
|
|
|
@@ -14,7 +14,7 @@ from .assertpy import (
|
|
|
14
14
|
soft_fail,
|
|
15
15
|
)
|
|
16
16
|
from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
|
|
17
|
-
from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace
|
|
17
|
+
from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace, VacuousAssertionWarning
|
|
18
18
|
from .file import contents_of
|
|
19
19
|
from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
|
|
20
20
|
from .snapshot import SnapshotCreatedWarning, SnapshotUpdatedWarning, register_snapshot_serializer
|
|
@@ -32,6 +32,7 @@ __all__ = [
|
|
|
32
32
|
"SnapshotUpdatedWarning",
|
|
33
33
|
"SoftAssertionCollector",
|
|
34
34
|
"SyncAssertionBuilder",
|
|
35
|
+
"VacuousAssertionWarning",
|
|
35
36
|
"WarningLoggingAdapter",
|
|
36
37
|
"__version__",
|
|
37
38
|
"add_extension",
|
|
@@ -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
|
|
|
@@ -86,7 +86,7 @@ def contract_drift(payload: object, model: Any, path: str = "") -> list[str]:
|
|
|
86
86
|
return drift
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def shape(value: object) -> object:
|
|
89
|
+
def shape(value: object, _seen: frozenset[int] = frozenset()) -> object:
|
|
90
90
|
"""The structural shape of a value: paths and type *categories*, never values.
|
|
91
91
|
|
|
92
92
|
Numbers collapse to one category (so ``5`` and ``5.0`` do not read as drift) and ``None`` becomes
|
|
@@ -101,10 +101,15 @@ def shape(value: object) -> object:
|
|
|
101
101
|
return "number"
|
|
102
102
|
if isinstance(value, str):
|
|
103
103
|
return "str"
|
|
104
|
-
if isinstance(value, dict):
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
if isinstance(value, (dict, list, tuple)):
|
|
105
|
+
if id(value) in _seen:
|
|
106
|
+
# a self-referential graph (an ORM backref, a tree with parent pointers) would otherwise
|
|
107
|
+
# recurse until the interpreter gives up; every sibling walker marks the cycle instead
|
|
108
|
+
return "<circular ref>"
|
|
109
|
+
nested = _seen | {id(value)}
|
|
110
|
+
if isinstance(value, dict):
|
|
111
|
+
return {key: shape(item, nested) for key, item in value.items()}
|
|
112
|
+
element_shapes = [shape(item, nested) for item in value]
|
|
108
113
|
return [reduce(_merge, element_shapes)] if element_shapes else []
|
|
109
114
|
return type(value).__name__
|
|
110
115
|
|
|
@@ -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()
|
|
@@ -38,6 +38,7 @@ class _MixinBase:
|
|
|
38
38
|
kind: str | None = ...,
|
|
39
39
|
expected: type[BaseException] | None = ...,
|
|
40
40
|
logger: logging.LoggerAdapter | None = ...,
|
|
41
|
+
origin: str | None = ...,
|
|
41
42
|
) -> Self: ...
|
|
42
43
|
|
|
43
44
|
# WarningMixin (terminals dispatched from ExceptionMixin.when_called_with)
|
|
@@ -54,8 +54,8 @@ if TYPE_CHECKING:
|
|
|
54
54
|
def is_callable(self) -> Self: ...
|
|
55
55
|
def is_not_callable(self) -> Self: ...
|
|
56
56
|
def satisfies(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
57
|
-
def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
58
|
-
def has_no_none_fields(self) -> Self: ...
|
|
57
|
+
def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
|
|
58
|
+
def has_no_none_fields(self, *, allow_empty: bool = ...) -> Self: ...
|
|
59
59
|
# ContainsMixin - universal
|
|
60
60
|
def is_in(self, *items: object) -> Self: ...
|
|
61
61
|
def is_not_in(self, *items: object) -> Self: ...
|
|
@@ -135,7 +135,7 @@ if TYPE_CHECKING:
|
|
|
135
135
|
def has_size_between(self, low: int, high: int) -> Self: ...
|
|
136
136
|
def is_empty(self) -> Self: ...
|
|
137
137
|
def is_not_empty(self) -> Self: ...
|
|
138
|
-
def is_subset_of(self, *supersets: object) -> Self: ...
|
|
138
|
+
def is_subset_of(self, *supersets: object, allow_empty: bool = ...) -> Self: ...
|
|
139
139
|
# StringMixin - regex
|
|
140
140
|
def extracting_group(self, pattern: str, group: int | str = ...) -> Self: ...
|
|
141
141
|
def matches_with_groups(self, pattern: str) -> Self: ...
|
|
@@ -203,18 +203,22 @@ if TYPE_CHECKING:
|
|
|
203
203
|
# CollectionMixin
|
|
204
204
|
def is_iterable(self) -> Self: ...
|
|
205
205
|
def is_not_iterable(self) -> Self: ...
|
|
206
|
-
def is_subset_of(self, *supersets: object) -> Self: ...
|
|
207
|
-
def is_sorted(
|
|
206
|
+
def is_subset_of(self, *supersets: object, allow_empty: bool = ...) -> Self: ...
|
|
207
|
+
def is_sorted(
|
|
208
|
+
self, key: Callable[..., object] = ..., reverse: bool = ..., *, allow_empty: bool = ...
|
|
209
|
+
) -> Self: ...
|
|
208
210
|
# ExtractingMixin - extracted values have a dynamic type, so the element type is dropped
|
|
209
211
|
def extracting(self, *names: object, **kwargs: object) -> _IterableAssertion[Any]: ...
|
|
210
212
|
# BaseMixin
|
|
211
|
-
def each(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
213
|
+
def each(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
|
|
212
214
|
def any_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
213
|
-
def all_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
215
|
+
def all_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
|
|
214
216
|
def none_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
215
217
|
def satisfies_exactly(self, *matchers: Matcher | Callable[..., bool]) -> Self: ...
|
|
216
218
|
def satisfies_exactly_in_any_order(self, *matchers: Matcher | Callable[..., bool]) -> Self: ...
|
|
217
|
-
def zip_satisfies(
|
|
219
|
+
def zip_satisfies(
|
|
220
|
+
self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = ...
|
|
221
|
+
) -> Self: ...
|
|
218
222
|
def contains_only_once(self, *items: object) -> Self: ...
|
|
219
223
|
def has_same_size_as(self, other: object) -> Self: ...
|
|
220
224
|
def has_size_greater_than(self, size: int) -> Self: ...
|
|
@@ -267,7 +271,7 @@ if TYPE_CHECKING:
|
|
|
267
271
|
def is_not_empty(self) -> Self: ...
|
|
268
272
|
# BaseMixin
|
|
269
273
|
def matches_structure(self, spec: dict) -> Self: ...
|
|
270
|
-
def each(self, matcher: Matcher | Callable[..., bool]) -> Self: ...
|
|
274
|
+
def each(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
|
|
271
275
|
# ExtractingMixin - extracted values have a dynamic type, so the key/value types are dropped
|
|
272
276
|
def extracting(self, *names: object, **kwargs: object) -> _DictAssertion[Any, Any]: ...
|
|
273
277
|
# JsonMixin - navigation yields a dynamically-typed value, so the key/value types are dropped
|
|
@@ -48,6 +48,16 @@ def _is_matcher(obj: object) -> TypeIs[Matcher]:
|
|
|
48
48
|
return isinstance(obj, Matcher)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def _require_matcher(operand: object, operator: str) -> None:
|
|
52
|
+
"""Reject a non-matcher operand where the combinator is built, not where it is later applied.
|
|
53
|
+
|
|
54
|
+
Combining silently would hand back a matcher that raises ``AttributeError: 'int' object has no
|
|
55
|
+
attribute 'matches'`` at assertion time, far from the line that built it.
|
|
56
|
+
"""
|
|
57
|
+
if not _is_matcher(operand):
|
|
58
|
+
raise TypeError(f"cannot combine a Matcher with <{type(operand).__name__}> using '{operator}'")
|
|
59
|
+
|
|
60
|
+
|
|
51
61
|
class BaseMatcher:
|
|
52
62
|
"""Abstract base for all matchers with operator support."""
|
|
53
63
|
|
|
@@ -61,11 +71,13 @@ class BaseMatcher:
|
|
|
61
71
|
return f"was <{value}>"
|
|
62
72
|
|
|
63
73
|
def __and__(self, other: Matcher) -> AllOfMatcher:
|
|
74
|
+
_require_matcher(other, "&")
|
|
64
75
|
left = list(self.matchers) if isinstance(self, AllOfMatcher) else [self]
|
|
65
76
|
right = list(other.matchers) if isinstance(other, AllOfMatcher) else [other]
|
|
66
77
|
return AllOfMatcher(*left, *right)
|
|
67
78
|
|
|
68
79
|
def __or__(self, other: Matcher) -> AnyOfMatcher:
|
|
80
|
+
_require_matcher(other, "|")
|
|
69
81
|
left = list(self.matchers) if isinstance(self, AnyOfMatcher) else [self]
|
|
70
82
|
right = list(other.matchers) if isinstance(other, AnyOfMatcher) else [other]
|
|
71
83
|
return AnyOfMatcher(*left, *right)
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import collections.abc
|
|
4
|
+
import os
|
|
5
|
+
import warnings
|
|
4
6
|
from typing import TYPE_CHECKING, Any
|
|
5
7
|
|
|
6
8
|
from ._engine._diff import _walk_leaves
|
|
7
9
|
from ._engine._introspection import is_attrs_instance, is_model_dump_object
|
|
8
10
|
from ._engine._mixin_base import _MixinBase
|
|
9
|
-
from .errors import DiffEntry, DiffResult
|
|
11
|
+
from .errors import DiffEntry, DiffResult, VacuousAssertionWarning
|
|
10
12
|
from .matchers import IsNotNoneMatcher, Matcher, StructureMatcher, _apply_matcher, _describe_matcher, _is_matcher
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
@@ -47,10 +49,53 @@ def _max_bipartite_assignment(satisfied: list[list[bool]]) -> list[int | None]:
|
|
|
47
49
|
return assignment
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _env_enabled() -> bool:
|
|
56
|
+
"""Read the environment switch, at import and from the pytest plugin rather than per assertion.
|
|
57
|
+
|
|
58
|
+
The switch cannot change within a process, and the lookup with its two string operations costs
|
|
59
|
+
around 300ns: more than the emptiness check it decides to skip, on every quantifier call.
|
|
60
|
+
"""
|
|
61
|
+
return os.environ.get("ASSERTPY2_VACUOUS", "").strip().lower() in _TRUTHY
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_VACUOUS_GUARD: bool = _env_enabled()
|
|
65
|
+
"""Whether a universal assertion over an empty value warns.
|
|
66
|
+
|
|
67
|
+
Off by default: a suite running ``filterwarnings = ["error"]`` would otherwise fail on upgrade, and a
|
|
68
|
+
property-based test generates empty collections as a matter of course. Turn it on with the pytest
|
|
69
|
+
``--assertpy2-vacuous`` flag or the ``ASSERTPY2_VACUOUS`` environment variable for other runners.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _warn_if_vacuous(name: str, value: object, allow_empty: bool) -> None:
|
|
74
|
+
"""Warn when a universal assertion is about to pass because there is nothing to quantify over.
|
|
75
|
+
|
|
76
|
+
Called from each public entry point rather than from the shared implementation, so the name in the
|
|
77
|
+
message is the one the caller used and ``stacklevel`` lands on their line rather than ours.
|
|
78
|
+
"""
|
|
79
|
+
if allow_empty or not _VACUOUS_GUARD:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
empty = len(value) == 0 # ty: ignore[invalid-argument-type] # guarded by the except below
|
|
83
|
+
except Exception: # a diagnostic must never crash the assertion it is watching
|
|
84
|
+
# no usable len(): an unsized value (a one-shot iterable) or a broken __len__. Either way,
|
|
85
|
+
# stay out of the way rather than draining the value or surfacing its error as our own.
|
|
86
|
+
return
|
|
87
|
+
if empty:
|
|
88
|
+
warnings.warn(
|
|
89
|
+
f"{name}() passed over an empty value, so nothing was checked. Pass allow_empty=True if that is intended.",
|
|
90
|
+
VacuousAssertionWarning,
|
|
91
|
+
stacklevel=3,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
50
95
|
class SatisfiesMixin(_MixinBase):
|
|
51
96
|
"""Predicate and matcher-application assertions: satisfies / each / *_satisfy / matches_structure."""
|
|
52
97
|
|
|
53
|
-
def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self:
|
|
98
|
+
def all_fields_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = False) -> Self:
|
|
54
99
|
"""Asserts that every scalar leaf in val's object graph satisfies the given matcher.
|
|
55
100
|
|
|
56
101
|
Walks val recursively (mappings, dataclasses, namedtuples, Pydantic models, lists, tuples) and
|
|
@@ -75,6 +120,7 @@ class SatisfiesMixin(_MixinBase):
|
|
|
75
120
|
AssertionError: if any leaf does **not** satisfy the matcher
|
|
76
121
|
TypeError: if matcher is neither a Matcher nor callable
|
|
77
122
|
"""
|
|
123
|
+
_warn_if_vacuous("all_fields_satisfy", self.val, allow_empty)
|
|
78
124
|
if not _is_matcher(matcher) and not callable(matcher):
|
|
79
125
|
raise TypeError("given arg must be a Matcher or callable")
|
|
80
126
|
description = _describe_matcher(matcher)
|
|
@@ -94,7 +140,7 @@ class SatisfiesMixin(_MixinBase):
|
|
|
94
140
|
)
|
|
95
141
|
return self
|
|
96
142
|
|
|
97
|
-
def has_no_none_fields(self) -> Self:
|
|
143
|
+
def has_no_none_fields(self, *, allow_empty: bool = False) -> Self:
|
|
98
144
|
"""Asserts that no scalar leaf in val's object graph is ``None``.
|
|
99
145
|
|
|
100
146
|
Convenience wrapper over `all_fields_satisfy()` with a not-``None`` matcher; reports the path
|
|
@@ -111,7 +157,8 @@ class SatisfiesMixin(_MixinBase):
|
|
|
111
157
|
Raises:
|
|
112
158
|
AssertionError: if any leaf **is** ``None``
|
|
113
159
|
"""
|
|
114
|
-
|
|
160
|
+
_warn_if_vacuous("has_no_none_fields", self.val, allow_empty)
|
|
161
|
+
return self.all_fields_satisfy(IsNotNoneMatcher(), allow_empty=True)
|
|
115
162
|
|
|
116
163
|
def satisfies(self, matcher: Matcher | Callable[..., bool]) -> Self:
|
|
117
164
|
"""Asserts that val satisfies the given matcher.
|
|
@@ -159,7 +206,7 @@ class SatisfiesMixin(_MixinBase):
|
|
|
159
206
|
raise TypeError("given arg must be a Matcher or callable")
|
|
160
207
|
return self
|
|
161
208
|
|
|
162
|
-
def each(self, matcher: Matcher | Callable[..., bool]) -> Self:
|
|
209
|
+
def each(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = False) -> Self:
|
|
163
210
|
"""Asserts that every item in val satisfies the given matcher.
|
|
164
211
|
|
|
165
212
|
Args:
|
|
@@ -184,6 +231,7 @@ class SatisfiesMixin(_MixinBase):
|
|
|
184
231
|
Raises:
|
|
185
232
|
AssertionError: if any item does **not** satisfy the matcher
|
|
186
233
|
"""
|
|
234
|
+
_warn_if_vacuous("each", self.val, allow_empty)
|
|
187
235
|
if not isinstance(self.val, collections.abc.Iterable):
|
|
188
236
|
raise TypeError("val is not iterable")
|
|
189
237
|
if _is_matcher(matcher):
|
|
@@ -322,17 +370,29 @@ class SatisfiesMixin(_MixinBase):
|
|
|
322
370
|
"""
|
|
323
371
|
if not isinstance(self.val, collections.abc.Iterable):
|
|
324
372
|
raise TypeError("val is not iterable")
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
373
|
+
description = _describe_matcher(matcher)
|
|
374
|
+
if _is_matcher(matcher) or callable(matcher):
|
|
375
|
+
if not any(_apply_matcher(matcher, item) for item in self.val):
|
|
376
|
+
# "none did" alone leaves the reader to fetch the items themselves; the universal
|
|
377
|
+
# sibling already lists every one that failed
|
|
378
|
+
items = list(self.val)
|
|
379
|
+
return self.error(
|
|
380
|
+
f"Expected any item to satisfy {description}, but none of the {len(items)} did.",
|
|
381
|
+
actual=self.val,
|
|
382
|
+
expected=description,
|
|
383
|
+
diff=DiffResult(
|
|
384
|
+
kind="match",
|
|
385
|
+
entries=[
|
|
386
|
+
DiffEntry(path=f"[{index}]", actual=item, expected=description)
|
|
387
|
+
for index, item in enumerate(items[:5])
|
|
388
|
+
],
|
|
389
|
+
),
|
|
390
|
+
)
|
|
331
391
|
else:
|
|
332
392
|
raise TypeError("given arg must be a Matcher or callable")
|
|
333
393
|
return self
|
|
334
394
|
|
|
335
|
-
def all_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self:
|
|
395
|
+
def all_satisfy(self, matcher: Matcher | Callable[..., bool], *, allow_empty: bool = False) -> Self:
|
|
336
396
|
"""Asserts that all items in val satisfy the given matcher.
|
|
337
397
|
|
|
338
398
|
Semantic alias for [`each()`][assertpy2.base.BaseMixin.each].
|
|
@@ -354,7 +414,8 @@ class SatisfiesMixin(_MixinBase):
|
|
|
354
414
|
Raises:
|
|
355
415
|
AssertionError: if any item does **not** satisfy the matcher
|
|
356
416
|
"""
|
|
357
|
-
|
|
417
|
+
_warn_if_vacuous("all_satisfy", self.val, allow_empty)
|
|
418
|
+
return self.each(matcher, allow_empty=True)
|
|
358
419
|
|
|
359
420
|
def none_satisfy(self, matcher: Matcher | Callable[..., bool]) -> Self:
|
|
360
421
|
"""Asserts that no item in val satisfies the given matcher.
|
|
@@ -533,7 +594,9 @@ class SatisfiesMixin(_MixinBase):
|
|
|
533
594
|
)
|
|
534
595
|
return self
|
|
535
596
|
|
|
536
|
-
def zip_satisfies(
|
|
597
|
+
def zip_satisfies(
|
|
598
|
+
self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = False
|
|
599
|
+
) -> Self:
|
|
537
600
|
"""Asserts that each pair from zipping val with other satisfies the two-arg predicate.
|
|
538
601
|
|
|
539
602
|
Pairs the i-th item of val with the i-th item of ``other`` and checks ``predicate(a, b)``.
|
|
@@ -556,6 +619,7 @@ class SatisfiesMixin(_MixinBase):
|
|
|
556
619
|
AssertionError: if the lengths differ, or any pair does **not** satisfy the predicate
|
|
557
620
|
TypeError: if val or other is not iterable, or predicate is not callable
|
|
558
621
|
"""
|
|
622
|
+
_warn_if_vacuous("zip_satisfies", self.val, allow_empty)
|
|
559
623
|
if not callable(predicate):
|
|
560
624
|
raise TypeError("given predicate must be callable")
|
|
561
625
|
if not isinstance(self.val, collections.abc.Iterable):
|
|
@@ -31,19 +31,27 @@ _SERIALIZERS: list[_Serializer] = []
|
|
|
31
31
|
_RESERVED_MARKERS = frozenset({"__type__", "__data__", "__tag__", "__class__", "__module__"})
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def _prepare(value):
|
|
34
|
+
def _prepare(value, _seen: frozenset[int] = frozenset()):
|
|
35
35
|
"""Pre-process a value so ``json`` round-trips dicts that it otherwise mangles: dicts with a
|
|
36
36
|
non-string key (which ``json`` coerces to a string) and dicts that collide with the codec's markers
|
|
37
37
|
(which the decoder would misread). Both are wrapped in a ``{"__type__": "dict", "__data__": [[k,
|
|
38
38
|
v], ...]}`` envelope; a plain string-keyed dict without markers is left as an ordinary object so
|
|
39
39
|
existing snapshots stay byte-identical."""
|
|
40
|
-
if isinstance(value, dict):
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
if isinstance(value, (dict, list, tuple)):
|
|
41
|
+
if id(value) in _seen:
|
|
42
|
+
# json cannot represent a cycle at all, so the only question is how this fails: naming the
|
|
43
|
+
# cycle beats a thousand frames of RecursionError from an unguarded walk
|
|
44
|
+
raise ValueError("cannot snapshot a value that contains a circular reference")
|
|
45
|
+
nested = _seen | {id(value)}
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
if all(isinstance(key, str) for key in value) and not _RESERVED_MARKERS.intersection(value):
|
|
48
|
+
return {key: _prepare(item, nested) for key, item in value.items()}
|
|
49
|
+
items = sorted(value.items(), key=lambda pair: repr(pair[0])) # deterministic for mixed keys
|
|
50
|
+
return {
|
|
51
|
+
"__type__": "dict",
|
|
52
|
+
"__data__": [[_prepare(key, nested), _prepare(item, nested)] for key, item in items],
|
|
53
|
+
}
|
|
54
|
+
return [_prepare(item, nested) for item in value]
|
|
47
55
|
return value
|
|
48
56
|
|
|
49
57
|
|