assertpy2 2.23.0__tar.gz → 2.24.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {assertpy2-2.23.0 → assertpy2-2.24.0}/CONTRIBUTING.md +54 -11
- {assertpy2-2.23.0 → assertpy2-2.24.0}/PKG-INFO +4 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/README.md +3 -3
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_clustering.py +7 -13
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_dangling.py +156 -53
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_builder_check_typing.py +82 -114
- assertpy2-2.24.0/assertpy2/_engine/_capable_typing.py +402 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_check_typing.py +37 -13
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_compare.py +10 -13
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_compat.py +14 -1
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_diff.py +5 -11
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_equality.py +3 -6
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_introspection.py +45 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_membership.py +29 -9
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_mixin_base.py +19 -7
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_operations.py +4 -11
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_ordering.py +5 -9
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_poll_typing.py +165 -229
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_require.py +29 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_size.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_typing.py +77 -49
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_hints.py +10 -21
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_matcher_impls.py +70 -59
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_satisfies.py +15 -17
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_snapshot_codec.py +4 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/assertpy.py +110 -91
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/async_assertions.py +10 -18
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/base.py +22 -15
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/behave_matchers.py +16 -10
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/collection.py +17 -27
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/contains.py +4 -5
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/dict.py +14 -11
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/dynamic.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/errors.py +26 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/exception.py +4 -3
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/extracting.py +19 -18
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/file.py +7 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/helpers.py +6 -11
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/http_mixin.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/json_mixin.py +3 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/matchers.py +6 -6
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/numeric.py +8 -5
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/pytest_plugin.py +170 -117
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/snapshot.py +42 -31
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/string.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/benchmarks/test_perf.py +140 -41
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/concepts/stability.md +60 -39
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/concepts/type-safety.md +100 -78
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/extending/custom-assertions.md +7 -6
- assertpy2-2.24.0/docs/getting-started/comparison.md +280 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/getting-started/migration.md +1 -1
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/assertions.md +66 -23
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/data.md +20 -11
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/errors.md +99 -60
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/matchers.md +8 -7
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/testing.md +23 -13
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/recipes.md +5 -3
- {assertpy2-2.23.0 → assertpy2-2.24.0}/pyproject.toml +30 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/scripts/generate_check_protocols.py +9 -9
- assertpy2-2.24.0/scripts/generate_poll_protocols.py +863 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/scripts/mutation_report.py +12 -6
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/api_snapshot.json +152 -144
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/api_surface.py +2 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/conftest.py +50 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/docs_fixtures.py +37 -13
- assertpy2-2.24.0/tests/pyright_baseline.py +108 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_api_compatibility.py +42 -39
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_api_vocabulary.py +5 -10
- assertpy2-2.24.0/tests/test_architecture_doc.py +133 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_async.py +13 -26
- assertpy2-2.24.0/tests/test_async_predicates.py +157 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_bytes.py +2 -4
- assertpy2-2.24.0/tests/test_capable_protocol.py +578 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_check.py +3 -6
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_check_protocols.py +41 -19
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_class.py +35 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_clustering.py +31 -37
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_collection.py +9 -14
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_core.py +5 -12
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_custom_dict.py +2 -5
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dangling.py +134 -20
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dataframe.py +3 -7
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_datetime.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dict.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dict_compare.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_docs_examples.py +45 -13
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_docs_typing.py +4 -6
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_dynamic.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_equals.py +7 -15
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_errors.py +2 -5
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_evaluation_core.py +107 -19
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_exception_context.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_expected_contract.py +2 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_expected_exception.py +4 -8
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_expected_warning.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_extensions.py +17 -11
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_extracting.py +3 -6
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_hints.py +7 -15
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_http.py +6 -12
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_inline_record.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_inline_snapshot.py +4 -8
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_iterable_cluster.py +8 -8
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_json.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_list.py +4 -8
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_matcher_parity.py +49 -30
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_matcher_registry.py +2 -5
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_matchers.py +25 -12
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_message_elision.py +2 -3
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_not.py +4 -7
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_openapi_contract.py +6 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_operation_contract.py +6 -12
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_optional_integration_contracts.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_overload_order.py +2 -7
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_pipeline.py +8 -9
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_poll_protocols.py +42 -23
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_property_attrs.py +2 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_property_based.py +143 -47
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_protocol_parity.py +33 -52
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_public_surface.py +48 -24
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_pyright_baseline.py +45 -21
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_pytest_plugin.py +147 -24
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare.py +1 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare_config.py +46 -44
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_refusals.py +30 -13
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_rich_diff.py +17 -33
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_satisfy.py +2 -4
- assertpy2-2.24.0/tests/test_skip_guard.py +115 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_snapshots.py +99 -15
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_soft.py +6 -12
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_structural.py +10 -19
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_surface_conformance.py +23 -28
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_sync_eventually.py +1 -2
- assertpy2-2.24.0/tests/test_type_expression_failures.py +148 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing.py +70 -38
- assertpy2-2.24.0/tests/test_typing_claims.py +194 -0
- assertpy2-2.24.0/tests/test_typing_completeness.py +142 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_conformance.py +64 -15
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_from_a_wheel.py +5 -9
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_http.py +2 -2
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_integrations.py +3 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_typing_negative.py +19 -24
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_vacuity_contract.py +9 -12
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_cases.py +84 -31
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_harness.py +28 -1
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_http.py +7 -7
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_integrations.py +7 -8
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_integrations_baseline.py +3 -4
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/typing_negative_baseline.py +77 -43
- assertpy2-2.23.0/docs/getting-started/comparison.md +0 -248
- assertpy2-2.23.0/scripts/generate_poll_protocols.py +0 -448
- assertpy2-2.23.0/tests/pyright_baseline.py +0 -66
- assertpy2-2.23.0/tests/test_typing_claims.py +0 -80
- {assertpy2-2.23.0 → assertpy2-2.24.0}/.gitignore +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/LICENSE +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/SECURITY.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/__init__.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_contract.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_path.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_engine/_text.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/_inline.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/date.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/outcome.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/extending/integrations.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/guides/fluent.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/index.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/logo.svg +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/async.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/core.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/errors.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/files.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/http.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/json.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/mkdocs.yml +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/mkdocs_hooks.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/group_compat.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_attrs_support.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_bool.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_callable.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_description.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_fail.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_file.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_in.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_match_result.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_none.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_string.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_type.py +0 -0
- {assertpy2-2.23.0 → assertpy2-2.24.0}/tests/test_warn.py +0 -0
|
@@ -23,17 +23,26 @@ Read more about how pulls work on GitHub's [About pull requests](https://help.gi
|
|
|
23
23
|
- Python 3.10+
|
|
24
24
|
- [uv](https://docs.astral.sh/uv/) as the package manager
|
|
25
25
|
|
|
26
|
+
## Before you change a public signature
|
|
27
|
+
|
|
28
|
+
Read [ARCHITECTURE.md](ARCHITECTURE.md). Four files under `assertpy2/_engine/` are generated, the public
|
|
29
|
+
surface is pinned by a recorded snapshot, and neither is discoverable from the files themselves.
|
|
30
|
+
|
|
26
31
|
## Verification pipeline
|
|
27
32
|
|
|
28
33
|
Run all checks before submitting a PR. Every step must pass.
|
|
29
34
|
|
|
30
|
-
Install
|
|
31
|
-
|
|
35
|
+
### Install
|
|
36
|
+
|
|
37
|
+
A bare `uv sync` leaves out the optional libraries several tests import, and those tests then fail
|
|
38
|
+
rather than skip. Install exactly this set:
|
|
32
39
|
|
|
33
40
|
```bash
|
|
34
41
|
uv sync --extra json --extra data --extra inline --group integrations --group docs-examples
|
|
35
42
|
```
|
|
36
43
|
|
|
44
|
+
### The main gate
|
|
45
|
+
|
|
37
46
|
```bash
|
|
38
47
|
uv run ruff check .
|
|
39
48
|
uv run ruff format --check .
|
|
@@ -43,22 +52,58 @@ uv run pytest tests/test_docs_examples.py tests/test_typing_claims.py
|
|
|
43
52
|
uv run --group docs mkdocs build --strict
|
|
44
53
|
```
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
CI requires 100% code coverage.
|
|
56
|
+
|
|
57
|
+
### Then once on the supported floor
|
|
58
|
+
|
|
59
|
+
Run the suite on Python 3.10 as well. This is not optional, and the block above does not cover it.
|
|
60
|
+
|
|
61
|
+
A union carries `__name__` from 3.14 onwards and not before. `logging.LoggerAdapter` became
|
|
62
|
+
subscriptable in 3.11. Neither difference is visible to a type checker told to target 3.10, and both
|
|
63
|
+
shipped as defects that every other gate passed.
|
|
64
|
+
|
|
65
|
+
Use a throwaway environment. `uv run --python 3.10` inside the project rebuilds `.venv` for that
|
|
66
|
+
version, and on Windows it can fail partway and leave you without pytest:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv venv /tmp/py310 --python 3.10
|
|
70
|
+
|
|
71
|
+
export VIRTUAL_ENV=/tmp/py310
|
|
72
|
+
uv pip install -e ".[json,data,inline]"
|
|
73
|
+
uv pip install pytest pytest-cov hypothesis ruff
|
|
74
|
+
uv pip install attrs pydantic requests httpx flask
|
|
75
|
+
|
|
76
|
+
/tmp/py310/bin/python -m pytest tests --ignore=tests/test_docs_examples.py -q
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`ruff` belongs in that list: the tests that compare a generated file against its generator shell out
|
|
80
|
+
to it, and without it they fail for a reason that has nothing to do with your change.
|
|
81
|
+
|
|
82
|
+
### The four type checkers
|
|
83
|
+
|
|
84
|
+
Run these after the main gate, not before. `uv run --group typecheck` adds the checkers to the same
|
|
85
|
+
`.venv` and leaves them there, and a coverage run with them installed collects a different set:
|
|
49
86
|
|
|
50
87
|
```bash
|
|
51
88
|
uv run --group typecheck mypy --strict --follow-imports=silent tests/test_typing.py
|
|
52
|
-
uv run --group typecheck pyright --pythonversion 3.14 tests/test_typing.py
|
|
89
|
+
PYRIGHT_PYTHON_FORCE_VERSION=1.1.413 uv run --group typecheck pyright --pythonversion 3.14 tests/test_typing.py
|
|
90
|
+
uv run --group typecheck pyrefly check tests/test_typing.py
|
|
53
91
|
uv run --group typecheck pytest tests/test_pyright_baseline.py
|
|
54
92
|
```
|
|
55
93
|
|
|
56
|
-
|
|
57
|
-
|
|
94
|
+
`PYRIGHT_PYTHON_FORCE_VERSION` picks the engine rather than the launcher. The `pyright` distribution on
|
|
95
|
+
PyPI stopped at 1.1.411 on 25 June while npm has shipped 1.1.412 and 1.1.413 since, so the launcher and
|
|
96
|
+
the engine move apart. The baseline is recorded against one engine, and `tests/typing_harness.py` pins
|
|
97
|
+
the same build, so only this direct invocation needs the variable.
|
|
98
|
+
|
|
99
|
+
`--pythonversion 3.14` is not decoration. Pyright reports against the interpreter it finds unless
|
|
100
|
+
told otherwise, and the count moves with it: 174 diagnostics for this package on 3.10 against 169 on
|
|
101
|
+
3.14.
|
|
102
|
+
|
|
58
103
|
Without it a contributor on the supported floor meets a red baseline that says nothing about their
|
|
59
104
|
change. The baseline test passes the same target itself, so it gives one answer everywhere.
|
|
60
105
|
|
|
61
|
-
Three details that cost time if you meet them the hard way
|
|
106
|
+
### Three details that cost time if you meet them the hard way
|
|
62
107
|
|
|
63
108
|
- `ty check` is scoped on purpose. Run over the whole tree it reports hundreds of diagnostics from
|
|
64
109
|
`tests/`, which is full of deliberate negative typing cases: calls that must not type-check.
|
|
@@ -68,8 +113,6 @@ Three details that cost time if you meet them the hard way:
|
|
|
68
113
|
- `tests/test_docs_examples.py` is run separately because it executes the snippets in `docs/`, which
|
|
69
114
|
needs the `docs-examples` group and a different collection.
|
|
70
115
|
|
|
71
|
-
CI requires 100% code coverage.
|
|
72
|
-
|
|
73
116
|
## Commit style
|
|
74
117
|
|
|
75
118
|
Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, etc.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.24.0
|
|
4
4
|
Summary: Fluent assertion library for Python with composable matchers, structural matching, and full type safety
|
|
5
5
|
Project-URL: Homepage, https://github.com/Solganis/assertpy2
|
|
6
6
|
Project-URL: Repository, https://github.com/Solganis/assertpy2
|
|
@@ -68,7 +68,7 @@ Description-Content-Type: text/markdown
|
|
|
68
68
|
<a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
|
|
69
69
|
<a href="https://pepy.tech/projects/assertpy2"><img src="https://static.pepy.tech/badge/assertpy2/month" alt="Downloads"></a>
|
|
70
70
|
<br>
|
|
71
|
-
<a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/
|
|
71
|
+
<a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/overloads-ty%20%7C%20mypy%20%7C%20pyright%20%7C%20pyrefly-2ea043" alt="every public overload checked by ty, mypy --strict, pyright and pyrefly with zero suppressions"></a>
|
|
72
72
|
<a href="https://solganis.github.io/assertpy2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
|
|
73
73
|
<a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://img.shields.io/ossf-scorecard/github.com/Solganis/assertpy2?label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard"></a>
|
|
74
74
|
</p>
|
|
@@ -201,7 +201,7 @@ There are [45 matchers](https://solganis.github.io/assertpy2/guides/matchers/),
|
|
|
201
201
|
<h2 align="center"><a href="https://solganis.github.io/assertpy2/concepts/type-safety/">Type-aware autocomplete</a></h2>
|
|
202
202
|
|
|
203
203
|
`assert_that()` uses `@overload` to return type-specific Protocols.<br>
|
|
204
|
-
Your IDE shows only methods relevant to the value you're testing, not
|
|
204
|
+
Your IDE shows only methods relevant to the value you're testing, not the whole surface:
|
|
205
205
|
|
|
206
206
|
- `assert_that("hello").` → string methods: `starts_with`, `matches`, `is_alpha`, ...
|
|
207
207
|
- `assert_that(42).` → numeric methods: `is_positive`, `is_between`, `is_close_to`, ...
|
|
@@ -221,7 +221,7 @@ An assertion hands the value back, statically narrowed.
|
|
|
221
221
|
<!-- docs-guard: skip -->
|
|
222
222
|
```python
|
|
223
223
|
order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
|
|
224
|
-
order.refund() # statically PaidOrder - verified by ty, mypy, and
|
|
224
|
+
order.refund() # statically PaidOrder - verified by ty, mypy, pyright and pyrefly
|
|
225
225
|
```
|
|
226
226
|
|
|
227
227
|
For API tests,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<a href="https://pypi.org/project/assertpy2/"><img src="https://img.shields.io/pypi/pyversions/assertpy2" alt="Python"></a>
|
|
17
17
|
<a href="https://pepy.tech/projects/assertpy2"><img src="https://static.pepy.tech/badge/assertpy2/month" alt="Downloads"></a>
|
|
18
18
|
<br>
|
|
19
|
-
<a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/
|
|
19
|
+
<a href="https://solganis.github.io/assertpy2/concepts/type-safety/"><img src="https://img.shields.io/badge/overloads-ty%20%7C%20mypy%20%7C%20pyright%20%7C%20pyrefly-2ea043" alt="every public overload checked by ty, mypy --strict, pyright and pyrefly with zero suppressions"></a>
|
|
20
20
|
<a href="https://solganis.github.io/assertpy2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
|
|
21
21
|
<a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/assertpy2"><img src="https://img.shields.io/ossf-scorecard/github.com/Solganis/assertpy2?label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard"></a>
|
|
22
22
|
</p>
|
|
@@ -149,7 +149,7 @@ There are [45 matchers](https://solganis.github.io/assertpy2/guides/matchers/),
|
|
|
149
149
|
<h2 align="center"><a href="https://solganis.github.io/assertpy2/concepts/type-safety/">Type-aware autocomplete</a></h2>
|
|
150
150
|
|
|
151
151
|
`assert_that()` uses `@overload` to return type-specific Protocols.<br>
|
|
152
|
-
Your IDE shows only methods relevant to the value you're testing, not
|
|
152
|
+
Your IDE shows only methods relevant to the value you're testing, not the whole surface:
|
|
153
153
|
|
|
154
154
|
- `assert_that("hello").` → string methods: `starts_with`, `matches`, `is_alpha`, ...
|
|
155
155
|
- `assert_that(42).` → numeric methods: `is_positive`, `is_between`, `is_close_to`, ...
|
|
@@ -169,7 +169,7 @@ An assertion hands the value back, statically narrowed.
|
|
|
169
169
|
<!-- docs-guard: skip -->
|
|
170
170
|
```python
|
|
171
171
|
order = assert_that(repo.find(42)).is_not_none().is_instance_of(PaidOrder).value
|
|
172
|
-
order.refund() # statically PaidOrder - verified by ty, mypy, and
|
|
172
|
+
order.refund() # statically PaidOrder - verified by ty, mypy, pyright and pyrefly
|
|
173
173
|
```
|
|
174
174
|
|
|
175
175
|
For API tests,
|
|
@@ -188,8 +188,7 @@ def stable_repr(value: object, _seen: frozenset[int] = frozenset()) -> str:
|
|
|
188
188
|
try:
|
|
189
189
|
if isinstance(value, (set, frozenset)):
|
|
190
190
|
members = sorted(stable_repr(member, inner) for member in value)
|
|
191
|
-
# an empty one falls back to `repr
|
|
192
|
-
# read apart
|
|
191
|
+
# an empty one falls back to `repr`: `{}` is a dict, so `set()` and the `frozenset()` it equals read apart
|
|
193
192
|
return "{" + ", ".join(members) + "}" if value else _safe_repr(value)
|
|
194
193
|
if isinstance(value, dict):
|
|
195
194
|
pairs = (f"{stable_repr(key, inner)}: {stable_repr(item, inner)}" for key, item in value.items())
|
|
@@ -287,8 +286,7 @@ def is_well_formed(key: Signature) -> bool:
|
|
|
287
286
|
)
|
|
288
287
|
if key.steps:
|
|
289
288
|
return False
|
|
290
|
-
#
|
|
291
|
-
# would print the `None` standing for a missing item
|
|
289
|
+
# a pair of values only for the kinds whose fields hold them, or a `contains` payload prints a `None`
|
|
292
290
|
if key.label:
|
|
293
291
|
return not key.values
|
|
294
292
|
return len(key.values) == 2 and key.where in _VALUES_ARE_VALUES
|
|
@@ -327,8 +325,7 @@ def clusters(
|
|
|
327
325
|
"""
|
|
328
326
|
if total_failures <= 0:
|
|
329
327
|
return []
|
|
330
|
-
# keyed on the node id: `pytest-rerunfailures`
|
|
331
|
-
# a cluster of six over two tests
|
|
328
|
+
# keyed on the node id: `pytest-rerunfailures` retries printed a cluster of six over two tests
|
|
332
329
|
grouped: dict[Signature, dict[str, None]] = {}
|
|
333
330
|
actuals: dict[Signature, dict[str, None]] = {}
|
|
334
331
|
expecteds: dict[Signature, dict[str, None]] = {}
|
|
@@ -473,21 +470,18 @@ def render(
|
|
|
473
470
|
lines.append(f"{collect_errors} collection error{'' if one else 's'}, not counted below")
|
|
474
471
|
for cluster, where in zip(shown, _headings(shown), strict=True):
|
|
475
472
|
lines.append(_headline(cluster, where, total_failures))
|
|
476
|
-
# a cluster keyed on its diagnostic
|
|
477
|
-
# it are the ones that vary between its failures rather than the thing they share
|
|
473
|
+
# a cluster keyed on its diagnostic says the difference in words; the values are what varies inside it
|
|
478
474
|
if cluster.signature.label:
|
|
479
475
|
lines.append(f" {cluster.signature.label}")
|
|
480
476
|
else:
|
|
481
477
|
lines.append(f" actual: {_side(cluster.actuals)}")
|
|
482
478
|
lines.append(f" expected: {_side(cluster.expecteds)}")
|
|
483
479
|
if omitted:
|
|
484
|
-
# said rather than dropped: five of eleven
|
|
485
|
-
#
|
|
480
|
+
# said rather than dropped: "five of eleven" silently reads as "these are the causes". "more" rather
|
|
481
|
+
# than "smaller", since equal-sized clusters are ordered by signature
|
|
486
482
|
lines.append(f"{len(omitted)} more cluster{'' if len(omitted) == 1 else 's'} not shown")
|
|
487
483
|
covered = len({nodeid for cluster in found for nodeid in cluster.nodeids})
|
|
488
484
|
if covered < total_failures:
|
|
489
|
-
# what the number measures,
|
|
490
|
-
# a floor of three are related, and the summary declined to print them, which is not the same as
|
|
491
|
-
# having found nothing about them
|
|
485
|
+
# what the number measures, not "not clustered": the summary declined to print a pair under the floor
|
|
492
486
|
lines.append(f"{total_failures - covered} of {total_failures} outside any cluster of {minimum}")
|
|
493
487
|
return lines
|
|
@@ -40,7 +40,7 @@ from __future__ import annotations
|
|
|
40
40
|
import ast
|
|
41
41
|
import io
|
|
42
42
|
import tokenize
|
|
43
|
-
from typing import
|
|
43
|
+
from typing import Final, NamedTuple
|
|
44
44
|
|
|
45
45
|
from ._engine._operations import (
|
|
46
46
|
ALSO_ASSERTS,
|
|
@@ -52,9 +52,6 @@ from ._engine._operations import (
|
|
|
52
52
|
WITHOUT_A_VERDICT,
|
|
53
53
|
)
|
|
54
54
|
|
|
55
|
-
if TYPE_CHECKING:
|
|
56
|
-
from collections.abc import Iterator
|
|
57
|
-
|
|
58
55
|
__tracebackhide__ = True
|
|
59
56
|
|
|
60
57
|
# the builder factories and only those: everything else either asserts by itself or is not an entry point
|
|
@@ -128,33 +125,67 @@ class _Bindings(NamedTuple):
|
|
|
128
125
|
return bool(self.direct or self.modules)
|
|
129
126
|
|
|
130
127
|
|
|
131
|
-
|
|
132
|
-
"""
|
|
128
|
+
class _Survey(NamedTuple):
|
|
129
|
+
"""Everything the checks need out of the tree, gathered in one walk.
|
|
130
|
+
|
|
131
|
+
Six separate walks were measured before this: resolving the entry points, the names the module
|
|
132
|
+
rebinds, the same two again for `pytest`, the blocks that demand a raise, and the statements that
|
|
133
|
+
consume a builder. Each is cheap on its own and the tree is walked whole every time, which came to
|
|
134
|
+
624 ms over this repository's own 97 test modules against 166 ms of parsing.
|
|
135
|
+
|
|
136
|
+
`statements` carries the scope each one sits in, as the chain of names around it. A chain rather
|
|
137
|
+
than one name because a bare name does not identify a test: two classes in one file may each define
|
|
138
|
+
`test_same`, and matching on the name alone handed both findings to whichever ran first.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
rebound: frozenset[str]
|
|
142
|
+
imports: tuple[ast.Import | ast.ImportFrom, ...]
|
|
143
|
+
blocks: tuple[ast.With | ast.AsyncWith, ...]
|
|
144
|
+
statements: tuple[tuple[ast.Expr | ast.Assert, tuple[str, ...]], ...]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _survey(tree: ast.Module) -> _Survey:
|
|
148
|
+
"""Walk once, keeping the four things the checks ask about.
|
|
133
149
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
alarm on somebody else's function.
|
|
150
|
+
The walk is its own rather than `ast.walk` because the scope chain has to be carried down, and
|
|
151
|
+
`ast.walk` hands back a flat stream with no way to know what encloses what.
|
|
137
152
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
153
|
+
Two statements consume a builder on the spot. An expression statement discards the value, and
|
|
154
|
+
anywhere else it is bound, passed or returned, where whether *that* asserts is not a question about
|
|
155
|
+
this statement. An `assert` reads it for its truth, which a builder and a bound method answer the
|
|
156
|
+
same way whatever the value is.
|
|
141
157
|
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
rebound: set[str] = set()
|
|
159
|
+
imports: list[ast.Import | ast.ImportFrom] = []
|
|
160
|
+
blocks: list[ast.With | ast.AsyncWith] = []
|
|
161
|
+
statements: list[tuple[ast.Expr | ast.Assert, tuple[str, ...]]] = []
|
|
162
|
+
stack: list[tuple[ast.AST, tuple[str, ...]]] = [(tree, ())]
|
|
163
|
+
while stack:
|
|
164
|
+
node, scope = stack.pop()
|
|
165
|
+
for child in ast.iter_child_nodes(node):
|
|
166
|
+
inner = scope
|
|
167
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
168
|
+
rebound.add(child.name)
|
|
169
|
+
inner = (*scope, child.name)
|
|
170
|
+
elif isinstance(child, ast.Expr | ast.Assert):
|
|
171
|
+
statements.append((child, scope))
|
|
172
|
+
elif isinstance(child, ast.Name):
|
|
173
|
+
if isinstance(child.ctx, ast.Store):
|
|
174
|
+
rebound.add(child.id)
|
|
175
|
+
elif isinstance(child, ast.arg):
|
|
176
|
+
rebound.add(child.arg)
|
|
177
|
+
# merging the branches is what ruff asks and a checker rejects: only the separate one narrows the name
|
|
178
|
+
elif isinstance(child, ast.ExceptHandler) and child.name:
|
|
179
|
+
rebound.add(child.name)
|
|
180
|
+
elif isinstance(child, (ast.Import, ast.ImportFrom)):
|
|
181
|
+
imports.append(child)
|
|
182
|
+
elif isinstance(child, (ast.With, ast.AsyncWith)):
|
|
183
|
+
blocks.append(child)
|
|
184
|
+
stack.append((child, inner))
|
|
185
|
+
return _Survey(frozenset(rebound), tuple(imports), tuple(blocks), tuple(statements))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _bindings(survey: _Survey, extra: frozenset[str] = frozenset()) -> _Bindings:
|
|
158
189
|
"""Resolve how this module spells the entry points, `as` aliases and star imports included.
|
|
159
190
|
|
|
160
191
|
A module that never imports one is skipped whole: a user function of their own named
|
|
@@ -168,7 +199,7 @@ def _bindings(tree: ast.Module, extra: frozenset[str] = frozenset()) -> _Binding
|
|
|
168
199
|
"""
|
|
169
200
|
direct: set[str] = set()
|
|
170
201
|
modules: set[str] = set()
|
|
171
|
-
for node in
|
|
202
|
+
for node in survey.imports:
|
|
172
203
|
if isinstance(node, ast.ImportFrom):
|
|
173
204
|
ours = (node.module or "").split(".")[0] == _PACKAGE
|
|
174
205
|
for alias in node.names:
|
|
@@ -176,11 +207,11 @@ def _bindings(tree: ast.Module, extra: frozenset[str] = frozenset()) -> _Binding
|
|
|
176
207
|
direct |= _ENTRY # the docs' own preamble; a star import binds every entry point
|
|
177
208
|
elif alias.name in extra or (ours and alias.name in _ENTRY):
|
|
178
209
|
direct.add(alias.asname or alias.name)
|
|
179
|
-
|
|
210
|
+
else:
|
|
180
211
|
for alias in node.names:
|
|
181
212
|
if alias.name.split(".")[0] == _PACKAGE:
|
|
182
213
|
modules.add(alias.asname or alias.name.split(".")[0])
|
|
183
|
-
shadowed =
|
|
214
|
+
shadowed = survey.rebound
|
|
184
215
|
return _Bindings(frozenset(direct) - shadowed, frozenset(modules) - shadowed)
|
|
185
216
|
|
|
186
217
|
|
|
@@ -287,28 +318,92 @@ def _closed(node: ast.expr) -> bool:
|
|
|
287
318
|
)
|
|
288
319
|
|
|
289
320
|
|
|
290
|
-
|
|
291
|
-
|
|
321
|
+
_DEMANDS_A_RAISE: Final = frozenset({"raises", "RaisesGroup"})
|
|
322
|
+
"""The `pytest` blocks that fail when their body reaches the end without raising.
|
|
292
323
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
324
|
+
`warns` and `deprecated_call` are deliberately not here. They expect their body to finish normally,
|
|
325
|
+
so a chain that asserts nothing inside one is as silent as it would be anywhere else. Measured: a
|
|
326
|
+
`pytest.warns` block whose body warns and then dangles passes.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _imported_as(survey: _Survey, package: str, wanted: frozenset[str]) -> tuple[frozenset[str], frozenset[str]]:
|
|
331
|
+
"""How this module spells names from *package*: bare after an import, and dotted through it.
|
|
332
|
+
|
|
333
|
+
Resolved rather than matched on the bare name, for the reason `_bindings` resolves this library's
|
|
334
|
+
own: a project helper that happens to be called `raises` or `fail` is not this one, and trusting
|
|
335
|
+
the name alone would silence a real finding inside it.
|
|
336
|
+
|
|
337
|
+
Three ways a spelling stops being the package's, all of them dropped:
|
|
338
|
+
|
|
339
|
+
* the module rebinds it, which the survey's `rebound` set answers
|
|
340
|
+
* another import binds the same spelling, where which one wins is the order they are written in
|
|
341
|
+
rather than anything a name can be read for
|
|
342
|
+
* it comes from somewhere else entirely
|
|
296
343
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
``("TestOne", "test_same")``.
|
|
344
|
+
What this cannot answer is a mutation at run time. `pytest.raises = something_else` leaves the
|
|
345
|
+
import reading exactly as it does here, and no static pass sees past that. The boundary is the
|
|
346
|
+
spelling, not the object it will hold.
|
|
301
347
|
"""
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
348
|
+
direct: set[str] = set()
|
|
349
|
+
modules: set[str] = set()
|
|
350
|
+
elsewhere: set[str] = set()
|
|
351
|
+
for node in survey.imports:
|
|
352
|
+
if isinstance(node, ast.ImportFrom):
|
|
353
|
+
ours = (node.module or "").split(".")[0] == package
|
|
354
|
+
for alias in node.names:
|
|
355
|
+
bound = alias.asname or alias.name
|
|
356
|
+
(direct if ours and alias.name in wanted else elsewhere).add(bound)
|
|
357
|
+
else:
|
|
358
|
+
for alias in node.names:
|
|
359
|
+
bound = alias.asname or alias.name.split(".")[0]
|
|
360
|
+
(modules if alias.name.split(".")[0] == package else elsewhere).add(bound)
|
|
361
|
+
taken = survey.rebound | elsewhere
|
|
362
|
+
return frozenset(direct - taken), frozenset(modules - taken)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _names_a_call(node: ast.expr, names: tuple[frozenset[str], frozenset[str]], wanted: frozenset[str]) -> bool:
|
|
366
|
+
"""Whether this call reaches one of *wanted* through the imports *names* resolved."""
|
|
367
|
+
if not isinstance(node, ast.Call):
|
|
368
|
+
return False
|
|
369
|
+
direct, modules = names
|
|
370
|
+
func = node.func
|
|
371
|
+
if isinstance(func, ast.Attribute):
|
|
372
|
+
return func.attr in wanted and isinstance(func.value, ast.Name) and func.value.id in modules
|
|
373
|
+
return isinstance(func, ast.Name) and func.id in direct
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _demands_a_raise(node: ast.With | ast.AsyncWith, names: tuple[frozenset[str], frozenset[str]]) -> bool:
|
|
377
|
+
"""Whether this block fails when its body reaches the end quietly, and nothing else can end it.
|
|
378
|
+
|
|
379
|
+
One item only. A sibling manager gets its `__exit__` run after the body, so
|
|
380
|
+
``with pytest.raises(TypeError), Boom():`` is satisfied by `Boom` and the body never had to raise.
|
|
381
|
+
"""
|
|
382
|
+
return len(node.items) == 1 and _names_a_call(node.items[0].context_expr, names, _DEMANDS_A_RAISE)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _only_statement_of_a_raising_block(survey: _Survey) -> frozenset[int]:
|
|
386
|
+
"""The statements a `pytest.raises` block holds alone, by object identity.
|
|
387
|
+
|
|
388
|
+
The exemption is this narrow because every wider one measured unsound. A body of one statement
|
|
389
|
+
leaves the argument with nothing to hide behind: that statement runs, and either it raises, which
|
|
390
|
+
is what the test asserts, or the block itself raises `DID NOT RAISE`.
|
|
391
|
+
|
|
392
|
+
What the block raises is where the promise stops. An enclosing `try` that catches it, or a
|
|
393
|
+
`finally` that returns, leaves the test green again, and no check reading one statement can see
|
|
394
|
+
that. The guarantee is about the immediate block, not about the test around it.
|
|
395
|
+
|
|
396
|
+
Each condition is here for a shape that passed silently without it. A second statement can supply
|
|
397
|
+
the exception instead (`assert_that(x)` then `raise TypeError`), or make the chain unreachable
|
|
398
|
+
(`raise TypeError` then the chain). A sibling `with` item can raise from its own `__exit__`.
|
|
399
|
+
|
|
400
|
+
Keyed on `id()` rather than on a line, because two statements share a line when they are written
|
|
401
|
+
`assert_that(x); raise TypeError`, and exempting the line would exempt the chain as well.
|
|
402
|
+
"""
|
|
403
|
+
names = _imported_as(survey, "pytest", _DEMANDS_A_RAISE)
|
|
404
|
+
return frozenset(
|
|
405
|
+
id(node.body[0]) for node in survey.blocks if len(node.body) == 1 and _demands_a_raise(node, names)
|
|
406
|
+
)
|
|
312
407
|
|
|
313
408
|
|
|
314
409
|
def _marked_lines(source: str) -> frozenset[int]:
|
|
@@ -321,7 +416,13 @@ def _marked_lines(source: str) -> frozenset[int]:
|
|
|
321
416
|
|
|
322
417
|
A file that tokenises differently from how it parsed yields nothing rather than raising: the parse
|
|
323
418
|
already succeeded, so the check goes on without the escape hatch instead of taking the run down.
|
|
419
|
+
|
|
420
|
+
The text search in front is only to decide whether to tokenise at all. A file without the marker's
|
|
421
|
+
words anywhere cannot have it in a comment either, and two of this repository's 97 test modules
|
|
422
|
+
carry one, so tokenising all 97 was 278 ms spent to read two.
|
|
324
423
|
"""
|
|
424
|
+
if ALLOW_MARKER not in source:
|
|
425
|
+
return frozenset()
|
|
325
426
|
try:
|
|
326
427
|
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
327
428
|
return frozenset(one.start[0] for one in tokens if one.type == tokenize.COMMENT and ALLOW_MARKER in one.string)
|
|
@@ -353,13 +454,15 @@ def findings(source: str, path: str, extra_entries: frozenset[str] = frozenset()
|
|
|
353
454
|
SyntaxError: if *source* does not parse; the caller decides whether that is its problem.
|
|
354
455
|
"""
|
|
355
456
|
tree = ast.parse(source)
|
|
356
|
-
|
|
457
|
+
survey = _survey(tree)
|
|
458
|
+
bindings = _bindings(survey, extra_entries)
|
|
357
459
|
if not bindings:
|
|
358
460
|
return []
|
|
359
461
|
marked = _marked_lines(source)
|
|
462
|
+
alone = _only_statement_of_a_raising_block(survey)
|
|
360
463
|
found: list[Finding] = []
|
|
361
|
-
for statement, scope in
|
|
362
|
-
if _silenced(statement, marked):
|
|
464
|
+
for statement, scope in survey.statements:
|
|
465
|
+
if _silenced(statement, marked) or id(statement) in alone:
|
|
363
466
|
continue
|
|
364
467
|
if isinstance(statement, ast.Assert):
|
|
365
468
|
if _reads_a_truthy_chain(statement.test, bindings):
|