assertpy2 2.29.0__tar.gz → 2.29.1__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.29.0 → assertpy2-2.29.1}/PKG-INFO +1 -1
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_contract.py +398 -50
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/assertpy.py +15 -11
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/concepts/type-safety.md +10 -2
- {assertpy2-2.29.0 → assertpy2-2.29.1}/pyproject.toml +1 -1
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_structural.py +573 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/.gitignore +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/CONTRIBUTING.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/LICENSE +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/README.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/SECURITY.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/__init__.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_clustering.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_dangling.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_builder_check_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_capable_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_check_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_compare.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_compat.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_diff.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_equality.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_introspection.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_membership.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_mixin_base.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_negated_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_operations.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_ordering.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_pairing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_path.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_poll_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_require.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_size.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_text.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_engine/_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_hints.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_inline.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_matcher_impls.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_satisfies.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/_snapshot_codec.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/base.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/collection.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/contains.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/date.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/dict.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/errors.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/exception.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/extracting.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/file.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/helpers.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/http_mixin.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/matchers.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/numeric.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/outcome.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/py.typed +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/pytest_plugin.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/string.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/assertpy2/warning.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/benchmarks/test_perf.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/concepts/stability.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/extending/custom-assertions.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/extending/integrations.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/getting-started/comparison.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/getting-started/migration.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/assertions.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/data.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/errors.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/fluent.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/matchers.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/guides/testing.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/index.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/logo-dark.svg +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/logo.svg +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/recipes.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/async.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/bytes.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/collections.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/containment.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/core.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dates.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dicts.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/errors.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/extracting.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/files.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/http.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/json.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/matchers.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/numbers.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/overview.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/strings.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/reference/warnings.md +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/mkdocs.yml +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/mkdocs_hooks.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/generate_check_protocols.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/generate_poll_protocols.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/mutation_report.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/scripts/regenerate.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/api_snapshot.json +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/api_surface.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/chain_corpus.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/chain_model.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/chain_surfaces.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/conftest.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/docs_fixtures.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/group_compat.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/poll_parity_baseline.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/poll_parity_cases.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/pyright_baseline.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_api_compatibility.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_api_vocabulary.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_architecture_doc.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_async.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_async_predicates.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_attrs_support.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_bool.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_bytes.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_call_awaitables.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_callable.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_capable_protocol.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_chain_machine.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_chaining.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_check.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_check_protocols.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_class.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_clustering.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_collection.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_complexity.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_core.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_custom_list.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dangling.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dataframe.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_datetime.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_declared_floors.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_deep_nesting.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_description.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dict.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_docs_examples.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_docs_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_duality.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_dynamic.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_equality_signals.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_equals.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_errors.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_evaluation_core.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_exception_context.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_expected_contract.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_extensions.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_extracting.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_fail.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_file.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_hints.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_hostile_values.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_http.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_in.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_inline_record.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_inline_snapshot.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_integer_arguments.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_json.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_list.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_match_result.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matcher_algebra.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matcher_parity.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_matchers.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_message_elision.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_negated_protocols.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_none.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_not.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_number_zoo.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_numbers.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_numeric_questions.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_openapi_contract.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_operation_contract.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_optional_integration_contracts.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_overload_order.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_overloads.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pin_coverage.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pipeline.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_poll_parity.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_poll_protocols.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_prerequisites.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_property_attrs.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_property_based.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_protocol_parity.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_public_surface.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pyright_baseline.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare_config.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_refusals.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_requirement.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_rich_diff.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_same_as.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_satisfy.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_skip_guard.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_snapshots.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_soft.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_string.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_surface_conformance.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_sync_eventually.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_traceback.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_type.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_type_expression_failures.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_chain_corpus.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_claims.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_completeness.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_conformance.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_from_a_wheel.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_http.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_integrations.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_negative.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_typing_promises.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_unrenderable_values.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_vacuity_contract.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_verdict_corpus.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/test_warn.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_cases.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_chain_corpus_baseline.json +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_harness.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_http.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_integrations.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_integrations_baseline.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_negative_baseline.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/typing_promises.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/verdict_corpus.py +0 -0
- {assertpy2-2.29.0 → assertpy2-2.29.1}/tests/verdict_golden.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.29.
|
|
3
|
+
Version: 2.29.1
|
|
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
|
|
@@ -12,8 +12,10 @@ import datetime
|
|
|
12
12
|
import decimal
|
|
13
13
|
import enum
|
|
14
14
|
import fractions
|
|
15
|
+
import inspect
|
|
15
16
|
import json
|
|
16
17
|
import pathlib
|
|
18
|
+
import sys
|
|
17
19
|
import types
|
|
18
20
|
import uuid
|
|
19
21
|
from functools import reduce
|
|
@@ -115,10 +117,166 @@ def _may_hold_model(annotation: object, outer: bool = True) -> bool:
|
|
|
115
117
|
return origin is not None or not isinstance(annotation, type) or not issubclass(annotation, _KEYLESS)
|
|
116
118
|
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
def _alternatives(annotation: object) -> tuple[object, ...]:
|
|
121
|
+
"""The members of a union, `Annotated` and nested unions opened, or the annotation alone."""
|
|
122
|
+
while get_origin(annotation) is Annotated:
|
|
123
|
+
annotation = get_args(annotation)[0]
|
|
124
|
+
if get_origin(annotation) in (Union, types.UnionType):
|
|
125
|
+
return tuple(member for argument in get_args(annotation) for member in _alternatives(argument))
|
|
126
|
+
return (annotation,)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_ITEM_ORIGINS = frozenset(
|
|
130
|
+
{
|
|
131
|
+
list,
|
|
132
|
+
tuple,
|
|
133
|
+
set,
|
|
134
|
+
frozenset,
|
|
135
|
+
collections.deque,
|
|
136
|
+
collections.abc.Sequence,
|
|
137
|
+
collections.abc.MutableSequence,
|
|
138
|
+
collections.abc.Set,
|
|
139
|
+
collections.abc.MutableSet,
|
|
140
|
+
collections.abc.Collection,
|
|
141
|
+
collections.abc.Iterable,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_MAPPING_ORIGINS = frozenset({dict, collections.abc.Mapping, collections.abc.MutableMapping})
|
|
147
|
+
|
|
148
|
+
_Declared = tuple[object, object]
|
|
149
|
+
"""A declared type and the model class whose field declared it, whose config its validation ran in."""
|
|
150
|
+
|
|
151
|
+
_DECLARED: dict[tuple[int, object], tuple[_Declared, Any]] = {}
|
|
152
|
+
"""What a declaration says a container built from it holds, per declaration and kind of container: worked out once,
|
|
153
|
+
since a list of 1000 orders asks it for each order. At most 1024 entries (`_remember`)."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _remember(
|
|
157
|
+
cache: dict[Any, tuple[_Declared, Any]], key: object, declared: _Declared, answer: object, bound: int
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Keep *answer* for *declared* under *key*, beside the declaration so the id stays its own. A full *cache* starts
|
|
160
|
+
over rather than refusing new entries: a long run fills it early, and what it asks now is what it asks again."""
|
|
161
|
+
if len(cache) >= bound:
|
|
162
|
+
cache.clear()
|
|
163
|
+
cache[key] = (declared, answer)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _declared_container(declared: _Declared | None) -> tuple[tuple[_Declared, ...] | None, _Declared | None] | None:
|
|
167
|
+
"""What *declared* says the items of a container built from it are declared as: one declaration per position of a
|
|
168
|
+
fixed tuple, or one for every item; ``None`` where it does not say.
|
|
169
|
+
|
|
170
|
+
Only a declaration that leaves validation no choice says (`_sole_member`): which member of a union validation took
|
|
171
|
+
depends on what the field's own validators made of the payload, and the kind of container they left does not tell.
|
|
172
|
+
"""
|
|
173
|
+
if declared is None:
|
|
174
|
+
return None
|
|
175
|
+
key = (id(declared), "items")
|
|
176
|
+
known = _DECLARED.get(key)
|
|
177
|
+
if known is not None and known[0] is declared:
|
|
178
|
+
return known[1]
|
|
179
|
+
annotation, owner = declared
|
|
180
|
+
container = _sole_member(annotation)
|
|
181
|
+
items = None
|
|
182
|
+
if get_origin(container) in _ITEM_ORIGINS and get_args(container):
|
|
183
|
+
arguments = get_args(container)
|
|
184
|
+
if _is_fixed_tuple(container):
|
|
185
|
+
items = (tuple((argument, owner) for argument in arguments), None)
|
|
186
|
+
else:
|
|
187
|
+
items = (None, (arguments[0], owner))
|
|
188
|
+
_remember(_DECLARED, key, declared, items, 1024)
|
|
189
|
+
return items
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _declared_mapping_value(declared: _Declared | None) -> _Declared | None:
|
|
193
|
+
"""The declaration of the values of the mapping *declared* is, where it leaves validation no choice, or ``None``."""
|
|
194
|
+
if declared is None:
|
|
195
|
+
return None
|
|
196
|
+
key = (id(declared), "values")
|
|
197
|
+
known = _DECLARED.get(key)
|
|
198
|
+
if known is not None and known[0] is declared:
|
|
199
|
+
return known[1]
|
|
200
|
+
annotation, owner = declared
|
|
201
|
+
mapping = _sole_member(annotation)
|
|
202
|
+
value = (
|
|
203
|
+
(get_args(mapping)[1], owner)
|
|
204
|
+
if get_origin(mapping) in _MAPPING_ORIGINS and len(get_args(mapping)) == 2
|
|
205
|
+
else None
|
|
206
|
+
)
|
|
207
|
+
_remember(_DECLARED, key, declared, value, 1024)
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _sole_member(annotation: object) -> object:
|
|
212
|
+
"""*annotation* without `Annotated` and without ``None`` beside it; ``None`` where a union leaves a choice."""
|
|
213
|
+
members = [member for member in _alternatives(annotation) if member is not type(None)]
|
|
214
|
+
return members[0] if len(members) == 1 else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _chooses(annotation: object) -> bool:
|
|
218
|
+
"""Whether validation chose anywhere in *annotation* above the models it names: between two or more members of a
|
|
219
|
+
union besides ``None``, or inside a named type alias (`typing` or `typing_extensions`, plain or parametrized),
|
|
220
|
+
which is not opened."""
|
|
221
|
+
members = [member for member in _alternatives(annotation) if member is not type(None)]
|
|
222
|
+
aliases = tuple(
|
|
223
|
+
kind
|
|
224
|
+
for module in ("typing", "typing_extensions")
|
|
225
|
+
if isinstance(kind := getattr(sys.modules.get(module), "TypeAliasType", None), type)
|
|
226
|
+
)
|
|
227
|
+
if len(members) > 1 or any(
|
|
228
|
+
isinstance(named, aliases) for member in members for named in (member, get_origin(member))
|
|
229
|
+
):
|
|
230
|
+
return True
|
|
231
|
+
return any(_chooses(argument) for member in members for argument in get_args(member) if argument is not Ellipsis)
|
|
232
|
+
|
|
119
233
|
|
|
120
|
-
|
|
121
|
-
|
|
234
|
+
def _is_fixed_tuple(container: object) -> bool:
|
|
235
|
+
arguments = get_args(container)
|
|
236
|
+
return get_origin(container) is tuple and not (len(arguments) == 2 and arguments[1] is Ellipsis)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _item_declarations(declared: _Declared | None, count: int) -> list[_Declared | None]:
|
|
240
|
+
"""The declaration of each of *count* items of a container built from *declared*, by position where a fixed tuple
|
|
241
|
+
declares one."""
|
|
242
|
+
items = _declared_container(declared)
|
|
243
|
+
if items is None:
|
|
244
|
+
return [None] * count
|
|
245
|
+
positional, common = items
|
|
246
|
+
if positional is None:
|
|
247
|
+
return [common] * count
|
|
248
|
+
return [positional[index] if index < len(positional) else None for index in range(count)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_ADAPTERS: dict[int, tuple[_Declared, Any]] = {}
|
|
252
|
+
"""A pydantic `TypeAdapter` per declaration, or ``None`` where none can be built: building one costs a schema. At
|
|
253
|
+
most 256 entries (`_remember`)."""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _adapter(declared: _Declared) -> Any:
|
|
257
|
+
"""A `TypeAdapter` for a declared type, in the config of the model that declared it, from the pydantic the models
|
|
258
|
+
came from; ``None`` where there is none."""
|
|
259
|
+
known = _ADAPTERS.get(id(declared))
|
|
260
|
+
if known is not None and known[0] is declared:
|
|
261
|
+
return known[1]
|
|
262
|
+
annotation, owner = declared
|
|
263
|
+
adapter_class = getattr(sys.modules.get("pydantic"), "TypeAdapter", None)
|
|
264
|
+
adapter = None
|
|
265
|
+
if adapter_class is not None:
|
|
266
|
+
# a schema pydantic refuses: TypeError in 2.0, RuntimeError in 2.13, NameError for a forward reference
|
|
267
|
+
try:
|
|
268
|
+
adapter = adapter_class(annotation, config=getattr(owner, "model_config", None) or None)
|
|
269
|
+
except (TypeError, RuntimeError, NameError):
|
|
270
|
+
adapter = None
|
|
271
|
+
_remember(_ADAPTERS, id(declared), declared, adapter, 256)
|
|
272
|
+
return adapter
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
_FieldReads = tuple[tuple[str, tuple[tuple[object, ...], ...], _Declared], ...]
|
|
276
|
+
|
|
277
|
+
_READS: dict[type, tuple[object, frozenset[str], _FieldReads, _Declared | None]] = {}
|
|
278
|
+
"""Per model class, with the validator they were read beside: the keys it declares, where each field is read, and
|
|
279
|
+
what a typed `__pydantic_extra__` declares its extra values as.
|
|
122
280
|
|
|
123
281
|
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
282
|
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.
|
|
@@ -126,20 +284,39 @@ A rebuild replaces the validator, and with it what the class reads, so an entry
|
|
|
126
284
|
was made with. At most 256 classes are kept, so models made on the fly cannot grow it without bound."""
|
|
127
285
|
|
|
128
286
|
|
|
129
|
-
def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads]:
|
|
287
|
+
def _reads_of(model: Any) -> tuple[frozenset[str], _FieldReads, _Declared | None]:
|
|
130
288
|
validator = getattr(model, "__pydantic_validator__", None)
|
|
131
289
|
known = _READS.get(model)
|
|
132
290
|
if known is None or known[0] is not validator:
|
|
133
291
|
config = getattr(model, "model_config", {})
|
|
134
292
|
fields = tuple(
|
|
135
|
-
(name, _field_sources(name, info, config))
|
|
293
|
+
(name, _field_sources(name, info, config), (getattr(info, "annotation", None), model))
|
|
136
294
|
for name, info in model.model_fields.items()
|
|
137
295
|
if _may_hold_model(getattr(info, "annotation", None))
|
|
138
296
|
)
|
|
139
|
-
|
|
297
|
+
extras = _extra_values(model) if config.get("extra") == "allow" else None
|
|
298
|
+
known = (validator, frozenset(_declared_keys(model)), fields, extras)
|
|
140
299
|
if model in _READS or len(_READS) < 256:
|
|
141
300
|
_READS[model] = known
|
|
142
|
-
return known[1], known[2]
|
|
301
|
+
return known[1], known[2], known[3]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _extra_values(model: Any) -> _Declared | None:
|
|
305
|
+
"""The declaration a typed `__pydantic_extra__` gives the extra values of *model*, as written.
|
|
306
|
+
|
|
307
|
+
``None`` where it has none, and where it cannot be read: text, which pydantic read in the namespace of the frame
|
|
308
|
+
that defined the model, where a name can mean another type than in its module, and, from 3.14, annotations that
|
|
309
|
+
name what is defined only later, which reading them evaluates.
|
|
310
|
+
"""
|
|
311
|
+
try:
|
|
312
|
+
for klass in getattr(model, "__mro__", ()):
|
|
313
|
+
own = inspect.get_annotations(klass)
|
|
314
|
+
if "__pydantic_extra__" in own:
|
|
315
|
+
annotation = own["__pydantic_extra__"]
|
|
316
|
+
return None if isinstance(annotation, str) else _declared_mapping_value((annotation, model))
|
|
317
|
+
except NameError:
|
|
318
|
+
return None
|
|
319
|
+
return None
|
|
143
320
|
|
|
144
321
|
|
|
145
322
|
def _followed(payload: object, path: tuple[object, ...]) -> tuple[bool, object]:
|
|
@@ -176,6 +353,10 @@ def _is_model(value: object) -> bool:
|
|
|
176
353
|
|
|
177
354
|
_CONTAINERS = (dict, list, tuple, set, frozenset)
|
|
178
355
|
|
|
356
|
+
_REPLAYED = (0, -1)
|
|
357
|
+
"""In the pairs a walk has seen, where no pair of ids can be: the walk is under a replay (`_replayed`), where a field
|
|
358
|
+
whose type offers a choice cannot be read (`_steered`)."""
|
|
359
|
+
|
|
179
360
|
|
|
180
361
|
class UncheckableDriftError(Exception):
|
|
181
362
|
"""A part of the payload no reading pairs with the model it became, so whether it drifted cannot be told."""
|
|
@@ -201,41 +382,84 @@ def contract_drift(
|
|
|
201
382
|
inside the JSON text it is) cannot be paired with a model built from it: a set, a resized list or an
|
|
202
383
|
object wrapped into either whose built items are not all one model class, or whose raw item that class
|
|
203
384
|
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
|
|
205
|
-
|
|
206
|
-
|
|
385
|
+
validation read up or left lazy, raw items no built item is left for that their declared type does not
|
|
386
|
+
build again or that it declares through a choice. The payload is read as validation left it: a validator
|
|
387
|
+
renaming keys, changing the payload in place, or reordering or rewriting a container's items without
|
|
388
|
+
changing its size is not seen, and the walk reads those items by position.
|
|
207
389
|
"""
|
|
208
390
|
model = type(instance)
|
|
391
|
+
declared, fields, extras = _reads_of(model)
|
|
392
|
+
replayed = _REPLAYED in _seen
|
|
209
393
|
if getattr(model, "__pydantic_root_model__", False):
|
|
210
|
-
return
|
|
394
|
+
return _declared_drift(payload, instance.root, path, _seen, fields[0][2], replayed) if fields else []
|
|
211
395
|
if not isinstance(payload, (dict, collections.abc.Mapping)):
|
|
212
396
|
_refuse_if_key_hides(
|
|
213
397
|
payload, instance, path, f"the payload holds a {type(payload).__name__} where a model was built"
|
|
214
398
|
)
|
|
215
399
|
return []
|
|
216
400
|
seen = _seen | {(id(payload), id(instance))}
|
|
217
|
-
declared, fields = _reads_of(model)
|
|
218
401
|
prefix = f"{path}." if path else ""
|
|
219
402
|
drift: list[str] = []
|
|
220
403
|
if getattr(model, "model_config", {}).get("extra") != "allow":
|
|
221
404
|
drift += [f"{prefix}{key}" for key in payload if key not in declared]
|
|
222
405
|
else:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if key in payload:
|
|
226
|
-
drift += _value_drift(payload[key], built, f"{prefix}{key}", seen)
|
|
227
|
-
for name, sources in fields:
|
|
406
|
+
drift += _extras_drift(payload, instance, prefix, seen, extras, replayed)
|
|
407
|
+
for name, sources, annotated in fields:
|
|
228
408
|
found, raw = _raw_field(payload, sources)
|
|
229
409
|
if not found:
|
|
230
410
|
continue
|
|
411
|
+
if replayed and _chooses(annotated[0]):
|
|
412
|
+
drift += _steered(raw, f"{prefix}{name}")
|
|
413
|
+
continue
|
|
231
414
|
value = getattr(instance, name, None)
|
|
232
415
|
# a scalar built from a scalar holds nothing to pair
|
|
233
416
|
if (value is None or isinstance(value, (str, int, float, bytes))) and not isinstance(raw, _CONTAINERS):
|
|
234
417
|
continue
|
|
235
|
-
drift += _value_drift(raw, value, f"{prefix}{name}", seen)
|
|
418
|
+
drift += _value_drift(raw, value, f"{prefix}{name}", seen, annotated)
|
|
236
419
|
return drift
|
|
237
420
|
|
|
238
421
|
|
|
422
|
+
def _extras_drift(
|
|
423
|
+
payload: collections.abc.Mapping,
|
|
424
|
+
instance: Any,
|
|
425
|
+
prefix: str,
|
|
426
|
+
seen: frozenset[tuple[int, int]],
|
|
427
|
+
declared: _Declared | None,
|
|
428
|
+
replayed: bool,
|
|
429
|
+
) -> list[str]:
|
|
430
|
+
"""Drift under the extras of a model that allows them: built through a typed `__pydantic_extra__` and walked as it
|
|
431
|
+
*declared* them; untyped ones are the payload's own objects."""
|
|
432
|
+
return [
|
|
433
|
+
entry
|
|
434
|
+
for key, built in (getattr(instance, "__pydantic_extra__", None) or {}).items()
|
|
435
|
+
if key in payload
|
|
436
|
+
for entry in _declared_drift(payload[key], built, f"{prefix}{key}", seen, declared, replayed)
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _declared_drift(
|
|
441
|
+
raw: object,
|
|
442
|
+
value: object,
|
|
443
|
+
path: str,
|
|
444
|
+
seen: frozenset[tuple[int, int]],
|
|
445
|
+
declared: _Declared | None,
|
|
446
|
+
replayed: bool,
|
|
447
|
+
) -> list[str]:
|
|
448
|
+
"""Drift under a value its model declared, a root's or an extra's; `_steered` under a replay where validation built
|
|
449
|
+
it through a declared type that offers a choice, or one that cannot be read."""
|
|
450
|
+
if replayed and value is not raw and (declared is None or _chooses(declared[0])):
|
|
451
|
+
return _steered(raw, path)
|
|
452
|
+
return _value_drift(raw, value, path, seen, declared)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _steered(raw: object, path: str) -> list[str]:
|
|
456
|
+
"""Nothing to read under a part a replay built where its type offered a choice, which the validators the replay
|
|
457
|
+
skipped could have steered; a refusal where a key could hide in it."""
|
|
458
|
+
if _holds(raw, _is_mapping, read_text=True):
|
|
459
|
+
raise UncheckableDriftError(path, "which of its declared types it became depends on validators not run again")
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
|
|
239
463
|
def exactness_failure(pairs: Any, *, carrier: str) -> str | None:
|
|
240
464
|
"""What an exact check of ``(payload, instance, path)`` pairs found, in the words a failure continues with.
|
|
241
465
|
|
|
@@ -254,8 +478,14 @@ def exactness_failure(pairs: Any, *, carrier: str) -> str | None:
|
|
|
254
478
|
return f"{carrier}{len(drift)} undeclared field(s) the model does not declare: {sorted(drift)}"
|
|
255
479
|
|
|
256
480
|
|
|
257
|
-
def _value_drift(
|
|
258
|
-
|
|
481
|
+
def _value_drift(
|
|
482
|
+
raw: object, value: object, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None = None
|
|
483
|
+
) -> list[str]:
|
|
484
|
+
"""Drift under one validated value: a model's own keys, and every element or dict value that became a model.
|
|
485
|
+
|
|
486
|
+
*declared* is the type the value was declared as and the model that declared it, where the walk knows them: what
|
|
487
|
+
a model field or a root declares, and from it the item type of each container below.
|
|
488
|
+
"""
|
|
259
489
|
# the payload's own object kept as it was, or a scalar: nothing was built from it
|
|
260
490
|
if raw is value or value is None or isinstance(value, (str, int, float, bytes)):
|
|
261
491
|
return []
|
|
@@ -268,48 +498,68 @@ def _value_drift(raw: object, value: object, path: str, seen: frozenset[tuple[in
|
|
|
268
498
|
seen = seen | {pair}
|
|
269
499
|
if _is_model(value):
|
|
270
500
|
return contract_drift(raw, value, path, seen)
|
|
271
|
-
return _container_drift(raw, value, path, seen)
|
|
501
|
+
return _container_drift(raw, value, path, seen, declared)
|
|
272
502
|
|
|
273
503
|
|
|
274
|
-
def _container_drift(
|
|
504
|
+
def _container_drift(
|
|
505
|
+
raw: object, value: object, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None
|
|
506
|
+
) -> list[str]:
|
|
275
507
|
"""Drift under a built container, paired with the raw part it came from as its kind allows."""
|
|
276
508
|
if isinstance(value, (list, tuple)):
|
|
277
|
-
return _sequence_drift(raw, value, path, seen)
|
|
509
|
+
return _sequence_drift(raw, value, path, seen, declared)
|
|
278
510
|
if isinstance(value, dict):
|
|
279
|
-
return _mapping_drift(raw, value, path, seen)
|
|
511
|
+
return _mapping_drift(raw, value, path, seen, declared)
|
|
280
512
|
if isinstance(value, (set, frozenset)):
|
|
281
|
-
return _set_drift(raw, value, path, seen)
|
|
513
|
+
return _set_drift(raw, value, path, seen, declared)
|
|
282
514
|
if isinstance(value, collections.abc.Mapping):
|
|
283
|
-
return _mapping_drift(raw, dict(value), path, seen)
|
|
515
|
+
return _mapping_drift(raw, dict(value), path, seen, declared)
|
|
284
516
|
if isinstance(value, collections.abc.Iterator):
|
|
285
517
|
if _holds(raw, _is_mapping, read_text=True):
|
|
286
518
|
raise UncheckableDriftError(path, "validation is lazy here and builds the models only as the value is read")
|
|
287
519
|
return []
|
|
288
520
|
# a sequence validation kept as the payload's own kind, a deque under `Sequence[A]`
|
|
289
521
|
items = _items_of(value)
|
|
290
|
-
return [] if items is None else _sequence_drift(raw, items, path, seen)
|
|
522
|
+
return [] if items is None else _sequence_drift(raw, items, path, seen, declared)
|
|
291
523
|
|
|
292
524
|
|
|
293
|
-
def _sequence_drift(
|
|
525
|
+
def _sequence_drift(
|
|
526
|
+
raw: object,
|
|
527
|
+
value: list | tuple,
|
|
528
|
+
path: str,
|
|
529
|
+
seen: frozenset[tuple[int, int]],
|
|
530
|
+
declared: _Declared | None = None,
|
|
531
|
+
) -> list[str]:
|
|
294
532
|
"""Paired by index, a collection pydantic also takes for a sequence (a deque, a set, a dict's values) in the
|
|
295
533
|
order it iterates, which is the order validation read it in."""
|
|
296
534
|
if not isinstance(raw, (list, tuple)):
|
|
297
535
|
items = _items_of(raw)
|
|
298
536
|
if items is None:
|
|
299
537
|
reason = f"the payload holds a {type(raw).__name__} where a sequence was built"
|
|
300
|
-
return _wrapped(raw, value, path, seen, reason)
|
|
538
|
+
return _wrapped(raw, value, path, seen, reason, declared)
|
|
301
539
|
raw = items
|
|
302
540
|
if len(raw) != len(value):
|
|
303
541
|
reason = f"validation changed its length, {len(raw)} items became {len(value)}"
|
|
304
|
-
|
|
542
|
+
parts = [(f"{path}[{i}]", part) for i, part in enumerate(raw)]
|
|
543
|
+
return _unpaired(raw, parts, value, path, seen, reason, declared=declared)
|
|
544
|
+
items = _declared_container(declared)
|
|
545
|
+
if items is None or items[0] is None:
|
|
546
|
+
common = None if items is None else items[1]
|
|
547
|
+
return [
|
|
548
|
+
entry
|
|
549
|
+
for index, (part, element) in enumerate(zip(raw, value, strict=True))
|
|
550
|
+
for entry in _value_drift(part, element, f"{path}[{index}]", seen, common)
|
|
551
|
+
]
|
|
552
|
+
declarations = _item_declarations(declared, len(value))
|
|
305
553
|
return [
|
|
306
554
|
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)
|
|
555
|
+
for index, (part, element, item) in enumerate(zip(raw, value, declarations, strict=True))
|
|
556
|
+
for entry in _value_drift(part, element, f"{path}[{index}]", seen, item)
|
|
309
557
|
]
|
|
310
558
|
|
|
311
559
|
|
|
312
|
-
def _mapping_drift(
|
|
560
|
+
def _mapping_drift(
|
|
561
|
+
raw: object, value: dict, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None = None
|
|
562
|
+
) -> list[str]:
|
|
313
563
|
"""Paired by key where validation kept the keys (a `TypedDict` reorders them), else by order, which dict validation
|
|
314
564
|
keeps while it coerces them; a key built into something other than text or a number is walked beside its raw key."""
|
|
315
565
|
if not isinstance(raw, (dict, collections.abc.Mapping)):
|
|
@@ -319,38 +569,48 @@ def _mapping_drift(raw: object, value: dict, path: str, seen: frozenset[tuple[in
|
|
|
319
569
|
# an overwritten value may have become another member of a union than the one that survived
|
|
320
570
|
_refuse_if_key_hides(raw, value, path, f"validation changed its size, {len(raw)} keys became {len(value)}")
|
|
321
571
|
return []
|
|
572
|
+
item = _declared_mapping_value(declared)
|
|
322
573
|
if raw.keys() == value.keys():
|
|
323
574
|
return [
|
|
324
575
|
entry
|
|
325
576
|
for key, part in raw.items()
|
|
326
|
-
for entry in _value_drift(part, value[key], f"{path}.{key}" if path else str(key), seen)
|
|
577
|
+
for entry in _value_drift(part, value[key], f"{path}.{key}" if path else str(key), seen, item)
|
|
327
578
|
]
|
|
328
579
|
drift: list[str] = []
|
|
329
580
|
for (key, part), (built, element) in zip(raw.items(), value.items(), strict=True):
|
|
330
581
|
if not isinstance(built, (str, int)):
|
|
331
582
|
drift += _value_drift(key, built, path, seen)
|
|
332
|
-
drift += _value_drift(part, element, f"{path}.{key}" if path else str(key), seen)
|
|
583
|
+
drift += _value_drift(part, element, f"{path}.{key}" if path else str(key), seen, item)
|
|
333
584
|
return drift
|
|
334
585
|
|
|
335
586
|
|
|
336
|
-
def _set_drift(
|
|
587
|
+
def _set_drift(
|
|
588
|
+
raw: object, value: set | frozenset, path: str, seen: frozenset[tuple[int, int]], declared: _Declared | None
|
|
589
|
+
) -> list[str]:
|
|
337
590
|
items = raw if isinstance(raw, (list, tuple)) else _items_of(raw)
|
|
338
591
|
if items is None:
|
|
339
592
|
reason = f"the payload holds a {type(raw).__name__} where a set was built"
|
|
340
|
-
return _wrapped(raw, value, path, seen, reason)
|
|
593
|
+
return _wrapped(raw, value, path, seen, reason, declared)
|
|
341
594
|
reason = "a set keeps no order to pair its items with the models they became"
|
|
342
595
|
parts = [(f"{path}[{i}]", part) for i, part in enumerate(items)]
|
|
343
|
-
return _unpaired(raw, parts, value, path, seen, reason, merged=True)
|
|
596
|
+
return _unpaired(raw, parts, value, path, seen, reason, merged=True, declared=declared)
|
|
344
597
|
|
|
345
598
|
|
|
346
|
-
def _wrapped(
|
|
599
|
+
def _wrapped(
|
|
600
|
+
raw: object,
|
|
601
|
+
value: object,
|
|
602
|
+
path: str,
|
|
603
|
+
seen: frozenset[tuple[int, int]],
|
|
604
|
+
reason: str,
|
|
605
|
+
declared: _Declared | None,
|
|
606
|
+
) -> list[str]:
|
|
347
607
|
"""Drift under a mapping a validator turned into a sequence or a set, a single object sent where many may be.
|
|
348
608
|
|
|
349
|
-
Only a mapping that became one item is that item; one that became several was expanded in a way no
|
|
350
|
-
pairs.
|
|
609
|
+
Only a mapping that became one item, or none, is that item; one that became several was expanded in a way no
|
|
610
|
+
reading pairs.
|
|
351
611
|
"""
|
|
352
|
-
if isinstance(raw, (dict, collections.abc.Mapping)) and len(_items_of(value) or ())
|
|
353
|
-
return _unpaired(raw, [(path, raw)], value, path, seen, reason)
|
|
612
|
+
if isinstance(raw, (dict, collections.abc.Mapping)) and len(_items_of(value) or ()) <= 1:
|
|
613
|
+
return _unpaired(raw, [(path, raw)], value, path, seen, reason, declared=declared)
|
|
354
614
|
_refuse_if_key_hides(raw, value, path, reason)
|
|
355
615
|
return []
|
|
356
616
|
|
|
@@ -364,21 +624,109 @@ def _unpaired(
|
|
|
364
624
|
reason: str,
|
|
365
625
|
*,
|
|
366
626
|
merged: bool = False,
|
|
627
|
+
declared: _Declared | None = None,
|
|
367
628
|
) -> list[str]:
|
|
368
629
|
"""Drift under a container whose raw items no reading pairs one by one with the items validation built.
|
|
369
630
|
|
|
370
631
|
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
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
632
|
+
could hide a key is validated again on its own by that class and walked beside what it becomes. Where validation
|
|
633
|
+
kept no model at all, the raw items are validated again whole by the type the field *declared*, in its model's
|
|
634
|
+
config, and what that builds is walked (`_replayed`). Anything else refuses with *reason*: items of mixed classes,
|
|
635
|
+
an item that does not validate on its own, which needed its parent, where equal items *merged*, a class whose own
|
|
636
|
+
`__eq__` could have merged an item of another class into it, and raw items no replay can tell the type of.
|
|
375
637
|
"""
|
|
376
|
-
if not _holds(
|
|
638
|
+
if not _holds(raw, _is_mapping, read_text=True):
|
|
377
639
|
return []
|
|
378
|
-
|
|
640
|
+
if _holds(value, _builds_model):
|
|
641
|
+
model = _sole_model_class(value)
|
|
642
|
+
if model is None or (merged and not _equal_only_within_its_class(model)):
|
|
643
|
+
raise UncheckableDriftError(path, reason)
|
|
644
|
+
return _revalidated(parts, model, path, seen, reason)
|
|
645
|
+
return _replayed(parts, path, seen, reason, declared)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _replayed(
|
|
649
|
+
parts: list[tuple[str, object]],
|
|
650
|
+
path: str,
|
|
651
|
+
seen: frozenset[tuple[int, int]],
|
|
652
|
+
reason: str,
|
|
653
|
+
declared: _Declared | None,
|
|
654
|
+
) -> list[str]:
|
|
655
|
+
"""Drift under raw items validation left without a model: validated again whole as the *declared* type, in Python
|
|
656
|
+
mode as validation read them (`_replay_target`). All of them are replayed first, so a fixed tuple's positions
|
|
657
|
+
see what validation saw; where pydantic refuses that, only the items that could hide a key. What that builds is
|
|
658
|
+
paired item by item; a set it builds tells the one class they became.
|
|
659
|
+
|
|
660
|
+
The replay skips the validators of the field itself, so whatever they did to the payload is missing from it, and
|
|
661
|
+
it refuses with *reason* wherever that leaves the type of the items open: a declaration that offers a choice
|
|
662
|
+
(`_chooses`) or none at all, items pydantic builds nothing from, or no sequence of as many.
|
|
663
|
+
"""
|
|
664
|
+
if declared is None or _chooses(declared[0]):
|
|
665
|
+
raise UncheckableDriftError(path, reason)
|
|
666
|
+
replayed = _replay_target(declared)
|
|
667
|
+
adapter = _adapter(replayed)
|
|
668
|
+
if adapter is None:
|
|
669
|
+
raise UncheckableDriftError(path, reason)
|
|
670
|
+
origin = get_origin(replayed[0])
|
|
671
|
+
kind = origin if origin in (tuple, collections.deque) else list
|
|
672
|
+
rebuilt = _replay(adapter, parts, kind)
|
|
673
|
+
if rebuilt is None:
|
|
674
|
+
parts = [(label, part) for label, part in parts if _holds(part, _is_mapping, read_text=True)]
|
|
675
|
+
rebuilt = _replay(adapter, parts, kind)
|
|
676
|
+
seen = seen | {_REPLAYED}
|
|
677
|
+
if isinstance(rebuilt, collections.abc.Set):
|
|
678
|
+
return _unpaired([part for _, part in parts], parts, rebuilt, path, seen, reason, merged=True)
|
|
679
|
+
elements = _items_of(rebuilt)
|
|
680
|
+
if elements is None or len(elements) != len(parts):
|
|
681
|
+
raise UncheckableDriftError(path, reason)
|
|
682
|
+
declarations = _item_declarations(replayed, len(elements))
|
|
683
|
+
return [
|
|
684
|
+
entry
|
|
685
|
+
for (label, part), element, item in zip(parts, elements, declarations, strict=True)
|
|
686
|
+
for entry in _value_drift(part, element, label, seen, item)
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _replay(adapter: Any, parts: list[tuple[str, object]], kind: Callable[[list[object]], object]) -> object:
|
|
691
|
+
"""What *adapter* builds from the raw items of *parts*, handed over as the *kind* of container a strict config
|
|
692
|
+
takes for its type, or ``None`` where pydantic refuses them."""
|
|
693
|
+
try:
|
|
694
|
+
return adapter.validate_python(kind([part for _, part in parts]))
|
|
695
|
+
# a validation error, or a schema pydantic builds only on first use and then refuses
|
|
696
|
+
except (ValueError, TypeError, RuntimeError, NameError):
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _replay_target(declared: _Declared) -> _Declared:
|
|
701
|
+
"""The type raw items are validated again as: the one member of the *declared* type without the `Annotated`
|
|
702
|
+
validators of the container itself, which are what emptied it, and a set as a list of its items, so no model is
|
|
703
|
+
hashed and each item keeps its place."""
|
|
704
|
+
key = (id(declared), "replay")
|
|
705
|
+
known = _DECLARED.get(key)
|
|
706
|
+
if known is not None and known[0] is declared:
|
|
707
|
+
return known[1]
|
|
708
|
+
annotation, owner = declared
|
|
709
|
+
member = _sole_member(annotation) or annotation
|
|
710
|
+
if member in (set, frozenset) or get_origin(member) in (
|
|
711
|
+
set,
|
|
712
|
+
frozenset,
|
|
713
|
+
collections.abc.Set,
|
|
714
|
+
collections.abc.MutableSet,
|
|
715
|
+
):
|
|
716
|
+
member = types.GenericAlias(list, get_args(member) or (Any,))
|
|
717
|
+
target = (member, owner)
|
|
718
|
+
_remember(_DECLARED, key, declared, target, 1024)
|
|
719
|
+
return target
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _revalidated(
|
|
723
|
+
parts: list[tuple[str, object]], model: type, path: str, seen: frozenset[tuple[int, int]], reason: str
|
|
724
|
+
) -> list[str]:
|
|
725
|
+
"""Drift under each raw item that could hide a key, validated again on its own by *model*, JSON text in JSON mode
|
|
726
|
+
as `Json[...]` read it."""
|
|
379
727
|
validate = getattr(model, "model_validate", None)
|
|
380
728
|
validate_json = getattr(model, "model_validate_json", None)
|
|
381
|
-
if validate is None or validate_json is None
|
|
729
|
+
if validate is None or validate_json is None:
|
|
382
730
|
raise UncheckableDriftError(path, reason)
|
|
383
731
|
drift: list[str] = []
|
|
384
732
|
for label, item in parts:
|
|
@@ -97,7 +97,7 @@ from .snapshot import SnapshotMixin
|
|
|
97
97
|
from .string import StringMixin
|
|
98
98
|
from .warning import WarningMixin
|
|
99
99
|
|
|
100
|
-
__version__ = "2.29.
|
|
100
|
+
__version__ = "2.29.1"
|
|
101
101
|
|
|
102
102
|
# the tracked value type of the generic AssertionBuilder fallback (_U appears only in narrowing stubs)
|
|
103
103
|
_T = TypeVar("_T")
|
|
@@ -701,17 +701,21 @@ def assert_conforms(
|
|
|
701
701
|
does not declare. ``model_validate`` silently drops undeclared fields, so a stale model keeps
|
|
702
702
|
passing after the live API grows new ones.
|
|
703
703
|
|
|
704
|
-
``exact`` catches that drift in
|
|
705
|
-
and root models
|
|
706
|
-
|
|
707
|
-
|
|
704
|
+
``exact`` catches that drift in the models the payload became inside lists, tuples, sets, dicts, unions
|
|
705
|
+
and root models, and reports the exact paths. It is alias-aware, and respects a model that opts into
|
|
706
|
+
extras (``extra="allow"``). Raw items that no longer pair one by one with what they became (a set, a
|
|
707
|
+
filtered list, an object wrapped into a list) are validated again by their model class when they all
|
|
708
708
|
became one, which runs that class's validators once more for them, and JSON text is read as the original
|
|
709
|
-
JSON input.
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
709
|
+
JSON input. Raw items a validator left no built item for (an emptied list) are checked against the type
|
|
710
|
+
their field declares: validated again as it, which runs the item model's validators but not the field's
|
|
711
|
+
own, so a field validator that would have built them otherwise is not seen. What still cannot be paired
|
|
712
|
+
(items of mixed classes in a set or a resized list, emptied items the declared type does not build or
|
|
713
|
+
declares through a union of two or more types besides ``None`` or a named type alias, merged dict keys, a
|
|
714
|
+
generator consumed during validation, a lazy ``Iterable``) fails with ``<path> cannot be checked`` and the
|
|
715
|
+
reason. It reads the payload as validation left it: a validator that renames keys, changes the payload in
|
|
716
|
+
place, or reorders or rewrites the items of a container without changing its size is not seen, and those
|
|
717
|
+
items are read by position. A model inside a container of plain values (``list[Any]``) or inside a
|
|
718
|
+
dataclass is not reached.
|
|
715
719
|
|
|
716
720
|
Args:
|
|
717
721
|
val: the raw payload to validate (e.g. a decoded JSON response)
|