assertpy2 2.17.0__tar.gz → 2.19.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.17.0 → assertpy2-2.19.0}/.github/workflows/ci.yml +9 -6
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/workflows/codeql.yml +4 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/workflows/codspeed.yml +4 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/workflows/docs.yml +5 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/workflows/mutation.yml +3 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/workflows/publish.yml +5 -4
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/workflows/scorecard.yml +4 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/workflows/zizmor.yml +3 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/PKG-INFO +24 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/README.md +23 -1
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/__init__.py +9 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/_compare.py +94 -7
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/_contract.py +10 -5
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/_diff.py +250 -49
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/_mixin_base.py +2 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/_typing.py +24 -11
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_matcher_impls.py +62 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_satisfies.py +78 -14
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_snapshot_codec.py +16 -8
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/assertpy.py +104 -29
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/async_assertions.py +28 -1
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/base.py +49 -13
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/collection.py +23 -10
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/contains.py +39 -4
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/dataframe.py +5 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/date.py +9 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/errors.py +78 -24
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/extracting.py +14 -4
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/helpers.py +14 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/json_mixin.py +26 -7
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/matchers.py +15 -3
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/pytest_plugin.py +142 -4
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/snapshot.py +163 -12
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/string.py +4 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/benchmarks/test_perf.py +65 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/concepts/type-safety.md +26 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/extending/custom-assertions.md +23 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/getting-started/comparison.md +32 -21
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/getting-started/migration.md +18 -14
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/guides/assertions.md +150 -4
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/guides/errors.md +112 -40
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/guides/fluent.md +11 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/guides/testing.md +170 -69
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/snapshots.md +6 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/pyproject.toml +18 -5
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_async.py +91 -1
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_collection.py +33 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_dataframe.py +21 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_datetime.py +31 -0
- assertpy2-2.19.0/tests/test_docs_typing.py +77 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_equals.py +26 -0
- assertpy2-2.19.0/tests/test_exception_context.py +73 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_extracting.py +9 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_inline_snapshot.py +16 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_json.py +27 -1
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_list.py +57 -7
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_matchers.py +92 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_property_based.py +166 -6
- assertpy2-2.19.0/tests/test_protocol_parity.py +219 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_pytest_plugin.py +336 -4
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_recursive_compare_config.py +286 -2
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_rich_diff.py +252 -4
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_satisfy.py +22 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_snapshots.py +129 -1
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_soft.py +39 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_structural.py +57 -0
- assertpy2-2.19.0/tests/test_traceback.py +86 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_typing.py +20 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_vacuity_contract.py +77 -1
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_warn.py +24 -1
- {assertpy2-2.17.0 → assertpy2-2.19.0}/uv.lock +585 -587
- assertpy2-2.17.0/tests/test_protocol_parity.py +0 -86
- assertpy2-2.17.0/tests/test_traceback.py +0 -38
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.codecov.yml +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.gitattributes +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/.gitignore +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/LICENSE +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/SECURITY.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/_compat.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_engine/_introspection.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/_inline.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/file.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/extending/integrations.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/guides/data.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/guides/matchers.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/index.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/logo.svg +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/recipes.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/async.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/core.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/errors.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/files.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/json.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/mkdocs.yml +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/mkdocs_hooks.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/conftest.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_attrs_support.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_bool.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_callable.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_class.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_core.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_description.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_dict.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_docs_examples.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_dynamic.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_errors.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_fail.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_file.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_in.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_inline_record.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_none.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_not.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_openapi_contract.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_property_attrs.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_readme.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_string.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_sync_eventually.py +0 -0
- {assertpy2-2.17.0 → assertpy2-2.19.0}/tests/test_type.py +0 -0
|
@@ -15,6 +15,7 @@ permissions:
|
|
|
15
15
|
|
|
16
16
|
jobs:
|
|
17
17
|
test:
|
|
18
|
+
timeout-minutes: 15
|
|
18
19
|
runs-on: ${{ matrix.os }}
|
|
19
20
|
strategy:
|
|
20
21
|
matrix:
|
|
@@ -31,15 +32,15 @@ jobs:
|
|
|
31
32
|
- os: macos-latest
|
|
32
33
|
python-version: "3.14"
|
|
33
34
|
steps:
|
|
34
|
-
- uses: actions/checkout@
|
|
35
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
35
36
|
with:
|
|
36
37
|
persist-credentials: false
|
|
37
38
|
|
|
38
39
|
- name: Install uv
|
|
39
|
-
uses: astral-sh/setup-uv@
|
|
40
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
40
41
|
|
|
41
42
|
- name: Set up Python ${{ matrix.python-version }}
|
|
42
|
-
uses: actions/setup-python@
|
|
43
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
43
44
|
with:
|
|
44
45
|
python-version: ${{ matrix.python-version }}
|
|
45
46
|
allow-prereleases: true
|
|
@@ -78,17 +79,18 @@ jobs:
|
|
|
78
79
|
files: coverage.xml
|
|
79
80
|
|
|
80
81
|
lint:
|
|
82
|
+
timeout-minutes: 10
|
|
81
83
|
runs-on: ubuntu-latest
|
|
82
84
|
steps:
|
|
83
|
-
- uses: actions/checkout@
|
|
85
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
84
86
|
with:
|
|
85
87
|
persist-credentials: false
|
|
86
88
|
|
|
87
89
|
- name: Install uv
|
|
88
|
-
uses: astral-sh/setup-uv@
|
|
90
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
89
91
|
|
|
90
92
|
- name: Set up Python
|
|
91
|
-
uses: actions/setup-python@
|
|
93
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
92
94
|
with:
|
|
93
95
|
python-version: "3.14"
|
|
94
96
|
|
|
@@ -111,6 +113,7 @@ jobs:
|
|
|
111
113
|
run: uv run pyright tests/test_typing.py
|
|
112
114
|
|
|
113
115
|
ci-ok:
|
|
116
|
+
timeout-minutes: 5
|
|
114
117
|
needs: [test, lint]
|
|
115
118
|
if: always()
|
|
116
119
|
runs-on: ubuntu-latest
|
|
@@ -13,14 +13,15 @@ permissions:
|
|
|
13
13
|
|
|
14
14
|
jobs:
|
|
15
15
|
analyze:
|
|
16
|
+
timeout-minutes: 15
|
|
16
17
|
runs-on: ubuntu-latest
|
|
17
18
|
permissions:
|
|
18
19
|
security-events: write
|
|
19
20
|
steps:
|
|
20
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
21
22
|
with:
|
|
22
23
|
persist-credentials: false
|
|
23
|
-
- uses: github/codeql-action/init@
|
|
24
|
+
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
|
24
25
|
with:
|
|
25
26
|
languages: python
|
|
26
|
-
- uses: github/codeql-action/analyze@
|
|
27
|
+
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
|
@@ -21,20 +21,21 @@ concurrency:
|
|
|
21
21
|
|
|
22
22
|
jobs:
|
|
23
23
|
benchmarks:
|
|
24
|
+
timeout-minutes: 20
|
|
24
25
|
runs-on: ubuntu-latest
|
|
25
26
|
permissions:
|
|
26
27
|
contents: read
|
|
27
28
|
id-token: write
|
|
28
29
|
steps:
|
|
29
|
-
- uses: actions/checkout@
|
|
30
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
30
31
|
with:
|
|
31
32
|
persist-credentials: false
|
|
32
33
|
|
|
33
34
|
- name: Install uv
|
|
34
|
-
uses: astral-sh/setup-uv@
|
|
35
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
35
36
|
|
|
36
37
|
- name: Run benchmarks under CodSpeed
|
|
37
|
-
uses: CodSpeedHQ/action@
|
|
38
|
+
uses: CodSpeedHQ/action@f22792bfac16f3e14eb9fbea76f4a48e9cc22b93 # v4.19.1
|
|
38
39
|
with:
|
|
39
40
|
mode: simulation
|
|
40
41
|
run: uv run --locked --group benchmark pytest benchmarks/ --codspeed --no-cov
|
|
@@ -15,17 +15,18 @@ concurrency:
|
|
|
15
15
|
|
|
16
16
|
jobs:
|
|
17
17
|
build:
|
|
18
|
+
timeout-minutes: 10
|
|
18
19
|
runs-on: ubuntu-latest
|
|
19
20
|
steps:
|
|
20
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
21
22
|
with:
|
|
22
23
|
persist-credentials: false
|
|
23
24
|
|
|
24
25
|
- name: Install uv
|
|
25
|
-
uses: astral-sh/setup-uv@
|
|
26
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
26
27
|
|
|
27
28
|
- name: Set up Python
|
|
28
|
-
uses: actions/setup-python@
|
|
29
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
29
30
|
with:
|
|
30
31
|
python-version: "3.14"
|
|
31
32
|
|
|
@@ -42,6 +43,7 @@ jobs:
|
|
|
42
43
|
path: site
|
|
43
44
|
|
|
44
45
|
deploy:
|
|
46
|
+
timeout-minutes: 10
|
|
45
47
|
if: github.ref == 'refs/heads/main'
|
|
46
48
|
needs: build
|
|
47
49
|
runs-on: ubuntu-latest
|
|
@@ -21,15 +21,15 @@ jobs:
|
|
|
21
21
|
matrix:
|
|
22
22
|
module: [base, numeric, string, contains, collection, dict, date, matchers, helpers, _compare, dataframe, json_mixin, snapshot, bytes_mixin, dynamic, extracting, warning, exception, async_assertions, file, behave_matchers]
|
|
23
23
|
steps:
|
|
24
|
-
- uses: actions/checkout@
|
|
24
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
25
25
|
with:
|
|
26
26
|
persist-credentials: false
|
|
27
27
|
|
|
28
28
|
- name: Install uv
|
|
29
|
-
uses: astral-sh/setup-uv@
|
|
29
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
30
30
|
|
|
31
31
|
- name: Set up Python
|
|
32
|
-
uses: actions/setup-python@
|
|
32
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
33
33
|
with:
|
|
34
34
|
python-version: "3.14"
|
|
35
35
|
|
|
@@ -9,6 +9,7 @@ permissions:
|
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
11
11
|
publish:
|
|
12
|
+
timeout-minutes: 20
|
|
12
13
|
runs-on: ubuntu-latest
|
|
13
14
|
environment: pypi
|
|
14
15
|
permissions:
|
|
@@ -16,17 +17,17 @@ jobs:
|
|
|
16
17
|
contents: write
|
|
17
18
|
attestations: write
|
|
18
19
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
20
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
20
21
|
with:
|
|
21
22
|
persist-credentials: false
|
|
22
23
|
|
|
23
24
|
- name: Install uv
|
|
24
|
-
uses: astral-sh/setup-uv@
|
|
25
|
+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
25
26
|
with:
|
|
26
27
|
enable-cache: false
|
|
27
28
|
|
|
28
29
|
- name: Set up Python
|
|
29
|
-
uses: actions/setup-python@
|
|
30
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
30
31
|
with:
|
|
31
32
|
python-version: "3.15"
|
|
32
33
|
allow-prereleases: true
|
|
@@ -65,7 +66,7 @@ jobs:
|
|
|
65
66
|
run: syft scan dir:dist -o "cyclonedx-json=provenance/assertpy2.sbom.cdx.json"
|
|
66
67
|
|
|
67
68
|
- name: Publish to PyPI
|
|
68
|
-
uses: pypa/gh-action-pypi-publish@
|
|
69
|
+
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
|
69
70
|
|
|
70
71
|
- name: Upload to GitHub Release
|
|
71
72
|
run: gh release upload "$TAG_NAME" dist/* provenance/*
|
|
@@ -11,15 +11,16 @@ permissions: read-all
|
|
|
11
11
|
|
|
12
12
|
jobs:
|
|
13
13
|
analysis:
|
|
14
|
+
timeout-minutes: 10
|
|
14
15
|
runs-on: ubuntu-latest
|
|
15
16
|
permissions:
|
|
16
17
|
security-events: write
|
|
17
18
|
id-token: write
|
|
18
19
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
20
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
20
21
|
with:
|
|
21
22
|
persist-credentials: false
|
|
22
|
-
- uses: ossf/scorecard-action@
|
|
23
|
+
- uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
|
23
24
|
with:
|
|
24
25
|
results_file: results.sarif
|
|
25
26
|
results_format: sarif
|
|
@@ -29,6 +30,6 @@ jobs:
|
|
|
29
30
|
name: SARIF file
|
|
30
31
|
path: results.sarif
|
|
31
32
|
retention-days: 5
|
|
32
|
-
- uses: github/codeql-action/upload-sarif@
|
|
33
|
+
- uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
|
33
34
|
with:
|
|
34
35
|
sarif_file: results.sarif
|
|
@@ -15,11 +15,12 @@ permissions:
|
|
|
15
15
|
|
|
16
16
|
jobs:
|
|
17
17
|
zizmor:
|
|
18
|
+
timeout-minutes: 10
|
|
18
19
|
runs-on: ubuntu-latest
|
|
19
20
|
permissions:
|
|
20
21
|
security-events: write
|
|
21
22
|
steps:
|
|
22
|
-
- uses: actions/checkout@
|
|
23
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
23
24
|
with:
|
|
24
25
|
persist-credentials: false
|
|
25
|
-
- uses: zizmorcore/zizmor-action@
|
|
26
|
+
- uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.19.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
|
|
@@ -88,9 +88,9 @@ def test_user():
|
|
|
88
88
|
user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}
|
|
89
89
|
|
|
90
90
|
assert_that(user).contains_key("name", "age")
|
|
91
|
+
assert_that(user).contains_entry({"name": "Alice"})
|
|
91
92
|
assert_that(user["age"]).is_between(18, 120)
|
|
92
93
|
assert_that(user["roles"]).contains("viewer").does_not_contain("admin")
|
|
93
|
-
assert_that(user).has_name("Alice")
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
The [full documentation](https://solganis.github.io/assertpy2/) covers every assertion, matcher, and integration.
|
|
@@ -100,6 +100,7 @@ The [full documentation](https://solganis.github.io/assertpy2/) covers every ass
|
|
|
100
100
|
A fluent chain reads as one intent and replaces several bare asserts -<br>
|
|
101
101
|
and your IDE offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/concepts/type-safety/):
|
|
102
102
|
|
|
103
|
+
<!-- docs-guard: skip -->
|
|
103
104
|
```python
|
|
104
105
|
# bare - three statements, no autocomplete help
|
|
105
106
|
assert isinstance(items, list)
|
|
@@ -123,6 +124,7 @@ E {'status': 'active'} != {'status': 'disabled'}
|
|
|
123
124
|
|
|
124
125
|
assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs), in color:
|
|
125
126
|
|
|
127
|
+
<!-- docs-guard: skip -->
|
|
126
128
|
```python
|
|
127
129
|
assert_that(response).is_equal_to(expected)
|
|
128
130
|
```
|
|
@@ -133,6 +135,23 @@ assert_that(response).is_equal_to(expected)
|
|
|
133
135
|
|
|
134
136
|
The diff recurses through nested containers, and matcher predicates get the same path-level treatment. For dynamic fields like IDs or timestamps, assert a subset with [`matches_structure()`](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching).
|
|
135
137
|
|
|
138
|
+
Matchers are ordinary values, so they also compose inside the expected structure itself, at any depth,
|
|
139
|
+
with or without the fluent chain:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
response = {"id": 7, "user": {"name": "Alice", "age": 30}, "tags": ["a", "b"]}
|
|
143
|
+
|
|
144
|
+
assert_that(response).is_equal_to(
|
|
145
|
+
{"id": match.greater_than(0), "user": {"name": "Alice", "age": match.between(18, 120)}, "tags": ["a", "b"]}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# or keep the bare `assert`, and pytest's own rewriting reports it
|
|
149
|
+
assert response == {"id": match.greater_than(0), "user": match.ignore(), "tags": ["a", "b"]}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The fluent form keeps the path-level diff, the bare form keeps pytest's. There are
|
|
153
|
+
[39 matchers](https://solganis.github.io/assertpy2/guides/matchers/), and they combine with `&`, `|` and `~`.
|
|
154
|
+
|
|
136
155
|
<p align="center">
|
|
137
156
|
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
|
|
138
157
|
</p>
|
|
@@ -156,6 +175,7 @@ Works in PyCharm, VS Code, and any LSP-compatible editor.
|
|
|
156
175
|
An assertion hands the value back, statically narrowed. `is_not_none()` strips `None`,
|
|
157
176
|
`is_instance_of()` narrows to the class, and `.value` returns it with no `cast` and no bare `assert`:
|
|
158
177
|
|
|
178
|
+
<!-- docs-guard: skip -->
|
|
159
179
|
```python
|
|
160
180
|
order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
|
|
161
181
|
order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
|
|
@@ -164,6 +184,7 @@ order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
|
|
|
164
184
|
For API tests, [`assert_conforms()`](https://solganis.github.io/assertpy2/concepts/type-safety/#contract-narrowing-with-assert_conforms) validates a raw payload against a Pydantic model and narrows the chain to it,<br>
|
|
165
185
|
with `exact=True` catching silent contract drift:
|
|
166
186
|
|
|
187
|
+
<!-- docs-guard: skip -->
|
|
167
188
|
```python
|
|
168
189
|
data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
169
190
|
```
|
|
@@ -175,6 +196,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
|
175
196
|
- [**Composable matchers**](https://solganis.github.io/assertpy2/guides/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`, usable in plain `assert ==`.
|
|
176
197
|
- [**Structural matching**](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching): `matches_structure()` for declarative dict/API-response validation.
|
|
177
198
|
- [**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.
|
|
199
|
+
- [**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
200
|
- [**Universal negation**](https://solganis.github.io/assertpy2/guides/fluent/#universal-negation): `.not_` inverts any assertion, no dedicated `is_not_*` methods.
|
|
179
201
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/guides/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
180
202
|
- [**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.
|
|
@@ -36,9 +36,9 @@ def test_user():
|
|
|
36
36
|
user = {"name": "Alice", "age": 30, "roles": ["viewer", "editor"]}
|
|
37
37
|
|
|
38
38
|
assert_that(user).contains_key("name", "age")
|
|
39
|
+
assert_that(user).contains_entry({"name": "Alice"})
|
|
39
40
|
assert_that(user["age"]).is_between(18, 120)
|
|
40
41
|
assert_that(user["roles"]).contains("viewer").does_not_contain("admin")
|
|
41
|
-
assert_that(user).has_name("Alice")
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
The [full documentation](https://solganis.github.io/assertpy2/) covers every assertion, matcher, and integration.
|
|
@@ -48,6 +48,7 @@ The [full documentation](https://solganis.github.io/assertpy2/) covers every ass
|
|
|
48
48
|
A fluent chain reads as one intent and replaces several bare asserts -<br>
|
|
49
49
|
and your IDE offers only the [methods that fit the value's type](https://solganis.github.io/assertpy2/concepts/type-safety/):
|
|
50
50
|
|
|
51
|
+
<!-- docs-guard: skip -->
|
|
51
52
|
```python
|
|
52
53
|
# bare - three statements, no autocomplete help
|
|
53
54
|
assert isinstance(items, list)
|
|
@@ -71,6 +72,7 @@ E {'status': 'active'} != {'status': 'disabled'}
|
|
|
71
72
|
|
|
72
73
|
assertpy2 reports the [exact path to every difference](https://solganis.github.io/assertpy2/guides/errors/#rich-pytest-diffs), in color:
|
|
73
74
|
|
|
75
|
+
<!-- docs-guard: skip -->
|
|
74
76
|
```python
|
|
75
77
|
assert_that(response).is_equal_to(expected)
|
|
76
78
|
```
|
|
@@ -81,6 +83,23 @@ assert_that(response).is_equal_to(expected)
|
|
|
81
83
|
|
|
82
84
|
The diff recurses through nested containers, and matcher predicates get the same path-level treatment. For dynamic fields like IDs or timestamps, assert a subset with [`matches_structure()`](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching).
|
|
83
85
|
|
|
86
|
+
Matchers are ordinary values, so they also compose inside the expected structure itself, at any depth,
|
|
87
|
+
with or without the fluent chain:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
response = {"id": 7, "user": {"name": "Alice", "age": 30}, "tags": ["a", "b"]}
|
|
91
|
+
|
|
92
|
+
assert_that(response).is_equal_to(
|
|
93
|
+
{"id": match.greater_than(0), "user": {"name": "Alice", "age": match.between(18, 120)}, "tags": ["a", "b"]}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# or keep the bare `assert`, and pytest's own rewriting reports it
|
|
97
|
+
assert response == {"id": match.greater_than(0), "user": match.ignore(), "tags": ["a", "b"]}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The fluent form keeps the path-level diff, the bare form keeps pytest's. There are
|
|
101
|
+
[39 matchers](https://solganis.github.io/assertpy2/guides/matchers/), and they combine with `&`, `|` and `~`.
|
|
102
|
+
|
|
84
103
|
<p align="center">
|
|
85
104
|
<img src="https://raw.githubusercontent.com/Solganis/assertpy2/main/docs/assets/diff-gallery.png" width="640" alt="Structured diffs in the terminal: dict path, list element, set extra/missing, and structural-matcher predicate diffs, side by side">
|
|
86
105
|
</p>
|
|
@@ -104,6 +123,7 @@ Works in PyCharm, VS Code, and any LSP-compatible editor.
|
|
|
104
123
|
An assertion hands the value back, statically narrowed. `is_not_none()` strips `None`,
|
|
105
124
|
`is_instance_of()` narrows to the class, and `.value` returns it with no `cast` and no bare `assert`:
|
|
106
125
|
|
|
126
|
+
<!-- docs-guard: skip -->
|
|
107
127
|
```python
|
|
108
128
|
order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
|
|
109
129
|
order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
|
|
@@ -112,6 +132,7 @@ order.refund() # statically PaidOrder - verified by ty, mypy, and pyright
|
|
|
112
132
|
For API tests, [`assert_conforms()`](https://solganis.github.io/assertpy2/concepts/type-safety/#contract-narrowing-with-assert_conforms) validates a raw payload against a Pydantic model and narrows the chain to it,<br>
|
|
113
133
|
with `exact=True` catching silent contract drift:
|
|
114
134
|
|
|
135
|
+
<!-- docs-guard: skip -->
|
|
115
136
|
```python
|
|
116
137
|
data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
117
138
|
```
|
|
@@ -123,6 +144,7 @@ data = assert_conforms(response.json(), OrderModel).value # data: OrderModel
|
|
|
123
144
|
- [**Composable matchers**](https://solganis.github.io/assertpy2/guides/matchers/): `match.greater_than(5)`, `match.is_uuid()`, combine with `&`, `|`, `~`, usable in plain `assert ==`.
|
|
124
145
|
- [**Structural matching**](https://solganis.github.io/assertpy2/guides/matchers/#structural-matching): `matches_structure()` for declarative dict/API-response validation.
|
|
125
146
|
- [**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.
|
|
147
|
+
- [**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
148
|
- [**Universal negation**](https://solganis.github.io/assertpy2/guides/fluent/#universal-negation): `.not_` inverts any assertion, no dedicated `is_not_*` methods.
|
|
127
149
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/guides/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
128
150
|
- [**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.
|
|
@@ -14,10 +14,15 @@ 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
|
-
from .snapshot import
|
|
20
|
+
from .snapshot import (
|
|
21
|
+
SnapshotCreatedWarning,
|
|
22
|
+
SnapshotKeyReusedWarning,
|
|
23
|
+
SnapshotUpdatedWarning,
|
|
24
|
+
register_snapshot_serializer,
|
|
25
|
+
)
|
|
21
26
|
|
|
22
27
|
__all__ = [
|
|
23
28
|
"AssertionFailure",
|
|
@@ -29,9 +34,11 @@ __all__ = [
|
|
|
29
34
|
"PollSample",
|
|
30
35
|
"PollTrace",
|
|
31
36
|
"SnapshotCreatedWarning",
|
|
37
|
+
"SnapshotKeyReusedWarning",
|
|
32
38
|
"SnapshotUpdatedWarning",
|
|
33
39
|
"SoftAssertionCollector",
|
|
34
40
|
"SyncAssertionBuilder",
|
|
41
|
+
"VacuousAssertionWarning",
|
|
35
42
|
"WarningLoggingAdapter",
|
|
36
43
|
"__version__",
|
|
37
44
|
"add_extension",
|
|
@@ -14,9 +14,13 @@ express to the type checker.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import dataclasses
|
|
17
|
+
import datetime
|
|
18
|
+
import decimal
|
|
17
19
|
import math
|
|
18
20
|
import numbers
|
|
21
|
+
import pathlib
|
|
19
22
|
import re
|
|
23
|
+
import uuid
|
|
20
24
|
from dataclasses import dataclass
|
|
21
25
|
from typing import TYPE_CHECKING
|
|
22
26
|
|
|
@@ -26,30 +30,71 @@ if TYPE_CHECKING:
|
|
|
26
30
|
from collections.abc import Callable
|
|
27
31
|
|
|
28
32
|
|
|
33
|
+
_EQ_ATOMIC = frozenset(
|
|
34
|
+
{
|
|
35
|
+
int,
|
|
36
|
+
float,
|
|
37
|
+
bool,
|
|
38
|
+
complex,
|
|
39
|
+
str,
|
|
40
|
+
bytes,
|
|
41
|
+
bytearray,
|
|
42
|
+
type(None),
|
|
43
|
+
datetime.datetime,
|
|
44
|
+
datetime.date,
|
|
45
|
+
datetime.time,
|
|
46
|
+
datetime.timedelta,
|
|
47
|
+
decimal.Decimal,
|
|
48
|
+
uuid.UUID,
|
|
49
|
+
pathlib.PurePosixPath,
|
|
50
|
+
pathlib.PureWindowsPath,
|
|
51
|
+
pathlib.PosixPath,
|
|
52
|
+
pathlib.WindowsPath,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
"""Types whose ``==`` is a plain bool and which have nothing inside to walk into.
|
|
56
|
+
|
|
57
|
+
The stdlib scalars past the builtins are here for cost, not for correctness. ``strict_types`` walks
|
|
58
|
+
past any value it does not know to be atomic, because a container's own ``==`` says nothing about the
|
|
59
|
+
types inside it - and a ``datetime`` is not a container, so that walk runs the whole introspection
|
|
60
|
+
ladder to come back with nothing. Over 200 records of timestamps, decimals and UUIDs that was 3.7 ms
|
|
61
|
+
against 0.5 ms with them listed.
|
|
62
|
+
|
|
63
|
+
Listing one changes no verdict: a type difference is decided by `_types_differ()` before atomicity is
|
|
64
|
+
ever consulted, so ``date`` against ``datetime`` still fails. Exact types, not ``isinstance``, so a
|
|
65
|
+
subclass with its own structure is still walked.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
29
69
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
30
70
|
class _CompareConfig:
|
|
31
71
|
"""Tolerance and custom comparators for a single ``is_equal_to`` call.
|
|
32
72
|
|
|
33
73
|
``tolerance`` is an absolute tolerance applied to real-number leaves; ``comparators`` maps a ``type`` or
|
|
34
74
|
an immediate field name to a ``(actual, expected) -> bool`` predicate that owns matching leaves;
|
|
35
|
-
``ignore_null`` skips a named field whenever the *expected* side leaves it ``None
|
|
75
|
+
``ignore_null`` skips a named field whenever the *expected* side leaves it ``None``;
|
|
76
|
+
``strict_types`` additionally requires both sides of every node to be the same type, which plain
|
|
77
|
+
``==`` does not (``True == 1``, ``Decimal("1") == 1``, and so on all the way down a payload).
|
|
36
78
|
"""
|
|
37
79
|
|
|
38
80
|
tolerance: float | None = None
|
|
39
81
|
comparators: dict[object, Callable[[object, object], bool]] | None = None
|
|
40
82
|
ignore_null: bool = False
|
|
83
|
+
strict_types: bool = False
|
|
41
84
|
|
|
42
85
|
|
|
43
|
-
def _build_compare_config(tolerance, comparators, ignore_null=False) -> _CompareConfig | None:
|
|
44
|
-
"""Validate the ``is_equal_to``
|
|
86
|
+
def _build_compare_config(tolerance, comparators, ignore_null=False, strict_types=False) -> _CompareConfig | None:
|
|
87
|
+
"""Validate the ``is_equal_to`` comparison kwargs and build a config.
|
|
45
88
|
|
|
46
89
|
Returns ``None`` when none are set. ``tolerance`` must be a non-negative real number (not
|
|
47
90
|
``bool``/``complex``/``NaN``); ``comparators`` must be a dict of ``(actual, expected) -> bool`` callables
|
|
48
|
-
keyed by ``type`` or field name; ``ignore_null`` must be
|
|
91
|
+
keyed by ``type`` or field name; ``ignore_null`` and ``strict_types`` must be bools.
|
|
49
92
|
"""
|
|
50
93
|
if ignore_null is not False and ignore_null is not True:
|
|
51
94
|
raise TypeError("given ignore_null arg must be a bool")
|
|
52
|
-
if
|
|
95
|
+
if strict_types is not False and strict_types is not True:
|
|
96
|
+
raise TypeError("given strict_types arg must be a bool")
|
|
97
|
+
if tolerance is None and comparators is None and not ignore_null and not strict_types:
|
|
53
98
|
return None
|
|
54
99
|
if tolerance is not None:
|
|
55
100
|
if isinstance(tolerance, bool) or not isinstance(tolerance, numbers.Number) or isinstance(tolerance, complex):
|
|
@@ -64,7 +109,9 @@ def _build_compare_config(tolerance, comparators, ignore_null=False) -> _Compare
|
|
|
64
109
|
for comparator in comparators.values():
|
|
65
110
|
if not callable(comparator):
|
|
66
111
|
raise TypeError("each comparator must be callable")
|
|
67
|
-
return _CompareConfig(
|
|
112
|
+
return _CompareConfig(
|
|
113
|
+
tolerance=tolerance, comparators=comparators, ignore_null=ignore_null, strict_types=strict_types
|
|
114
|
+
)
|
|
68
115
|
|
|
69
116
|
|
|
70
117
|
def _ambiguous_array_operand(value: object, other: object) -> object | None:
|
|
@@ -208,13 +255,35 @@ def _resolve_comparator(actual, config: _CompareConfig, *, field):
|
|
|
208
255
|
return None
|
|
209
256
|
|
|
210
257
|
|
|
258
|
+
def _types_differ(actual, expected) -> bool:
|
|
259
|
+
"""Whether ``strict_types`` should reject this pair.
|
|
260
|
+
|
|
261
|
+
A matcher standing in for a value is not a value, so it is exempt: the expected side of
|
|
262
|
+
``is_equal_to({"id": match.greater_than(0)})`` is a ``GreaterThanMatcher`` by construction, and
|
|
263
|
+
comparing its type against an ``int`` would break every composed matcher rather than catch a bug.
|
|
264
|
+
|
|
265
|
+
``_is_matcher`` is imported here rather than at module scope because ``_matcher_impls`` imports
|
|
266
|
+
``_guarded_not_equal`` from this module, so the module-level import would be a cycle.
|
|
267
|
+
"""
|
|
268
|
+
if type(actual) is type(expected):
|
|
269
|
+
return False
|
|
270
|
+
from .._matcher_impls import _is_matcher
|
|
271
|
+
|
|
272
|
+
return not _is_matcher(expected) and not _is_matcher(actual)
|
|
273
|
+
|
|
274
|
+
|
|
211
275
|
def _node_decision(actual, expected, config: _CompareConfig | None, *, field=None) -> str:
|
|
212
|
-
"""Classify a node as ``"equal"``, ``"leaf"`` or ``"
|
|
276
|
+
"""Classify a node as ``"equal"``, ``"leaf"``, ``"recurse"`` or ``"strict"``.
|
|
213
277
|
|
|
214
278
|
With ``config is None`` this is exactly the engine's historical behavior: differing values ``"recurse"``
|
|
215
279
|
(to decompose into a sub-diff), equal values are ``"equal"`` (skipped); ``"leaf"`` never occurs. With a
|
|
216
280
|
config, a matching comparator or tolerance owns the node - it is classified ``"equal"`` or ``"leaf"`` and
|
|
217
281
|
never recursed into.
|
|
282
|
+
|
|
283
|
+
``"strict"`` is the fourth: the two sides are equal and the same type, but ``strict_types`` still has
|
|
284
|
+
to look inside, because a container's ``==`` says nothing about the types of its members. It differs
|
|
285
|
+
from ``"recurse"`` only in what an undecomposable value means, which
|
|
286
|
+
`assertpy2._engine._diff._child_entries()` is the single place to know.
|
|
218
287
|
"""
|
|
219
288
|
if config is not None:
|
|
220
289
|
if config.ignore_null and field is not None and expected is None:
|
|
@@ -222,6 +291,24 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
|
|
|
222
291
|
comparator = _resolve_comparator(actual, config, field=field)
|
|
223
292
|
if comparator is not None:
|
|
224
293
|
return "equal" if comparator(actual, expected) else "leaf"
|
|
294
|
+
if config.strict_types:
|
|
295
|
+
if actual is expected:
|
|
296
|
+
# what `PyObject_RichCompareBool` hands a container for free, and what forcing the walk
|
|
297
|
+
# below would otherwise take away: one object is equal to itself without consulting
|
|
298
|
+
# `__eq__`, which is why a shared subnode is cheap and why `[nan] == [nan]` is true when
|
|
299
|
+
# both elements are the same float
|
|
300
|
+
return "equal"
|
|
301
|
+
if _types_differ(actual, expected):
|
|
302
|
+
# ahead of tolerance on purpose: a tolerance says how far apart two numbers may be, it
|
|
303
|
+
# does not say they may be different types, and a strict run that quietly accepted int
|
|
304
|
+
# vs float inside its own tolerance would be the surprise, not the rule
|
|
305
|
+
return "leaf"
|
|
306
|
+
if type(actual) not in _EQ_ATOMIC and not _guarded_not_equal(actual, expected):
|
|
307
|
+
# a container's own `==` says nothing about the types inside it: `[True] == [1]`. The
|
|
308
|
+
# walk normally stops here, so under strict types it has to keep going. Whether the
|
|
309
|
+
# walker can actually take this value apart is not predicted here: `"strict"` tells the
|
|
310
|
+
# caller that a value it cannot decompose is one that is already equal
|
|
311
|
+
return "strict"
|
|
225
312
|
if config.tolerance is not None and _is_real_number(actual) and _is_real_number(expected):
|
|
226
313
|
return "equal" if _within_tolerance(actual, expected, config.tolerance) else "leaf"
|
|
227
314
|
return "recurse" if _guarded_not_equal(actual, expected) else "equal"
|
|
@@ -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
|
|