assertpy2 2.14.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.14.0 → assertpy2-2.15.0}/.github/workflows/publish.yml +2 -2
- {assertpy2-2.14.0 → assertpy2-2.15.0}/PKG-INFO +9 -6
- {assertpy2-2.14.0 → assertpy2-2.15.0}/README.md +8 -5
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/__init__.py +7 -2
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_compare.py +16 -7
- assertpy2-2.15.0/assertpy2/_compat.py +25 -0
- assertpy2-2.15.0/assertpy2/_contract.py +161 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_mixin_base.py +1 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_typing.py +100 -26
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/assertpy.py +416 -54
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/async_assertions.py +144 -14
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/base.py +271 -15
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/collection.py +94 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/contains.py +107 -18
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/errors.py +12 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/exception.py +119 -2
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/extracting.py +12 -9
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/helpers.py +51 -6
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/matchers.py +120 -6
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/pytest_plugin.py +92 -0
- assertpy2-2.15.0/assertpy2/snapshot.py +753 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/string.py +102 -2
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assertions.md +20 -1
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/comparison.md +15 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/errors.md +31 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/fluent.md +12 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/matchers.md +3 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/async.md +10 -1
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/entry-points.md +11 -0
- assertpy2-2.15.0/docs/reference/snapshots.md +23 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/testing.md +146 -12
- assertpy2-2.15.0/docs/type-safety.md +213 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/pyproject.toml +1 -1
- assertpy2-2.15.0/tests/conftest.py +22 -0
- assertpy2-2.15.0/tests/test_boundary_cases.py +135 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_bytes.py +6 -6
- assertpy2-2.15.0/tests/test_core.py +168 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dataframe.py +1 -2
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dynamic.py +3 -5
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_equals.py +7 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_expected_exception.py +183 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_extracting.py +1 -4
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_file.py +1 -3
- {assertpy2-2.14.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.14.0 → assertpy2-2.15.0}/tests/test_list.py +168 -1
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_matchers.py +46 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_not.py +47 -0
- assertpy2-2.15.0/tests/test_property_based.py +704 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_pytest_plugin.py +166 -6
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_assertion.py +10 -1
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare_config.py +44 -3
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_rich_diff.py +71 -5
- assertpy2-2.15.0/tests/test_snapshots.py +988 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_string.py +92 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_structural.py +350 -1
- assertpy2-2.15.0/tests/test_sync_eventually.py +216 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_type.py +61 -0
- assertpy2-2.15.0/tests/test_typing.py +197 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/uv.lock +9 -9
- assertpy2-2.14.0/assertpy2/_compat.py +0 -15
- assertpy2-2.14.0/assertpy2/snapshot.py +0 -296
- assertpy2-2.14.0/docs/reference/snapshots.md +0 -11
- assertpy2-2.14.0/docs/type-safety.md +0 -67
- assertpy2-2.14.0/tests/test_core.py +0 -82
- assertpy2-2.14.0/tests/test_iterable_cluster.py +0 -201
- assertpy2-2.14.0/tests/test_mutation_hardening.py +0 -138
- assertpy2-2.14.0/tests/test_property_based.py +0 -333
- assertpy2-2.14.0/tests/test_snapshots.py +0 -349
- assertpy2-2.14.0/tests/test_typing.py +0 -94
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.codecov.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.gitattributes +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/.gitignore +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/LICENSE +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/SECURITY.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_diff.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/_introspection.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/date.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/file.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/data.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/extending.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/getting-started.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/index.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/integrations.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/logo.svg +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/migration.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/core.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/errors.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/files.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/json.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/mkdocs.yml +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_async.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_bool.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_callable.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_class.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_collection.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_description.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dict.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_errors.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_fail.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_in.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_json.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_none.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_protocol_parity.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_readme.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_satisfy.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_soft.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.14.0 → assertpy2-2.15.0}/tests/test_warn.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,20 +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
|
+
- [**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.
|
|
191
192
|
|
|
192
193
|
**Type safety**
|
|
193
194
|
|
|
194
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.
|
|
195
198
|
- **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
196
199
|
|
|
197
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,20 +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
|
+
- [**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.
|
|
143
144
|
|
|
144
145
|
**Type safety**
|
|
145
146
|
|
|
146
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.
|
|
147
150
|
- **py.typed**: `Self` return types, PEP 561 compliant ([PEP 561](https://peps.python.org/pep-0561/)).
|
|
148
151
|
|
|
149
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 .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
|
|
16
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",
|
|
@@ -28,11 +29,14 @@ __all__ = [
|
|
|
28
29
|
"PollSample",
|
|
29
30
|
"PollTrace",
|
|
30
31
|
"SnapshotCreatedWarning",
|
|
32
|
+
"SnapshotUpdatedWarning",
|
|
31
33
|
"SoftAssertionCollector",
|
|
34
|
+
"SyncAssertionBuilder",
|
|
32
35
|
"WarningLoggingAdapter",
|
|
33
36
|
"__version__",
|
|
34
37
|
"add_extension",
|
|
35
38
|
"assert_all",
|
|
39
|
+
"assert_conforms",
|
|
36
40
|
"assert_that",
|
|
37
41
|
"assert_warn",
|
|
38
42
|
"clear_custom_matchers",
|
|
@@ -40,6 +44,7 @@ __all__ = [
|
|
|
40
44
|
"fail",
|
|
41
45
|
"match",
|
|
42
46
|
"register_matcher",
|
|
47
|
+
"register_snapshot_serializer",
|
|
43
48
|
"remove_extension",
|
|
44
49
|
"soft_assertions",
|
|
45
50
|
"soft_fail",
|
|
@@ -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:
|
|
@@ -206,6 +213,8 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
|
|
|
206
213
|
never recursed into.
|
|
207
214
|
"""
|
|
208
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
|
|
209
218
|
comparator = _resolve_comparator(actual, config, field=field)
|
|
210
219
|
if comparator is not None:
|
|
211
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)}")]
|