assertpy2 2.13.0__tar.gz → 2.14.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.13.0 → assertpy2-2.14.0}/PKG-INFO +1 -2
- {assertpy2-2.13.0 → assertpy2-2.14.0}/README.md +0 -1
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/__init__.py +3 -1
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_compare.py +14 -3
- assertpy2-2.14.0/assertpy2/_diff.py +346 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_introspection.py +14 -1
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_mixin_base.py +1 -11
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_typing.py +17 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/assertpy.py +44 -13
- assertpy2-2.14.0/assertpy2/async_assertions.py +208 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/base.py +17 -343
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/dataframe.py +9 -4
- assertpy2-2.14.0/assertpy2/errors.py +166 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/helpers.py +10 -9
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/matchers.py +8 -2
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/pytest_plugin.py +76 -11
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/snapshot.py +7 -2
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/errors.md +32 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/integrations.md +36 -4
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/matchers.md +5 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/errors.md +12 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/testing.md +45 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/pyproject.toml +1 -1
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_async.py +213 -2
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dataframe.py +21 -4
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dict_compare.py +21 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_equals.py +9 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_errors.py +49 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_extensions.py +65 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_not.py +5 -0
- assertpy2-2.14.0/tests/test_protocol_parity.py +86 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_pytest_plugin.py +170 -14
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_assertion.py +20 -20
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_rich_diff.py +78 -78
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_snapshots.py +19 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_structural.py +31 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_typing.py +26 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_warn.py +39 -23
- {assertpy2-2.13.0 → assertpy2-2.14.0}/uv.lock +1 -1
- assertpy2-2.13.0/assertpy2/async_assertions.py +0 -113
- assertpy2-2.13.0/assertpy2/errors.py +0 -51
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.codecov.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.gitattributes +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/dependabot.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/ci.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/codeql.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/docs.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/mutation.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/publish.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/scorecard.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.github/workflows/zizmor.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/.gitignore +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/CONTRIBUTING.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/LICENSE +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/SECURITY.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/_compat.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/behave_matchers.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/bytes_mixin.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/collection.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/contains.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/date.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/dict.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/dynamic.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/exception.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/extracting.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/file.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/json_mixin.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/numeric.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/py.typed +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/string.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/assertpy2/warning.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assertions.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-equal.png +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-equal.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-gallery.png +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-match.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-sequence.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/assets/diff-set.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/comparison.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/data.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/extending.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/fluent.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/getting-started.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/index.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/logo-dark.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/logo.svg +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/migration.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/async.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/bytes.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/collections.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/containment.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/core.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dataframes.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dates.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dicts.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/dynamic.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/entry-points.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/exceptions.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/extracting.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/files.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/json.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/matchers.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/numbers.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/overview.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/snapshots.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/strings.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/reference/warnings.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/stylesheets/extra.css +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/docs/type-safety.md +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/mkdocs.yml +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_behave_matchers.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_bool.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_bytes.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_callable.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_chaining.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_class.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_collection.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_core.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_custom_dict.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_custom_list.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_datetime.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_description.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dict.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_dynamic.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_expected_exception.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_expected_warning.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_extracting.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_fail.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_file.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_grouped_soft.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_in.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_iterable_cluster.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_json.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_list.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_matcher_registry.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_matchers.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_mutation_hardening.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_namedtuple.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_none.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_numbers.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_overloads.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_pipeline.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_property_based.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_readme.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare_attrs.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare_config.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_recursive_compare_pydantic.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_regex_groups.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_same_as.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_satisfy.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_soft.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_soft_fail.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_string.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_traceback.py +0 -0
- {assertpy2-2.13.0 → assertpy2-2.14.0}/tests/test_type.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertpy2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.14.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
|
|
@@ -188,7 +188,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
188
188
|
- [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
|
|
189
189
|
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
|
|
190
190
|
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
191
|
-
- **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
|
|
192
191
|
|
|
193
192
|
**Type safety**
|
|
194
193
|
|
|
@@ -140,7 +140,6 @@ See the [**Type Safety**](https://solganis.github.io/assertpy2/type-safety/) gui
|
|
|
140
140
|
- [**Structured errors**](https://solganis.github.io/assertpy2/errors/#structured-errors): `AssertionFailure` with `.actual`, `.expected`, `.diff` attributes.
|
|
141
141
|
- [**Rich pytest diffs**](https://solganis.github.io/assertpy2/errors/#rich-pytest-diffs): recursive structural diffs for lists, sets, strings, dicts, dataclasses, namedtuples, Pydantic models, and matcher-based assertions (`matches_structure()`, `satisfies()`, `each()`). Circular reference protection.
|
|
142
142
|
- [**Snapshot testing**](https://solganis.github.io/assertpy2/testing/#snapshot-testing): store and compare data structures in JSON format.
|
|
143
|
-
- **Property-based tested**: comparison, selective-diff, matcher algebra, and collection logic are checked with [Hypothesis](https://hypothesis.readthedocs.io) against reference semantics, on top of 100% branch coverage.
|
|
144
143
|
|
|
145
144
|
**Type safety**
|
|
146
145
|
|
|
@@ -13,7 +13,7 @@ from .assertpy import (
|
|
|
13
13
|
soft_fail,
|
|
14
14
|
)
|
|
15
15
|
from .async_assertions import AsyncAssertionBuilder
|
|
16
|
-
from .errors import AssertionFailure, DiffEntry, DiffResult
|
|
16
|
+
from .errors import AssertionFailure, DiffEntry, DiffResult, PollSample, PollTrace
|
|
17
17
|
from .file import contents_of
|
|
18
18
|
from .matchers import Matcher, clear_custom_matchers, match, register_matcher, unregister_matcher
|
|
19
19
|
from .snapshot import SnapshotCreatedWarning
|
|
@@ -25,6 +25,8 @@ __all__ = [
|
|
|
25
25
|
"DiffResult",
|
|
26
26
|
"Matcher",
|
|
27
27
|
"NegatedBuilder",
|
|
28
|
+
"PollSample",
|
|
29
|
+
"PollTrace",
|
|
28
30
|
"SnapshotCreatedWarning",
|
|
29
31
|
"SoftAssertionCollector",
|
|
30
32
|
"WarningLoggingAdapter",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
``is_equal_to`` builds a `_CompareConfig` from its ``tolerance``/``comparators`` kwargs and threads it
|
|
4
4
|
through both the boolean comparison (`HelpersMixin._dict_not_equal()`) and the diff/message rendering
|
|
5
|
-
(`
|
|
5
|
+
(`assertpy2._diff._sub_diff_entries()`, `HelpersMixin._dict_err()`). `_node_decision()` is the single
|
|
6
6
|
switch both sides consult, so a tolerated or comparator-equal leaf is reported in neither. With ``config is
|
|
7
7
|
None`` every helper reproduces the engine's historical ``actual != expected`` behavior exactly.
|
|
8
8
|
|
|
@@ -133,7 +133,7 @@ def _find_ambiguous_operand(actual, expected, _seen=None):
|
|
|
133
133
|
return None
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
def _guarded_not_equal(actual, expected) -> bool:
|
|
136
|
+
def _guarded_not_equal(actual, expected, *, method="is_equal_to") -> bool:
|
|
137
137
|
"""``bool(actual != expected)``, converting the ambiguity raised from *inside* a container's ``==``
|
|
138
138
|
(where the top-level operand gate cannot see the array member) into the actionable ``TypeError``."""
|
|
139
139
|
try:
|
|
@@ -142,7 +142,18 @@ def _guarded_not_equal(actual, expected) -> bool:
|
|
|
142
142
|
operand = _find_ambiguous_operand(actual, expected)
|
|
143
143
|
if operand is None:
|
|
144
144
|
raise
|
|
145
|
-
raise _array_equality_error(
|
|
145
|
+
raise _array_equality_error(method, operand) from error
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _guarded_equal(actual, expected, *, method) -> bool:
|
|
149
|
+
"""``bool(actual == expected)`` with the same nested array/frame-like guard as `_guarded_not_equal`."""
|
|
150
|
+
try:
|
|
151
|
+
return bool(actual == expected)
|
|
152
|
+
except (ValueError, TypeError) as error:
|
|
153
|
+
operand = _find_ambiguous_operand(actual, expected)
|
|
154
|
+
if operand is None:
|
|
155
|
+
raise
|
|
156
|
+
raise _array_equality_error(method, operand) from error
|
|
146
157
|
|
|
147
158
|
|
|
148
159
|
def _is_real_number(value) -> bool:
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Recursive diff engine shared by the equality assertions and the dict error path.
|
|
2
|
+
|
|
3
|
+
Three walkers coexist on purpose and must not be merged: `_build_equality_diff()` dispatches a
|
|
4
|
+
top-level pair (top-level dicts are handled by `HelpersMixin._dict_err()` instead, so its ladder
|
|
5
|
+
starts at namedtuples), `_sub_diff_entries()` decomposes nested values (mappings first), and
|
|
6
|
+
`_walk_leaves()` iterates scalar leaves for the recursive leaf assertions. Their dispatch orders
|
|
7
|
+
differ deliberately; a shared type classifier was investigated and rejected, because a single global
|
|
8
|
+
precedence changes behavior for values that quack like several container shapes at once.
|
|
9
|
+
`_sequence_diff_entries()` and `_dataclass_diff_entries()` are the pieces genuinely shared by the
|
|
10
|
+
diff builders.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
from ._compare import _node_decision
|
|
19
|
+
from ._introspection import is_mapping_like, is_model_dump_object, is_namedtuple
|
|
20
|
+
from .errors import DiffEntry, DiffResult, _safe_repr, _safe_str
|
|
21
|
+
|
|
22
|
+
__tracebackhide__ = True
|
|
23
|
+
|
|
24
|
+
_SENTINEL: Final = object()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sequence_diff_entries(actual, expected, prefix, seen, config=None) -> list[DiffEntry]:
|
|
28
|
+
"""Diff two sequences element-by-element, recursing into nested containers.
|
|
29
|
+
|
|
30
|
+
``seen`` must already include the ids of ``actual``/``expected`` so a self-referential element
|
|
31
|
+
is caught. Shared by the top-level (`_build_equality_diff()`) and nested
|
|
32
|
+
(`_sub_diff_entries()`) paths so both decompose sequences identically. Elements have no field
|
|
33
|
+
name, so a ``config`` applies only type comparators and tolerance to them.
|
|
34
|
+
"""
|
|
35
|
+
entries: list[DiffEntry] = []
|
|
36
|
+
max_len = max(len(actual), len(expected))
|
|
37
|
+
for i in range(max_len):
|
|
38
|
+
path = f"{prefix}[{i}]" if prefix else f"[{i}]"
|
|
39
|
+
if i >= len(actual):
|
|
40
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected[i]))
|
|
41
|
+
elif i >= len(expected):
|
|
42
|
+
entries.append(DiffEntry(path=path, actual=actual[i], expected=None))
|
|
43
|
+
else:
|
|
44
|
+
decision = _node_decision(actual[i], expected[i], config)
|
|
45
|
+
if decision == "leaf":
|
|
46
|
+
entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
|
|
47
|
+
elif decision == "recurse":
|
|
48
|
+
sub_entries = _sub_diff_entries(actual[i], expected[i], path, _seen=seen, config=config)
|
|
49
|
+
if sub_entries is not None:
|
|
50
|
+
entries.extend(sub_entries)
|
|
51
|
+
else:
|
|
52
|
+
entries.append(DiffEntry(path=path, actual=actual[i], expected=expected[i]))
|
|
53
|
+
return entries
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dataclass_diff_entries(actual, expected, prefix, seen, config=None) -> list[DiffEntry]:
|
|
57
|
+
"""Diff two dataclasses over the sorted union of field names, both directions, recursing.
|
|
58
|
+
|
|
59
|
+
Reports fields present on only one side, and recurses into nested containers. ``seen`` must
|
|
60
|
+
already include the ids of ``actual``/``expected``. Shared by the top-level and nested paths
|
|
61
|
+
so both report dataclass fields identically.
|
|
62
|
+
"""
|
|
63
|
+
entries: list[DiffEntry] = []
|
|
64
|
+
actual_names = {field.name for field in dataclasses.fields(actual)}
|
|
65
|
+
expected_names = {field.name for field in dataclasses.fields(expected)}
|
|
66
|
+
for field in sorted(actual_names | expected_names):
|
|
67
|
+
path = f"{prefix}.{field}"
|
|
68
|
+
if field not in expected_names:
|
|
69
|
+
entries.append(DiffEntry(path=path, actual=getattr(actual, field), expected=None))
|
|
70
|
+
elif field not in actual_names:
|
|
71
|
+
entries.append(DiffEntry(path=path, actual=None, expected=getattr(expected, field)))
|
|
72
|
+
else:
|
|
73
|
+
actual_value = getattr(actual, field)
|
|
74
|
+
expected_value = getattr(expected, field)
|
|
75
|
+
decision = _node_decision(actual_value, expected_value, config, field=field)
|
|
76
|
+
if decision == "leaf":
|
|
77
|
+
entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
|
|
78
|
+
elif decision == "recurse":
|
|
79
|
+
sub_entries = _sub_diff_entries(actual_value, expected_value, path, _seen=seen, config=config)
|
|
80
|
+
if sub_entries is not None:
|
|
81
|
+
entries.extend(sub_entries)
|
|
82
|
+
else:
|
|
83
|
+
entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
|
|
84
|
+
return entries
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_equality_diff(
|
|
88
|
+
actual: object, expected: object, *, _prefix: str = "", _seen: set[int] | None = None, config=None
|
|
89
|
+
) -> DiffResult:
|
|
90
|
+
if _seen is None:
|
|
91
|
+
_seen = set()
|
|
92
|
+
pair_key = (id(actual), id(expected))
|
|
93
|
+
if pair_key[0] in _seen or pair_key[1] in _seen:
|
|
94
|
+
return DiffResult(
|
|
95
|
+
kind="scalar",
|
|
96
|
+
entries=[DiffEntry(path=_prefix or ".", actual="<circular ref>", expected="<circular ref>")],
|
|
97
|
+
)
|
|
98
|
+
_seen = _seen | {pair_key[0], pair_key[1]}
|
|
99
|
+
|
|
100
|
+
if config is not None:
|
|
101
|
+
decision = _node_decision(actual, expected, config)
|
|
102
|
+
if decision == "equal":
|
|
103
|
+
return DiffResult(kind="scalar", entries=[])
|
|
104
|
+
if decision == "leaf":
|
|
105
|
+
return DiffResult(kind="scalar", entries=[DiffEntry(path=_prefix or ".", actual=actual, expected=expected)])
|
|
106
|
+
|
|
107
|
+
def _field_entries(field_actual: object, field_expected: object, field_path: str) -> list[DiffEntry]:
|
|
108
|
+
nested = _sub_diff_entries(field_actual, field_expected, field_path, _seen=_seen, config=config)
|
|
109
|
+
if nested is not None:
|
|
110
|
+
return nested
|
|
111
|
+
return [DiffEntry(path=field_path, actual=field_actual, expected=field_expected)]
|
|
112
|
+
|
|
113
|
+
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
114
|
+
entries: list[DiffEntry] = []
|
|
115
|
+
for field in actual._fields:
|
|
116
|
+
actual_value = getattr(actual, field)
|
|
117
|
+
expected_value = getattr(expected, field, _SENTINEL)
|
|
118
|
+
path = f"{_prefix}.{field}"
|
|
119
|
+
if expected_value is _SENTINEL:
|
|
120
|
+
entries.append(DiffEntry(path=path, actual=actual_value, expected=None))
|
|
121
|
+
else:
|
|
122
|
+
decision = _node_decision(actual_value, expected_value, config, field=field)
|
|
123
|
+
if decision == "leaf":
|
|
124
|
+
entries.append(DiffEntry(path=path, actual=actual_value, expected=expected_value))
|
|
125
|
+
elif decision == "recurse":
|
|
126
|
+
entries.extend(_field_entries(actual_value, expected_value, path))
|
|
127
|
+
entries.extend(
|
|
128
|
+
DiffEntry(path=f"{_prefix}.{field}", actual=None, expected=getattr(expected, field))
|
|
129
|
+
for field in expected._fields
|
|
130
|
+
if not hasattr(actual, field)
|
|
131
|
+
)
|
|
132
|
+
return DiffResult(kind="namedtuple", entries=entries)
|
|
133
|
+
if (
|
|
134
|
+
dataclasses.is_dataclass(actual)
|
|
135
|
+
and not isinstance(actual, type)
|
|
136
|
+
and dataclasses.is_dataclass(expected)
|
|
137
|
+
and not isinstance(expected, type)
|
|
138
|
+
):
|
|
139
|
+
return DiffResult(
|
|
140
|
+
kind="dataclass",
|
|
141
|
+
entries=_dataclass_diff_entries(actual, expected, _prefix, _seen, config),
|
|
142
|
+
)
|
|
143
|
+
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
144
|
+
actual_dict = actual.model_dump()
|
|
145
|
+
expected_dict = expected.model_dump()
|
|
146
|
+
entries = []
|
|
147
|
+
for key in sorted(set(actual_dict) | set(expected_dict)):
|
|
148
|
+
path = f"{_prefix}.{key}" if _prefix else f".{key}"
|
|
149
|
+
if key not in expected_dict:
|
|
150
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=None))
|
|
151
|
+
elif key not in actual_dict:
|
|
152
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[key]))
|
|
153
|
+
else:
|
|
154
|
+
decision = _node_decision(actual_dict[key], expected_dict[key], config, field=key)
|
|
155
|
+
if decision == "leaf":
|
|
156
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
157
|
+
elif decision == "recurse":
|
|
158
|
+
sub_entries = _sub_diff_entries(
|
|
159
|
+
actual_dict[key], expected_dict[key], path, _seen=_seen, config=config
|
|
160
|
+
)
|
|
161
|
+
if sub_entries is not None:
|
|
162
|
+
entries.extend(sub_entries)
|
|
163
|
+
else:
|
|
164
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
165
|
+
return DiffResult(kind="model", entries=entries)
|
|
166
|
+
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
167
|
+
return DiffResult(
|
|
168
|
+
kind="sequence",
|
|
169
|
+
entries=_sequence_diff_entries(actual, expected, _prefix, _seen, config),
|
|
170
|
+
)
|
|
171
|
+
if isinstance(actual, (set, frozenset)) and isinstance(expected, (set, frozenset)):
|
|
172
|
+
entries = []
|
|
173
|
+
for item in sorted(actual - expected, key=_safe_repr):
|
|
174
|
+
entries.append(DiffEntry(path="extra", actual=item, expected=None))
|
|
175
|
+
for item in sorted(expected - actual, key=_safe_repr):
|
|
176
|
+
entries.append(DiffEntry(path="missing", actual=None, expected=item))
|
|
177
|
+
return DiffResult(kind="set", entries=entries)
|
|
178
|
+
if isinstance(actual, str) and isinstance(expected, str):
|
|
179
|
+
entries = []
|
|
180
|
+
actual_lines = actual.splitlines()
|
|
181
|
+
expected_lines = expected.splitlines()
|
|
182
|
+
max_len = max(len(actual_lines), len(expected_lines))
|
|
183
|
+
for i in range(max_len):
|
|
184
|
+
if i >= len(actual_lines):
|
|
185
|
+
entries.append(DiffEntry(path=f"line {i + 1}", actual=None, expected=expected_lines[i]))
|
|
186
|
+
elif i >= len(expected_lines):
|
|
187
|
+
entries.append(DiffEntry(path=f"line {i + 1}", actual=actual_lines[i], expected=None))
|
|
188
|
+
elif actual_lines[i] != expected_lines[i]:
|
|
189
|
+
entries.append(DiffEntry(path=f"line {i + 1}", actual=actual_lines[i], expected=expected_lines[i]))
|
|
190
|
+
if not entries:
|
|
191
|
+
entries.append(DiffEntry(path=".", actual=actual, expected=expected))
|
|
192
|
+
return DiffResult(kind="string", entries=entries)
|
|
193
|
+
return DiffResult(kind="scalar", entries=[DiffEntry(path=_prefix or ".", actual=actual, expected=expected)])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _sub_diff_entries(
|
|
197
|
+
actual: object, expected: object, prefix: str, *, _seen: set[int] | None = None, config=None
|
|
198
|
+
) -> list[DiffEntry] | None:
|
|
199
|
+
"""Canonical recursive diff for a value, returning path-level entries (or ``None`` for a leaf).
|
|
200
|
+
|
|
201
|
+
Recurses into mappings, dataclasses, namedtuples, model-dump objects and sequences, returning a
|
|
202
|
+
(possibly empty) list for those; anything else returns ``None`` so the caller renders a single
|
|
203
|
+
leaf entry. The empty-list-vs-``None`` distinction lets a caller tell a recursable value whose
|
|
204
|
+
children are all ``config``-tolerated (empty list, no entry) from a genuinely differing leaf
|
|
205
|
+
(``None``, one entry). This is the single nested engine shared by the top-level paths:
|
|
206
|
+
`_build_equality_diff()` (lists, dataclasses, ...) and the dict path
|
|
207
|
+
(`HelpersMixin._dict_err()`), which calls it with an empty ``prefix`` so the top-level dict
|
|
208
|
+
keys render bare (``b``) and nested keys render dotted (``u.b``).
|
|
209
|
+
"""
|
|
210
|
+
if _seen is None:
|
|
211
|
+
_seen = set()
|
|
212
|
+
if id(actual) in _seen or id(expected) in _seen:
|
|
213
|
+
return [DiffEntry(path=prefix, actual="<circular ref>", expected="<circular ref>")]
|
|
214
|
+
|
|
215
|
+
if is_mapping_like(actual) and is_mapping_like(expected):
|
|
216
|
+
child_seen = _seen | {id(actual), id(expected)}
|
|
217
|
+
entries: list[DiffEntry] = []
|
|
218
|
+
actual_keys = set(actual)
|
|
219
|
+
expected_keys = set(expected)
|
|
220
|
+
for key in sorted(actual_keys | expected_keys, key=_safe_repr):
|
|
221
|
+
path = f"{prefix}.{_safe_str(key)}" if prefix else _safe_str(key)
|
|
222
|
+
if key not in expected_keys:
|
|
223
|
+
entries.append(DiffEntry(path=path, actual=actual[key], expected=None))
|
|
224
|
+
elif key not in actual_keys:
|
|
225
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected[key]))
|
|
226
|
+
else:
|
|
227
|
+
decision = _node_decision(actual[key], expected[key], config, field=key)
|
|
228
|
+
if decision == "leaf":
|
|
229
|
+
entries.append(DiffEntry(path=path, actual=actual[key], expected=expected[key]))
|
|
230
|
+
elif decision == "recurse":
|
|
231
|
+
sub_entries = _sub_diff_entries(actual[key], expected[key], path, _seen=child_seen, config=config)
|
|
232
|
+
if sub_entries is not None:
|
|
233
|
+
entries.extend(sub_entries)
|
|
234
|
+
else:
|
|
235
|
+
entries.append(DiffEntry(path=path, actual=actual[key], expected=expected[key]))
|
|
236
|
+
return entries
|
|
237
|
+
if (
|
|
238
|
+
dataclasses.is_dataclass(actual)
|
|
239
|
+
and not isinstance(actual, type)
|
|
240
|
+
and dataclasses.is_dataclass(expected)
|
|
241
|
+
and not isinstance(expected, type)
|
|
242
|
+
):
|
|
243
|
+
child_seen = _seen | {id(actual), id(expected)}
|
|
244
|
+
return _dataclass_diff_entries(actual, expected, prefix, child_seen, config)
|
|
245
|
+
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
246
|
+
child_seen = _seen | {id(actual), id(expected)}
|
|
247
|
+
entries = []
|
|
248
|
+
for field_name in actual._fields:
|
|
249
|
+
actual_value = getattr(actual, field_name)
|
|
250
|
+
expected_value = getattr(expected, field_name, _SENTINEL)
|
|
251
|
+
if expected_value is _SENTINEL:
|
|
252
|
+
entries.append(DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=None))
|
|
253
|
+
else:
|
|
254
|
+
decision = _node_decision(actual_value, expected_value, config, field=field_name)
|
|
255
|
+
if decision == "leaf":
|
|
256
|
+
entries.append(
|
|
257
|
+
DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=expected_value)
|
|
258
|
+
)
|
|
259
|
+
elif decision == "recurse":
|
|
260
|
+
sub_entries = _sub_diff_entries(
|
|
261
|
+
actual_value, expected_value, f"{prefix}.{field_name}", _seen=child_seen, config=config
|
|
262
|
+
)
|
|
263
|
+
if sub_entries is not None:
|
|
264
|
+
entries.extend(sub_entries)
|
|
265
|
+
else:
|
|
266
|
+
entries.append(
|
|
267
|
+
DiffEntry(path=f"{prefix}.{field_name}", actual=actual_value, expected=expected_value)
|
|
268
|
+
)
|
|
269
|
+
for field_name in expected._fields:
|
|
270
|
+
if not hasattr(actual, field_name):
|
|
271
|
+
entries.append(
|
|
272
|
+
DiffEntry(path=f"{prefix}.{field_name}", actual=None, expected=getattr(expected, field_name))
|
|
273
|
+
)
|
|
274
|
+
return entries
|
|
275
|
+
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
276
|
+
child_seen = _seen | {id(actual), id(expected)}
|
|
277
|
+
actual_dict = actual.model_dump()
|
|
278
|
+
expected_dict = expected.model_dump()
|
|
279
|
+
entries = []
|
|
280
|
+
for key in sorted(set(actual_dict) | set(expected_dict)):
|
|
281
|
+
path = f"{prefix}.{key}"
|
|
282
|
+
if key not in expected_dict:
|
|
283
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=None))
|
|
284
|
+
elif key not in actual_dict:
|
|
285
|
+
entries.append(DiffEntry(path=path, actual=None, expected=expected_dict[key]))
|
|
286
|
+
else:
|
|
287
|
+
decision = _node_decision(actual_dict[key], expected_dict[key], config, field=key)
|
|
288
|
+
if decision == "leaf":
|
|
289
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
290
|
+
elif decision == "recurse":
|
|
291
|
+
sub_entries = _sub_diff_entries(
|
|
292
|
+
actual_dict[key], expected_dict[key], path, _seen=child_seen, config=config
|
|
293
|
+
)
|
|
294
|
+
if sub_entries is not None:
|
|
295
|
+
entries.extend(sub_entries)
|
|
296
|
+
else:
|
|
297
|
+
entries.append(DiffEntry(path=path, actual=actual_dict[key], expected=expected_dict[key]))
|
|
298
|
+
return entries
|
|
299
|
+
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
300
|
+
child_seen = _seen | {id(actual), id(expected)}
|
|
301
|
+
return _sequence_diff_entries(actual, expected, prefix, child_seen, config)
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _walk_leaves(value, prefix="", _seen=None):
|
|
306
|
+
"""Yield ``(path, leaf)`` for every scalar leaf of an object graph, depth-first.
|
|
307
|
+
|
|
308
|
+
Recurses into the same containers as the rich-diff engine (`_sub_diff_entries()`): mappings,
|
|
309
|
+
dataclasses, namedtuples, model-dump objects, lists and tuples. Anything else - scalars, strings,
|
|
310
|
+
sets, opaque objects - is yielded as a single leaf, so the paths match the diffs. A circular
|
|
311
|
+
reference yields one ``(path, "<circular ref>")`` leaf and stops, mirroring the cycle guard.
|
|
312
|
+
"""
|
|
313
|
+
if _seen is None:
|
|
314
|
+
_seen = set()
|
|
315
|
+
if id(value) in _seen:
|
|
316
|
+
yield (prefix or ".", "<circular ref>")
|
|
317
|
+
return
|
|
318
|
+
if is_mapping_like(value):
|
|
319
|
+
child_seen = _seen | {id(value)}
|
|
320
|
+
for key in value:
|
|
321
|
+
yield from _walk_leaves(value[key], f"{prefix}.{_safe_str(key)}" if prefix else _safe_str(key), child_seen)
|
|
322
|
+
return
|
|
323
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
324
|
+
child_seen = _seen | {id(value)}
|
|
325
|
+
for field in dataclasses.fields(value):
|
|
326
|
+
path = f"{prefix}.{field.name}" if prefix else field.name
|
|
327
|
+
yield from _walk_leaves(getattr(value, field.name), path, child_seen)
|
|
328
|
+
return
|
|
329
|
+
if is_namedtuple(value):
|
|
330
|
+
child_seen = _seen | {id(value)}
|
|
331
|
+
for field_name in value._fields:
|
|
332
|
+
path = f"{prefix}.{field_name}" if prefix else field_name
|
|
333
|
+
yield from _walk_leaves(getattr(value, field_name), path, child_seen)
|
|
334
|
+
return
|
|
335
|
+
if is_model_dump_object(value):
|
|
336
|
+
child_seen = _seen | {id(value)}
|
|
337
|
+
dumped = value.model_dump()
|
|
338
|
+
for key in dumped:
|
|
339
|
+
yield from _walk_leaves(dumped[key], f"{prefix}.{key}" if prefix else str(key), child_seen)
|
|
340
|
+
return
|
|
341
|
+
if isinstance(value, (list, tuple)):
|
|
342
|
+
child_seen = _seen | {id(value)}
|
|
343
|
+
for index, item in enumerate(value):
|
|
344
|
+
yield from _walk_leaves(item, f"{prefix}[{index}]" if prefix else f"[{index}]", child_seen)
|
|
345
|
+
return
|
|
346
|
+
yield (prefix or ".", value)
|
|
@@ -9,7 +9,14 @@ models (``model_dump``), ``attrs`` classes (``__attrs_attrs__``) and namedtuples
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from typing import Any, Protocol, TypeGuard, runtime_checkable
|
|
12
|
+
from typing import Any, Final, Protocol, TypeGuard, runtime_checkable
|
|
13
|
+
|
|
14
|
+
# Exact builtin types whose instances cannot carry protocol members (no instance ``__dict__``), so the
|
|
15
|
+
# expensive ``runtime_checkable`` isinstance (getattr_static per member) is skipped for them. Exact
|
|
16
|
+
# types only: subclasses always take the full structural check.
|
|
17
|
+
_ATOMIC_TYPES: Final = frozenset(
|
|
18
|
+
{type(None), bool, int, float, complex, str, bytes, bytearray, list, tuple, set, frozenset}
|
|
19
|
+
)
|
|
13
20
|
|
|
14
21
|
|
|
15
22
|
@runtime_checkable
|
|
@@ -48,6 +55,8 @@ class MappingLike(Protocol):
|
|
|
48
55
|
|
|
49
56
|
def is_model_dump_object(obj: object) -> TypeGuard[SupportsModelDump]:
|
|
50
57
|
"""Return whether ``obj`` exposes a callable ``model_dump()`` (e.g. a pydantic model)."""
|
|
58
|
+
if type(obj) is dict or type(obj) in _ATOMIC_TYPES:
|
|
59
|
+
return False
|
|
51
60
|
return isinstance(obj, SupportsModelDump) and callable(obj.model_dump)
|
|
52
61
|
|
|
53
62
|
|
|
@@ -63,4 +72,8 @@ def is_attrs_instance(obj: object) -> TypeGuard[AttrsInstance]:
|
|
|
63
72
|
|
|
64
73
|
def is_mapping_like(obj: object) -> TypeGuard[MappingLike]:
|
|
65
74
|
"""Return whether ``obj`` is dict-like: iterable with ``keys()`` and ``[]`` access."""
|
|
75
|
+
if type(obj) is dict:
|
|
76
|
+
return True
|
|
77
|
+
if type(obj) in _ATOMIC_TYPES:
|
|
78
|
+
return False
|
|
66
79
|
return isinstance(obj, MappingLike) and callable(obj.keys)
|
|
@@ -7,7 +7,7 @@ if TYPE_CHECKING:
|
|
|
7
7
|
|
|
8
8
|
from ._compare import _CompareConfig
|
|
9
9
|
from ._compat import Self
|
|
10
|
-
from .errors import
|
|
10
|
+
from .errors import DiffResult
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class _MixinBase:
|
|
@@ -99,16 +99,6 @@ class _MixinBase:
|
|
|
99
99
|
@staticmethod
|
|
100
100
|
def _to_comparable_dict(obj: object) -> dict[str, object] | None: ...
|
|
101
101
|
|
|
102
|
-
@staticmethod
|
|
103
|
-
def _sub_diff_entries(
|
|
104
|
-
actual: object,
|
|
105
|
-
expected: object,
|
|
106
|
-
prefix: str,
|
|
107
|
-
*,
|
|
108
|
-
_seen: set[int] | None = ...,
|
|
109
|
-
config: _CompareConfig | None = ...,
|
|
110
|
-
) -> list[DiffEntry] | None: ...
|
|
111
|
-
|
|
112
102
|
# NumericMixin class attrs used by HelpersMixin._validate_between_args
|
|
113
103
|
_NUMERIC_COMPAREABLE: frozenset[type]
|
|
114
104
|
_NUMERIC_NON_COMPAREABLE: frozenset[type]
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
|
+
import datetime
|
|
6
7
|
from collections.abc import Callable, Hashable, Iterable
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any, Protocol
|
|
@@ -81,6 +82,11 @@ if TYPE_CHECKING:
|
|
|
81
82
|
def contains_any_of(self, *items: str) -> Self: ...
|
|
82
83
|
def contains_none_of(self, *items: str) -> Self: ...
|
|
83
84
|
def is_unicode(self) -> Self: ...
|
|
85
|
+
# NumericMixin - lexicographic ordering
|
|
86
|
+
def is_greater_than(self, other: str) -> Self: ...
|
|
87
|
+
def is_greater_than_or_equal_to(self, other: str) -> Self: ...
|
|
88
|
+
def is_less_than(self, other: str) -> Self: ...
|
|
89
|
+
def is_less_than_or_equal_to(self, other: str) -> Self: ...
|
|
84
90
|
# ContainsMixin
|
|
85
91
|
def contains(self, *items: object) -> Self: ...
|
|
86
92
|
def does_not_contain(self, *items: object) -> Self: ...
|
|
@@ -215,6 +221,12 @@ if TYPE_CHECKING:
|
|
|
215
221
|
def is_equal_to_ignoring_milliseconds(self, other: object) -> Self: ...
|
|
216
222
|
def is_equal_to_ignoring_seconds(self, other: object) -> Self: ...
|
|
217
223
|
def is_equal_to_ignoring_time(self, other: object) -> Self: ...
|
|
224
|
+
# NumericMixin - chronological ordering; at runtime operands must match val's exact type
|
|
225
|
+
def is_greater_than(self, other: datetime.date) -> Self: ...
|
|
226
|
+
def is_greater_than_or_equal_to(self, other: datetime.date) -> Self: ...
|
|
227
|
+
def is_less_than(self, other: datetime.date) -> Self: ...
|
|
228
|
+
def is_less_than_or_equal_to(self, other: datetime.date) -> Self: ...
|
|
229
|
+
def is_between(self, low: datetime.date, high: datetime.date) -> Self: ...
|
|
218
230
|
|
|
219
231
|
class _PathAssertion(_CoreAssertion, Protocol):
|
|
220
232
|
"""Assertions available for ``pathlib.Path`` values."""
|
|
@@ -245,6 +257,11 @@ if TYPE_CHECKING:
|
|
|
245
257
|
def has_byte_at(self, index: int, expected: int) -> Self: ...
|
|
246
258
|
def is_hex_equal_to(self, expected_hex: str) -> Self: ...
|
|
247
259
|
def decoded_as(self, encoding: str = ...) -> _StringAssertion: ...
|
|
260
|
+
# NumericMixin - lexicographic ordering
|
|
261
|
+
def is_greater_than(self, other: bytes | bytearray) -> Self: ...
|
|
262
|
+
def is_greater_than_or_equal_to(self, other: bytes | bytearray) -> Self: ...
|
|
263
|
+
def is_less_than(self, other: bytes | bytearray) -> Self: ...
|
|
264
|
+
def is_less_than_or_equal_to(self, other: bytes | bytearray) -> Self: ...
|
|
248
265
|
|
|
249
266
|
class _InvokedAssertion(_StringAssertion, Protocol):
|
|
250
267
|
"""Assertions available after ``when_called_with()`` captured an exception/warning message.
|
|
@@ -49,7 +49,7 @@ from .snapshot import SnapshotMixin
|
|
|
49
49
|
from .string import StringMixin
|
|
50
50
|
from .warning import WarningMixin
|
|
51
51
|
|
|
52
|
-
__version__ = "2.
|
|
52
|
+
__version__ = "2.14.0"
|
|
53
53
|
|
|
54
54
|
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
55
55
|
contextlib.__tracebackhide__ = True # ty: ignore[unresolved-attribute] # pytest monkey-patch
|
|
@@ -410,7 +410,12 @@ def add_extension(func):
|
|
|
410
410
|
"""
|
|
411
411
|
if not callable(func):
|
|
412
412
|
raise TypeError("func must be callable")
|
|
413
|
-
|
|
413
|
+
if isinstance(func, types.FunctionType):
|
|
414
|
+
# plain functions bind once here via the descriptor protocol, keeping assert_that() free of
|
|
415
|
+
# per-call grafting; the dedicated subclass keeps AssertionBuilder itself pristine on removal
|
|
416
|
+
setattr(_ExtendedBuilder, func.__name__, func)
|
|
417
|
+
else:
|
|
418
|
+
_extensions[func.__name__] = func
|
|
414
419
|
|
|
415
420
|
|
|
416
421
|
def remove_extension(func):
|
|
@@ -428,13 +433,18 @@ def remove_extension(func):
|
|
|
428
433
|
"""
|
|
429
434
|
if not callable(func):
|
|
430
435
|
raise TypeError("func must be callable")
|
|
431
|
-
if func.__name__ in
|
|
432
|
-
|
|
436
|
+
if func.__name__ in vars(_ExtendedBuilder):
|
|
437
|
+
delattr(_ExtendedBuilder, func.__name__)
|
|
438
|
+
_extensions.pop(func.__name__, None)
|
|
433
439
|
|
|
434
440
|
|
|
435
441
|
def _builder(val, description="", kind=None, expected=None, logger=None):
|
|
436
|
-
"""Internal helper to build a new `AssertionBuilder` instance and glue on any extension methods.
|
|
437
|
-
|
|
442
|
+
"""Internal helper to build a new `AssertionBuilder` instance and glue on any extension methods.
|
|
443
|
+
|
|
444
|
+
Function extensions already live on `_ExtendedBuilder`; only non-function callables (which the
|
|
445
|
+
descriptor protocol cannot bind) still need per-instance grafting here.
|
|
446
|
+
"""
|
|
447
|
+
ab = _ExtendedBuilder(val, description, kind, expected, logger)
|
|
438
448
|
if _extensions:
|
|
439
449
|
for name, func in _extensions.items():
|
|
440
450
|
meth = types.MethodType(func, ab)
|
|
@@ -454,15 +464,19 @@ class WarningLoggingAdapter(logging.LoggerAdapter):
|
|
|
454
464
|
frame = frame.f_back
|
|
455
465
|
|
|
456
466
|
previous_frame = None
|
|
457
|
-
for frame in reversed(
|
|
458
|
-
frames
|
|
459
|
-
): # pragma: no branch - loop always finds an assertpy frame when called from error()
|
|
467
|
+
for frame in reversed(frames):
|
|
460
468
|
for assertpy_filename in ASSERTPY_FILES:
|
|
461
469
|
if frame[0].endswith(assertpy_filename):
|
|
462
470
|
return previous_frame
|
|
463
471
|
previous_frame = frame
|
|
464
|
-
|
|
465
|
-
|
|
472
|
+
return None # pragma: no cover - error() is always reached through an assertpy frame
|
|
473
|
+
|
|
474
|
+
# a user file living under a directory named "assertpy2" can shadow every frame, so the
|
|
475
|
+
# location prefix is skipped rather than crashing the warning
|
|
476
|
+
caller = _unwind(inspect.currentframe())
|
|
477
|
+
if caller is None:
|
|
478
|
+
return msg, kwargs
|
|
479
|
+
filename, lineno = caller
|
|
466
480
|
return f"[{os.path.basename(filename)}:{lineno}]: {msg}", kwargs
|
|
467
481
|
|
|
468
482
|
|
|
@@ -482,6 +496,10 @@ class NegatedBuilder:
|
|
|
482
496
|
self._builder = builder
|
|
483
497
|
|
|
484
498
|
def __getattr__(self, name: str) -> object:
|
|
499
|
+
if name == "eventually":
|
|
500
|
+
# eventually() switches the chain to polling instead of asserting, so "inverting" it is
|
|
501
|
+
# meaningless and would otherwise fail loudly with a misleading message
|
|
502
|
+
raise TypeError("eventually() cannot be negated with not_; assert the inverted condition instead")
|
|
485
503
|
attr = getattr(self._builder, name)
|
|
486
504
|
if not callable(attr):
|
|
487
505
|
return attr
|
|
@@ -590,8 +608,9 @@ class AssertionBuilder(
|
|
|
590
608
|
val: the value to be tested (aka the actual value)
|
|
591
609
|
description (str, optional): the extra error message description. Defaults to ``''``
|
|
592
610
|
(aka empty string)
|
|
593
|
-
kind (str, optional): the
|
|
594
|
-
Defaults to ``None
|
|
611
|
+
kind (str, optional): the failure mode of the assertions, one of ``None`` (raise),
|
|
612
|
+
``soft`` (collect), or ``warn`` (log). Defaults to ``None``. Unrelated to
|
|
613
|
+
[`DiffResult.kind`][assertpy2.errors.DiffResult], which is a diff category
|
|
595
614
|
expected (Error, optional): the expected exception. Defaults to ``None``
|
|
596
615
|
logger (Logger, optional): the logger for warning messages. Defaults to ``None``
|
|
597
616
|
"""
|
|
@@ -650,6 +669,11 @@ class AssertionBuilder(
|
|
|
650
669
|
while a service boots, a record not yet visible) can be retried too by listing those exception
|
|
651
670
|
types in ``ignoring``.
|
|
652
671
|
|
|
672
|
+
Polling itself is always strict - retrying *requires* hard failures - but the final timeout
|
|
673
|
+
failure honors the builder's mode: inside
|
|
674
|
+
[`soft_assertions()`][assertpy2.assertpy.soft_assertions] it is collected instead of raised,
|
|
675
|
+
and under [`assert_warn()`][assertpy2.assertpy.assert_warn] it is logged.
|
|
676
|
+
|
|
653
677
|
Args:
|
|
654
678
|
timeout: maximum seconds to keep retrying (default ``5.0``)
|
|
655
679
|
interval: seconds between retries (default ``0.5``)
|
|
@@ -695,4 +719,11 @@ class AssertionBuilder(
|
|
|
695
719
|
timeout=timeout,
|
|
696
720
|
interval=interval,
|
|
697
721
|
ignoring=_normalize_ignoring(ignoring),
|
|
722
|
+
kind=self.kind,
|
|
723
|
+
logger=self.logger,
|
|
698
724
|
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
class _ExtendedBuilder(AssertionBuilder):
|
|
728
|
+
"""Host for user extensions: `add_extension()` installs plain functions here, so binding happens
|
|
729
|
+
once at registration and `AssertionBuilder` itself stays pristine when an extension is removed."""
|