assertpy2 2.28.0__tar.gz → 2.29.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.28.0 → assertpy2-2.29.0}/PKG-INFO +1 -1
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_compare.py +1 -1
- assertpy2-2.29.0/assertpy2/_engine/_contract.py +579 -0
- assertpy2-2.29.0/assertpy2/_engine/_diff.py +916 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_equality.py +86 -18
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_membership.py +2 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_ordering.py +34 -4
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_path.py +70 -12
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_matcher_impls.py +54 -31
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_satisfies.py +36 -26
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/assertpy.py +23 -14
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/base.py +12 -7
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/collection.py +21 -6
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/contains.py +34 -21
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/date.py +5 -5
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/dynamic.py +3 -1
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/errors.py +13 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/helpers.py +34 -31
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/numeric.py +6 -6
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/snapshot.py +3 -3
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/string.py +21 -12
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/concepts/type-safety.md +21 -1
- {assertpy2-2.28.0 → assertpy2-2.29.0}/pyproject.toml +1 -1
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_complexity.py +4 -10
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_datetime.py +25 -24
- assertpy2-2.29.0/tests/test_deep_nesting.py +437 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_duality.py +13 -156
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_evaluation_core.py +486 -2
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_match_result.py +53 -0
- assertpy2-2.29.0/tests/test_matcher_algebra.py +230 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_property_based.py +9 -4
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare.py +17 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_structural.py +831 -38
- assertpy2-2.29.0/tests/test_unrenderable_values.py +244 -0
- assertpy2-2.28.0/assertpy2/_engine/_contract.py +0 -166
- assertpy2-2.28.0/assertpy2/_engine/_diff.py +0 -718
- {assertpy2-2.28.0 → assertpy2-2.29.0}/.gitignore +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/LICENSE +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/README.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/SECURITY.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_clustering.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_dangling.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_builder_check_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_capable_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_check_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_compat.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_introspection.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_mixin_base.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_negated_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_operations.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_pairing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_poll_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_require.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_size.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_text.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_engine/_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_hints.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_inline.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/_snapshot_codec.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/extracting.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/file.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/http_mixin.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/matchers.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/outcome.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/benchmarks/test_perf.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/concepts/stability.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/extending/custom-assertions.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/extending/integrations.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/getting-started/comparison.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/getting-started/migration.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/assertions.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/data.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/errors.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/fluent.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/matchers.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/guides/testing.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/index.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/logo.svg +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/recipes.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/async.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/core.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/errors.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/files.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/http.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/json.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/mkdocs.yml +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/mkdocs_hooks.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/generate_check_protocols.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/generate_poll_protocols.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/mutation_report.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/scripts/regenerate.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/api_snapshot.json +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/api_surface.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/chain_corpus.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/chain_model.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/chain_surfaces.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/conftest.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/docs_fixtures.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/group_compat.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/poll_parity_baseline.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/poll_parity_cases.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/pyright_baseline.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_api_compatibility.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_api_vocabulary.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_architecture_doc.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_async.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_async_predicates.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_attrs_support.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_bool.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_call_awaitables.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_callable.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_capable_protocol.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_chain_machine.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_check.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_check_protocols.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_class.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_clustering.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_collection.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_core.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dangling.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dataframe.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_declared_floors.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_description.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dict.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_docs_examples.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_docs_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_dynamic.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_equality_signals.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_equals.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_errors.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_exception_context.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_expected_contract.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_extensions.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_extracting.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_fail.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_file.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_hints.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_hostile_values.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_http.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_in.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_inline_record.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_inline_snapshot.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_integer_arguments.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_json.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_list.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_matcher_parity.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_matchers.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_message_elision.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_negated_protocols.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_none.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_not.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_number_zoo.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_numeric_questions.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_openapi_contract.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_operation_contract.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_optional_integration_contracts.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_overload_order.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pin_coverage.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_poll_parity.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_poll_protocols.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_prerequisites.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_property_attrs.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_protocol_parity.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_public_surface.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pyright_baseline.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare_config.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_refusals.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_requirement.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_rich_diff.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_satisfy.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_skip_guard.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_snapshots.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_soft.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_string.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_surface_conformance.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_sync_eventually.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_type.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_type_expression_failures.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_chain_corpus.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_claims.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_completeness.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_conformance.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_from_a_wheel.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_http.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_integrations.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_negative.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_typing_promises.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_vacuity_contract.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_verdict_corpus.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/test_warn.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_cases.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_chain_corpus_baseline.json +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_harness.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_http.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_integrations.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_integrations_baseline.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_negative_baseline.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/typing_promises.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/verdict_corpus.py +0 -0
- {assertpy2-2.28.0 → assertpy2-2.29.0}/tests/verdict_golden.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.29.0
|
|
4
4
|
Summary: Fluent, fully typed assertion library for Python: your type checker sees only the assertions that fit the value, and every failure carries a structured diff. Soft assertions, polling, inline snapshots, OpenAPI contracts.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Solganis/assertpy2
|
|
6
6
|
Project-URL: Repository, https://github.com/Solganis/assertpy2
|
|
@@ -630,7 +630,7 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
|
|
|
630
630
|
``"strict"`` is the fourth: the two sides are equal, but ``strict_types`` or a comparator still has to
|
|
631
631
|
look inside, because a container's ``==`` says nothing about the types of its members, nor about what a
|
|
632
632
|
comparator says of them. It differs from ``"recurse"`` only in what an undecomposable value means,
|
|
633
|
-
which `assertpy2._engine._diff.
|
|
633
|
+
which `assertpy2._engine._diff._Walk.descend()` is the single place to know.
|
|
634
634
|
"""
|
|
635
635
|
if config is not None:
|
|
636
636
|
if config.ignore_null and field is not None and as_held(expected) is None:
|
|
@@ -0,0 +1,579 @@
|
|
|
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 walked beside the validated instance, so every nested model is checked where pydantic put one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import collections.abc
|
|
11
|
+
import datetime
|
|
12
|
+
import decimal
|
|
13
|
+
import enum
|
|
14
|
+
import fractions
|
|
15
|
+
import json
|
|
16
|
+
import pathlib
|
|
17
|
+
import types
|
|
18
|
+
import uuid
|
|
19
|
+
from functools import reduce
|
|
20
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, Union, get_args, get_origin
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _alias_sources(alias: object) -> list[tuple[object, ...]]:
|
|
27
|
+
"""The payload paths an alias reads, in the order pydantic tries them (str, ``AliasChoices``, ``AliasPath``)."""
|
|
28
|
+
if isinstance(alias, str):
|
|
29
|
+
return [(alias,)]
|
|
30
|
+
choices = getattr(alias, "choices", None)
|
|
31
|
+
if choices is not None:
|
|
32
|
+
return [source for choice in choices for source in _alias_sources(choice)]
|
|
33
|
+
path = getattr(alias, "path", None)
|
|
34
|
+
return [tuple(path)] if path else []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _declared_keys(model: Any) -> set[str]:
|
|
38
|
+
"""Field names plus the top-level key of every alias, so an aliased payload key is not mistaken for drift."""
|
|
39
|
+
keys: set[str] = set()
|
|
40
|
+
for name, info in model.model_fields.items():
|
|
41
|
+
keys.add(name)
|
|
42
|
+
for alias in (getattr(info, "alias", None), getattr(info, "validation_alias", None)):
|
|
43
|
+
if alias is not None:
|
|
44
|
+
keys.update(source[0] for source in _alias_sources(alias) if isinstance(source[0], str))
|
|
45
|
+
return keys
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _field_sources(name: str, info: Any, config: Any) -> tuple[tuple[object, ...], ...]:
|
|
49
|
+
"""Where pydantic reads field *name*, in the order it looks: its alias sources, then the name if allowed."""
|
|
50
|
+
validation_alias = getattr(info, "validation_alias", None)
|
|
51
|
+
alias = validation_alias if validation_alias is not None else getattr(info, "alias", None)
|
|
52
|
+
if alias is None:
|
|
53
|
+
return ((name,),)
|
|
54
|
+
sources = _alias_sources(alias) if config.get("validate_by_alias") is not False else []
|
|
55
|
+
by_name = config.get("validate_by_name")
|
|
56
|
+
if by_name if by_name is not None else config.get("populate_by_name"):
|
|
57
|
+
sources.append((name,))
|
|
58
|
+
return tuple(sources)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_KEYLESS = (
|
|
62
|
+
int,
|
|
63
|
+
float,
|
|
64
|
+
complex,
|
|
65
|
+
str,
|
|
66
|
+
bytes,
|
|
67
|
+
bytearray,
|
|
68
|
+
type(None),
|
|
69
|
+
decimal.Decimal,
|
|
70
|
+
fractions.Fraction,
|
|
71
|
+
datetime.date,
|
|
72
|
+
datetime.time,
|
|
73
|
+
datetime.timedelta,
|
|
74
|
+
uuid.UUID,
|
|
75
|
+
enum.Enum,
|
|
76
|
+
pathlib.PurePath,
|
|
77
|
+
)
|
|
78
|
+
_PLAIN_ORIGINS = {
|
|
79
|
+
list,
|
|
80
|
+
tuple,
|
|
81
|
+
set,
|
|
82
|
+
frozenset,
|
|
83
|
+
dict,
|
|
84
|
+
collections.abc.Sequence,
|
|
85
|
+
collections.abc.MutableSequence,
|
|
86
|
+
collections.abc.Set,
|
|
87
|
+
collections.abc.MutableSet,
|
|
88
|
+
collections.abc.Mapping,
|
|
89
|
+
collections.abc.MutableMapping,
|
|
90
|
+
collections.abc.Iterable,
|
|
91
|
+
collections.abc.Iterator,
|
|
92
|
+
collections.abc.Collection,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _may_hold_model(annotation: object, outer: bool = True) -> bool:
|
|
97
|
+
"""Whether a model can be built anywhere under a field's *annotation*; only a type made of plain parts says no.
|
|
98
|
+
|
|
99
|
+
``Any`` says yes at the top of the field, where only a validator puts a model and the payload's own object kept
|
|
100
|
+
there costs one identity check, and no inside a container, where passing each kept item costs what skipping saves.
|
|
101
|
+
"""
|
|
102
|
+
if annotation is Any or annotation is object:
|
|
103
|
+
return outer
|
|
104
|
+
if annotation in (list, tuple, set, frozenset, dict):
|
|
105
|
+
return False
|
|
106
|
+
origin = get_origin(annotation)
|
|
107
|
+
if origin is Literal:
|
|
108
|
+
return False
|
|
109
|
+
if origin is Annotated:
|
|
110
|
+
return _may_hold_model(get_args(annotation)[0], outer)
|
|
111
|
+
if origin is Union or origin is types.UnionType:
|
|
112
|
+
return any(_may_hold_model(arg, outer) for arg in get_args(annotation))
|
|
113
|
+
if origin in _PLAIN_ORIGINS:
|
|
114
|
+
return any(_may_hold_model(arg, outer=False) for arg in get_args(annotation) if arg is not Ellipsis)
|
|
115
|
+
return origin is not None or not isinstance(annotation, type) or not issubclass(annotation, _KEYLESS)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_FieldReads = tuple[tuple[str, tuple[tuple[object, ...], ...]], ...]
|
|
119
|
+
|
|
120
|
+
_READS: dict[type, tuple[object, frozenset[str], _FieldReads]] = {}
|
|
121
|
+
"""Per model class, with the validator they were read beside: the keys it declares and where each field is read.
|
|
122
|
+
|
|
123
|
+
Worked out per level of every payload, it was most of the walk: a list of 1000 nested models asked for it 1000 times.
|
|
124
|
+
A field whose type cannot hold a model is left out: a 300 by 300 grid of floats cost 35 ms to walk and holds nothing.
|
|
125
|
+
A rebuild replaces the validator, and with it what the class reads, so an entry answers only for the validator it
|
|
126
|
+
was made with. At most 256 classes are kept, so models made on the fly cannot grow it without bound."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads]:
|
|
130
|
+
validator = getattr(model, "__pydantic_validator__", None)
|
|
131
|
+
known = _READS.get(model)
|
|
132
|
+
if known is None or known[0] is not validator:
|
|
133
|
+
config = getattr(model, "model_config", {})
|
|
134
|
+
fields = tuple(
|
|
135
|
+
(name, _field_sources(name, info, config))
|
|
136
|
+
for name, info in model.model_fields.items()
|
|
137
|
+
if _may_hold_model(getattr(info, "annotation", None))
|
|
138
|
+
)
|
|
139
|
+
known = (validator, frozenset(_declared_keys(model)), fields)
|
|
140
|
+
if model in _READS or len(_READS) < 256:
|
|
141
|
+
_READS[model] = known
|
|
142
|
+
return known[1], known[2]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _followed(payload: object, path: tuple[object, ...]) -> tuple[bool, object]:
|
|
146
|
+
"""What *path* reaches in *payload*, key by key and index by index, and whether it reached anything."""
|
|
147
|
+
current: Any = payload
|
|
148
|
+
for step in path:
|
|
149
|
+
if isinstance(current, dict):
|
|
150
|
+
if step not in current:
|
|
151
|
+
return False, None
|
|
152
|
+
current = current[step]
|
|
153
|
+
elif not isinstance(current, str):
|
|
154
|
+
# read as pydantic reads it, through whatever `__getitem__` the object has
|
|
155
|
+
try:
|
|
156
|
+
current = current[step]
|
|
157
|
+
except (LookupError, TypeError):
|
|
158
|
+
return False, None
|
|
159
|
+
else:
|
|
160
|
+
return False, None
|
|
161
|
+
return True, current
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _raw_field(payload: collections.abc.Mapping, sources: tuple[tuple[object, ...], ...]) -> tuple[bool, object]:
|
|
165
|
+
"""The part of *payload* pydantic validated a field from: the first of its sources that is there."""
|
|
166
|
+
for source in sources:
|
|
167
|
+
found, value = _followed(payload, source)
|
|
168
|
+
if found:
|
|
169
|
+
return True, value
|
|
170
|
+
return False, None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_model(value: object) -> bool:
|
|
174
|
+
return not isinstance(value, type) and hasattr(type(value), "model_fields")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_CONTAINERS = (dict, list, tuple, set, frozenset)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class UncheckableDriftError(Exception):
|
|
181
|
+
"""A part of the payload no reading pairs with the model it became, so whether it drifted cannot be told."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, path: str, reason: str) -> None:
|
|
184
|
+
super().__init__(f"{path or 'the payload'}: {reason}")
|
|
185
|
+
self.path = path
|
|
186
|
+
self.reason = reason
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def contract_drift(
|
|
190
|
+
payload: object, instance: Any, path: str = "", _seen: frozenset[tuple[int, int]] = frozenset()
|
|
191
|
+
) -> list[str]:
|
|
192
|
+
"""Paths of fields ``payload`` carries that the model ``instance`` was validated into does not declare.
|
|
193
|
+
|
|
194
|
+
Walked beside the instance rather than read off the annotations: which member of a union, which element type
|
|
195
|
+
of a tuple and which dict value a part of the payload became is what pydantic decided, and the instance holds
|
|
196
|
+
the answer. A model whose config opts into extras (``extra="allow"``) keeps them intentionally, so its own
|
|
197
|
+
level is skipped.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
UncheckableDriftError: where a part of the payload that could hide an undeclared key (a dict inside it, or
|
|
201
|
+
inside the JSON text it is) cannot be paired with a model built from it: a set, a resized list or an
|
|
202
|
+
object wrapped into either whose built items are not all one model class, or whose raw item that class
|
|
203
|
+
refuses on its own, dict keys coercion merged, a part of another shape than what was built, an iterable
|
|
204
|
+
validation read up or left lazy. The payload is read as validation left it: a validator renaming keys,
|
|
205
|
+
changing the payload in place, or reordering or rewriting a container's items without changing its size
|
|
206
|
+
is not seen, and the walk reads those items by position.
|
|
207
|
+
"""
|
|
208
|
+
model = type(instance)
|
|
209
|
+
if getattr(model, "__pydantic_root_model__", False):
|
|
210
|
+
return _value_drift(payload, instance.root, path, _seen) if _reads_of(model)[1] else []
|
|
211
|
+
if not isinstance(payload, (dict, collections.abc.Mapping)):
|
|
212
|
+
_refuse_if_key_hides(
|
|
213
|
+
payload, instance, path, f"the payload holds a {type(payload).__name__} where a model was built"
|
|
214
|
+
)
|
|
215
|
+
return []
|
|
216
|
+
seen = _seen | {(id(payload), id(instance))}
|
|
217
|
+
declared, fields = _reads_of(model)
|
|
218
|
+
prefix = f"{path}." if path else ""
|
|
219
|
+
drift: list[str] = []
|
|
220
|
+
if getattr(model, "model_config", {}).get("extra") != "allow":
|
|
221
|
+
drift += [f"{prefix}{key}" for key in payload if key not in declared]
|
|
222
|
+
else:
|
|
223
|
+
# extras typed through `__pydantic_extra__` are built too; untyped ones are the payload's own objects
|
|
224
|
+
for key, built in (getattr(instance, "__pydantic_extra__", None) or {}).items():
|
|
225
|
+
if key in payload:
|
|
226
|
+
drift += _value_drift(payload[key], built, f"{prefix}{key}", seen)
|
|
227
|
+
for name, sources in fields:
|
|
228
|
+
found, raw = _raw_field(payload, sources)
|
|
229
|
+
if not found:
|
|
230
|
+
continue
|
|
231
|
+
value = getattr(instance, name, None)
|
|
232
|
+
# a scalar built from a scalar holds nothing to pair
|
|
233
|
+
if (value is None or isinstance(value, (str, int, float, bytes))) and not isinstance(raw, _CONTAINERS):
|
|
234
|
+
continue
|
|
235
|
+
drift += _value_drift(raw, value, f"{prefix}{name}", seen)
|
|
236
|
+
return drift
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def exactness_failure(pairs: Any, *, carrier: str) -> str | None:
|
|
240
|
+
"""What an exact check of ``(payload, instance, path)`` pairs found, in the words a failure continues with.
|
|
241
|
+
|
|
242
|
+
The undeclared fields, after *carrier*, or the first part that cannot be checked; ``None`` when neither.
|
|
243
|
+
"""
|
|
244
|
+
drift: list[str] = []
|
|
245
|
+
try:
|
|
246
|
+
for payload, instance, path in pairs:
|
|
247
|
+
drift += contract_drift(payload, instance, path)
|
|
248
|
+
except UncheckableDriftError as refusal:
|
|
249
|
+
return f"<{refusal.path or 'the payload'}> cannot be checked: {refusal.reason}"
|
|
250
|
+
except RecursionError:
|
|
251
|
+
return "<the payload> cannot be checked: it nests deeper than the walk can follow"
|
|
252
|
+
if not drift:
|
|
253
|
+
return None
|
|
254
|
+
return f"{carrier}{len(drift)} undeclared field(s) the model does not declare: {sorted(drift)}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _value_drift(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
|
|
258
|
+
"""Drift under one validated value: a model's own keys, and every element or dict value that became a model."""
|
|
259
|
+
# the payload's own object kept as it was, or a scalar: nothing was built from it
|
|
260
|
+
if raw is value or value is None or isinstance(value, (str, int, float, bytes)):
|
|
261
|
+
return []
|
|
262
|
+
if isinstance(raw, (str, bytes, bytearray)):
|
|
263
|
+
raw = _decoded_container(raw)
|
|
264
|
+
# a pair already on the path is a cycle, the payload's own or one a validator built by assigning a model to itself
|
|
265
|
+
pair = (id(raw), id(value))
|
|
266
|
+
if pair in seen:
|
|
267
|
+
return []
|
|
268
|
+
seen = seen | {pair}
|
|
269
|
+
if _is_model(value):
|
|
270
|
+
return contract_drift(raw, value, path, seen)
|
|
271
|
+
return _container_drift(raw, value, path, seen)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _container_drift(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
|
|
275
|
+
"""Drift under a built container, paired with the raw part it came from as its kind allows."""
|
|
276
|
+
if isinstance(value, (list, tuple)):
|
|
277
|
+
return _sequence_drift(raw, value, path, seen)
|
|
278
|
+
if isinstance(value, dict):
|
|
279
|
+
return _mapping_drift(raw, value, path, seen)
|
|
280
|
+
if isinstance(value, (set, frozenset)):
|
|
281
|
+
return _set_drift(raw, value, path, seen)
|
|
282
|
+
if isinstance(value, collections.abc.Mapping):
|
|
283
|
+
return _mapping_drift(raw, dict(value), path, seen)
|
|
284
|
+
if isinstance(value, collections.abc.Iterator):
|
|
285
|
+
if _holds(raw, _is_mapping, read_text=True):
|
|
286
|
+
raise UncheckableDriftError(path, "validation is lazy here and builds the models only as the value is read")
|
|
287
|
+
return []
|
|
288
|
+
# a sequence validation kept as the payload's own kind, a deque under `Sequence[A]`
|
|
289
|
+
items = _items_of(value)
|
|
290
|
+
return [] if items is None else _sequence_drift(raw, items, path, seen)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _sequence_drift(raw: object, value: list | tuple, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
|
|
294
|
+
"""Paired by index, a collection pydantic also takes for a sequence (a deque, a set, a dict's values) in the
|
|
295
|
+
order it iterates, which is the order validation read it in."""
|
|
296
|
+
if not isinstance(raw, (list, tuple)):
|
|
297
|
+
items = _items_of(raw)
|
|
298
|
+
if items is None:
|
|
299
|
+
reason = f"the payload holds a {type(raw).__name__} where a sequence was built"
|
|
300
|
+
return _wrapped(raw, value, path, seen, reason)
|
|
301
|
+
raw = items
|
|
302
|
+
if len(raw) != len(value):
|
|
303
|
+
reason = f"validation changed its length, {len(raw)} items became {len(value)}"
|
|
304
|
+
return _unpaired(raw, [(f"{path}[{i}]", part) for i, part in enumerate(raw)], value, path, seen, reason)
|
|
305
|
+
return [
|
|
306
|
+
entry
|
|
307
|
+
for index, (part, element) in enumerate(zip(raw, value, strict=True))
|
|
308
|
+
for entry in _value_drift(part, element, f"{path}[{index}]", seen)
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _mapping_drift(raw: object, value: dict, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
|
|
313
|
+
"""Paired by key where validation kept the keys (a `TypedDict` reorders them), else by order, which dict validation
|
|
314
|
+
keeps while it coerces them; a key built into something other than text or a number is walked beside its raw key."""
|
|
315
|
+
if not isinstance(raw, (dict, collections.abc.Mapping)):
|
|
316
|
+
_refuse_if_key_hides(raw, value, path, f"the payload holds a {type(raw).__name__} where a mapping was built")
|
|
317
|
+
return []
|
|
318
|
+
if len(raw) != len(value):
|
|
319
|
+
# an overwritten value may have become another member of a union than the one that survived
|
|
320
|
+
_refuse_if_key_hides(raw, value, path, f"validation changed its size, {len(raw)} keys became {len(value)}")
|
|
321
|
+
return []
|
|
322
|
+
if raw.keys() == value.keys():
|
|
323
|
+
return [
|
|
324
|
+
entry
|
|
325
|
+
for key, part in raw.items()
|
|
326
|
+
for entry in _value_drift(part, value[key], f"{path}.{key}" if path else str(key), seen)
|
|
327
|
+
]
|
|
328
|
+
drift: list[str] = []
|
|
329
|
+
for (key, part), (built, element) in zip(raw.items(), value.items(), strict=True):
|
|
330
|
+
if not isinstance(built, (str, int)):
|
|
331
|
+
drift += _value_drift(key, built, path, seen)
|
|
332
|
+
drift += _value_drift(part, element, f"{path}.{key}" if path else str(key), seen)
|
|
333
|
+
return drift
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _set_drift(raw: object, value: set | frozenset, path: str, seen: frozenset[tuple[int, int]]) -> list[str]:
|
|
337
|
+
items = raw if isinstance(raw, (list, tuple)) else _items_of(raw)
|
|
338
|
+
if items is None:
|
|
339
|
+
reason = f"the payload holds a {type(raw).__name__} where a set was built"
|
|
340
|
+
return _wrapped(raw, value, path, seen, reason)
|
|
341
|
+
reason = "a set keeps no order to pair its items with the models they became"
|
|
342
|
+
parts = [(f"{path}[{i}]", part) for i, part in enumerate(items)]
|
|
343
|
+
return _unpaired(raw, parts, value, path, seen, reason, merged=True)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _wrapped(raw: object, value: object, path: str, seen: frozenset[tuple[int, int]], reason: str) -> list[str]:
|
|
347
|
+
"""Drift under a mapping a validator turned into a sequence or a set, a single object sent where many may be.
|
|
348
|
+
|
|
349
|
+
Only a mapping that became one item is that item; one that became several was expanded in a way no reading
|
|
350
|
+
pairs.
|
|
351
|
+
"""
|
|
352
|
+
if isinstance(raw, (dict, collections.abc.Mapping)) and len(_items_of(value) or ()) == 1:
|
|
353
|
+
return _unpaired(raw, [(path, raw)], value, path, seen, reason)
|
|
354
|
+
_refuse_if_key_hides(raw, value, path, reason)
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _unpaired(
|
|
359
|
+
raw: object,
|
|
360
|
+
parts: list[tuple[str, object]],
|
|
361
|
+
value: object,
|
|
362
|
+
path: str,
|
|
363
|
+
seen: frozenset[tuple[int, int]],
|
|
364
|
+
reason: str,
|
|
365
|
+
*,
|
|
366
|
+
merged: bool = False,
|
|
367
|
+
) -> list[str]:
|
|
368
|
+
"""Drift under a container whose raw items no reading pairs one by one with the items validation built.
|
|
369
|
+
|
|
370
|
+
Where every built item is an instance of one model class, each raw item became one too, so each raw item that
|
|
371
|
+
could hide a key is validated again on its own by that class, JSON text in JSON mode as `Json[...]` read it, and
|
|
372
|
+
walked beside what it becomes. Anything else refuses with *reason*: items of mixed classes, an item the class
|
|
373
|
+
refuses on its own, which needed its parent, and, where equal items *merged*, a class whose own `__eq__` could
|
|
374
|
+
have merged an item of another class into it.
|
|
375
|
+
"""
|
|
376
|
+
if not _holds(value, _builds_model) or not _holds(raw, _is_mapping, read_text=True):
|
|
377
|
+
return []
|
|
378
|
+
model = _sole_model_class(value)
|
|
379
|
+
validate = getattr(model, "model_validate", None)
|
|
380
|
+
validate_json = getattr(model, "model_validate_json", None)
|
|
381
|
+
if validate is None or validate_json is None or (merged and not _equal_only_within_its_class(model)):
|
|
382
|
+
raise UncheckableDriftError(path, reason)
|
|
383
|
+
drift: list[str] = []
|
|
384
|
+
for label, item in parts:
|
|
385
|
+
part = _decoded_container(item) if isinstance(item, (str, bytes, bytearray)) else item
|
|
386
|
+
if _holds(part, _is_mapping, read_text=True):
|
|
387
|
+
try:
|
|
388
|
+
built = validate(part) if part is item else validate_json(item)
|
|
389
|
+
except ValueError:
|
|
390
|
+
raise UncheckableDriftError(path, reason) from None
|
|
391
|
+
drift += _value_drift(part, built, label, seen)
|
|
392
|
+
return drift
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _equal_only_within_its_class(model: type | None) -> bool:
|
|
396
|
+
"""Whether equal instances of *model* share its class: its `__eq__` is pydantic's own, which compares classes.
|
|
397
|
+
|
|
398
|
+
A parametrized generic model does not: pydantic compares its origin, which every specialization shares.
|
|
399
|
+
"""
|
|
400
|
+
if (getattr(model, "__pydantic_generic_metadata__", None) or {}).get("origin") is not None:
|
|
401
|
+
return False
|
|
402
|
+
owner = next((klass for klass in getattr(model, "__mro__", ()) if "__eq__" in vars(klass)), object)
|
|
403
|
+
return owner.__module__.startswith("pydantic.")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _sole_model_class(value: object) -> type | None:
|
|
407
|
+
"""The one model class every item of a built container is an instance of, or ``None``."""
|
|
408
|
+
items = _items_of(value) or []
|
|
409
|
+
kinds = {type(item) for item in items}
|
|
410
|
+
return kinds.pop() if len(kinds) == 1 and _is_model(items[0]) else None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _decoded_container(text: str | bytes | bytearray) -> object:
|
|
414
|
+
"""What JSON *text* decodes to, decoded again while that is text, when it ends in a mapping or a list; else *text*.
|
|
415
|
+
|
|
416
|
+
A model read from JSON text (`Json[...]`) is walked beside what the text holds. Text that is not JSON, or nests
|
|
417
|
+
deeper than the decoder goes, is left as it is.
|
|
418
|
+
"""
|
|
419
|
+
try:
|
|
420
|
+
current = json.loads(text)
|
|
421
|
+
while isinstance(current, str):
|
|
422
|
+
current = json.loads(current)
|
|
423
|
+
except (ValueError, RecursionError):
|
|
424
|
+
return text
|
|
425
|
+
return current if isinstance(current, (dict, list)) else text
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _refuse_if_key_hides(raw: object, value: object, path: str, reason: str) -> None:
|
|
429
|
+
"""Refuse a part no reading pairs with what it became, when a model was built there and a key could hide in it.
|
|
430
|
+
|
|
431
|
+
A number, a model given as is, an object read by attributes and text that is not JSON hold no key to drift.
|
|
432
|
+
"""
|
|
433
|
+
if _holds(value, _builds_model) and _holds(raw, _is_mapping, read_text=True):
|
|
434
|
+
raise UncheckableDriftError(path, reason)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _holds(value: object, found: Callable[[object], bool], *, read_text: bool = False) -> bool:
|
|
438
|
+
"""Whether *value*, or anything inside its containers (a dict's keys too), is *found*.
|
|
439
|
+
|
|
440
|
+
With *read_text*, text counts as the JSON it decodes to, decoded again while that is text, and two things count
|
|
441
|
+
as found, since what they hold cannot be told: text nesting deeper than the decoder goes, and an iterable that is
|
|
442
|
+
no collection, which validation may have read up or may answer differently when read again. Every part stays
|
|
443
|
+
referenced until the search ends, so the id of one already seen is never taken by a part decoded later.
|
|
444
|
+
"""
|
|
445
|
+
pending, visited = [value], {id(value): value}
|
|
446
|
+
while pending:
|
|
447
|
+
current = pending.pop()
|
|
448
|
+
if read_text and isinstance(current, (str, bytes, bytearray)):
|
|
449
|
+
try:
|
|
450
|
+
pending.append(json.loads(current))
|
|
451
|
+
except ValueError:
|
|
452
|
+
pass
|
|
453
|
+
except RecursionError:
|
|
454
|
+
return True
|
|
455
|
+
continue
|
|
456
|
+
elif read_text and _read_once(current):
|
|
457
|
+
return True
|
|
458
|
+
if found(current):
|
|
459
|
+
return True
|
|
460
|
+
for part in _parts(current):
|
|
461
|
+
if id(part) not in visited:
|
|
462
|
+
visited[id(part)] = part
|
|
463
|
+
pending.append(part)
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _is_mapping(value: object) -> bool:
|
|
468
|
+
return isinstance(value, (dict, collections.abc.Mapping))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _builds_model(value: object) -> bool:
|
|
472
|
+
"""A model, or an iterator validation left lazy, which builds its models only as it is read."""
|
|
473
|
+
return _is_model(value) or isinstance(value, collections.abc.Iterator)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _read_once(value: object) -> bool:
|
|
477
|
+
"""An iterable that is no collection and no model given as is: what it held cannot be read again."""
|
|
478
|
+
return (
|
|
479
|
+
isinstance(value, collections.abc.Iterable)
|
|
480
|
+
and not isinstance(value, collections.abc.Collection)
|
|
481
|
+
and not _is_model(value)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _items_of(value: object) -> list[Any] | None:
|
|
486
|
+
"""The items of a container read again as often as asked, not text and not a mapping; ``None`` for the rest."""
|
|
487
|
+
if isinstance(value, collections.abc.Collection) and not isinstance(
|
|
488
|
+
value, (str, bytes, bytearray, collections.abc.Mapping)
|
|
489
|
+
):
|
|
490
|
+
return list(value)
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _parts(value: object) -> Any:
|
|
495
|
+
"""What a container holds, a mapping's keys too, or nothing for anything else."""
|
|
496
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
497
|
+
return value
|
|
498
|
+
if isinstance(value, (dict, collections.abc.Mapping)):
|
|
499
|
+
return [*value, *value.values()]
|
|
500
|
+
return _items_of(value) or ()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def shape(value: object, _seen: frozenset[int] = frozenset()) -> object:
|
|
504
|
+
"""The structural shape of a value: paths and type *categories*, never values.
|
|
505
|
+
|
|
506
|
+
Numbers collapse to one category (so ``5`` and ``5.0`` do not read as drift) and ``None`` becomes
|
|
507
|
+
``"null"`` (a nullable wildcard). A list becomes a single merged element shape. This is what a
|
|
508
|
+
contract snapshot stores, so later runs pass when values change but fail on structural drift.
|
|
509
|
+
"""
|
|
510
|
+
if value is None:
|
|
511
|
+
return "null"
|
|
512
|
+
if isinstance(value, bool):
|
|
513
|
+
return "bool"
|
|
514
|
+
if isinstance(value, (int, float)):
|
|
515
|
+
return "number"
|
|
516
|
+
if isinstance(value, str):
|
|
517
|
+
return "str"
|
|
518
|
+
if isinstance(value, (dict, list, tuple)):
|
|
519
|
+
if id(value) in _seen:
|
|
520
|
+
# a self-referential graph would otherwise recurse until the interpreter gives up
|
|
521
|
+
return "<circular ref>"
|
|
522
|
+
nested = _seen | {id(value)}
|
|
523
|
+
if isinstance(value, dict):
|
|
524
|
+
return {key: shape(item, nested) for key, item in value.items()}
|
|
525
|
+
element_shapes = [shape(item, nested) for item in value]
|
|
526
|
+
return [reduce(_merge, element_shapes)] if element_shapes else []
|
|
527
|
+
return type(value).__name__
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _merge(left: Any, right: Any) -> Any:
|
|
531
|
+
"""Merge two element shapes into one representative shape (``null`` yields to a concrete type)."""
|
|
532
|
+
if left == right:
|
|
533
|
+
return left
|
|
534
|
+
if left == "null":
|
|
535
|
+
return right
|
|
536
|
+
if right == "null":
|
|
537
|
+
return left
|
|
538
|
+
if isinstance(left, dict) and isinstance(right, dict):
|
|
539
|
+
return {key: _merge(left.get(key, "null"), right.get(key, "null")) for key in set(left) | set(right)}
|
|
540
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
541
|
+
if not left:
|
|
542
|
+
return right
|
|
543
|
+
if not right:
|
|
544
|
+
return left
|
|
545
|
+
return [_merge(left[0], right[0])]
|
|
546
|
+
return "mixed"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _shape_name(part: object) -> str:
|
|
550
|
+
if isinstance(part, str):
|
|
551
|
+
return part
|
|
552
|
+
return "object" if isinstance(part, dict) else "list"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _join(path: str, key: str) -> str:
|
|
556
|
+
return f"{path}.{key}" if path else key
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def shape_diff(old: Any, new: Any, path: str = "") -> list[tuple[str, str, str]]:
|
|
560
|
+
"""Structural drift between two shapes: ``(kind, path, detail)`` for added / removed / retyped leaves.
|
|
561
|
+
|
|
562
|
+
A ``null`` on either side is a nullable wildcard and never counts as drift.
|
|
563
|
+
"""
|
|
564
|
+
if old == new or old == "null" or new == "null":
|
|
565
|
+
return []
|
|
566
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
567
|
+
drift: list[tuple[str, str, str]] = [("added", _join(path, key), "") for key in new if key not in old]
|
|
568
|
+
for key in old:
|
|
569
|
+
child = _join(path, key)
|
|
570
|
+
if key not in new:
|
|
571
|
+
drift.append(("removed", child, ""))
|
|
572
|
+
else:
|
|
573
|
+
drift += shape_diff(old[key], new[key], child)
|
|
574
|
+
return drift
|
|
575
|
+
if isinstance(old, list) and isinstance(new, list):
|
|
576
|
+
if not old or not new:
|
|
577
|
+
return []
|
|
578
|
+
return shape_diff(old[0], new[0], f"{path}[*]")
|
|
579
|
+
return [("retyped", path, f"{_shape_name(old)} -> {_shape_name(new)}")]
|