assertpy2 2.20.0__tar.gz → 2.20.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.20.0 → assertpy2-2.20.1}/.github/workflows/ci.yml +9 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/codeql.yml +2 -2
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/codspeed.yml +1 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/publish.yml +1 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/scorecard.yml +1 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/zizmor.yml +1 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/PKG-INFO +1 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_diff.py +29 -9
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/assertpy.py +80 -46
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/dynamic.py +16 -2
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/errors.py +11 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/helpers.py +13 -9
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/pytest_plugin.py +9 -3
- assertpy2-2.20.1/docs/concepts/stability.md +63 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/concepts/type-safety.md +35 -3
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/extending/custom-assertions.md +9 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/getting-started/migration.md +2 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/assertions.md +13 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/mkdocs.yml +1 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/pyproject.toml +1 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/docs_fixtures.py +68 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/pyright_baseline.py +4 -4
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_core.py +3 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_docs_examples.py +13 -14
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_docs_typing.py +9 -16
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_property_based.py +35 -2
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_public_surface.py +36 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_rich_diff.py +28 -4
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_typing.py +5 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_warn.py +6 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/uv.lock +1 -1
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.codecov.yml +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.gitattributes +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/dependabot.yml +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/.gitignore +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/CONTRIBUTING.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/LICENSE +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/README.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/SECURITY.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/__init__.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/__init__.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_compare.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_compat.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_contract.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_introspection.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_mixin_base.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_path.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_engine/_typing.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_hints.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_inline.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_matcher_impls.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_satisfies.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/_snapshot_codec.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/async_assertions.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/base.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/collection.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/contains.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/dataframe.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/date.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/dict.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/exception.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/extracting.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/file.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/matchers.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/numeric.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/outcome.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/py.typed +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/snapshot.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/string.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/assertpy2/warning.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/benchmarks/test_perf.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/assets/diff-string.svg +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/extending/integrations.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/getting-started/comparison.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/getting-started/quickstart.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/data.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/errors.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/fluent.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/matchers.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/guides/testing.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/index.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/logo-dark.svg +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/logo.svg +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/recipes.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/async.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/bytes.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/collections.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/containment.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/core.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dates.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dicts.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/errors.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/extracting.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/files.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/json.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/matchers.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/numbers.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/overview.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/strings.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/reference/warnings.md +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/mkdocs_hooks.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/scripts/mutation_report.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/conftest.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_api_vocabulary.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_async.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_attrs_support.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_bool.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_boundary_cases.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_bytes.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_callable.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_chaining.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_check.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_class.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_collection.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_custom_list.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dataframe.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_datetime.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_description.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dict.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dict_compare.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_dynamic.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_equals.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_errors.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_exception_context.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_extensions.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_extracting.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_fail.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_file.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_hints.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_in.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_inline_record.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_inline_snapshot.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_json.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_list.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_match_result.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_matcher_parity.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_matchers.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_message_elision.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_none.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_not.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_numbers.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_openapi_contract.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_optional_integration_contracts.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_overloads.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_pipeline.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_property_attrs.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_protocol_parity.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_pyright_baseline.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_pytest_plugin.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_assertion.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare_config.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_same_as.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_satisfy.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_snapshots.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_soft.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_string.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_structural.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_sync_eventually.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_traceback.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_type.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_typing_claims.py +0 -0
- {assertpy2-2.20.0 → assertpy2-2.20.1}/tests/test_vacuity_contract.py +0 -0
|
@@ -17,6 +17,10 @@ jobs:
|
|
|
17
17
|
test:
|
|
18
18
|
timeout-minutes: 15
|
|
19
19
|
runs-on: ${{ matrix.os }}
|
|
20
|
+
# mapped at job level so the upload step can ask whether it has a token before it runs: the secrets
|
|
21
|
+
# context is not readable from a step `if`, and step-level env is not in scope for its own condition
|
|
22
|
+
env:
|
|
23
|
+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
20
24
|
strategy:
|
|
21
25
|
matrix:
|
|
22
26
|
os: [ubuntu-latest]
|
|
@@ -75,7 +79,11 @@ jobs:
|
|
|
75
79
|
# Only the full-deps cell. The bare 3.14/ubuntu cell skips every integration test and used
|
|
76
80
|
# to upload too: Codecov merges reports so the number stayed right, but one of the two was
|
|
77
81
|
# always a partial report sent at a 100% target.
|
|
78
|
-
|
|
82
|
+
# A run without repository secrets gets an empty token, and Codecov rejects an untokened upload
|
|
83
|
+
# to a protected branch, so the job went red on the upload while every test passed. The gate is
|
|
84
|
+
# the token rather than who opened the pull request: dependabot pushes its branches into this
|
|
85
|
+
# repository, so a fork test reads false for it and it still runs without secrets.
|
|
86
|
+
if: matrix.full-deps && env.CODECOV_TOKEN != ''
|
|
79
87
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
|
80
88
|
with:
|
|
81
89
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
22
22
|
with:
|
|
23
23
|
persist-credentials: false
|
|
24
|
-
- uses: github/codeql-action/init@
|
|
24
|
+
- uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
|
|
25
25
|
with:
|
|
26
26
|
languages: python
|
|
27
|
-
- uses: github/codeql-action/analyze@
|
|
27
|
+
- uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
|
|
@@ -35,7 +35,7 @@ jobs:
|
|
|
35
35
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
36
36
|
|
|
37
37
|
- name: Run benchmarks under CodSpeed
|
|
38
|
-
uses: CodSpeedHQ/action@
|
|
38
|
+
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
|
|
39
39
|
with:
|
|
40
40
|
mode: simulation
|
|
41
41
|
run: uv run --locked --group benchmark pytest benchmarks/ --codspeed --no-cov
|
|
@@ -66,7 +66,7 @@ jobs:
|
|
|
66
66
|
run: syft scan dir:dist -o "cyclonedx-json=provenance/assertpy2.sbom.cdx.json"
|
|
67
67
|
|
|
68
68
|
- name: Publish to PyPI
|
|
69
|
-
uses: pypa/gh-action-pypi-publish@
|
|
69
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
70
70
|
|
|
71
71
|
- name: Upload to GitHub Release
|
|
72
72
|
run: gh release upload "$TAG_NAME" dist/* provenance/*
|
|
@@ -30,6 +30,6 @@ jobs:
|
|
|
30
30
|
name: SARIF file
|
|
31
31
|
path: results.sarif
|
|
32
32
|
retention-days: 5
|
|
33
|
-
- uses: github/codeql-action/upload-sarif@
|
|
33
|
+
- uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
|
|
34
34
|
with:
|
|
35
35
|
sarif_file: results.sarif
|
|
@@ -23,4 +23,4 @@ jobs:
|
|
|
23
23
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
24
24
|
with:
|
|
25
25
|
persist-credentials: false
|
|
26
|
-
- uses: zizmorcore/zizmor-action@
|
|
26
|
+
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.20.
|
|
3
|
+
Version: 2.20.1
|
|
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
|
|
@@ -29,12 +29,18 @@ from __future__ import annotations
|
|
|
29
29
|
|
|
30
30
|
import dataclasses
|
|
31
31
|
import difflib
|
|
32
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
32
33
|
|
|
33
34
|
from ..errors import DiffEntry, DiffResult, _safe_repr
|
|
34
35
|
from ._compare import _node_decision
|
|
35
36
|
from ._introspection import is_attrs_instance, is_mapping_like, is_model_dump_object, is_namedtuple
|
|
36
37
|
from ._path import _ROOT, _Path
|
|
37
38
|
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Hashable, Iterable
|
|
41
|
+
|
|
42
|
+
_K = TypeVar("_K", bound="Hashable") # a mapping key or a field name, kept as itself through the walk
|
|
43
|
+
|
|
38
44
|
__tracebackhide__ = True
|
|
39
45
|
|
|
40
46
|
|
|
@@ -254,20 +260,34 @@ def _element_entries(actual_item, expected_item, path: _Path, seen, config) -> l
|
|
|
254
260
|
return _child_entries(actual_item, expected_item, path, descended_for=decision, _seen=seen, config=config)
|
|
255
261
|
|
|
256
262
|
|
|
263
|
+
def _ordered_keys(actual: Iterable[_K], expected: Iterable[_K]) -> list[_K]:
|
|
264
|
+
"""Every key of both sides, in the order a reader wrote them.
|
|
265
|
+
|
|
266
|
+
A union of two sets loses insertion order, which is why this used to be sorted: without an order
|
|
267
|
+
imposed, the report varied with the hash seed. Sorting bought determinism at the price of the one
|
|
268
|
+
ordering that carries meaning - a JSON response reads in the order its fields arrived, and `k0, k1,
|
|
269
|
+
k10, k100` reads as no order at all. Walking the actual side and then the keys only the expected
|
|
270
|
+
side has is just as deterministic, and it is the order pytest shows.
|
|
271
|
+
"""
|
|
272
|
+
seen = set(actual)
|
|
273
|
+
return [*actual, *(key for key in expected if key not in seen)]
|
|
274
|
+
|
|
275
|
+
|
|
257
276
|
def _dataclass_diff_entries(actual, expected, prefix: _Path, seen, config=None) -> list[DiffEntry]:
|
|
258
|
-
"""Diff two dataclasses over
|
|
277
|
+
"""Diff two dataclasses over both sides' field names in declaration order, recursing.
|
|
259
278
|
|
|
260
279
|
Reports fields present on only one side, and recurses into nested containers. ``seen`` must
|
|
261
280
|
already include the ids of ``actual``/``expected``. Shared by the top-level and nested paths
|
|
262
281
|
so both report dataclass fields identically.
|
|
263
282
|
"""
|
|
264
283
|
entries: list[DiffEntry] = []
|
|
265
|
-
actual_names =
|
|
266
|
-
expected_names =
|
|
267
|
-
|
|
268
|
-
|
|
284
|
+
actual_names = [field.name for field in dataclasses.fields(actual)]
|
|
285
|
+
expected_names = [field.name for field in dataclasses.fields(expected)]
|
|
286
|
+
in_actual, in_expected = set(actual_names), set(expected_names)
|
|
287
|
+
for field in _ordered_keys(actual_names, expected_names):
|
|
288
|
+
if field not in in_expected:
|
|
269
289
|
entries.append(prefix.attr(field).entry(actual=getattr(actual, field), expected=None, absent="expected"))
|
|
270
|
-
elif field not in
|
|
290
|
+
elif field not in in_actual:
|
|
271
291
|
entries.append(prefix.attr(field).entry(actual=None, absent="actual", expected=getattr(expected, field)))
|
|
272
292
|
else:
|
|
273
293
|
actual_value = getattr(actual, field)
|
|
@@ -356,7 +376,7 @@ def _build_equality_diff(
|
|
|
356
376
|
actual_dict = _field_dict(actual, both_model)
|
|
357
377
|
expected_dict = _field_dict(expected, both_model)
|
|
358
378
|
entries = []
|
|
359
|
-
for key in
|
|
379
|
+
for key in _ordered_keys(actual_dict, expected_dict):
|
|
360
380
|
if key not in expected_dict:
|
|
361
381
|
entries.append(_prefix.attr(key).entry(actual=actual_dict[key], expected=None, absent="expected"))
|
|
362
382
|
elif key not in actual_dict:
|
|
@@ -439,7 +459,7 @@ def _sub_diff_entries(
|
|
|
439
459
|
entries: list[DiffEntry] = []
|
|
440
460
|
actual_keys = set(actual)
|
|
441
461
|
expected_keys = set(expected)
|
|
442
|
-
for key in
|
|
462
|
+
for key in _ordered_keys(actual, expected):
|
|
443
463
|
if key not in expected_keys:
|
|
444
464
|
entries.append(prefix.key(key).entry(actual=actual[key], expected=None, absent="expected"))
|
|
445
465
|
elif key not in actual_keys:
|
|
@@ -504,7 +524,7 @@ def _sub_diff_entries(
|
|
|
504
524
|
actual_dict = _field_dict(actual, both_model)
|
|
505
525
|
expected_dict = _field_dict(expected, both_model)
|
|
506
526
|
entries = []
|
|
507
|
-
for key in
|
|
527
|
+
for key in _ordered_keys(actual_dict, expected_dict):
|
|
508
528
|
if key not in expected_dict:
|
|
509
529
|
entries.append(prefix.attr(key).entry(actual=actual_dict[key], expected=None, absent="expected"))
|
|
510
530
|
elif key not in actual_dict:
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import contextlib
|
|
6
5
|
import contextvars
|
|
7
|
-
import inspect
|
|
8
6
|
import logging
|
|
9
7
|
import os
|
|
10
8
|
import sys
|
|
@@ -16,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeVar, overloa
|
|
|
16
14
|
if TYPE_CHECKING:
|
|
17
15
|
import datetime
|
|
18
16
|
import pathlib
|
|
19
|
-
from collections.abc import Callable
|
|
17
|
+
from collections.abc import Callable
|
|
20
18
|
|
|
21
19
|
from typing_extensions import TypeIs
|
|
22
20
|
|
|
@@ -60,7 +58,7 @@ from .snapshot import SnapshotMixin
|
|
|
60
58
|
from .string import StringMixin
|
|
61
59
|
from .warning import WarningMixin
|
|
62
60
|
|
|
63
|
-
__version__ = "2.20.
|
|
61
|
+
__version__ = "2.20.1"
|
|
64
62
|
|
|
65
63
|
# the tracked value type of the generic AssertionBuilder fallback (_U appears only in narrowing stubs)
|
|
66
64
|
_T = TypeVar("_T")
|
|
@@ -73,33 +71,44 @@ if TYPE_CHECKING:
|
|
|
73
71
|
_V = TypeVar("_V") # dict value type
|
|
74
72
|
|
|
75
73
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
76
|
-
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
77
74
|
|
|
78
75
|
# assertpy2 source files, used to strip internal frames when locating the caller for warn-mode messages.
|
|
79
76
|
# Derived from the package directory so new modules are covered automatically (no hand-maintained list).
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
# Absolute paths in a set, not suffixes in a list: membership is one hash lookup per frame where the
|
|
78
|
+
# suffix scan ran `endswith` once per module, thirty string comparisons a frame on a path taken for
|
|
79
|
+
# every collected soft failure. Exact paths also stop a user file of the same name from shadowing ours.
|
|
80
|
+
ASSERTPY_FILES: Final = frozenset(
|
|
81
|
+
os.path.join(os.path.dirname(__file__), name)
|
|
82
|
+
for name in os.listdir(os.path.dirname(__file__))
|
|
83
|
+
if name.endswith(".py")
|
|
84
|
+
)
|
|
83
85
|
|
|
84
86
|
|
|
85
87
|
def _caller_location() -> tuple[str, int] | None:
|
|
86
88
|
"""The ``(filename, lineno)`` of the user frame that called into assertpy2, skipping internal frames.
|
|
87
89
|
|
|
88
|
-
Used to locate a warn-mode warning and each collected soft-assertion failure.
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
Used to locate a warn-mode warning and each collected soft-assertion failure.
|
|
91
|
+
|
|
92
|
+
The answer is the frame just outside the *outermost* assertpy2 frame, which is why the walk cannot
|
|
93
|
+
stop at the first user frame it meets going inwards: a predicate passed to `satisfies()` or `each()`
|
|
94
|
+
runs inside our own call, and the line worth reporting is the assertion in the test rather than the
|
|
95
|
+
lambda we invoked. Walking outwards and keeping the last handover does the same in one pass,
|
|
96
|
+
without building a list of the whole stack.
|
|
97
|
+
|
|
98
|
+
``None`` where no such handover exists, so a caller that cannot be located logs without the prefix
|
|
99
|
+
instead of crashing on unpacking.
|
|
91
100
|
"""
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
frame: types.FrameType | None = sys._getframe(1) # CPython accessor; the inspect equivalent is 10x slower here
|
|
102
|
+
location: tuple[str, int] | None = None
|
|
103
|
+
inner_is_internal = False
|
|
94
104
|
while frame:
|
|
95
|
-
|
|
105
|
+
filename = frame.f_code.co_filename
|
|
106
|
+
is_internal = filename in ASSERTPY_FILES
|
|
107
|
+
if inner_is_internal and not is_internal:
|
|
108
|
+
location = (filename, frame.f_lineno)
|
|
109
|
+
inner_is_internal = is_internal
|
|
96
110
|
frame = frame.f_back
|
|
97
|
-
|
|
98
|
-
for filename, lineno in reversed(frames):
|
|
99
|
-
if any(filename.endswith(internal) for internal in ASSERTPY_FILES):
|
|
100
|
-
return previous
|
|
101
|
-
previous = (filename, lineno)
|
|
102
|
-
return None # pragma: no cover - error() is always reached through an assertpy frame
|
|
111
|
+
return location
|
|
103
112
|
|
|
104
113
|
|
|
105
114
|
# soft assertions (contextvars for thread/async safety)
|
|
@@ -110,12 +119,33 @@ _soft_err: contextvars.ContextVar[list[AssertionOutcome]] = contextvars.ContextV
|
|
|
110
119
|
_soft_group: contextvars.ContextVar[str | None] = contextvars.ContextVar("assertpy2_soft_group", default=None)
|
|
111
120
|
|
|
112
121
|
|
|
122
|
+
class _Group:
|
|
123
|
+
"""The context `group()` hands back.
|
|
124
|
+
|
|
125
|
+
A class rather than a `@contextlib.contextmanager` generator, and the same goes for the soft block
|
|
126
|
+
itself. A generator-based manager puts `contextlib.__exit__` on the stack, so a failure raised on
|
|
127
|
+
the way out is reported against `contextlib.py` instead of the `with` line in the test. The old
|
|
128
|
+
cure was setting `__tracebackhide__` on the `contextlib` module, which fixed our two managers by
|
|
129
|
+
changing how every third-party context manager in the process is reported.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
__slots__ = ("_label", "_token")
|
|
133
|
+
|
|
134
|
+
def __init__(self, label: str) -> None:
|
|
135
|
+
self._label = label
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> None:
|
|
138
|
+
self._token = _soft_group.set(self._label)
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *_exc: object) -> None:
|
|
141
|
+
_soft_group.reset(self._token)
|
|
142
|
+
|
|
143
|
+
|
|
113
144
|
class SoftAssertionCollector:
|
|
114
145
|
"""Collector returned by [`soft_assertions()`][assertpy2.assertpy.soft_assertions] for grouping
|
|
115
146
|
errors hierarchically."""
|
|
116
147
|
|
|
117
|
-
|
|
118
|
-
def group(self, label: str) -> Iterator[None]:
|
|
148
|
+
def group(self, label: str) -> _Group:
|
|
119
149
|
"""Group subsequent assertion failures under *label*.
|
|
120
150
|
|
|
121
151
|
Examples:
|
|
@@ -127,11 +157,7 @@ class SoftAssertionCollector:
|
|
|
127
157
|
with sa.group("Body"):
|
|
128
158
|
assert_that(body["status"]).is_equal_to("ok")
|
|
129
159
|
"""
|
|
130
|
-
|
|
131
|
-
try:
|
|
132
|
-
yield
|
|
133
|
-
finally:
|
|
134
|
-
_soft_group.reset(token)
|
|
160
|
+
return _Group(label)
|
|
135
161
|
|
|
136
162
|
|
|
137
163
|
def _located(location: tuple[str, int] | None, msg: str) -> str:
|
|
@@ -184,8 +210,32 @@ def _format_soft_errors(errs: list[AssertionOutcome]) -> str:
|
|
|
184
210
|
return "\n".join(lines)
|
|
185
211
|
|
|
186
212
|
|
|
187
|
-
|
|
188
|
-
|
|
213
|
+
class _SoftAssertions:
|
|
214
|
+
"""The context `soft_assertions()` hands back. See `_Group` for why this is a class."""
|
|
215
|
+
|
|
216
|
+
__slots__ = ()
|
|
217
|
+
|
|
218
|
+
def __enter__(self) -> SoftAssertionCollector:
|
|
219
|
+
ctx = _soft_ctx.get()
|
|
220
|
+
if ctx == 0:
|
|
221
|
+
_soft_err.set([])
|
|
222
|
+
_soft_ctx.set(ctx + 1)
|
|
223
|
+
return SoftAssertionCollector()
|
|
224
|
+
|
|
225
|
+
def __exit__(self, exc_type: type[BaseException] | None, *_exc: object) -> None:
|
|
226
|
+
_soft_ctx.set(_soft_ctx.get() - 1)
|
|
227
|
+
if exc_type is not None:
|
|
228
|
+
return # an error out of the block wins: it says more than the failures collected before it
|
|
229
|
+
errs = _soft_err.get([])
|
|
230
|
+
if errs and _soft_ctx.get() == 0:
|
|
231
|
+
out = _format_soft_errors(errs)
|
|
232
|
+
_soft_err.set([])
|
|
233
|
+
# the same class as a single failure, so one `except AssertionFailure` covers a soft block
|
|
234
|
+
# too, and it hands back what it aggregated rather than only the text it rendered
|
|
235
|
+
raise AssertionFailure(out, failures=tuple(errs))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def soft_assertions() -> _SoftAssertions:
|
|
189
239
|
"""Create a soft assertion context.
|
|
190
240
|
|
|
191
241
|
Normally, any assertion failure will halt test execution immediately by raising an error.
|
|
@@ -232,23 +282,7 @@ def soft_assertions() -> Iterator[SoftAssertionCollector]:
|
|
|
232
282
|
If you need more forgiving behavior, use [`soft_fail()`][assertpy2.assertpy.soft_fail] to add
|
|
233
283
|
a failure message without halting test execution.
|
|
234
284
|
"""
|
|
235
|
-
|
|
236
|
-
if ctx == 0:
|
|
237
|
-
_soft_err.set([])
|
|
238
|
-
_soft_ctx.set(ctx + 1)
|
|
239
|
-
|
|
240
|
-
try:
|
|
241
|
-
yield SoftAssertionCollector()
|
|
242
|
-
finally:
|
|
243
|
-
_soft_ctx.set(_soft_ctx.get() - 1)
|
|
244
|
-
|
|
245
|
-
errs = _soft_err.get([])
|
|
246
|
-
if errs and _soft_ctx.get() == 0:
|
|
247
|
-
out = _format_soft_errors(errs)
|
|
248
|
-
_soft_err.set([])
|
|
249
|
-
# the same class as a single failure, so one `except AssertionFailure` covers a soft block too,
|
|
250
|
-
# and it hands back what it aggregated rather than only the text it rendered
|
|
251
|
-
raise AssertionFailure(out, failures=tuple(errs))
|
|
285
|
+
return _SoftAssertions()
|
|
252
286
|
|
|
253
287
|
|
|
254
288
|
def assert_all(*callables: Callable[[], object]) -> None:
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import collections.abc
|
|
4
4
|
import inspect
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
7
|
from ._engine._introspection import is_namedtuple
|
|
7
8
|
from ._engine._mixin_base import _MixinBase
|
|
@@ -43,9 +44,22 @@ class DynamicMixin(_MixinBase):
|
|
|
43
44
|
assert_that(fred).has_shoe_size(12)
|
|
44
45
|
"""
|
|
45
46
|
|
|
46
|
-
def __getattr__(self, attr):
|
|
47
|
+
def __getattr__(self, attr: str) -> Any:
|
|
47
48
|
"""Asserts that val has attribute attr and that its value is equal to other via a dynamic
|
|
48
|
-
assertion of the form ``has_<attr>()``.
|
|
49
|
+
assertion of the form ``has_<attr>()``.
|
|
50
|
+
|
|
51
|
+
The return stays ``Any`` on purpose, and the reason is written down because the annotation looks
|
|
52
|
+
like an oversight worth fixing. Two unrelated things resolve through this hook: a ``has_``
|
|
53
|
+
wrapper, which takes one argument and hands back the builder, and any name registered with
|
|
54
|
+
`add_extension`, which takes and returns whatever it declares. No single signature is true of
|
|
55
|
+
both. Pinning the argument list reported a correct zero-argument extension call as too few
|
|
56
|
+
arguments, and pinning the return type reported a correct extension result as the wrong type
|
|
57
|
+
where it was used. Both were measured against mypy, pyright and ty.
|
|
58
|
+
|
|
59
|
+
The narrower annotation also bought less than it looked like. It could not catch an assertion
|
|
60
|
+
chained onto a dynamic step, because that access resolves back through this same hook; the only
|
|
61
|
+
thing it added was a verdict in assignment position, which is where it was wrong for extensions.
|
|
62
|
+
"""
|
|
49
63
|
if not attr.startswith("has_"):
|
|
50
64
|
raise AttributeError(f"assertpy has no assertion <{attr}()>")
|
|
51
65
|
|
|
@@ -198,6 +198,17 @@ def _diff_side(value: object, limit: int = 400) -> str:
|
|
|
198
198
|
return _truncated(_safe_repr(value), limit)
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
def _diff_sides(actual: object, expected: object, limit: int = 400) -> tuple[str, str]:
|
|
202
|
+
"""Both sides of one comparison, capped onto the difference rather than from the start.
|
|
203
|
+
|
|
204
|
+
`_diff_side` caps each side on its own, which is right for a row that stands alone. A pair read
|
|
205
|
+
together needs the pair's window: two 10 000-character strings differing in the middle were both
|
|
206
|
+
cut at character 400, so the section printed two identical-looking values under a heading saying
|
|
207
|
+
they were not equal, while the diff below it pointed straight at the change.
|
|
208
|
+
"""
|
|
209
|
+
return _windowed(_safe_repr(actual), _safe_repr(expected), limit)
|
|
210
|
+
|
|
211
|
+
|
|
201
212
|
def _windowed(actual: str, expected: str, width: int = 160) -> tuple[str, str]:
|
|
202
213
|
"""Both lines cut to a window around their first difference.
|
|
203
214
|
|
|
@@ -6,11 +6,6 @@ import math
|
|
|
6
6
|
import numbers
|
|
7
7
|
import re
|
|
8
8
|
|
|
9
|
-
try:
|
|
10
|
-
import attrs
|
|
11
|
-
except ImportError: # pragma: no cover - optional dependency; the attrs branch runs only when present
|
|
12
|
-
attrs = None # ty: ignore[invalid-assignment] # sentinel for the absent optional module
|
|
13
|
-
|
|
14
9
|
from assertpy2.errors import DiffResult, _safe_repr, _truncated, _windowed
|
|
15
10
|
|
|
16
11
|
from ._engine._compare import _CompareConfig, _config_note, _guarded_not_equal, _node_decision, _spec_matches
|
|
@@ -437,9 +432,10 @@ class HelpersMixin(_MixinBase):
|
|
|
437
432
|
_seen = _seen | {id(mapping)}
|
|
438
433
|
parts = []
|
|
439
434
|
ellip = False
|
|
440
|
-
# build items by iterating keys + [] (the dict-likeness gate guarantees those, not items())
|
|
441
|
-
|
|
442
|
-
|
|
435
|
+
# build items by iterating keys + [] (the dict-likeness gate guarantees those, not items()).
|
|
436
|
+
# left in the order the mapping holds them, which is what the diff under this line prints:
|
|
437
|
+
# sorting only here made the two halves of one message disagree about where a key sits
|
|
438
|
+
for key, value in ((key, mapping[key]) for key in mapping):
|
|
443
439
|
if key not in counterpart:
|
|
444
440
|
parts.append(f"{_safe_repr(key)}: {_safe_repr(value)}")
|
|
445
441
|
else:
|
|
@@ -550,7 +546,15 @@ class HelpersMixin(_MixinBase):
|
|
|
550
546
|
if is_model_dump_object(obj):
|
|
551
547
|
return obj.model_dump()
|
|
552
548
|
if is_attrs_instance(obj):
|
|
553
|
-
#
|
|
549
|
+
# deferred, and the only import in the package that is: at module level it cost 8.5 ms and
|
|
550
|
+
# 22 modules of a 39.8 ms import, a fifth of it, paid by every pytest run in an environment
|
|
551
|
+
# where attrs happens to be installed. The auto-loaded plugin means that is every run, and
|
|
552
|
+
# it was paid whether or not any attrs instance ever reached a comparison. Here it is paid
|
|
553
|
+
# by the one branch that needs it, and the guard above already proved the library is present
|
|
554
|
+
# (nothing carries `__attrs_attrs__` unless attrs built it). Every other optional
|
|
555
|
+
# dependency in the package is imported inside its own branch for the same reason.
|
|
556
|
+
import attrs
|
|
557
|
+
|
|
554
558
|
# asdict recurses like dataclasses.asdict, flattening nested attrs for ignore/include
|
|
555
559
|
return attrs.asdict(obj)
|
|
556
560
|
if hasattr(obj, "__dict__") and not isinstance(obj, type):
|
|
@@ -12,7 +12,7 @@ from . import _inline, _satisfies, async_assertions, errors
|
|
|
12
12
|
from . import snapshot as _snapshot
|
|
13
13
|
from ._engine._diff import _sub_diff_entries
|
|
14
14
|
from ._engine._path import _ROOT
|
|
15
|
-
from .errors import _diff_side, _json_safe, _render_diff
|
|
15
|
+
from .errors import _diff_side, _diff_sides, _json_safe, _render_diff
|
|
16
16
|
|
|
17
17
|
try:
|
|
18
18
|
import allure # ty: ignore[unresolved-import] # optional dependency
|
|
@@ -368,9 +368,15 @@ def pytest_runtest_makereport(item, call):
|
|
|
368
368
|
# capped like the diff rows: this section is read on a terminal, and the untouched values stay
|
|
369
369
|
# on the exception for anything that wants them
|
|
370
370
|
lines = []
|
|
371
|
-
if named_actual:
|
|
371
|
+
if named_actual and named_expected:
|
|
372
|
+
# windowed as a pair: capping each side on its own hides the difference when it sits past
|
|
373
|
+
# the cap, and prints two values that look identical under a heading saying they are not
|
|
374
|
+
left, right = _diff_sides(actual, expected)
|
|
375
|
+
lines.append(f" actual: {left}")
|
|
376
|
+
lines.append(f" expected: {right}")
|
|
377
|
+
elif named_actual:
|
|
372
378
|
lines.append(f" actual: {_diff_side(actual)}")
|
|
373
|
-
|
|
379
|
+
else:
|
|
374
380
|
lines.append(f" expected: {_diff_side(expected)}")
|
|
375
381
|
report.sections.append(("AssertionFailure", "\n".join(lines)))
|
|
376
382
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Stability
|
|
2
|
+
|
|
3
|
+
What you can build on, and what enforces it. Every row below is held by a test that fails CI, not by
|
|
4
|
+
an intention.
|
|
5
|
+
|
|
6
|
+
## The contract
|
|
7
|
+
|
|
8
|
+
| You can depend on | Enforced by |
|
|
9
|
+
|---|---|
|
|
10
|
+
| The 36 names `assertpy2` exports, and the fields of every record it hands you | [`test_public_surface.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_public_surface.py) pins both against a hand-written list |
|
|
11
|
+
| Every assertion the type checker offers you existing at runtime | [`test_protocol_parity.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_protocol_parity.py) walks all ten protocols |
|
|
12
|
+
| The type your chain has after each step | [`test_typing.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_typing.py), 123 `assert_type` checks under ty, mypy `--strict` and Pyright, zero suppressions |
|
|
13
|
+
| One relation keeping one name across the API | [`test_api_vocabulary.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_api_vocabulary.py) |
|
|
14
|
+
| The three-method `Matcher` protocol your custom matchers implement | [`test_matcher_parity.py`](https://github.com/Solganis/assertpy2/blob/main/tests/test_matcher_parity.py) |
|
|
15
|
+
| The Allure attachment schema | versioned in its own `format` field, so a consumer branches on a number rather than guessing |
|
|
16
|
+
| Your assertions keeping their verdict | 4100 tests at 100 % branch coverage, plus mutation testing |
|
|
17
|
+
|
|
18
|
+
The records in the first row are `AssertionOutcome`, `MatchResult`, `DiffEntry`, `DiffResult`, `Step`,
|
|
19
|
+
`PollSample` and `PollTrace`. Fields are added, never renamed or removed, inside a major version.
|
|
20
|
+
|
|
21
|
+
Documentation is held to the same bar: every example in these guides is executed and type-checked in
|
|
22
|
+
CI, so a snippet you copy is one that ran.
|
|
23
|
+
|
|
24
|
+
## Reading a failure from code
|
|
25
|
+
|
|
26
|
+
Failures carry [structured data](../guides/errors.md#structured-errors) for exactly this. Reach for it
|
|
27
|
+
rather than for the rendered text:
|
|
28
|
+
|
|
29
|
+
| You want | Use |
|
|
30
|
+
|---|---|
|
|
31
|
+
| the values that were compared | `failure.actual`, `failure.expected` |
|
|
32
|
+
| where they differ | `failure.diff.entries`, each with a `path` |
|
|
33
|
+
| a location you can walk in code | [`entry.steps`](../guides/errors.md#paths-a-program-can-follow) |
|
|
34
|
+
| every failure a soft block collected | `failure.failures` |
|
|
35
|
+
| a verdict without an exception | [`check()`](../guides/errors.md#asking-instead-of-asserting) |
|
|
36
|
+
|
|
37
|
+
Those are covered by the table above. **Message wording is not**: it improves in minor releases, so a
|
|
38
|
+
`pytest.raises(match=...)` written against our phrasing, or a snapshot of a failing run, is the one
|
|
39
|
+
thing that predictably needs updating. Match on the exception type and read the fields.
|
|
40
|
+
|
|
41
|
+
## Upgrading
|
|
42
|
+
|
|
43
|
+
Semantic versioning, read strictly: a new assertion is a minor, a patch carries fixes only.
|
|
44
|
+
|
|
45
|
+
Two kinds of change ship in a minor, and the release notes list each one under **Behaviour changes**:
|
|
46
|
+
|
|
47
|
+
- **an input that was silently wrong starts raising**, such as an empty prefix that no value could fail
|
|
48
|
+
- **a wrong verdict is corrected**, so an assertion that passed and should not have begins to fail
|
|
49
|
+
|
|
50
|
+
Both are the reason to read that section before upgrading. Nothing else in a minor is designed to
|
|
51
|
+
change what your suite reports.
|
|
52
|
+
|
|
53
|
+
## Not part of the API
|
|
54
|
+
|
|
55
|
+
Anything whose name starts with `_`, and the module an assertion happens to live in. The reference
|
|
56
|
+
lists `matches_structure()` because you can call it from `assert_that()`, and its anchor reads
|
|
57
|
+
`assertpy2._satisfies.SatisfiesMixin.matches_structure` because that is where the code sits.
|
|
58
|
+
Importing from there is not supported.
|
|
59
|
+
|
|
60
|
+
Python 3.10 and up. Dropping a version that upstream still supports would be a major.
|
|
61
|
+
|
|
62
|
+
Code written against [assertpy](../getting-started/migration.md) keeps working, and that holds for
|
|
63
|
+
refactors: an internal change that would break a documented assertpy call is not internal.
|
|
@@ -94,6 +94,20 @@ This is the deliberate cost of keeping the original assertpy API working unchang
|
|
|
94
94
|
mutually satisfiable: for a checker to accept `has_first_name` on a dict, `_DictAssertion` would need a
|
|
95
95
|
`__getattr__`, and that same declaration would stop it reporting `contins_key` as a typo.
|
|
96
96
|
|
|
97
|
+
[Custom assertions](../extending/custom-assertions.md) registered with `add_extension()` sit on the same
|
|
98
|
+
line, for the same reason. The name is attached at runtime, so it reaches a checker through that one
|
|
99
|
+
`__getattr__` and nowhere else:
|
|
100
|
+
|
|
101
|
+
<!-- docs-guard: untyped -->
|
|
102
|
+
```python
|
|
103
|
+
assert_that(order).is_paid() # clean: a user class falls to the generic builder
|
|
104
|
+
assert_that(5).is_5() # type error: _NumericAssertion has no attribute is_5
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Both lines run. If your extension targets a value with an overload of its own, the call needs
|
|
108
|
+
`# type: ignore[attr-defined]` at each site, and the return type a checker infers for it is `Any`
|
|
109
|
+
rather than whatever the extension actually returns.
|
|
110
|
+
|
|
97
111
|
## Typed narrowing with .value
|
|
98
112
|
|
|
99
113
|
Assertions don't just check a value - they can hand it back, typed. The `value` property ends a chain
|
|
@@ -274,9 +288,27 @@ A few refinements keep it precise:
|
|
|
274
288
|
|
|
275
289
|
## Set up your type checker
|
|
276
290
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
291
|
+
Under mypy, one setting decides whether any of this reaches your tests. mypy does not look inside a
|
|
292
|
+
function with no annotations at all, and a test written as `def test_orders():` is exactly that, so the
|
|
293
|
+
narrowing is silently skipped for the file where you wanted it most. The same three mistakes below are
|
|
294
|
+
reported six times by Pyright and `ty` out of the box, three times by mypy at its defaults, and six by
|
|
295
|
+
mypy once it is told to read those bodies:
|
|
296
|
+
|
|
297
|
+
<!-- docs-guard: skip -->
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
def test_unannotated(): # mypy default: not checked at all
|
|
301
|
+
assert_that("abc").is_greater_than(3)
|
|
302
|
+
|
|
303
|
+
def test_annotated() -> None: # mypy default: checked
|
|
304
|
+
assert_that("abc").is_greater_than(3)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Either annotate every test with `-> None`, or set `check_untyped_defs = true`, which `strict = true`
|
|
308
|
+
already includes. Pyright and `ty` need neither.
|
|
309
|
+
|
|
310
|
+
Strict mode then surfaces the most - a wrong method called on a narrowed value, a missing return
|
|
311
|
+
annotation, a `.value` read where the type was never narrowed. Turn it on for your checker:
|
|
280
312
|
|
|
281
313
|
```toml
|
|
282
314
|
# pyproject.toml - mypy
|
|
@@ -4,6 +4,8 @@ Add your own assertions to `assertpy2` with `add_extension()`.
|
|
|
4
4
|
|
|
5
5
|
## A custom assertion
|
|
6
6
|
|
|
7
|
+
<!-- docs-guard: untyped -->
|
|
8
|
+
|
|
7
9
|
```python
|
|
8
10
|
from assertpy2 import add_extension, assert_that
|
|
9
11
|
|
|
@@ -18,6 +20,11 @@ assert_that(5).is_5()
|
|
|
18
20
|
assert_that(6).is_5() # fails!
|
|
19
21
|
```
|
|
20
22
|
|
|
23
|
+
Both lines run, but a type checker rejects the first one: `5` resolves to the numeric protocol, which
|
|
24
|
+
declares no `is_5`. An extension is only visible to a checker on values that fall to the generic
|
|
25
|
+
builder, which means your own classes rather than `str`, `int`, `dict` and the rest. The reason and the
|
|
26
|
+
ways round it are in [Where the typed surface ends](../concepts/type-safety.md#where-the-typed-surface-ends).
|
|
27
|
+
|
|
21
28
|
`remove_extension()` takes the same function and unregisters it, so a temporary assertion does not
|
|
22
29
|
leak into other tests:
|
|
23
30
|
|
|
@@ -48,6 +55,8 @@ def my_extensions():
|
|
|
48
55
|
|
|
49
56
|
Then request the fixture in any test that needs the extensions:
|
|
50
57
|
|
|
58
|
+
<!-- docs-guard: untyped -->
|
|
59
|
+
|
|
51
60
|
```python
|
|
52
61
|
from assertpy2 import assert_that
|
|
53
62
|
|
|
@@ -106,4 +106,5 @@ New capabilities the original never had, ready whenever you want them:
|
|
|
106
106
|
- [Async and blocking polling](../guides/testing.md#async-assertions) for eventual consistency.
|
|
107
107
|
- [JSON Path / Schema](../guides/data.md), [regex group extraction](../guides/data.md#regex-group-extraction), and [bytes assertions](../guides/assertions.md#bytes--bytearray).
|
|
108
108
|
|
|
109
|
-
See the [comparison](comparison.md) for the full feature delta.
|
|
109
|
+
See the [comparison](comparison.md) for the full feature delta, and [stability](../concepts/stability.md)
|
|
110
|
+
for what is covered by a compatibility promise and what is not.
|