assertpy2 2.13.0__tar.gz → 2.15.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.13.0 → assertpy2-2.15.0}/.github/workflows/publish.yml +2 -2
- {assertpy2-2.13.0 → assertpy2-2.15.0}/PKG-INFO +9 -7
- {assertpy2-2.13.0 → assertpy2-2.15.0}/README.md +8 -6
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/__init__.py +10 -3
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_compare.py +30 -10
- assertpy2-2.15.0/assertpy2/_compat.py +25 -0
- assertpy2-2.15.0/assertpy2/_contract.py +161 -0
- assertpy2-2.15.0/assertpy2/_diff.py +346 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_introspection.py +14 -1
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_mixin_base.py +2 -11
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/_typing.py +117 -26
- assertpy2-2.15.0/assertpy2/assertpy.py +1091 -0
- assertpy2-2.15.0/assertpy2/async_assertions.py +338 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/base.py +285 -355
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/collection.py +94 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/contains.py +107 -18
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/dataframe.py +9 -4
- assertpy2-2.15.0/assertpy2/errors.py +178 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/exception.py +119 -2
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/extracting.py +12 -9
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/helpers.py +60 -14
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/matchers.py +128 -8
- assertpy2-2.15.0/assertpy2/pytest_plugin.py +353 -0
- assertpy2-2.15.0/assertpy2/snapshot.py +753 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/string.py +102 -2
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assertions.md +20 -1
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/comparison.md +15 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/errors.md +63 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/fluent.md +12 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/integrations.md +36 -4
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/matchers.md +8 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/async.md +10 -1
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/entry-points.md +11 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/errors.md +12 -0
- assertpy2-2.15.0/docs/reference/snapshots.md +23 -0
- assertpy2-2.15.0/docs/testing.md +327 -0
- assertpy2-2.15.0/docs/type-safety.md +213 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/pyproject.toml +1 -1
- assertpy2-2.15.0/tests/conftest.py +22 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_async.py +213 -2
- assertpy2-2.15.0/tests/test_boundary_cases.py +135 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_bytes.py +6 -6
- assertpy2-2.15.0/tests/test_core.py +168 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dataframe.py +22 -6
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dict_compare.py +21 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dynamic.py +3 -5
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_equals.py +16 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_errors.py +49 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_expected_exception.py +183 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_extensions.py +65 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_extracting.py +1 -4
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_file.py +1 -3
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_grouped_soft.py +21 -3
- assertpy2-2.15.0/tests/test_iterable_cluster.py +410 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_list.py +168 -1
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_matchers.py +46 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_not.py +52 -0
- assertpy2-2.15.0/tests/test_property_based.py +704 -0
- assertpy2-2.15.0/tests/test_protocol_parity.py +86 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_pytest_plugin.py +336 -20
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_assertion.py +30 -21
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare_config.py +44 -3
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_rich_diff.py +149 -83
- assertpy2-2.15.0/tests/test_snapshots.py +988 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_string.py +92 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_structural.py +381 -1
- assertpy2-2.15.0/tests/test_sync_eventually.py +216 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_type.py +61 -0
- assertpy2-2.15.0/tests/test_typing.py +197 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_warn.py +39 -23
- {assertpy2-2.13.0 → assertpy2-2.15.0}/uv.lock +9 -9
- assertpy2-2.13.0/assertpy2/_compat.py +0 -15
- assertpy2-2.13.0/assertpy2/assertpy.py +0 -698
- assertpy2-2.13.0/assertpy2/async_assertions.py +0 -113
- assertpy2-2.13.0/assertpy2/errors.py +0 -51
- assertpy2-2.13.0/assertpy2/pytest_plugin.py +0 -196
- assertpy2-2.13.0/assertpy2/snapshot.py +0 -291
- assertpy2-2.13.0/docs/reference/snapshots.md +0 -11
- assertpy2-2.13.0/docs/testing.md +0 -148
- assertpy2-2.13.0/docs/type-safety.md +0 -67
- assertpy2-2.13.0/tests/test_core.py +0 -82
- assertpy2-2.13.0/tests/test_iterable_cluster.py +0 -201
- assertpy2-2.13.0/tests/test_mutation_hardening.py +0 -138
- assertpy2-2.13.0/tests/test_property_based.py +0 -333
- assertpy2-2.13.0/tests/test_snapshots.py +0 -330
- assertpy2-2.13.0/tests/test_typing.py +0 -68
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.codecov.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.gitattributes +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/.gitignore +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/LICENSE +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/SECURITY.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/date.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/file.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/data.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/extending.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/getting-started.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/index.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/logo.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/migration.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/core.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/files.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/json.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/mkdocs.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_bool.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_callable.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_class.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_collection.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_description.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_dict.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_fail.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_in.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_json.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_none.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_readme.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_satisfy.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_soft.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.15.0}/tests/test_traceback.py +0 -0
|
@@ -36,13 +36,13 @@ jobs:
|
|
|
36
36
|
|
|
37
37
|
- name: Attest wheel
|
|
38
38
|
id: attest-wheel
|
|
39
|
-
uses: actions/attest-build-provenance@
|
|
39
|
+
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
|
40
40
|
with:
|
|
41
41
|
subject-path: dist/*.whl
|
|
42
42
|
|
|
43
43
|
- name: Attest sdist
|
|
44
44
|
id: attest-sdist
|
|
45
|
-
uses: actions/attest-build-provenance@
|
|
45
|
+
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
|
46
46
|
with:
|
|
47
47
|
subject-path: dist/*.tar.gz
|
|
48
48
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.15.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
|
|
@@ -169,7 +169,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
169
169
|
- [**Recursive field assertions**](https://solganis.github.io/assertpy2/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph, reporting the exact path.
|
|
170
170
|
- [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
171
171
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
172
|
-
- [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()
|
|
172
|
+
- [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
|
|
173
173
|
- [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
|
|
174
174
|
|
|
175
175
|
**Built-in types**
|
|
@@ -178,21 +178,23 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
178
178
|
- [**Bytes assertions**](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray): `is_valid_utf8()`, `starts_with_bytes()`, `is_hex_equal_to()`, `decoded_as()` for `bytes`/`bytearray`.
|
|
179
179
|
- [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
|
|
180
180
|
- [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects), including by regex or type.
|
|
181
|
-
- [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth)
|
|
181
|
+
- [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth), `comparators` (per-type / per-field predicates), or `ignore_null` (skip fields the expected template leaves `None`) for nested structures.
|
|
182
182
|
- [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
|
|
183
183
|
|
|
184
184
|
**Testing**
|
|
185
185
|
|
|
186
|
-
- [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
|
|
187
|
-
- [**
|
|
186
|
+
- [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`; each collected failure is reported with its `file:line`. Group errors with `sa.group()`, or use `assert_all()`.
|
|
187
|
+
- [**Polling assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` (async) / `eventually_sync()` (blocking) retry for eventual consistency, with a convergence trace pinpointing why a timeout never settled.
|
|
188
|
+
- [**Expected exceptions**](https://solganis.github.io/assertpy2/errors/#expected-exceptions): `raises().when_called_with()` then assert on the message, walk the cause chain (`caused_by()`, `has_root_cause()`), match an `ExceptionGroup` (`contains_error()`), or pivot to the exception object (`raised()`).
|
|
188
189
|
- [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
|
|
189
190
|
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
|
|
190
|
-
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
191
|
-
- **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
|
|
191
|
+
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format; update via `--assertpy2-snapshot-update`. [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/testing/#contract-snapshots) catches structural regressions, value-tolerant.
|
|
192
192
|
|
|
193
193
|
**Type safety**
|
|
194
194
|
|
|
195
195
|
- [**Type-aware autocomplete**](https://solganis.github.io/assertpy2/type-safety/): 9 Protocols, IDE shows only relevant methods per type.
|
|
196
|
+
- [**Typed narrowing**](https://solganis.github.io/assertpy2/type-safety/#typed-narrowing-with-value): `.value` hands the checked value back; `is_not_none()`, `is_instance_of()`, and a [`satisfies()` `TypeIs` predicate](https://solganis.github.io/assertpy2/type-safety/#refinement-narrowing-with-a-typeis-predicate-advanced) narrow its static type - no casts.
|
|
197
|
+
- [**Contract testing**](https://solganis.github.io/assertpy2/type-safety/#contract-narrowing-with-assert_conforms): `assert_conforms()` validates a raw payload against a Pydantic v2 model and narrows the chain to it - the capstone for API-response tests; [`exact=True`](https://solganis.github.io/assertpy2/type-safety/#contract-drift-with-exacttrue) catches silent contract drift (undeclared fields), `each=True` validates list endpoints.
|
|
196
198
|
- **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
197
199
|
|
|
198
200
|
**Extensibility**
|
|
@@ -121,7 +121,7 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
121
121
|
- [**Recursive field assertions**](https://solganis.github.io/assertpy2/assertions/#recursive-field-assertions): `all_fields_satisfy()` / `has_no_none_fields()` apply a predicate to every leaf of an object graph, reporting the exact path.
|
|
122
122
|
- [**Universal negation**](https://solganis.github.io/assertpy2/fluent/#universal-negation): `.not_` inverts any assertion without dedicated `is_not_*` methods.
|
|
123
123
|
- [**Collection pipeline**](https://solganis.github.io/assertpy2/fluent/#collection-pipeline): `filtered_on()`, `mapped()`, `flat_mapped()`, `first()`, `last()`, `element()`, `single()`.
|
|
124
|
-
- [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()
|
|
124
|
+
- [**Positional & pairwise checks**](https://solganis.github.io/assertpy2/assertions/#lists): `satisfies_exactly()`, `zip_satisfies()`, `contains_only_once()`, `has_same_size_as()`, plus `*_in_any_order` variants.
|
|
125
125
|
- [**Fluent chaining**](https://solganis.github.io/assertpy2/fluent/#chaining): write assertions as readable one-liners that chain naturally.
|
|
126
126
|
|
|
127
127
|
**Built-in types**
|
|
@@ -130,21 +130,23 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
130
130
|
- [**Bytes assertions**](https://solganis.github.io/assertpy2/assertions/#bytes--bytearray): `is_valid_utf8()`, `starts_with_bytes()`, `is_hex_equal_to()`, `decoded_as()` for `bytes`/`bytearray`.
|
|
131
131
|
- [**Dynamic assertions**](https://solganis.github.io/assertpy2/assertions/#dynamic-assertions-on-objects): `has_<name>()` for any attribute, property, or zero-argument method.
|
|
132
132
|
- [**Dict comparison**](https://solganis.github.io/assertpy2/assertions/#selective-comparison-ignore--include): `is_equal_to()` with `ignore` and `include` for selective key/field matching (dicts, dataclasses, namedtuples, Pydantic models, attrs, plain objects), including by regex or type.
|
|
133
|
-
- [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth)
|
|
133
|
+
- [**Recursive comparison**](https://solganis.github.io/assertpy2/assertions/#recursive-comparison-tolerance--custom-comparators): `is_equal_to()` with `tolerance` (absolute float tolerance at any depth), `comparators` (per-type / per-field predicates), or `ignore_null` (skip fields the expected template leaves `None`) for nested structures.
|
|
134
134
|
- [**Extracting**](https://solganis.github.io/assertpy2/assertions/#extracting-attributes-from-objects): flatten collections on attributes with `filter` and `sort` support.
|
|
135
135
|
|
|
136
136
|
**Testing**
|
|
137
137
|
|
|
138
|
-
- [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`. Group errors with `sa.group()`, or use `assert_all()`.
|
|
139
|
-
- [**
|
|
138
|
+
- [**Soft assertions**](https://solganis.github.io/assertpy2/testing/#soft-assertions): thread-safe, async-safe via `contextvars`; each collected failure is reported with its `file:line`. Group errors with `sa.group()`, or use `assert_all()`.
|
|
139
|
+
- [**Polling assertions**](https://solganis.github.io/assertpy2/testing/#async-assertions): `eventually()` (async) / `eventually_sync()` (blocking) retry for eventual consistency, with a convergence trace pinpointing why a timeout never settled.
|
|
140
|
+
- [**Expected exceptions**](https://solganis.github.io/assertpy2/errors/#expected-exceptions): `raises().when_called_with()` then assert on the message, walk the cause chain (`caused_by()`, `has_root_cause()`), match an `ExceptionGroup` (`contains_error()`), or pivot to the exception object (`raised()`).
|
|
140
141
|
- [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
|
|
141
142
|
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
|
|
142
|
-
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
143
|
-
- **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
|
|
143
|
+
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format; update via `--assertpy2-snapshot-update`. [`matches_contract_snapshot()`](https://solganis.github.io/assertpy2/testing/#contract-snapshots) catches structural regressions, value-tolerant.
|
|
144
144
|
|
|
145
145
|
**Type safety**
|
|
146
146
|
|
|
147
147
|
- [**Type-aware autocomplete**](https://solganis.github.io/assertpy2/type-safety/): 9 Protocols, IDE shows only relevant methods per type.
|
|
148
|
+
- [**Typed narrowing**](https://solganis.github.io/assertpy2/type-safety/#typed-narrowing-with-value): `.value` hands the checked value back; `is_not_none()`, `is_instance_of()`, and a [`satisfies()` `TypeIs` predicate](https://solganis.github.io/assertpy2/type-safety/#refinement-narrowing-with-a-typeis-predicate-advanced) narrow its static type - no casts.
|
|
149
|
+
- [**Contract testing**](https://solganis.github.io/assertpy2/type-safety/#contract-narrowing-with-assert_conforms): `assert_conforms()` validates a raw payload against a Pydantic v2 model and narrows the chain to it - the capstone for API-response tests; [`exact=True`](https://solganis.github.io/assertpy2/type-safety/#contract-drift-with-exacttrue) catches silent contract drift (undeclared fields), `each=True` validates list endpoints.
|
|
148
150
|
- **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
149
151
|
|
|
150
152
|
**Extensibility**
|
|
@@ -5,6 +5,7 @@ from .assertpy import (
|
|
|
5
5
|
__version__,
|
|
6
6
|
add_extension,
|
|
7
7
|
assert_all,
|
|
8
|
+
assert_conforms,
|
|
8
9
|
assert_that,
|
|
9
10
|
assert_warn,
|
|
10
11
|
fail,
|
|
@@ -12,11 +13,11 @@ from .assertpy import (
|
|
|
12
13
|
soft_assertions,
|
|
13
14
|
soft_fail,
|
|
14
15
|
)
|
|
15
|
-
from .async_assertions import AsyncAssertionBuilder
|
|
16
|
-
from .errors import AssertionFailure, DiffEntry, DiffResult
|
|
16
|
+
from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
|
|
17
|
+
from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace
|
|
17
18
|
from .file import contents_of
|
|
18
19
|
from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
|
|
19
|
-
from .snapshot import SnapshotCreatedWarning
|
|
20
|
+
from .snapshot import SnapshotCreatedWarning, SnapshotUpdatedWarning, register_snapshot_serializer
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
22
23
|
"AssertionFailure",
|
|
@@ -25,12 +26,17 @@ __all__ = [
|
|
|
25
26
|
"DiffResult",
|
|
26
27
|
"Matcher",
|
|
27
28
|
"NegatedBuilder",
|
|
29
|
+
"PollSample",
|
|
30
|
+
"PollTrace",
|
|
28
31
|
"SnapshotCreatedWarning",
|
|
32
|
+
"SnapshotUpdatedWarning",
|
|
29
33
|
"SoftAssertionCollector",
|
|
34
|
+
"SyncAssertionBuilder",
|
|
30
35
|
"WarningLoggingAdapter",
|
|
31
36
|
"__version__",
|
|
32
37
|
"add_extension",
|
|
33
38
|
"assert_all",
|
|
39
|
+
"assert_conforms",
|
|
34
40
|
"assert_that",
|
|
35
41
|
"assert_warn",
|
|
36
42
|
"clear_custom_matchers",
|
|
@@ -38,6 +44,7 @@ __all__ = [
|
|
|
38
44
|
"fail",
|
|
39
45
|
"match",
|
|
40
46
|
"register_matcher",
|
|
47
|
+
"register_snapshot_serializer",
|
|
41
48
|
"remove_extension",
|
|
42
49
|
"soft_assertions",
|
|
43
50
|
"soft_fail",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
``is_equal_to`` builds a `_CompareConfig` from its ``tolerance``/``comparators`` kwargs and threads it
|
|
4
4
|
through both the boolean comparison (`HelpersMixin._dict_not_equal()`) and the diff/message rendering
|
|
5
|
-
(`
|
|
5
|
+
(`assertpy2._diff._sub_diff_entries()`, `HelpersMixin._dict_err()`). `_node_decision()` is the single
|
|
6
6
|
switch both sides consult, so a tolerated or comparator-equal leaf is reported in neither. With ``config is
|
|
7
7
|
None`` every helper reproduces the engine's historical ``actual != expected`` behavior exactly.
|
|
8
8
|
|
|
@@ -31,20 +31,25 @@ class _CompareConfig:
|
|
|
31
31
|
"""Tolerance and custom comparators for a single ``is_equal_to`` call.
|
|
32
32
|
|
|
33
33
|
``tolerance`` is an absolute tolerance applied to real-number leaves; ``comparators`` maps a ``type`` or
|
|
34
|
-
an immediate field name to a ``(actual, expected) -> bool`` predicate that owns matching leaves
|
|
34
|
+
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``.
|
|
35
36
|
"""
|
|
36
37
|
|
|
37
38
|
tolerance: float | None = None
|
|
38
39
|
comparators: dict[object, Callable[[object, object], bool]] | None = None
|
|
40
|
+
ignore_null: bool = False
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
def _build_compare_config(tolerance, comparators) -> _CompareConfig | None:
|
|
42
|
-
"""Validate the ``is_equal_to`` ``tolerance``/``comparators`` kwargs and build a config
|
|
43
|
+
def _build_compare_config(tolerance, comparators, ignore_null=False) -> _CompareConfig | None:
|
|
44
|
+
"""Validate the ``is_equal_to`` ``tolerance``/``comparators``/``ignore_null`` kwargs and build a config.
|
|
43
45
|
|
|
44
|
-
``tolerance`` must be a non-negative real number (not
|
|
45
|
-
dict of ``(actual, expected) -> bool`` callables
|
|
46
|
+
Returns ``None`` when none are set. ``tolerance`` must be a non-negative real number (not
|
|
47
|
+
``bool``/``complex``/``NaN``); ``comparators`` must be a dict of ``(actual, expected) -> bool`` callables
|
|
48
|
+
keyed by ``type`` or field name; ``ignore_null`` must be a bool.
|
|
46
49
|
"""
|
|
47
|
-
if
|
|
50
|
+
if ignore_null is not False and ignore_null is not True:
|
|
51
|
+
raise TypeError("given ignore_null arg must be a bool")
|
|
52
|
+
if tolerance is None and comparators is None and not ignore_null:
|
|
48
53
|
return None
|
|
49
54
|
if tolerance is not None:
|
|
50
55
|
if isinstance(tolerance, bool) or not isinstance(tolerance, numbers.Number) or isinstance(tolerance, complex):
|
|
@@ -59,7 +64,7 @@ def _build_compare_config(tolerance, comparators) -> _CompareConfig | None:
|
|
|
59
64
|
for comparator in comparators.values():
|
|
60
65
|
if not callable(comparator):
|
|
61
66
|
raise TypeError("each comparator must be callable")
|
|
62
|
-
return _CompareConfig(tolerance=tolerance, comparators=comparators)
|
|
67
|
+
return _CompareConfig(tolerance=tolerance, comparators=comparators, ignore_null=ignore_null)
|
|
63
68
|
|
|
64
69
|
|
|
65
70
|
def _ambiguous_array_operand(value: object, other: object) -> object | None:
|
|
@@ -71,6 +76,8 @@ def _ambiguous_array_operand(value: object, other: object) -> object | None:
|
|
|
71
76
|
truth test is actually attempted, so 0-d / scalar array values (which *are* truth-testable) pass
|
|
72
77
|
through unchanged.
|
|
73
78
|
"""
|
|
79
|
+
if not hasattr(value, "__array__") and not hasattr(other, "__array__"):
|
|
80
|
+
return None # fast path: no array-like operand, skip the tuple/loop on every is_equal_to
|
|
74
81
|
for candidate, counterpart in ((value, other), (other, value)):
|
|
75
82
|
if hasattr(candidate, "__array__"):
|
|
76
83
|
try:
|
|
@@ -133,7 +140,7 @@ def _find_ambiguous_operand(actual, expected, _seen=None):
|
|
|
133
140
|
return None
|
|
134
141
|
|
|
135
142
|
|
|
136
|
-
def _guarded_not_equal(actual, expected) -> bool:
|
|
143
|
+
def _guarded_not_equal(actual, expected, *, method="is_equal_to") -> bool:
|
|
137
144
|
"""``bool(actual != expected)``, converting the ambiguity raised from *inside* a container's ``==``
|
|
138
145
|
(where the top-level operand gate cannot see the array member) into the actionable ``TypeError``."""
|
|
139
146
|
try:
|
|
@@ -142,7 +149,18 @@ def _guarded_not_equal(actual, expected) -> bool:
|
|
|
142
149
|
operand = _find_ambiguous_operand(actual, expected)
|
|
143
150
|
if operand is None:
|
|
144
151
|
raise
|
|
145
|
-
raise _array_equality_error(
|
|
152
|
+
raise _array_equality_error(method, operand) from error
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _guarded_equal(actual, expected, *, method) -> bool:
|
|
156
|
+
"""``bool(actual == expected)`` with the same nested array/frame-like guard as `_guarded_not_equal`."""
|
|
157
|
+
try:
|
|
158
|
+
return bool(actual == expected)
|
|
159
|
+
except (ValueError, TypeError) as error:
|
|
160
|
+
operand = _find_ambiguous_operand(actual, expected)
|
|
161
|
+
if operand is None:
|
|
162
|
+
raise
|
|
163
|
+
raise _array_equality_error(method, operand) from error
|
|
146
164
|
|
|
147
165
|
|
|
148
166
|
def _is_real_number(value) -> bool:
|
|
@@ -195,6 +213,8 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
|
|
|
195
213
|
never recursed into.
|
|
196
214
|
"""
|
|
197
215
|
if config is not None:
|
|
216
|
+
if config.ignore_null and field is not None and expected is None:
|
|
217
|
+
return "equal" # a named field the expected side leaves None is not compared
|
|
198
218
|
comparator = _resolve_comparator(actual, config, field=field)
|
|
199
219
|
if comparator is not None:
|
|
200
220
|
return "equal" if comparator(actual, expected) else "leaf"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Internal compatibility shims.
|
|
2
|
+
|
|
3
|
+
``Self`` entered the standard library's `typing` in Python 3.11; on 3.10 it is provided by
|
|
4
|
+
``typing_extensions``. Re-exporting it from one place lets every mixin import ``Self`` without repeating
|
|
5
|
+
the version gate, and lets ``typing_extensions`` be dropped as a runtime dependency on Python 3.11+.
|
|
6
|
+
|
|
7
|
+
``BaseExceptionGroup`` is a builtin from Python 3.11; on 3.10 it comes from the ``exceptiongroup``
|
|
8
|
+
backport when installed, and otherwise degrades to an empty tuple so ``isinstance`` is simply always
|
|
9
|
+
``False`` (a 3.10 interpreter without the backport cannot have produced a group anyway).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
if sys.version_info >= (3, 11):
|
|
15
|
+
from builtins import BaseExceptionGroup
|
|
16
|
+
from typing import Self
|
|
17
|
+
else: # pragma: no cover - exercised only on Python 3.10
|
|
18
|
+
from typing_extensions import Self
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from exceptiongroup import BaseExceptionGroup # ty: ignore[unresolved-import] # optional 3.10 backport
|
|
22
|
+
except ImportError:
|
|
23
|
+
BaseExceptionGroup = ()
|
|
24
|
+
|
|
25
|
+
__all__ = ["BaseExceptionGroup", "Self"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Contract-drift detection for `assert_conforms(..., exact=True)`.
|
|
2
|
+
|
|
3
|
+
Reports fields a raw payload carries that its pydantic v2 model does **not** declare - the silent API
|
|
4
|
+
growth that `model_validate` drops by default. Duck-typed on ``model_fields`` (no pydantic import),
|
|
5
|
+
alias-aware, and recursive into nested sub-models and ``list``/``tuple`` of sub-models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import types
|
|
11
|
+
import typing
|
|
12
|
+
from functools import reduce
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _submodel(annotation: object) -> type | None:
|
|
17
|
+
"""The nested model class for an annotation, peeling ``Optional`` / ``list`` / ``tuple``; else ``None``."""
|
|
18
|
+
origin = typing.get_origin(annotation)
|
|
19
|
+
args = typing.get_args(annotation)
|
|
20
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
21
|
+
non_none = [arg for arg in args if arg is not type(None)]
|
|
22
|
+
return _submodel(non_none[0]) if len(non_none) == 1 else None
|
|
23
|
+
if origin in (list, tuple, set, frozenset) and args:
|
|
24
|
+
return _submodel(args[0])
|
|
25
|
+
if isinstance(annotation, type) and hasattr(annotation, "model_fields"):
|
|
26
|
+
return annotation
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _collect_aliases(keys: list[str], alias: object) -> None:
|
|
31
|
+
"""Add every top-level payload key an alias spec maps to (str, ``AliasChoices``, ``AliasPath``)."""
|
|
32
|
+
if isinstance(alias, str):
|
|
33
|
+
keys.append(alias)
|
|
34
|
+
elif alias is not None:
|
|
35
|
+
for choice in getattr(alias, "choices", ()): # AliasChoices
|
|
36
|
+
_collect_aliases(keys, choice)
|
|
37
|
+
path = getattr(alias, "path", None) # AliasPath: its first segment is the consumed top-level key
|
|
38
|
+
if path and isinstance(path[0], str):
|
|
39
|
+
keys.append(path[0])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _alias_keys(info: Any) -> list[str]:
|
|
43
|
+
"""Every top-level key a field may appear under, across ``alias`` and ``validation_alias``."""
|
|
44
|
+
keys: list[str] = []
|
|
45
|
+
_collect_aliases(keys, getattr(info, "alias", None))
|
|
46
|
+
_collect_aliases(keys, getattr(info, "validation_alias", None))
|
|
47
|
+
return keys
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _declared_keys(model: Any) -> set[str]:
|
|
51
|
+
"""Field names plus all their aliases, so an aliased payload key is not mistaken for drift."""
|
|
52
|
+
keys: set[str] = set()
|
|
53
|
+
for name, info in model.model_fields.items():
|
|
54
|
+
keys.add(name)
|
|
55
|
+
keys.update(_alias_keys(info))
|
|
56
|
+
return keys
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def contract_drift(payload: object, model: Any, path: str = "") -> list[str]:
|
|
60
|
+
"""Paths of fields ``payload`` carries that ``model`` does not declare, recursively.
|
|
61
|
+
|
|
62
|
+
A model whose config opts into extras (``extra="allow"``) keeps them intentionally, so its level is
|
|
63
|
+
skipped. Non-dict payloads (already validated) contribute nothing.
|
|
64
|
+
"""
|
|
65
|
+
if not isinstance(payload, dict):
|
|
66
|
+
return []
|
|
67
|
+
drift: list[str] = []
|
|
68
|
+
if getattr(model, "model_config", {}).get("extra") != "allow":
|
|
69
|
+
declared = _declared_keys(model)
|
|
70
|
+
drift += [f"{path}{key}" for key in payload if key not in declared]
|
|
71
|
+
for name, info in model.model_fields.items():
|
|
72
|
+
submodel = _submodel(info.annotation)
|
|
73
|
+
if submodel is None:
|
|
74
|
+
continue
|
|
75
|
+
value = payload.get(name)
|
|
76
|
+
if value is None:
|
|
77
|
+
for alias in _alias_keys(info):
|
|
78
|
+
if alias in payload:
|
|
79
|
+
value = payload.get(alias)
|
|
80
|
+
break
|
|
81
|
+
if isinstance(value, (list, tuple)):
|
|
82
|
+
for index, element in enumerate(value):
|
|
83
|
+
drift += contract_drift(element, submodel, f"{path}{name}[{index}].")
|
|
84
|
+
elif isinstance(value, dict):
|
|
85
|
+
drift += contract_drift(value, submodel, f"{path}{name}.")
|
|
86
|
+
return drift
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def shape(value: object) -> object:
|
|
90
|
+
"""The structural shape of a value: paths and type *categories*, never values.
|
|
91
|
+
|
|
92
|
+
Numbers collapse to one category (so ``5`` and ``5.0`` do not read as drift) and ``None`` becomes
|
|
93
|
+
``"null"`` (a nullable wildcard). A list becomes a single merged element shape. This is what a
|
|
94
|
+
contract snapshot stores, so later runs pass when values change but fail on structural drift.
|
|
95
|
+
"""
|
|
96
|
+
if value is None:
|
|
97
|
+
return "null"
|
|
98
|
+
if isinstance(value, bool):
|
|
99
|
+
return "bool"
|
|
100
|
+
if isinstance(value, (int, float)):
|
|
101
|
+
return "number"
|
|
102
|
+
if isinstance(value, str):
|
|
103
|
+
return "str"
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
return {key: shape(item) for key, item in value.items()}
|
|
106
|
+
if isinstance(value, (list, tuple)):
|
|
107
|
+
element_shapes = [shape(item) for item in value]
|
|
108
|
+
return [reduce(_merge, element_shapes)] if element_shapes else []
|
|
109
|
+
return type(value).__name__
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _merge(left: Any, right: Any) -> Any:
|
|
113
|
+
"""Merge two element shapes into one representative shape (``null`` yields to a concrete type)."""
|
|
114
|
+
if left == right:
|
|
115
|
+
return left
|
|
116
|
+
if left == "null":
|
|
117
|
+
return right
|
|
118
|
+
if right == "null":
|
|
119
|
+
return left
|
|
120
|
+
if isinstance(left, dict) and isinstance(right, dict):
|
|
121
|
+
return {key: _merge(left.get(key, "null"), right.get(key, "null")) for key in set(left) | set(right)}
|
|
122
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
123
|
+
if not left:
|
|
124
|
+
return right
|
|
125
|
+
if not right:
|
|
126
|
+
return left
|
|
127
|
+
return [_merge(left[0], right[0])]
|
|
128
|
+
return "mixed"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _shape_name(part: object) -> str:
|
|
132
|
+
if isinstance(part, str):
|
|
133
|
+
return part
|
|
134
|
+
return "object" if isinstance(part, dict) else "list"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _join(path: str, key: str) -> str:
|
|
138
|
+
return f"{path}.{key}" if path else key
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def shape_diff(old: Any, new: Any, path: str = "") -> list[tuple[str, str, str]]:
|
|
142
|
+
"""Structural drift between two shapes: ``(kind, path, detail)`` for added / removed / retyped leaves.
|
|
143
|
+
|
|
144
|
+
A ``null`` on either side is a nullable wildcard and never counts as drift.
|
|
145
|
+
"""
|
|
146
|
+
if old == new or old == "null" or new == "null":
|
|
147
|
+
return []
|
|
148
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
149
|
+
drift: list[tuple[str, str, str]] = [("added", _join(path, key), "") for key in new if key not in old]
|
|
150
|
+
for key in old:
|
|
151
|
+
child = _join(path, key)
|
|
152
|
+
if key not in new:
|
|
153
|
+
drift.append(("removed", child, ""))
|
|
154
|
+
else:
|
|
155
|
+
drift += shape_diff(old[key], new[key], child)
|
|
156
|
+
return drift
|
|
157
|
+
if isinstance(old, list) and isinstance(new, list):
|
|
158
|
+
if not old or not new:
|
|
159
|
+
return []
|
|
160
|
+
return shape_diff(old[0], new[0], f"{path}[*]")
|
|
161
|
+
return [("retyped", path, f"{_shape_name(old)} -> {_shape_name(new)}")]
|