assertpy2 2.27.0__tar.gz → 2.27.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.27.0 → assertpy2-2.27.1}/CONTRIBUTING.md +14 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/PKG-INFO +2 -2
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_capable_typing.py +37 -31
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_compare.py +60 -24
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_diff.py +47 -14
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_equality.py +69 -25
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_introspection.py +150 -5
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_hints.py +14 -9
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_satisfies.py +4 -2
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_snapshot_codec.py +100 -6
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/assertpy.py +1 -1
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/helpers.py +19 -4
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/numeric.py +41 -22
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/pytest_plugin.py +5 -2
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/concepts/stability.md +1 -1
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/assertions.md +3 -1
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/errors.md +1 -1
- {assertpy2-2.27.0 → assertpy2-2.27.1}/mkdocs.yml +1 -1
- {assertpy2-2.27.0 → assertpy2-2.27.1}/pyproject.toml +2 -2
- {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/generate_poll_protocols.py +59 -5
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/api_snapshot.json +17 -21
- assertpy2-2.27.1/tests/test_attrs_support.py +478 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_capable_protocol.py +413 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_complexity.py +2 -3
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_equals.py +12 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_hints.py +20 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_numbers.py +83 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pin_coverage.py +4 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_property_attrs.py +43 -1
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_property_based.py +29 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pytest_plugin.py +8 -5
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_compare_config.py +35 -0
- assertpy2-2.27.1/tests/test_recursive_compare_pydantic.py +466 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing.py +22 -4
- assertpy2-2.27.1/tests/test_verdict_corpus.py +137 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_cases.py +1 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_negative_baseline.py +2 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_promises.py +35 -0
- assertpy2-2.27.1/tests/verdict_corpus.py +632 -0
- assertpy2-2.27.1/tests/verdict_golden.txt +127 -0
- assertpy2-2.27.0/tests/test_attrs_support.py +0 -170
- assertpy2-2.27.0/tests/test_recursive_compare_pydantic.py +0 -83
- {assertpy2-2.27.0 → assertpy2-2.27.1}/.gitignore +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/LICENSE +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/README.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/SECURITY.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/__init__.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_clustering.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_dangling.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_builder_check_typing.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_check_typing.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_compat.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_contract.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_membership.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_mixin_base.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_negated_typing.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_operations.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_ordering.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_pairing.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_path.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_poll_typing.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_require.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_size.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_text.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_engine/_typing.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_inline.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/_matcher_impls.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/base.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/collection.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/contains.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/date.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/dict.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/errors.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/exception.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/extracting.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/file.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/http_mixin.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/matchers.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/outcome.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/py.typed +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/string.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/assertpy2/warning.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/benchmarks/test_perf.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/concepts/type-safety.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/extending/custom-assertions.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/extending/integrations.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/getting-started/comparison.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/getting-started/migration.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/data.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/fluent.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/matchers.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/guides/testing.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/index.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/logo-dark.svg +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/logo.svg +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/recipes.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/async.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/bytes.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/collections.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/containment.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/core.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dates.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dicts.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/errors.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/extracting.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/files.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/http.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/json.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/matchers.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/numbers.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/overview.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/strings.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/reference/warnings.md +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/mkdocs_hooks.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/generate_check_protocols.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/mutation_report.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/scripts/regenerate.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/api_surface.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/conftest.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/docs_fixtures.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/group_compat.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/poll_parity_baseline.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/poll_parity_cases.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/pyright_baseline.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_api_compatibility.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_api_vocabulary.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_architecture_doc.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_async.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_async_predicates.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_bool.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_bytes.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_callable.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_chaining.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_check.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_check_protocols.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_class.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_clustering.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_collection.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_core.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_custom_list.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dangling.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dataframe.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_datetime.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_declared_floors.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_description.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dict.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_docs_examples.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_docs_typing.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_dynamic.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_errors.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_evaluation_core.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_exception_context.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_expected_contract.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_extensions.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_extracting.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_fail.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_file.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_hostile_values.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_http.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_in.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_inline_record.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_inline_snapshot.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_json.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_list.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_match_result.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_matcher_parity.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_matchers.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_message_elision.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_negated_protocols.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_none.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_not.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_openapi_contract.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_operation_contract.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_optional_integration_contracts.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_overload_order.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_overloads.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pipeline.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_poll_parity.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_poll_protocols.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_protocol_parity.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_public_surface.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_pyright_baseline.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_refusals.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_requirement.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_rich_diff.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_same_as.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_satisfy.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_skip_guard.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_snapshots.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_soft.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_string.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_structural.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_surface_conformance.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_sync_eventually.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_traceback.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_type.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_type_expression_failures.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_claims.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_completeness.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_conformance.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_from_a_wheel.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_http.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_integrations.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_negative.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_typing_promises.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_vacuity_contract.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/test_warn.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_harness.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_http.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_integrations.py +0 -0
- {assertpy2-2.27.0 → assertpy2-2.27.1}/tests/typing_integrations_baseline.py +0 -0
|
@@ -114,6 +114,20 @@ change. The baseline test passes the same target itself, so it gives one answer
|
|
|
114
114
|
- `tests/test_docs_examples.py` is run separately because it executes the snippets in `docs/`, which
|
|
115
115
|
needs the `docs-examples` group and a different collection.
|
|
116
116
|
|
|
117
|
+
### When a gate says an answer moved
|
|
118
|
+
|
|
119
|
+
Two gates record what the library currently decides rather than what it should. Both fail on a change
|
|
120
|
+
and neither is a failure to paper over: read the diff, decide whether the change is meant to ship, then
|
|
121
|
+
re-record it in the same commit so the diff carries the decision into review.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
ASSERTPY2_UPDATE_API=1 uv run pytest tests/test_api_compatibility.py # the public surface
|
|
125
|
+
ASSERTPY2_UPDATE_VERDICTS=1 uv run pytest tests/test_verdict_corpus.py # what each input decides
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The second one's diff is the draft of the release's Behaviour changes section, so read it before
|
|
129
|
+
writing that section rather than after.
|
|
130
|
+
|
|
117
131
|
## Commit style
|
|
118
132
|
|
|
119
133
|
Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, etc.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.27.
|
|
4
|
-
Summary: Fluent assertion library for Python
|
|
3
|
+
Version: 2.27.1
|
|
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
|
|
7
7
|
Project-URL: Issues, https://github.com/Solganis/assertpy2/issues
|
|
@@ -35,8 +35,18 @@ if TYPE_CHECKING:
|
|
|
35
35
|
from ..matchers import Matcher
|
|
36
36
|
from ._builder_check_typing import _CheckAnyValue
|
|
37
37
|
from ._compat import Self
|
|
38
|
-
from ._poll_typing import _AsyncPoll, _SyncPoll
|
|
39
|
-
from ._typing import
|
|
38
|
+
from ._poll_typing import _AsyncPoll, _NoExpectationOnAChain, _SyncPoll
|
|
39
|
+
from ._typing import (
|
|
40
|
+
_E,
|
|
41
|
+
_K,
|
|
42
|
+
_R,
|
|
43
|
+
_U,
|
|
44
|
+
_V,
|
|
45
|
+
_ExpectedCompletionAssertion,
|
|
46
|
+
_ExpectedRaiseAssertion,
|
|
47
|
+
_ExpectedWarningAssertion,
|
|
48
|
+
_ListAssertion,
|
|
49
|
+
)
|
|
40
50
|
|
|
41
51
|
# covariant: the façade only ever hands the subject back, through `value`
|
|
42
52
|
_CapableT_co = TypeVar("_CapableT_co", covariant=True)
|
|
@@ -194,6 +204,18 @@ if TYPE_CHECKING:
|
|
|
194
204
|
ignoring: type[Exception] | tuple[type[Exception], ...] = ...,
|
|
195
205
|
trace: bool = ...,
|
|
196
206
|
) -> _SyncPoll[Any]: ...
|
|
207
|
+
# where the callable view lands: flattened, the runtime's `-> Self` kept a caught message typed as the callable
|
|
208
|
+
def raises(self: _CapableAssertion[_Callable], ex: type) -> _ExpectedRaiseAssertion[Any]: ...
|
|
209
|
+
def does_not_raise(self: _CapableAssertion[_Callable], ex: type) -> _ExpectedCompletionAssertion[Any]: ...
|
|
210
|
+
def warns(
|
|
211
|
+
self: _CapableAssertion[_Callable], warning: type[Warning] = ...
|
|
212
|
+
) -> _ExpectedWarningAssertion[Any]: ...
|
|
213
|
+
def does_not_warn(
|
|
214
|
+
self: _CapableAssertion[_Callable], warning: type[Warning] = ...
|
|
215
|
+
) -> _ExpectedCompletionAssertion[Any]: ...
|
|
216
|
+
|
|
217
|
+
when_called_with: _NoExpectationOnAChain
|
|
218
|
+
|
|
197
219
|
# the builder's own helpers: left to `__getattr__`, `builder()` read as this facade over the value already here
|
|
198
220
|
def builder(
|
|
199
221
|
self,
|
|
@@ -220,8 +242,8 @@ if TYPE_CHECKING:
|
|
|
220
242
|
) -> Self: ...
|
|
221
243
|
def all_satisfy(self, matcher: Matcher[Any] | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
|
|
222
244
|
def any_satisfy(self, matcher: Matcher[Any] | Callable[..., bool]) -> Self: ...
|
|
223
|
-
def at_json_path(self, path: str) ->
|
|
224
|
-
def caused_by(self, ex: type) ->
|
|
245
|
+
def at_json_path(self, path: str) -> AssertionBuilder[Any]: ...
|
|
246
|
+
def caused_by(self, ex: type) -> AssertionBuilder[Any]: ...
|
|
225
247
|
def conforms_to_openapi(
|
|
226
248
|
self,
|
|
227
249
|
spec: dict[str, Any],
|
|
@@ -255,9 +277,7 @@ if TYPE_CHECKING:
|
|
|
255
277
|
def contains_value(
|
|
256
278
|
self: _CapableAssertion[_KeyedWithValues], *values: object
|
|
257
279
|
) -> _CapableAssertion[_CapableT_co]: ...
|
|
258
|
-
def decoded_as(
|
|
259
|
-
self: _CapableAssertion[bytes | bytearray], encoding: str = ...
|
|
260
|
-
) -> _CapableAssertion[_CapableT_co]: ...
|
|
280
|
+
def decoded_as(self: _CapableAssertion[bytes | bytearray], encoding: str = ...) -> AssertionBuilder[Any]: ...
|
|
261
281
|
def decoded_as_json(self) -> AssertionBuilder[object]: ...
|
|
262
282
|
def described_as(self, description: str) -> Self: ...
|
|
263
283
|
def does_not_contain(self, *items: object) -> Self: ...
|
|
@@ -273,28 +293,24 @@ if TYPE_CHECKING:
|
|
|
273
293
|
def does_not_exist(self: _CapableAssertion[_PathLike]) -> _CapableAssertion[_CapableT_co]: ...
|
|
274
294
|
def does_not_have_json_path(self, path: str) -> Self: ...
|
|
275
295
|
def does_not_match(self: _CapableAssertion[str], pattern: str) -> _CapableAssertion[_CapableT_co]: ...
|
|
276
|
-
def does_not_raise(self: _CapableAssertion[_Callable], ex: type) -> _CapableAssertion[_CapableT_co]: ...
|
|
277
|
-
def does_not_warn(
|
|
278
|
-
self: _CapableAssertion[_Callable], warning: type[Warning] = ...
|
|
279
|
-
) -> _CapableAssertion[_CapableT_co]: ...
|
|
280
296
|
def each(self, matcher: Matcher[Any] | Callable[..., bool], *, allow_empty: bool = ...) -> Self: ...
|
|
281
297
|
def ends_with(self, suffix: object) -> Self: ...
|
|
282
298
|
def ends_with_ignoring_case(self: _CapableAssertion[str], suffix: str) -> _CapableAssertion[_CapableT_co]: ...
|
|
283
|
-
def error_of(self, ex: type) ->
|
|
284
|
-
def errors(self) ->
|
|
299
|
+
def error_of(self, ex: type) -> AssertionBuilder[Any]: ...
|
|
300
|
+
def errors(self) -> AssertionBuilder[Any]: ...
|
|
285
301
|
def exists(self: _CapableAssertion[_PathLike]) -> _CapableAssertion[_CapableT_co]: ...
|
|
286
|
-
def extracting(self, *names: object, **kwargs: object) ->
|
|
302
|
+
def extracting(self, *names: object, **kwargs: object) -> _ListAssertion[Any]: ...
|
|
287
303
|
def extracting_group(
|
|
288
304
|
self: _CapableAssertion[str], pattern: str, group: int | str = ...
|
|
289
|
-
) ->
|
|
290
|
-
def filtered_on(self, predicate: Matcher[Any] | Callable[[Any], bool]) ->
|
|
291
|
-
def flat_mapped(self, func: Callable[[Any], Iterable[Any]]) ->
|
|
305
|
+
) -> AssertionBuilder[Any]: ...
|
|
306
|
+
def filtered_on(self, predicate: Matcher[Any] | Callable[[Any], bool]) -> _ListAssertion[Any]: ...
|
|
307
|
+
def flat_mapped(self, func: Callable[[Any], Iterable[Any]]) -> _ListAssertion[Any]: ...
|
|
292
308
|
def has_byte_at(
|
|
293
309
|
self: _CapableAssertion[bytes | bytearray], index: int, expected: int
|
|
294
310
|
) -> _CapableAssertion[_CapableT_co]: ...
|
|
295
311
|
def has_json_path(self, path: str) -> Self: ...
|
|
296
312
|
def has_no_none_fields(self, *, allow_empty: bool = ...) -> Self: ...
|
|
297
|
-
def has_root_cause(self, ex: type) ->
|
|
313
|
+
def has_root_cause(self, ex: type) -> AssertionBuilder[Any]: ...
|
|
298
314
|
def has_same_size_as(self, other: Sized) -> Self: ...
|
|
299
315
|
def has_size_between(self, low: int, high: int) -> Self: ...
|
|
300
316
|
def has_size_greater_than(self, size: int) -> Self: ...
|
|
@@ -420,11 +436,10 @@ if TYPE_CHECKING:
|
|
|
420
436
|
def matches_json_schema(self, schema: dict[str, Any]) -> Self: ...
|
|
421
437
|
def matches_json_schema_from_file(self, path: str | Path) -> Self: ...
|
|
422
438
|
def matches_structure(self, spec: dict[Any, Any]) -> Self: ...
|
|
423
|
-
def matches_with_groups(self: _CapableAssertion[str], pattern: str) ->
|
|
439
|
+
def matches_with_groups(self: _CapableAssertion[str], pattern: str) -> AssertionBuilder[Any]: ...
|
|
424
440
|
def none_satisfy(self, matcher: Matcher[Any] | Callable[..., bool]) -> Self: ...
|
|
425
|
-
def raised(self) ->
|
|
426
|
-
def
|
|
427
|
-
def returned(self) -> Self: ...
|
|
441
|
+
def raised(self) -> AssertionBuilder[Any]: ...
|
|
442
|
+
def returned(self) -> AssertionBuilder[Any]: ...
|
|
428
443
|
def satisfies_exactly(self, *matchers: Matcher[Any] | Callable[..., bool]) -> Self: ...
|
|
429
444
|
def satisfies_exactly_in_any_order(self, *matchers: Matcher[Any] | Callable[..., bool]) -> Self: ...
|
|
430
445
|
def snapshot(
|
|
@@ -443,12 +458,6 @@ if TYPE_CHECKING:
|
|
|
443
458
|
self: _CapableAssertion[bytes | bytearray], prefix: bytes | bytearray
|
|
444
459
|
) -> _CapableAssertion[_CapableT_co]: ...
|
|
445
460
|
def starts_with_ignoring_case(self: _CapableAssertion[str], prefix: str) -> _CapableAssertion[_CapableT_co]: ...
|
|
446
|
-
def warns(
|
|
447
|
-
self: _CapableAssertion[_Callable], warning: type[Warning] = ...
|
|
448
|
-
) -> _CapableAssertion[_CapableT_co]: ...
|
|
449
|
-
def when_called_with(
|
|
450
|
-
self: _CapableAssertion[_Callable], *some_args: object, **some_kwargs: object
|
|
451
|
-
) -> _CapableAssertion[_CapableT_co]: ...
|
|
452
461
|
def zip_satisfies(
|
|
453
462
|
self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = ...
|
|
454
463
|
) -> Self: ...
|
|
@@ -721,9 +730,6 @@ if TYPE_CHECKING:
|
|
|
721
730
|
def starts_with_ignoring_case(
|
|
722
731
|
self: _NegatedCapableAssertion[str], prefix: str
|
|
723
732
|
) -> _CapableAssertion[_CapableT_co]: ...
|
|
724
|
-
def when_called_with(
|
|
725
|
-
self: _NegatedCapableAssertion[_Callable], *some_args: object, **some_kwargs: object
|
|
726
|
-
) -> _CapableAssertion[_CapableT_co]: ...
|
|
727
733
|
def zip_satisfies(
|
|
728
734
|
self, other: Iterable[object], predicate: Callable[..., bool], *, allow_empty: bool = ...
|
|
729
735
|
) -> _CapableAssertion[_CapableT_co]: ...
|
|
@@ -26,12 +26,21 @@ import uuid
|
|
|
26
26
|
from dataclasses import dataclass
|
|
27
27
|
from typing import TYPE_CHECKING, Any
|
|
28
28
|
|
|
29
|
-
from ._introspection import
|
|
29
|
+
from ._introspection import (
|
|
30
|
+
KeyedValue,
|
|
31
|
+
as_held,
|
|
32
|
+
eq_keyed,
|
|
33
|
+
is_attrs_instance,
|
|
34
|
+
is_mapping_like,
|
|
35
|
+
is_model_dump_object,
|
|
36
|
+
kind_of,
|
|
37
|
+
model_field_values,
|
|
38
|
+
)
|
|
30
39
|
from ._ordering import nan_operand
|
|
31
40
|
from ._require import raised_inside, verdict
|
|
32
41
|
|
|
33
42
|
if TYPE_CHECKING:
|
|
34
|
-
from collections.abc import Callable
|
|
43
|
+
from collections.abc import Callable, Iterable
|
|
35
44
|
|
|
36
45
|
|
|
37
46
|
_EQ_ATOMIC = frozenset(
|
|
@@ -164,33 +173,56 @@ def _find_ambiguous_operand(actual, expected, _seen=None):
|
|
|
164
173
|
operand = _ambiguous_array_operand(actual, expected)
|
|
165
174
|
if operand is not None:
|
|
166
175
|
return operand
|
|
176
|
+
for left, right in _members_compared(actual, expected):
|
|
177
|
+
found = _find_ambiguous_operand(left, right, _seen)
|
|
178
|
+
if found is not None:
|
|
179
|
+
return found
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _members_compared(actual: Any, expected: Any) -> Iterable[tuple[Any, Any]]:
|
|
184
|
+
"""The pairs ``==`` walks into for two values of one container kind, and none for any other pair."""
|
|
167
185
|
if is_mapping_like(actual) and is_mapping_like(expected):
|
|
168
186
|
expected_keys = set(expected)
|
|
169
|
-
for key in actual
|
|
170
|
-
if key in expected_keys:
|
|
171
|
-
found = _find_ambiguous_operand(actual[key], expected[key], _seen)
|
|
172
|
-
if found is not None:
|
|
173
|
-
return found
|
|
174
|
-
return None
|
|
187
|
+
return ((actual[key], expected[key]) for key in actual if key in expected_keys)
|
|
175
188
|
if (
|
|
176
189
|
dataclasses.is_dataclass(actual)
|
|
177
190
|
and not isinstance(actual, type)
|
|
178
191
|
and dataclasses.is_dataclass(expected)
|
|
179
192
|
and not isinstance(expected, type)
|
|
180
193
|
):
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
194
|
+
return (
|
|
195
|
+
(getattr(actual, one.name), getattr(expected, one.name, None))
|
|
196
|
+
for one in dataclasses.fields(actual)
|
|
197
|
+
if one.compare
|
|
198
|
+
)
|
|
199
|
+
if is_attrs_instance(actual) and is_attrs_instance(expected):
|
|
200
|
+
return _keyed_members(actual, expected)
|
|
186
201
|
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
187
|
-
return
|
|
202
|
+
return ((model_field_values(actual), model_field_values(expected)),)
|
|
188
203
|
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
return zip(actual, expected, strict=False)
|
|
205
|
+
return ()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _keyed_members(actual: Any, expected: Any) -> tuple[tuple[Any, Any], ...]:
|
|
209
|
+
"""The attrs fields ``==`` compares, each read through its key as ``==`` reads it, or none.
|
|
210
|
+
|
|
211
|
+
Only what ``==`` compares can have broken it. attrs reads every key before it compares a field, so a key
|
|
212
|
+
that raises is the error being explained and no array is to blame: read again here, it raised a second
|
|
213
|
+
error while the first was handled, and skipped, it let a later array take the blame.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
return tuple(
|
|
217
|
+
(
|
|
218
|
+
eq_keyed(one, getattr(actual, one.name)),
|
|
219
|
+
eq_keyed(one, getattr(expected, one.name)) if hasattr(expected, one.name) else None,
|
|
220
|
+
)
|
|
221
|
+
for one in actual.__attrs_attrs__
|
|
222
|
+
if one.eq is not False
|
|
223
|
+
)
|
|
224
|
+
except (TypeError, ValueError): # the two errors `_guarded_not_equal` hands to this search
|
|
225
|
+
return ()
|
|
194
226
|
|
|
195
227
|
|
|
196
228
|
def _guarded_not_equal(actual, expected, *, method="is_equal_to") -> bool:
|
|
@@ -306,6 +338,7 @@ def _resolve_comparator(actual, config: _CompareConfig, *, field):
|
|
|
306
338
|
comparators = config.comparators
|
|
307
339
|
if comparators is None:
|
|
308
340
|
return None
|
|
341
|
+
actual = as_held(actual)
|
|
309
342
|
if field is not None and not isinstance(field, type) and field in comparators:
|
|
310
343
|
return comparators[field]
|
|
311
344
|
if type(actual) in comparators:
|
|
@@ -326,7 +359,7 @@ def _types_differ(actual, expected) -> bool:
|
|
|
326
359
|
``_is_matcher`` is imported here rather than at module scope because ``_matcher_impls`` imports
|
|
327
360
|
``_guarded_not_equal`` from this module, so the module-level import would be a cycle.
|
|
328
361
|
"""
|
|
329
|
-
if
|
|
362
|
+
if kind_of(actual) is kind_of(expected):
|
|
330
363
|
return False
|
|
331
364
|
from .._matcher_impls import _is_matcher
|
|
332
365
|
|
|
@@ -412,11 +445,12 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
|
|
|
412
445
|
`assertpy2._engine._diff._child_entries()` is the single place to know.
|
|
413
446
|
"""
|
|
414
447
|
if config is not None:
|
|
415
|
-
if config.ignore_null and field is not None and expected is None:
|
|
448
|
+
if config.ignore_null and field is not None and as_held(expected) is None:
|
|
416
449
|
return "equal" # a named field the expected side leaves None is not compared
|
|
417
450
|
comparator = _resolve_comparator(actual, config, field=field)
|
|
418
451
|
if comparator is not None:
|
|
419
|
-
|
|
452
|
+
agreed = verdict(comparator(as_held(actual), as_held(expected)), subject="the comparator")
|
|
453
|
+
return "equal" if agreed else "leaf"
|
|
420
454
|
if config.strict_types:
|
|
421
455
|
if actual is expected and not at_root:
|
|
422
456
|
# identity, free from `PyObject_RichCompareBool`. Not at the root, where it made `strict_types` weaker
|
|
@@ -430,8 +464,10 @@ def _node_decision(actual, expected, config: _CompareConfig | None, *, field=Non
|
|
|
430
464
|
if type(actual) not in _EQ_ATOMIC and not _guarded_not_equal(actual, expected):
|
|
431
465
|
# `[True] == [1]`: a container says nothing about the types inside it, so the walk keeps going
|
|
432
466
|
return "strict"
|
|
433
|
-
if config.tolerance is not None and _is_real_number(actual) and _is_real_number(expected):
|
|
434
|
-
|
|
467
|
+
if config.tolerance is not None and _is_real_number(as_held(actual)) and _is_real_number(as_held(expected)):
|
|
468
|
+
# a keyed field's key still holds equal what the tolerance would not: it only ever loosens `==`
|
|
469
|
+
within = _within_tolerance(as_held(actual), as_held(expected), config.tolerance)
|
|
470
|
+
return "equal" if within or (type(actual) is KeyedValue and actual == expected) else "leaf"
|
|
435
471
|
return _plain_decision(actual, expected, config, at_root=at_root)
|
|
436
472
|
|
|
437
473
|
|
|
@@ -34,7 +34,17 @@ from typing import TYPE_CHECKING, TypeVar
|
|
|
34
34
|
|
|
35
35
|
from ..errors import DiffEntry, DiffResult, _safe_repr
|
|
36
36
|
from ._compare import _guarded_not_equal, _node_decision
|
|
37
|
-
from ._introspection import
|
|
37
|
+
from ._introspection import (
|
|
38
|
+
TakenApart,
|
|
39
|
+
is_attrs_instance,
|
|
40
|
+
is_mapping_like,
|
|
41
|
+
is_model_dump_object,
|
|
42
|
+
is_namedtuple,
|
|
43
|
+
keyed_names,
|
|
44
|
+
keyed_pair,
|
|
45
|
+
keyed_snapshot,
|
|
46
|
+
model_field_values,
|
|
47
|
+
)
|
|
38
48
|
from ._path import _ROOT, _Path
|
|
39
49
|
|
|
40
50
|
if TYPE_CHECKING:
|
|
@@ -46,10 +56,20 @@ __tracebackhide__ = True
|
|
|
46
56
|
|
|
47
57
|
|
|
48
58
|
def _field_dict(obj, is_model):
|
|
49
|
-
"""Field mapping of a pydantic-style model
|
|
59
|
+
"""Field mapping of a pydantic-style model or an attrs instance (shallow), as the values its fields hold.
|
|
60
|
+
|
|
61
|
+
An attrs instance's carries the keys its fields are compared through, so the walk decides a field by the
|
|
62
|
+
key both sides declare and still shows the values held: ``str.lower`` would otherwise print a value
|
|
63
|
+
neither side has, and listed a field ``==`` holds equal.
|
|
64
|
+
"""
|
|
50
65
|
if is_model:
|
|
51
|
-
return obj
|
|
52
|
-
|
|
66
|
+
return model_field_values(obj)
|
|
67
|
+
compared = [field for field in obj.__attrs_attrs__ if field.eq is not False]
|
|
68
|
+
return TakenApart(
|
|
69
|
+
type(obj),
|
|
70
|
+
{field.name: getattr(obj, field.name) for field in compared},
|
|
71
|
+
{field.name: field.eq_key for field in compared if getattr(field, "eq_key", None) is not None},
|
|
72
|
+
)
|
|
53
73
|
|
|
54
74
|
|
|
55
75
|
def _child_entries(actual, expected, path: _Path, *, descended_for, _seen=None, config=None) -> list[DiffEntry]:
|
|
@@ -422,8 +442,8 @@ def _build_equality_diff(
|
|
|
422
442
|
both_model = is_model_dump_object(actual) and is_model_dump_object(expected)
|
|
423
443
|
both_attrs = is_attrs_instance(actual) and is_attrs_instance(expected)
|
|
424
444
|
if both_model or both_attrs:
|
|
425
|
-
actual_dict = _field_dict(actual, both_model)
|
|
426
|
-
|
|
445
|
+
actual_dict, expected_dict = _field_dict(actual, both_model), _field_dict(expected, both_model)
|
|
446
|
+
keyed = keyed_names(actual_dict, expected_dict)
|
|
427
447
|
entries = []
|
|
428
448
|
for key in _ordered_keys(actual_dict, expected_dict):
|
|
429
449
|
if key not in expected_dict:
|
|
@@ -431,7 +451,11 @@ def _build_equality_diff(
|
|
|
431
451
|
elif key not in actual_dict:
|
|
432
452
|
entries.append(_prefix.attr(key).entry(actual=None, absent="actual", expected=expected_dict[key]))
|
|
433
453
|
else:
|
|
434
|
-
decision =
|
|
454
|
+
decision = (
|
|
455
|
+
_node_decision(*keyed_pair(actual_dict, expected_dict, key), config, field=key)
|
|
456
|
+
if key in keyed
|
|
457
|
+
else _node_decision(actual_dict[key], expected_dict[key], config, field=key)
|
|
458
|
+
)
|
|
435
459
|
if decision == "leaf":
|
|
436
460
|
entries.append(_prefix.attr(key).entry(actual=actual_dict[key], expected=expected_dict[key]))
|
|
437
461
|
elif decision != "equal":
|
|
@@ -495,6 +519,7 @@ def _mapping_diff_entries(actual, expected, prefix: _Path, child_seen: set[int],
|
|
|
495
519
|
actual_keys = set(kept)
|
|
496
520
|
expected_keys = set(kept_expected)
|
|
497
521
|
entries.extend(_order_entries(actual, expected, kept, kept_expected, prefix))
|
|
522
|
+
keyed = keyed_names(kept, kept_expected)
|
|
498
523
|
if config is not None and config.strict_types:
|
|
499
524
|
# `{True} & {1}` hands back whichever side the set drew from, losing the type that differs
|
|
500
525
|
stored = {key: key for key in kept_expected}
|
|
@@ -508,7 +533,11 @@ def _mapping_diff_entries(actual, expected, prefix: _Path, child_seen: set[int],
|
|
|
508
533
|
elif key not in actual_keys:
|
|
509
534
|
entries.append(prefix.key(key).entry(actual=None, absent="actual", expected=kept_expected[key]))
|
|
510
535
|
else:
|
|
511
|
-
decision =
|
|
536
|
+
decision = (
|
|
537
|
+
_node_decision(*keyed_pair(kept, kept_expected, key), config, field=key)
|
|
538
|
+
if key in keyed
|
|
539
|
+
else _node_decision(kept[key], kept_expected[key], config, field=key)
|
|
540
|
+
)
|
|
512
541
|
if decision == "leaf":
|
|
513
542
|
entries.append(prefix.key(key).entry(actual=kept[key], expected=kept_expected[key]))
|
|
514
543
|
elif decision != "equal":
|
|
@@ -596,8 +625,8 @@ def _sub_diff_entries(
|
|
|
596
625
|
both_attrs = is_attrs_instance(actual) and is_attrs_instance(expected)
|
|
597
626
|
if both_model or both_attrs:
|
|
598
627
|
child_seen = _seen | {id(actual), id(expected)}
|
|
599
|
-
actual_dict = _field_dict(actual, both_model)
|
|
600
|
-
|
|
628
|
+
actual_dict, expected_dict = _field_dict(actual, both_model), _field_dict(expected, both_model)
|
|
629
|
+
keyed = keyed_names(actual_dict, expected_dict)
|
|
601
630
|
entries = []
|
|
602
631
|
for key in _ordered_keys(actual_dict, expected_dict):
|
|
603
632
|
if key not in expected_dict:
|
|
@@ -605,7 +634,11 @@ def _sub_diff_entries(
|
|
|
605
634
|
elif key not in actual_dict:
|
|
606
635
|
entries.append(prefix.attr(key).entry(actual=None, absent="actual", expected=expected_dict[key]))
|
|
607
636
|
else:
|
|
608
|
-
decision =
|
|
637
|
+
decision = (
|
|
638
|
+
_node_decision(*keyed_pair(actual_dict, expected_dict, key), config, field=key)
|
|
639
|
+
if key in keyed
|
|
640
|
+
else _node_decision(actual_dict[key], expected_dict[key], config, field=key)
|
|
641
|
+
)
|
|
609
642
|
if decision == "leaf":
|
|
610
643
|
entries.append(prefix.attr(key).entry(actual=actual_dict[key], expected=expected_dict[key]))
|
|
611
644
|
elif decision != "equal":
|
|
@@ -661,9 +694,9 @@ def _walk_leaves(value, prefix: _Path = _ROOT, _seen=None):
|
|
|
661
694
|
return
|
|
662
695
|
if is_model_dump_object(value):
|
|
663
696
|
child_seen = _seen | {id(value)}
|
|
664
|
-
|
|
665
|
-
for key in
|
|
666
|
-
yield from _walk_leaves(
|
|
697
|
+
held = model_field_values(value)
|
|
698
|
+
for key in held:
|
|
699
|
+
yield from _walk_leaves(held[key], prefix.attr(str(key), dotted_at_root=False), child_seen)
|
|
667
700
|
return
|
|
668
701
|
if is_attrs_instance(value):
|
|
669
702
|
child_seen = _seen | {id(value)}
|
|
@@ -32,9 +32,18 @@ from ._compare import (
|
|
|
32
32
|
_kinds_never_equal,
|
|
33
33
|
_node_decision,
|
|
34
34
|
_spec_matches,
|
|
35
|
+
_types_differ,
|
|
35
36
|
)
|
|
36
37
|
from ._diff import _sub_diff_entries
|
|
37
|
-
from ._introspection import
|
|
38
|
+
from ._introspection import (
|
|
39
|
+
TakenApart,
|
|
40
|
+
is_attrs_instance,
|
|
41
|
+
is_model_dump_object,
|
|
42
|
+
is_namedtuple,
|
|
43
|
+
keyed_names,
|
|
44
|
+
keyed_pair,
|
|
45
|
+
model_field_values,
|
|
46
|
+
)
|
|
38
47
|
from ._path import _ROOT
|
|
39
48
|
from ._require import refuse
|
|
40
49
|
|
|
@@ -74,22 +83,21 @@ def comparable_fields(obj: object) -> dict | None:
|
|
|
74
83
|
|
|
75
84
|
Dataclasses are converted by reference rather than through `dataclasses.asdict`, which deep-copies and
|
|
76
85
|
crashes on a field that cannot be copied, and a field declared ``compare=False`` is left out, as the
|
|
77
|
-
dataclass's own ``==`` leaves it out. An attrs field declared ``eq=False`` is left out the same way
|
|
86
|
+
dataclass's own ``==`` leaves it out. An attrs field declared ``eq=False`` is left out the same way, and
|
|
87
|
+
one declared with a key, ``eq=str.lower``, is read through it: `attrs.asdict` read the raw value, and
|
|
88
|
+
``ignore=`` failed on two instances ``==`` holds equal.
|
|
78
89
|
|
|
79
90
|
A value of a builtin kind is not a bag of fields even when it carries a ``__dict__``: a subclass of
|
|
80
91
|
`Decimal` or `str` has an empty one, and reading it made every two such values compare equal.
|
|
81
92
|
"""
|
|
82
93
|
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
83
|
-
return cast("dict",
|
|
94
|
+
return cast("dict", _flattened(obj, frozenset({"dataclass"})))
|
|
84
95
|
if is_namedtuple(obj):
|
|
85
|
-
return
|
|
96
|
+
return TakenApart(type(obj), obj._asdict())
|
|
86
97
|
if is_model_dump_object(obj):
|
|
87
|
-
return obj
|
|
98
|
+
return cast("dict", _flattened(obj, frozenset({"model", "dataclass"})))
|
|
88
99
|
if is_attrs_instance(obj):
|
|
89
|
-
|
|
90
|
-
import attrs
|
|
91
|
-
|
|
92
|
-
return attrs.asdict(obj, filter=lambda attribute, _value: attribute.eq is not False)
|
|
100
|
+
return cast("dict", _flattened(obj, frozenset({"attrs"})))
|
|
93
101
|
builtin_kinds = (
|
|
94
102
|
type,
|
|
95
103
|
numbers.Number,
|
|
@@ -104,27 +112,58 @@ def comparable_fields(obj: object) -> dict | None:
|
|
|
104
112
|
pathlib.PurePath,
|
|
105
113
|
)
|
|
106
114
|
if hasattr(obj, "__dict__") and not isinstance(obj, builtin_kinds):
|
|
107
|
-
return
|
|
115
|
+
return TakenApart(type(obj), vars(obj))
|
|
108
116
|
return None
|
|
109
117
|
|
|
110
118
|
|
|
111
|
-
def
|
|
112
|
-
"""
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
def _flattened(node: Any, through: frozenset[str]) -> Any:
|
|
120
|
+
"""Fields by reference, as each value's ``==`` reads them, taken apart through the kinds *through* names.
|
|
121
|
+
|
|
122
|
+
A nested value is taken apart where the conversion each kind used to go through took it apart, so a
|
|
123
|
+
nested value is judged as it was: `dataclasses.asdict` went through dataclasses, `attrs.asdict` through
|
|
124
|
+
attrs instances, and `model_dump()` through models and the dataclasses inside them. What is read is
|
|
125
|
+
what is held, though, not what a serialiser or a copy makes of it.
|
|
126
|
+
"""
|
|
127
|
+
if "dataclass" in through and dataclasses.is_dataclass(node) and not isinstance(node, type):
|
|
128
|
+
return TakenApart(
|
|
129
|
+
type(node),
|
|
130
|
+
{
|
|
131
|
+
field.name: _flattened(getattr(node, field.name), through)
|
|
132
|
+
for field in dataclasses.fields(node)
|
|
133
|
+
if field.compare
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
if "attrs" in through and is_attrs_instance(node):
|
|
137
|
+
compared = [attribute for attribute in node.__attrs_attrs__ if attribute.eq is not False]
|
|
138
|
+
keys = {attribute.name: _key_of(attribute) for attribute in compared}
|
|
139
|
+
return TakenApart(
|
|
140
|
+
type(node),
|
|
141
|
+
{
|
|
142
|
+
attribute.name: getattr(node, attribute.name)
|
|
143
|
+
if keys[attribute.name]
|
|
144
|
+
else _flattened(getattr(node, attribute.name), through)
|
|
145
|
+
for attribute in compared
|
|
146
|
+
},
|
|
147
|
+
{name: key for name, key in keys.items() if key is not None},
|
|
148
|
+
)
|
|
149
|
+
if "model" in through and is_model_dump_object(node):
|
|
150
|
+
return TakenApart(
|
|
151
|
+
type(node), {name: _flattened(value, through) for name, value in model_field_values(node).items()}
|
|
152
|
+
)
|
|
119
153
|
if isinstance(node, tuple) and hasattr(node, "_fields"):
|
|
120
|
-
return type(node)(*[
|
|
154
|
+
return type(node)(*[_flattened(item, through) for item in node])
|
|
121
155
|
if isinstance(node, (list, tuple)):
|
|
122
|
-
return type(node)(
|
|
156
|
+
return type(node)(_flattened(item, through) for item in node)
|
|
123
157
|
if isinstance(node, dict):
|
|
124
|
-
return {
|
|
158
|
+
return {_flattened(key, through): _flattened(value, through) for key, value in node.items()}
|
|
125
159
|
return node
|
|
126
160
|
|
|
127
161
|
|
|
162
|
+
def _key_of(attribute: Any) -> Any:
|
|
163
|
+
"""The key an attrs field is compared through, given as ``eq=``, or ``None``: its value is then held raw."""
|
|
164
|
+
return getattr(attribute, "eq_key", None)
|
|
165
|
+
|
|
166
|
+
|
|
128
167
|
def _as_fields(value: object) -> dict | None:
|
|
129
168
|
"""A plain dict as itself, anything else through `comparable_fields`."""
|
|
130
169
|
return value if isinstance(value, dict) else comparable_fields(value)
|
|
@@ -356,15 +395,20 @@ def mapping_differs(
|
|
|
356
395
|
if (
|
|
357
396
|
config is not None
|
|
358
397
|
and config.strict_types
|
|
359
|
-
and
|
|
360
|
-
|
|
361
|
-
|
|
398
|
+
and (
|
|
399
|
+
# two elements taken apart reach here with no parent walk to have compared their classes
|
|
400
|
+
_types_differ(actual, expected)
|
|
401
|
+
or _keyed_types_differ(
|
|
402
|
+
dict.fromkeys(key for key in left if key in keys_in_actual),
|
|
403
|
+
dict.fromkeys(key for key in right if key in keys_in_expected),
|
|
404
|
+
)
|
|
362
405
|
)
|
|
363
406
|
):
|
|
364
407
|
# `{True: "a"}` and `{1: "a"}` are equal to Python and not under strict types; only the keys still compared
|
|
365
408
|
return True
|
|
409
|
+
keyed = keyed_names(actual, expected)
|
|
366
410
|
for key in keys_in_actual:
|
|
367
|
-
nested_left, nested_right = left[key], right[key]
|
|
411
|
+
nested_left, nested_right = keyed_pair(left, right, key) if key in keyed else (left[key], right[key])
|
|
368
412
|
if config is not None:
|
|
369
413
|
decision = _node_decision(nested_left, nested_right, config, field=key)
|
|
370
414
|
if decision == "equal":
|