assertpy 2.35.0__py3-none-any.whl
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.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
"""Per-call configuration for tolerant / custom-comparator equality, shared by the equality and diff code.
|
|
2
|
+
|
|
3
|
+
``is_equal_to`` builds a `_CompareConfig` from its ``tolerance``/``comparators`` kwargs and threads it
|
|
4
|
+
through both the boolean comparison (`HelpersMixin._dict_not_equal()`) and the diff/message rendering
|
|
5
|
+
(`assertpy._engine._diff._sub_diff_entries()`, `HelpersMixin._dict_err()`). `_node_decision()` is the single
|
|
6
|
+
switch both sides consult, so a tolerated or comparator-equal leaf is reported in neither. With ``config is
|
|
7
|
+
None`` every helper reproduces plain ``actual == expected`` exactly.
|
|
8
|
+
|
|
9
|
+
Following the package convention, the impl helpers take unannotated args (the typed public surface lives in
|
|
10
|
+
`assertpy._engine._typing`); they operate on arbitrary user values whose operators ``numbers.Number`` cannot
|
|
11
|
+
express to the type checker.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import datetime
|
|
18
|
+
import decimal
|
|
19
|
+
import fractions
|
|
20
|
+
import inspect
|
|
21
|
+
import math
|
|
22
|
+
import numbers
|
|
23
|
+
import pathlib
|
|
24
|
+
import re
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
28
|
+
|
|
29
|
+
from ._introspection import (
|
|
30
|
+
as_held,
|
|
31
|
+
definition_of,
|
|
32
|
+
eq_keyed,
|
|
33
|
+
is_attrs_instance,
|
|
34
|
+
is_mapping_like,
|
|
35
|
+
is_model_dump_object,
|
|
36
|
+
kind_of,
|
|
37
|
+
model_field_values,
|
|
38
|
+
)
|
|
39
|
+
from ._ordering import (
|
|
40
|
+
_NOT_FIXED,
|
|
41
|
+
_SEQUENCES,
|
|
42
|
+
REFUSALS,
|
|
43
|
+
UnorderableError,
|
|
44
|
+
_exact_real,
|
|
45
|
+
broadcasts,
|
|
46
|
+
equal_past,
|
|
47
|
+
held_key,
|
|
48
|
+
holds,
|
|
49
|
+
integral_kind,
|
|
50
|
+
numpy_duration,
|
|
51
|
+
numpy_float,
|
|
52
|
+
python_number,
|
|
53
|
+
python_numbers,
|
|
54
|
+
rational_overflow,
|
|
55
|
+
unwrapped,
|
|
56
|
+
)
|
|
57
|
+
from ._require import raised_inside, verdict
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from collections.abc import Callable, Iterable
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_EQ_ATOMIC = frozenset(
|
|
64
|
+
{
|
|
65
|
+
int,
|
|
66
|
+
float,
|
|
67
|
+
bool,
|
|
68
|
+
complex,
|
|
69
|
+
str,
|
|
70
|
+
bytes,
|
|
71
|
+
bytearray,
|
|
72
|
+
type(None),
|
|
73
|
+
datetime.datetime,
|
|
74
|
+
datetime.date,
|
|
75
|
+
datetime.time,
|
|
76
|
+
datetime.timedelta,
|
|
77
|
+
decimal.Decimal,
|
|
78
|
+
uuid.UUID,
|
|
79
|
+
pathlib.PurePosixPath,
|
|
80
|
+
pathlib.PureWindowsPath,
|
|
81
|
+
pathlib.PosixPath,
|
|
82
|
+
pathlib.WindowsPath,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
"""Types whose ``==`` is a plain bool and which have nothing inside to walk into.
|
|
86
|
+
|
|
87
|
+
The stdlib scalars past the builtins are here for cost, not for correctness. ``strict_types`` walks
|
|
88
|
+
past any value it does not know to be atomic, because a container's own ``==`` says nothing about the
|
|
89
|
+
types inside it - and a ``datetime`` is not a container, so that walk runs the whole introspection
|
|
90
|
+
ladder to come back with nothing. Over 200 records of timestamps, decimals and UUIDs that was 3.7 ms
|
|
91
|
+
against 0.5 ms with them listed.
|
|
92
|
+
|
|
93
|
+
Listing one changes no verdict: a type difference is decided by `_types_differ()` before atomicity is
|
|
94
|
+
ever consulted, so ``date`` against ``datetime`` still fails. Exact types, not ``isinstance``, so a
|
|
95
|
+
subclass with its own structure is still walked.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
100
|
+
class _CompareConfig:
|
|
101
|
+
"""Tolerance and custom comparators for a single ``is_equal_to`` call.
|
|
102
|
+
|
|
103
|
+
``tolerance`` is an absolute tolerance applied to real-number leaves; ``comparators`` maps a ``type`` or
|
|
104
|
+
an immediate field name to a ``(actual, expected) -> bool`` predicate that decides every leaf it matches;
|
|
105
|
+
``ignore_null`` skips a named field whenever the *expected* side leaves it ``None``;
|
|
106
|
+
``strict_types`` additionally requires both sides of every node a comparator does not decide to be the
|
|
107
|
+
same type, which plain ``==`` does not (``True == 1``, ``Decimal("1") == 1``, and so on all the way down
|
|
108
|
+
a payload).
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
tolerance: float | None = None
|
|
112
|
+
rel: float | None = None
|
|
113
|
+
"""A tolerance relative to the expected number, held beside the absolute one: either is enough."""
|
|
114
|
+
# `dict` and not `Mapping`: the runtime refuses anything else, so a checker-approved `MappingProxyType` would fail
|
|
115
|
+
comparators: dict[Any, Callable[[Any, Any], Any]] | None = None
|
|
116
|
+
ignore_null: bool = False
|
|
117
|
+
strict_types: bool = False
|
|
118
|
+
# read once for the whole comparison: a `numpy` duration measures only another
|
|
119
|
+
duration: bool = False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _build_compare_config(
|
|
123
|
+
tolerance, comparators, ignore_null=False, strict_types=False, rel=None
|
|
124
|
+
) -> _CompareConfig | None:
|
|
125
|
+
"""Validate the ``is_equal_to`` comparison kwargs and build a config.
|
|
126
|
+
|
|
127
|
+
Returns ``None`` when none are set. ``tolerance`` and ``rel`` must be non-negative real numbers (not
|
|
128
|
+
``bool``/``complex``/``NaN``); ``comparators`` must be a dict of ``(actual, expected) -> bool`` callables
|
|
129
|
+
keyed by ``type`` or field name; ``ignore_null`` and ``strict_types`` must be bools.
|
|
130
|
+
"""
|
|
131
|
+
if ignore_null is not False and ignore_null is not True:
|
|
132
|
+
raise TypeError("given ignore_null arg must be a bool")
|
|
133
|
+
if strict_types is not False and strict_types is not True:
|
|
134
|
+
raise TypeError("given strict_types arg must be a bool")
|
|
135
|
+
if tolerance is None and rel is None and comparators is None and not ignore_null and not strict_types:
|
|
136
|
+
return None
|
|
137
|
+
if rel is not None:
|
|
138
|
+
require_rel(rel)
|
|
139
|
+
if tolerance is not None:
|
|
140
|
+
_require_tolerance(tolerance)
|
|
141
|
+
if comparators is not None:
|
|
142
|
+
if not isinstance(comparators, dict):
|
|
143
|
+
raise TypeError("given comparators arg must be a dict")
|
|
144
|
+
for comparator in comparators.values():
|
|
145
|
+
if not callable(comparator):
|
|
146
|
+
raise TypeError("each comparator must be callable")
|
|
147
|
+
duration = type(tolerance) not in (int, float) and numpy_duration(tolerance)
|
|
148
|
+
if duration and rel is not None:
|
|
149
|
+
raise TypeError("given rel arg is a fraction of a number's size, which a numpy duration tolerance has none of")
|
|
150
|
+
return _CompareConfig(
|
|
151
|
+
tolerance=tolerance,
|
|
152
|
+
rel=rel,
|
|
153
|
+
comparators=comparators,
|
|
154
|
+
ignore_null=ignore_null,
|
|
155
|
+
strict_types=strict_types,
|
|
156
|
+
duration=duration,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _require_tolerance(tolerance: Any) -> None:
|
|
161
|
+
"""Refuse an absolute tolerance that is no non-negative real number: a `bool`, a complex one, a NaN."""
|
|
162
|
+
if isinstance(tolerance, bool) or not isinstance(tolerance, numbers.Number) or isinstance(tolerance, complex):
|
|
163
|
+
raise TypeError("given tolerance arg must be a real number")
|
|
164
|
+
if _is_nan(tolerance):
|
|
165
|
+
raise ValueError("given tolerance arg must not be NaN")
|
|
166
|
+
if tolerance < zero_of(tolerance):
|
|
167
|
+
raise ValueError("given tolerance arg must not be negative")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def require_rel(rel: object) -> None:
|
|
171
|
+
"""Refuse a relative tolerance that is no non-negative real number: a `bool`, a complex one, a NaN, a duration."""
|
|
172
|
+
if isinstance(rel, bool) or not _is_real_number(rel):
|
|
173
|
+
raise TypeError("given rel arg must be a real number")
|
|
174
|
+
if _is_nan(rel):
|
|
175
|
+
raise ValueError("given rel arg must not be NaN")
|
|
176
|
+
if cast("float", rel) < 0:
|
|
177
|
+
raise ValueError("given rel arg must not be negative")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _ambiguous_array_operand(value: object, other: object) -> object | None:
|
|
181
|
+
"""Return the array/frame-like operand whose ``==`` has no single truth value, else ``None``.
|
|
182
|
+
|
|
183
|
+
numpy/pandas/polars containers expose ``__array__`` and compare element-wise, so ``bool(a == b)``
|
|
184
|
+
raises rather than yielding one bool (and a ``DataFrame`` also quacks dict-like, which would otherwise
|
|
185
|
+
mis-dispatch the comparison). The ``__array__`` gate keeps the extra comparison off the hot path; the
|
|
186
|
+
truth test is actually attempted, so 0-d / scalar array values (which *are* truth-testable) pass
|
|
187
|
+
through unchanged.
|
|
188
|
+
"""
|
|
189
|
+
if not hasattr(value, "__array__") and not hasattr(other, "__array__"):
|
|
190
|
+
return None # fast path: no array-like operand, skip the tuple/loop on every is_equal_to
|
|
191
|
+
if (
|
|
192
|
+
type(value) is not type(other)
|
|
193
|
+
and (isinstance(value, _SEQUENCES) or isinstance(other, _SEQUENCES))
|
|
194
|
+
and broadcasts(value, other)
|
|
195
|
+
):
|
|
196
|
+
return None # a `numpy` scalar against a list, which `_guarded_equal` answers as unequal
|
|
197
|
+
for candidate, counterpart in ((value, other), (other, value)):
|
|
198
|
+
if hasattr(candidate, "__array__"):
|
|
199
|
+
try:
|
|
200
|
+
bool(candidate == counterpart)
|
|
201
|
+
except (ValueError, TypeError):
|
|
202
|
+
return candidate
|
|
203
|
+
except OverflowError as overflow:
|
|
204
|
+
if raised_inside(overflow):
|
|
205
|
+
raise
|
|
206
|
+
continue # a `numpy` scalar converting a bignum, which says nothing about element-wise `==`
|
|
207
|
+
except decimal.InvalidOperation as signal:
|
|
208
|
+
if raised_inside(signal):
|
|
209
|
+
raise
|
|
210
|
+
continue # a signalling NaN, which `_guarded_equal` answers
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _array_equality_error(method: str, operand: object) -> TypeError:
|
|
215
|
+
"""Build the actionable error raised when ``method`` is given an element-wise array/frame-like."""
|
|
216
|
+
# the dedicated assertion names the differing column or index, where wrapping `.equals()` reports a bare False
|
|
217
|
+
dedicated = "is_frame_equal(expected)" if hasattr(operand, "equals") else "is_array_equal(expected)"
|
|
218
|
+
return TypeError(
|
|
219
|
+
f"{method}() cannot directly compare <{type(operand).__name__}>: its '==' is element-wise and has"
|
|
220
|
+
f" no single truth value. Use {dedicated}, assert on extracted scalars (columns, shape, length),"
|
|
221
|
+
" or use satisfies(...) with an explicit predicate."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _find_ambiguous_operand(actual, expected, _seen=None):
|
|
226
|
+
"""Locate the array/frame-like member that broke a comparison, walking the diff engine's containers.
|
|
227
|
+
|
|
228
|
+
Cold error-path only; ``None`` means the error was not array-caused and must be re-raised unchanged.
|
|
229
|
+
"""
|
|
230
|
+
if _seen is None:
|
|
231
|
+
_seen = set()
|
|
232
|
+
pair = (id(actual), id(expected))
|
|
233
|
+
if pair in _seen:
|
|
234
|
+
return None
|
|
235
|
+
_seen = _seen | {pair}
|
|
236
|
+
operand = _ambiguous_array_operand(actual, expected)
|
|
237
|
+
if operand is not None:
|
|
238
|
+
return operand
|
|
239
|
+
for left, right in _members_compared(actual, expected):
|
|
240
|
+
found = _find_ambiguous_operand(left, right, _seen)
|
|
241
|
+
if found is not None:
|
|
242
|
+
return found
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _members_compared(actual: Any, expected: Any) -> Iterable[tuple[Any, Any]]:
|
|
247
|
+
"""The pairs ``==`` walks into for two values of one container kind, and none for any other pair."""
|
|
248
|
+
if is_mapping_like(actual) and is_mapping_like(expected):
|
|
249
|
+
expected_keys = set(expected)
|
|
250
|
+
found = ((key, *held_key(expected_keys, key)) for key in actual)
|
|
251
|
+
return ((actual[key], expected[held]) for key, present, held in found if present)
|
|
252
|
+
if (
|
|
253
|
+
dataclasses.is_dataclass(actual)
|
|
254
|
+
and not isinstance(actual, type)
|
|
255
|
+
and dataclasses.is_dataclass(expected)
|
|
256
|
+
and not isinstance(expected, type)
|
|
257
|
+
):
|
|
258
|
+
return (
|
|
259
|
+
(getattr(actual, one.name), getattr(expected, one.name, None))
|
|
260
|
+
for one in dataclasses.fields(actual)
|
|
261
|
+
if one.compare
|
|
262
|
+
)
|
|
263
|
+
if is_attrs_instance(actual) and is_attrs_instance(expected):
|
|
264
|
+
return _keyed_members(actual, expected)
|
|
265
|
+
if is_model_dump_object(actual) and is_model_dump_object(expected):
|
|
266
|
+
return ((model_field_values(actual), model_field_values(expected)),)
|
|
267
|
+
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
268
|
+
return zip(actual, expected, strict=False)
|
|
269
|
+
return ()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _keyed_members(actual: Any, expected: Any) -> tuple[tuple[Any, Any], ...]:
|
|
273
|
+
"""The attrs fields ``==`` compares, each read through its key as ``==`` reads it, or none.
|
|
274
|
+
|
|
275
|
+
Only what ``==`` compares can have broken it. attrs reads every key before it compares a field, so a key
|
|
276
|
+
that raises is the error being explained and no array is to blame: read again here, it raised a second
|
|
277
|
+
error while the first was handled, and skipped, it let a later array take the blame.
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
return tuple(
|
|
281
|
+
(
|
|
282
|
+
eq_keyed(one, getattr(actual, one.name)),
|
|
283
|
+
eq_keyed(one, getattr(expected, one.name)) if hasattr(expected, one.name) else None,
|
|
284
|
+
)
|
|
285
|
+
for one in actual.__attrs_attrs__
|
|
286
|
+
if one.eq is not False
|
|
287
|
+
)
|
|
288
|
+
except (TypeError, ValueError): # the two errors `_guarded_equal` hands to this search
|
|
289
|
+
return ()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _guarded_equal(actual, expected, *, method="is_equal_to") -> bool:
|
|
293
|
+
"""``bool(actual == expected)``, converting the ambiguity raised from *inside* a container's ``==``
|
|
294
|
+
(where the top-level operand gate cannot see the array member) into the actionable ``TypeError``.
|
|
295
|
+
|
|
296
|
+
The one question every equality verdict asks, the negative ones included: a type's ``__ne__`` need
|
|
297
|
+
not be the negation of its ``__eq__``, and asking it made ``is_equal_to`` and ``is_not_equal_to``
|
|
298
|
+
fail the same pair while a list holding that pair, which compares its members with ``==``, passed.
|
|
299
|
+
A signalling NaN or an overflowing `numpy` float, anywhere in the pair, is answered as `equals` answers it.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
equal = actual == expected
|
|
303
|
+
if type(equal) is bool or type(actual) is type(expected):
|
|
304
|
+
return bool(equal)
|
|
305
|
+
return not broadcasts(actual, expected, answer=equal) and bool(equal)
|
|
306
|
+
except REFUSALS as refusal:
|
|
307
|
+
return _equal_past_refusal(actual, expected, refusal, method)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _equal_past_refusal(actual, expected, refusal: Exception, method: str) -> bool:
|
|
311
|
+
"""What a pair whose ``==`` raised *refusal* answers: the actionable error for an array, else `equal_past`."""
|
|
312
|
+
if issubclass(type(refusal), (ValueError, TypeError)):
|
|
313
|
+
operand = _find_ambiguous_operand(actual, expected)
|
|
314
|
+
if operand is not None:
|
|
315
|
+
raise _array_equality_error(method, operand) from refusal
|
|
316
|
+
return equal_past(actual, expected, refusal)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_real_number(value) -> bool:
|
|
320
|
+
"""Return whether ``value`` is a real number eligible for tolerance (excludes ``bool`` and ``complex``).
|
|
321
|
+
|
|
322
|
+
Array/frame-likes are not `numbers.Number`, so they are excluded too - tolerance never triggers
|
|
323
|
+
their element-wise ``==`` that has no single truth value. An exact `int` or `float` answers before
|
|
324
|
+
the ABC check, which cost a hundred nanoseconds on every leaf a tolerance is asked about. A `numpy`
|
|
325
|
+
duration is none, though `numpy` registers it as an integer: only another duration measures it.
|
|
326
|
+
"""
|
|
327
|
+
return type(value) in (int, float) or (
|
|
328
|
+
isinstance(value, numbers.Number) and not isinstance(value, (bool, complex)) and not numpy_duration(value)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def zero_of(tolerance: Any) -> Any:
|
|
333
|
+
"""The zero a *tolerance* is signed against: a `numpy` duration's in its own unit, which a bare ``0`` is not."""
|
|
334
|
+
return tolerance - tolerance if type(tolerance) not in (int, float) and numpy_duration(tolerance) else 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def plainly_within(actual, expected, tolerance) -> bool | None:
|
|
338
|
+
"""`_within_tolerance` over three exact `int` or `float` operands, or ``None`` where an `int` overflows a `float`.
|
|
339
|
+
|
|
340
|
+
The same rule in its fewest steps: a NaN is close to nothing, an infinity only to an equal one, and a finite
|
|
341
|
+
pair by its difference or by the window around either side, two integers by their difference alone.
|
|
342
|
+
"""
|
|
343
|
+
if actual != actual or expected != expected:
|
|
344
|
+
return False
|
|
345
|
+
try:
|
|
346
|
+
if actual in (math.inf, -math.inf) or expected in (math.inf, -math.inf):
|
|
347
|
+
return actual == expected
|
|
348
|
+
if actual == expected or abs(actual - expected) <= tolerance:
|
|
349
|
+
return True
|
|
350
|
+
if type(actual) is int and type(expected) is int:
|
|
351
|
+
return False
|
|
352
|
+
return (
|
|
353
|
+
expected - tolerance <= actual <= expected + tolerance
|
|
354
|
+
or actual - tolerance <= expected <= actual + tolerance
|
|
355
|
+
)
|
|
356
|
+
except OverflowError:
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def within_either(actual, expected, tolerance, rel) -> bool:
|
|
361
|
+
"""Whether the absolute tolerance holds, where one is given, or else the relative one, where one is given.
|
|
362
|
+
|
|
363
|
+
Either is enough, as `pytest.approx` takes the larger of its two allowances. Added together, the two
|
|
364
|
+
widened each other: ``100`` against ``103`` within ``1`` and ``0.02`` held by their sum and by neither.
|
|
365
|
+
"""
|
|
366
|
+
if tolerance is not None and _within_tolerance(actual, expected, tolerance):
|
|
367
|
+
return True
|
|
368
|
+
return rel is not None and relatively_within(actual, expected, rel)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def relatively_within(actual, expected, rel) -> bool:
|
|
372
|
+
"""Whether *actual* is within *rel* of the size of *expected*, measured as that absolute tolerance is.
|
|
373
|
+
|
|
374
|
+
Scaled by the expected side, as `pytest.approx`, `numpy.testing.assert_allclose` and AssertJ scale it.
|
|
375
|
+
Scaled by the larger of the two, as `math.isclose` scales a pair with no expected side, the value under
|
|
376
|
+
test widened its own allowance: any two numbers of one sign were within ``1`` of each other, and ``2``
|
|
377
|
+
was within ``0.5`` of ``1``. The allowance is then measured as `_within_tolerance` measures any, so a NaN
|
|
378
|
+
is within nothing, an infinity only of an equal one, and an infinite *rel* covers every finite pair, a
|
|
379
|
+
zero's too, where ``inf * 0`` would be a NaN.
|
|
380
|
+
"""
|
|
381
|
+
if type(actual) in (int, float) and type(expected) in (int, float) and type(rel) in (int, float):
|
|
382
|
+
try:
|
|
383
|
+
allowance = rel * abs(expected)
|
|
384
|
+
except OverflowError:
|
|
385
|
+
allowance = math.inf
|
|
386
|
+
# an overflowed or infinite allowance, or the NaN of `inf * 0`, is the general path's
|
|
387
|
+
if allowance - allowance == 0:
|
|
388
|
+
within = plainly_within(actual, expected, allowance)
|
|
389
|
+
if within is not None:
|
|
390
|
+
return within
|
|
391
|
+
actual, expected, rel = _as_measured(actual, expected, rel)
|
|
392
|
+
# answered by rule before any arithmetic, which a trapping `Decimal` context may refuse
|
|
393
|
+
actual_kind, expected_kind = _non_finite(actual), _non_finite(expected)
|
|
394
|
+
if actual_kind or expected_kind:
|
|
395
|
+
return actual_kind == expected_kind == "inf" and bool(actual == expected)
|
|
396
|
+
if _non_finite(rel) == "inf":
|
|
397
|
+
return True
|
|
398
|
+
return _within_tolerance(actual, expected, _fraction_of(expected, rel))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _fraction_of(expected, rel):
|
|
402
|
+
"""*rel* of the size of *expected*, in their own arithmetic, and exactly where that refuses or overflows a float.
|
|
403
|
+
|
|
404
|
+
Overflowed to an infinity, the allowance covered every finite pair: ``1e308`` was within ``1.9`` of
|
|
405
|
+
``-1e308``, when ``2e308`` exceeds ``1.9e308``. A `Decimal` refuses a `float` and a bignum overflows
|
|
406
|
+
one, and those are taken as fractions too. A `Decimal` context that traps its own overflow raises it
|
|
407
|
+
here, as it does in the difference an absolute tolerance takes.
|
|
408
|
+
"""
|
|
409
|
+
try:
|
|
410
|
+
allowance = rel * abs(expected)
|
|
411
|
+
except (TypeError, OverflowError) as error:
|
|
412
|
+
# their own `__abs__` or `__mul__` raised: a bug in the value, not a refusal
|
|
413
|
+
if raised_inside(error) and not rational_overflow(error):
|
|
414
|
+
raise
|
|
415
|
+
else:
|
|
416
|
+
if _non_finite(allowance) != "inf":
|
|
417
|
+
return allowance
|
|
418
|
+
return _as_fraction(rel) * abs(_as_fraction(expected))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _within_tolerance(actual, expected, tolerance) -> bool:
|
|
422
|
+
"""Whether two values lie within ``tolerance`` of each other, the same answer whichever comes first.
|
|
423
|
+
|
|
424
|
+
One distance, measured three ways that a float rounds differently at the boundary: the difference,
|
|
425
|
+
and the window around each side. Any of them holding is enough. Measured one way each, the three
|
|
426
|
+
spellings disagreed: ``-1.1`` was close to ``-0.9`` within ``0.2`` for `is_close_to`, which windows
|
|
427
|
+
around the other operand, and not for `match.close_to`, which windows around the value, nor for
|
|
428
|
+
``is_equal_to(tolerance=)``, which takes the difference. Each spelling now takes any of the three, so a
|
|
429
|
+
pair all of them measure gets one answer.
|
|
430
|
+
|
|
431
|
+
A window the ordering engine cannot order is one way fewer of holding, and the difference still
|
|
432
|
+
answers: a `Decimal` refuses to order against a `numpy.int64` it subtracts exactly. Only a pair with
|
|
433
|
+
no difference either raises `UnorderableError` out of the windows, for the caller to refuse or to read
|
|
434
|
+
as no match.
|
|
435
|
+
|
|
436
|
+
``NaN`` is never within, and an infinity is within only of itself, whatever the tolerance, the rule
|
|
437
|
+
`math.isclose` keeps. The operands are classified before equality is asked, since a value's own
|
|
438
|
+
`__eq__` could otherwise call a NaN or a finite number equal to what it is not, and the distance is
|
|
439
|
+
never classified: two finite values far enough apart overflow their difference to an infinity and are
|
|
440
|
+
still measured. An infinite tolerance was a wildcard before this, `1` was close to `inf` within `inf`
|
|
441
|
+
through the window around `1`, and it now covers every finite pair and no more.
|
|
442
|
+
|
|
443
|
+
A `numpy` integer is measured as the `int` it holds (`_as_measured`): taken through its own arithmetic,
|
|
444
|
+
`uint8(3)` was 9 away from 250. Two of `int` and of a `Fraction` of two `int` are measured by their
|
|
445
|
+
difference alone, which is exact: a window under a float tolerance rounds an integer past ``2**53``, and
|
|
446
|
+
``2**53 + 1`` was within ``0.0`` of ``2**53``. A `Fraction` built from a `numpy` integer keeps it as a
|
|
447
|
+
part, wraps in its own arithmetic as it does in Python's, and is measured as before.
|
|
448
|
+
"""
|
|
449
|
+
kind, other, span = type(actual), type(expected), type(tolerance)
|
|
450
|
+
# asked of the classes kept: a call for every triple cost two `float64` 10%
|
|
451
|
+
if not (kind is float and other is float and span is float) and (
|
|
452
|
+
(kind is not int and type(kind) is type and kind not in _NOT_FIXED)
|
|
453
|
+
or (other is not int and type(other) is type and other not in _NOT_FIXED)
|
|
454
|
+
or (span is not int and span is not float and (kind is int or (type(span) is type and span not in _NOT_FIXED)))
|
|
455
|
+
):
|
|
456
|
+
actual, expected, tolerance = _as_measured(actual, expected, tolerance)
|
|
457
|
+
actual_kind, expected_kind = _non_finite(actual), _non_finite(expected)
|
|
458
|
+
if actual_kind or expected_kind:
|
|
459
|
+
return actual_kind == expected_kind == "inf" and bool(actual == expected)
|
|
460
|
+
try:
|
|
461
|
+
if actual == expected:
|
|
462
|
+
return True
|
|
463
|
+
except (TypeError, OverflowError) as refusal:
|
|
464
|
+
# `Decimal` refuses to compare a `numpy.int64` it subtracts exactly: the equality is unknown, not false
|
|
465
|
+
if raised_inside(refusal):
|
|
466
|
+
raise
|
|
467
|
+
difference = _difference_within(actual, expected, tolerance)
|
|
468
|
+
if difference or (difference is not None and _differ_exactly(actual, expected)):
|
|
469
|
+
return bool(difference)
|
|
470
|
+
unordered = None
|
|
471
|
+
try:
|
|
472
|
+
if _window_holds(expected, actual, tolerance):
|
|
473
|
+
return True
|
|
474
|
+
except UnorderableError as refusal:
|
|
475
|
+
unordered = refusal
|
|
476
|
+
try:
|
|
477
|
+
if _window_holds(actual, expected, tolerance):
|
|
478
|
+
return True
|
|
479
|
+
except UnorderableError as refusal:
|
|
480
|
+
unordered = unordered or refusal
|
|
481
|
+
if unordered is not None and difference is None:
|
|
482
|
+
raise unordered
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _differ_exactly(actual, expected) -> bool:
|
|
487
|
+
"""Whether each of the two is an `int` or a `Fraction` of two, so their difference is exact.
|
|
488
|
+
|
|
489
|
+
A `Fraction` built from a `numpy` integer keeps it as a part, and its own arithmetic wraps at that width.
|
|
490
|
+
"""
|
|
491
|
+
return _exact_rational(actual) and _exact_rational(expected)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _exact_rational(value) -> bool:
|
|
495
|
+
kind = type(value)
|
|
496
|
+
if kind is int:
|
|
497
|
+
return True
|
|
498
|
+
return kind is fractions.Fraction and type(value.numerator) is int and type(value.denominator) is int
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _as_measured(actual, expected, tolerance) -> tuple[Any, Any, Any]:
|
|
502
|
+
"""The operands of a closeness as they are measured where one of them may be a `numpy` integer.
|
|
503
|
+
|
|
504
|
+
With one in the pair, all three are read as the Python numbers they hold (`python_numbers`). So is the
|
|
505
|
+
tolerance of a pair whose difference is exact: held against a `numpy` float, an exact distance would be
|
|
506
|
+
rounded to the float's width, and ``2**24 + 1`` was within ``float32(2**24)`` of zero. Standing as the
|
|
507
|
+
tolerance alone a `numpy` integer is read as its `int`, since a window around a Python number wraps it. Not
|
|
508
|
+
beside a pair that holds a `numpy` float, whatever that float holds: `numpy` measures a float against an
|
|
509
|
+
integer of any width without wrapping, and a Python `int` in its place would be rounded to the float's
|
|
510
|
+
width.
|
|
511
|
+
"""
|
|
512
|
+
one, other = python_numbers(actual, expected)
|
|
513
|
+
if one is not actual or other is not expected or _differ_exactly(one, other):
|
|
514
|
+
return one, other, python_number(tolerance)
|
|
515
|
+
if numpy_float(actual) or numpy_float(expected):
|
|
516
|
+
return actual, expected, tolerance
|
|
517
|
+
return actual, expected, unwrapped(tolerance)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _difference_within(actual, expected, tolerance) -> bool | None:
|
|
521
|
+
"""``abs(actual - expected) <= tolerance``, or ``None`` for two values that cannot be subtracted.
|
|
522
|
+
|
|
523
|
+
Tried as written, so two floats keep the arithmetic they always had. A `Decimal` against a ``float``
|
|
524
|
+
refuses to subtract at all and a bignum ``int`` or a `Fraction` past the float range overflows one, and
|
|
525
|
+
those pairs are measured exactly instead, through `fractions.Fraction`. The tolerance is read as the
|
|
526
|
+
ordering engine reads a number, so an infinite one keeps its sign and covers every finite distance, and
|
|
527
|
+
through `fractions.Fraction` where that reader has no exact value for it.
|
|
528
|
+
"""
|
|
529
|
+
try:
|
|
530
|
+
return abs(actual - expected) <= tolerance
|
|
531
|
+
except (TypeError, OverflowError) as error:
|
|
532
|
+
# their own `__sub__` or `__abs__` raised: a bug in the value, not a refusal
|
|
533
|
+
if raised_inside(error) and not rational_overflow(error):
|
|
534
|
+
raise
|
|
535
|
+
try:
|
|
536
|
+
distance = abs(_as_fraction(actual) - _as_fraction(expected))
|
|
537
|
+
bound = _exact_real(tolerance)
|
|
538
|
+
rank, exact = bound if isinstance(bound, tuple) else (0, _as_fraction(tolerance))
|
|
539
|
+
except (TypeError, ValueError, OverflowError):
|
|
540
|
+
return None
|
|
541
|
+
return rank > 0 if rank else distance <= exact
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _as_fraction(value: Any) -> fractions.Fraction:
|
|
545
|
+
"""*value* as an exact `fractions.Fraction`, read as the ordering engine reads a finite number where it can.
|
|
546
|
+
|
|
547
|
+
`Fraction` keeps the numerator an `Integral` gives it, so a `numpy.int64` stayed a fixed-width integer inside
|
|
548
|
+
it and overflowed again the moment a bignum met it in the exact arithmetic, and some interpreters' `Fraction`
|
|
549
|
+
refuses a `numpy` float outright. An infinity or a NaN is left to `Fraction`, which refuses both.
|
|
550
|
+
"""
|
|
551
|
+
exact = _exact_real(value)
|
|
552
|
+
if isinstance(exact, tuple) and not exact[0]:
|
|
553
|
+
return exact[1]
|
|
554
|
+
return fractions.Fraction(int(value) if isinstance(value, numbers.Integral) else value)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _window_holds(middle, value, tolerance) -> bool:
|
|
558
|
+
"""Whether *value* lies in the closed window *tolerance* either side of *middle*."""
|
|
559
|
+
try:
|
|
560
|
+
low, high = tolerance_window(middle, tolerance)
|
|
561
|
+
except WindowRefusedError:
|
|
562
|
+
return False
|
|
563
|
+
if type(value) is type(low) is type(high) is float:
|
|
564
|
+
return low <= value <= high
|
|
565
|
+
return holds(value, low, "ge") and holds(value, high, "le")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _is_nan(value) -> bool:
|
|
569
|
+
"""Whether the value is a NaN, without converting what never is one.
|
|
570
|
+
|
|
571
|
+
A `Decimal` is asked through its base type: a signalling NaN refuses to become a float, and reading
|
|
572
|
+
it as "not a NaN" is the one answer it certainly is not. Not through the value's own `is_nan`,
|
|
573
|
+
which a subclass owns: measured, one saying it was a NaN turned a passing `is_close_to` into a
|
|
574
|
+
failure. `_is_infinite` reads the same question the same way.
|
|
575
|
+
|
|
576
|
+
An `int` or any other rational is never a NaN and is not converted: past the float range the
|
|
577
|
+
conversion overflows, and for a `Fraction` it overflows inside Python code, which read as a bug in the
|
|
578
|
+
value. Any other value is converted, and an error from a conversion of its own is handed on:
|
|
579
|
+
swallowed, `is_not_nan()` held on a value nothing could read.
|
|
580
|
+
"""
|
|
581
|
+
if isinstance(value, float):
|
|
582
|
+
return math.isnan(value)
|
|
583
|
+
if isinstance(value, decimal.Decimal):
|
|
584
|
+
return decimal.Decimal.is_nan(value)
|
|
585
|
+
if isinstance(value, (int, numbers.Rational)):
|
|
586
|
+
return False
|
|
587
|
+
return math.isnan(value)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _is_infinite(value) -> bool:
|
|
591
|
+
"""Whether a real number other than a `Decimal` is itself an infinity.
|
|
592
|
+
|
|
593
|
+
A `float` directly. Any other value is asked through `math.isinf`, since a `numpy.float32` infinity is
|
|
594
|
+
not a `float` and was read as finite, and one with no conversion is refused there, as the assertions
|
|
595
|
+
always refused it. A rational never is one, and an `int` or a `Fraction` past the float range would
|
|
596
|
+
overflow the conversion. `_non_finite` reads a `Decimal` through its base type's own method, and the
|
|
597
|
+
assertions asking this refuse one.
|
|
598
|
+
|
|
599
|
+
The conversion alone would call a finite value past the float range infinite, a `numpy.longdouble` or
|
|
600
|
+
an arbitrary-precision float, so the value's own comparison has to agree. A conversion of their own
|
|
601
|
+
that raises is a bug in the value and is handed on, as `_is_nan` does.
|
|
602
|
+
"""
|
|
603
|
+
if isinstance(value, float):
|
|
604
|
+
return math.isinf(value)
|
|
605
|
+
if isinstance(value, (int, numbers.Rational)):
|
|
606
|
+
return False
|
|
607
|
+
return math.isinf(value) and bool(value == math.inf or value == -math.inf)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _non_finite(value) -> str:
|
|
611
|
+
"""``"nan"`` or ``"inf"`` for a value closeness answers by rule, ``""`` for one it measures.
|
|
612
|
+
|
|
613
|
+
One pass per operand: a float, a `Decimal` and a type with no conversion answer before either question.
|
|
614
|
+
The conversion is read off the type rather than the `numbers` ABCs, which cost a datetime pair a quarter
|
|
615
|
+
of its `is_close_to`, measured, and a datetime is measured rather than refused.
|
|
616
|
+
"""
|
|
617
|
+
if isinstance(value, float):
|
|
618
|
+
if math.isfinite(value):
|
|
619
|
+
return ""
|
|
620
|
+
return "nan" if math.isnan(value) else "inf"
|
|
621
|
+
if isinstance(value, decimal.Decimal):
|
|
622
|
+
if decimal.Decimal.is_finite(value):
|
|
623
|
+
return ""
|
|
624
|
+
return "nan" if decimal.Decimal.is_nan(value) else "inf"
|
|
625
|
+
if isinstance(value, int) or not hasattr(type(value), "__float__") or integral_kind(type(value)):
|
|
626
|
+
return ""
|
|
627
|
+
if _is_nan(value):
|
|
628
|
+
return "nan"
|
|
629
|
+
return "inf" if _is_infinite(value) else ""
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class WindowRefusedError(TypeError):
|
|
633
|
+
"""The two operands form no window: neither the arithmetic nor an exact conversion takes them.
|
|
634
|
+
|
|
635
|
+
A `TypeError` still, so a caller that let the operand's own refusal out keeps letting this one out,
|
|
636
|
+
and a matcher, which may not raise, can tell it from a `__sub__` of somebody's own that raised.
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def tolerance_window(middle: Any, tolerance: Any) -> tuple[Any, Any]:
|
|
641
|
+
"""The closed interval ``middle`` plus and minus ``tolerance``.
|
|
642
|
+
|
|
643
|
+
Typed as `Any` because the pairing is a run-time fact: the assertion has refused every combination
|
|
644
|
+
but two before it asks, a number against a number and a datetime against a timedelta, and a checker
|
|
645
|
+
reading the public signature alone is right to refuse the arithmetic. A pair inside that domain can
|
|
646
|
+
still refuse, since a class registered as a `numbers.Number` need not subtract, which is what
|
|
647
|
+
`WindowRefusedError` is for: it hands back the operands' own refusal as something a matcher may answer
|
|
648
|
+
"no match" to.
|
|
649
|
+
"""
|
|
650
|
+
try:
|
|
651
|
+
return middle - tolerance, middle + tolerance
|
|
652
|
+
except (TypeError, OverflowError) as refusal:
|
|
653
|
+
if raised_inside(refusal) and not rational_overflow(refusal): # their own `__sub__` raised: a bug
|
|
654
|
+
raise
|
|
655
|
+
if not all(isinstance(operand, numbers.Number) for operand in (middle, tolerance)):
|
|
656
|
+
raise WindowRefusedError(str(refusal)) from None
|
|
657
|
+
# a `Decimal` refuses a `float` or a `Fraction` and a bignum overflows a `float`, all converting
|
|
658
|
+
# exactly, while an infinite or NaN one does not convert at all
|
|
659
|
+
try:
|
|
660
|
+
exact, span = _as_fraction(middle), _as_fraction(tolerance)
|
|
661
|
+
except (TypeError, ValueError, OverflowError) as failed:
|
|
662
|
+
raise WindowRefusedError(str(failed)) from None
|
|
663
|
+
return exact - span, exact + span
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _resolve_comparator(actual, config: _CompareConfig, *, field):
|
|
667
|
+
"""Resolve the comparator owning a leaf: immediate field name first, then exact type, then ``isinstance``.
|
|
668
|
+
|
|
669
|
+
``field`` is the leaf's immediate key/field name (``None`` for sequence elements and bare scalars, which
|
|
670
|
+
have no name). Returns ``None`` when no comparator applies, so the caller falls back to tolerance / ``==``.
|
|
671
|
+
"""
|
|
672
|
+
comparators = config.comparators
|
|
673
|
+
if comparators is None:
|
|
674
|
+
return None
|
|
675
|
+
actual = as_held(actual)
|
|
676
|
+
if field is not None and not isinstance(field, type) and field in comparators:
|
|
677
|
+
return comparators[field]
|
|
678
|
+
if type(actual) in comparators:
|
|
679
|
+
return comparators[type(actual)]
|
|
680
|
+
for key, comparator in comparators.items():
|
|
681
|
+
if isinstance(key, type) and isinstance(actual, key):
|
|
682
|
+
return comparator
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _types_differ(actual, expected) -> bool:
|
|
687
|
+
"""Whether ``strict_types`` should reject this pair.
|
|
688
|
+
|
|
689
|
+
A matcher standing in for a value is not a value, so it is exempt: the expected side of
|
|
690
|
+
``is_equal_to({"id": match.greater_than(0)})`` is a ``GreaterThanMatcher`` by construction, and
|
|
691
|
+
comparing its type against an ``int`` would break every composed matcher rather than catch a bug.
|
|
692
|
+
|
|
693
|
+
``_is_matcher`` is imported here rather than at module scope because ``_matcher_impls`` imports
|
|
694
|
+
``_guarded_equal`` from this module, so the module-level import would be a cycle.
|
|
695
|
+
"""
|
|
696
|
+
if kind_of(actual) is kind_of(expected):
|
|
697
|
+
return False
|
|
698
|
+
from .._matcher_impls import _is_matcher
|
|
699
|
+
|
|
700
|
+
return not _is_matcher(expected) and not _is_matcher(actual)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _keyed_types_differ(actual, expected) -> bool:
|
|
704
|
+
"""Whether two equal containers disagree on the *types* of what they are keyed by.
|
|
705
|
+
|
|
706
|
+
The rest of `strict_types` works pair by pair, and a mapping key or a set member never becomes a
|
|
707
|
+
pair: the container matched them itself, by hash and equality, before the walk ever saw them. So
|
|
708
|
+
`{True: "a"} == {1: "a"}` and `{1} == {1.0}` both passed a strict comparison, which is the one
|
|
709
|
+
thing the flag's name promises they would not.
|
|
710
|
+
|
|
711
|
+
Each side is reduced to its members paired with their types. `(bool, True)` and `(int, 1)` are
|
|
712
|
+
different pairs where `True` and `1` are the same key, which is exactly the distinction being made.
|
|
713
|
+
Values are left alone here: they do become pairs, and the walk judges them.
|
|
714
|
+
|
|
715
|
+
A member that is no atom is then read beside the one it was matched with (`typed_apart`): `(1, 2)` and
|
|
716
|
+
`(True, 2)` are both a tuple, and the type that differs is inside.
|
|
717
|
+
"""
|
|
718
|
+
# structural, like everything here: the concrete-type check let a `UserDict` pass where the matcher refused it
|
|
719
|
+
keyed = is_mapping_like(actual) and is_mapping_like(expected)
|
|
720
|
+
if not keyed and not (isinstance(actual, (set, frozenset)) and isinstance(expected, (set, frozenset))):
|
|
721
|
+
return False
|
|
722
|
+
if of_one_atom(actual, expected):
|
|
723
|
+
return False
|
|
724
|
+
typed = {(type(member), member) for member in actual}
|
|
725
|
+
if typed != {(type(member), member) for member in expected}:
|
|
726
|
+
return True
|
|
727
|
+
if _EQ_ATOMIC.issuperset(map(type, actual)):
|
|
728
|
+
return False
|
|
729
|
+
held = {member: member for member in expected}
|
|
730
|
+
return any(kind not in _EQ_ATOMIC and typed_apart(member, held.get(member, member)) for kind, member in typed)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def of_one_atom(actual: Any, expected: Any) -> bool:
|
|
734
|
+
"""Whether every member of both is of one class, and that class an atom: no two of them differ in type then.
|
|
735
|
+
|
|
736
|
+
Asked of the classes alone, ahead of any pass that pairs the members.
|
|
737
|
+
"""
|
|
738
|
+
kinds = set(map(type, actual))
|
|
739
|
+
return len(kinds) == 1 and kinds <= _EQ_ATOMIC and kinds == set(map(type, expected))
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def typed_apart(member: Any, counterpart: Any) -> bool:
|
|
743
|
+
"""Whether two values a hash holds equal differ in type, at any depth of the tuples and frozensets in them.
|
|
744
|
+
|
|
745
|
+
Those two are the containers a hash reads through, and each is read where its ``==`` is the builtin one,
|
|
746
|
+
which is what pairs the parts: a tuple or a frozenset of a class with an ``==`` of its own may hold two
|
|
747
|
+
equal with nothing in them alike, and is compared by its class alone, as a record used as a key is.
|
|
748
|
+
Walked on a list, since a tuple nested past the recursion limit is still a key.
|
|
749
|
+
"""
|
|
750
|
+
pending = [(member, counterpart)]
|
|
751
|
+
while pending:
|
|
752
|
+
one, other = pending.pop()
|
|
753
|
+
kind = type(one)
|
|
754
|
+
if kind is not type(other):
|
|
755
|
+
return True
|
|
756
|
+
if kind is tuple or (issubclass(kind, tuple) and _keeps_equality_of(kind, tuple)):
|
|
757
|
+
pending.extend(zip(tuple.__iter__(one), tuple.__iter__(other), strict=False))
|
|
758
|
+
elif kind is frozenset or (issubclass(kind, frozenset) and _keeps_equality_of(kind, frozenset)):
|
|
759
|
+
held = {part: part for part in frozenset.__iter__(other)}
|
|
760
|
+
pending.extend((part, held.get(part, part)) for part in frozenset.__iter__(one))
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _keeps_equality_of(kind: type, builtin: type) -> bool:
|
|
765
|
+
"""Whether the ``==`` *kind* answers with is the one *builtin* defines, read off the class tree."""
|
|
766
|
+
return (definition_of(kind, "__eq__") or (object, None))[0] is builtin
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _kinds_never_equal(actual, expected) -> bool:
|
|
770
|
+
"""Whether ``==`` rejects the pair by its kind alone, which a walk over the parts cannot see.
|
|
771
|
+
|
|
772
|
+
Under a compare config the walker decides by parts, so ``[1.0]`` against ``(1.0,)``, or two dataclasses
|
|
773
|
+
of different classes holding the same fields, had no differing part and passed, while ``==`` rejects
|
|
774
|
+
both outright. Asked once ``==`` has said no, or of two sequences a key option takes element by element.
|
|
775
|
+
Either way it never fails a pair ``==`` accepts, by the second paragraph.
|
|
776
|
+
|
|
777
|
+
The kind alone is not the answer: a list subclass may define ``__eq__`` to accept a tuple. So a pair
|
|
778
|
+
of different kinds counts only when both sides' own ``__eq__`` decline it, which leaves ``==`` at
|
|
779
|
+
identity whatever the parts hold. A pydantic model answers ``False`` rather than declining, so which of
|
|
780
|
+
class or fields decided cannot be told apart, and models are left to the walk.
|
|
781
|
+
"""
|
|
782
|
+
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
783
|
+
kinds_differ = isinstance(actual, list) is not isinstance(expected, list)
|
|
784
|
+
else:
|
|
785
|
+
both_dataclasses = all(
|
|
786
|
+
dataclasses.is_dataclass(side) and not isinstance(side, type) for side in (actual, expected)
|
|
787
|
+
)
|
|
788
|
+
both_attrs = is_attrs_instance(actual) and is_attrs_instance(expected)
|
|
789
|
+
kinds_differ = (both_dataclasses or both_attrs) and type(actual) is not type(expected)
|
|
790
|
+
return kinds_differ and _both_decline(actual, expected)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _declines(value: Any, other: Any) -> bool:
|
|
794
|
+
"""Whether *value*'s own ``__eq__`` answers ``NotImplemented`` for *other*, asked as ``==`` asks it.
|
|
795
|
+
|
|
796
|
+
Read statically off the type, since `type(x).__eq__` is an ordinary read that a metaclass answers,
|
|
797
|
+
and bound through the descriptor protocol, since that is what turns a function, a `staticmethod` or
|
|
798
|
+
anything else on the class into the callable the operator uses.
|
|
799
|
+
"""
|
|
800
|
+
held: Any = inspect.getattr_static(type(value), "__eq__", None)
|
|
801
|
+
bind: Any = getattr(type(held), "__get__", None)
|
|
802
|
+
# measured against `==` itself: a callable that binds to nothing is called with the other side alone
|
|
803
|
+
asking = held if bind is None else bind(held, value, type(value))
|
|
804
|
+
return asking(other) is NotImplemented
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _both_decline(actual: Any, expected: Any) -> bool:
|
|
808
|
+
"""Whether each side's own ``__eq__`` answers ``NotImplemented`` for the other."""
|
|
809
|
+
try:
|
|
810
|
+
return _declines(actual, expected) and _declines(expected, actual)
|
|
811
|
+
except Exception: # an `__eq__` that raises decides nothing here, and the walk goes on as before
|
|
812
|
+
return False
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _node_decision(
|
|
816
|
+
actual, expected, config: _CompareConfig | None, *, field=None, at_root: bool = False, keys_left_out: bool = False
|
|
817
|
+
) -> str:
|
|
818
|
+
"""Classify a node as ``"equal"``, ``"leaf"``, ``"recurse"``, ``"unanswered"`` or ``"strict"``.
|
|
819
|
+
|
|
820
|
+
With ``config is None`` this is exactly the engine's historical behavior: differing values ``"recurse"``
|
|
821
|
+
(to decompose into a sub-diff), equal values are ``"equal"`` (skipped); ``"leaf"`` never occurs. With a
|
|
822
|
+
config, a matching comparator decides the node, ahead of ``strict_types``, and a tolerance widens ``==``
|
|
823
|
+
for a pair of real numbers; either classifies the node ``"equal"`` or ``"leaf"``, never recursed into.
|
|
824
|
+
|
|
825
|
+
``"strict"`` is the fourth: the two sides are equal, but ``strict_types`` or a comparator still has to
|
|
826
|
+
look inside, because a container's ``==`` says nothing about the types of its members, nor about what a
|
|
827
|
+
comparator says of them. It differs from ``"recurse"`` only in what an undecomposable value means,
|
|
828
|
+
which `assertpy._engine._diff._Walk.descend()` is the single place to know.
|
|
829
|
+
|
|
830
|
+
``"unanswered"`` is a graph ``==`` ran out of stack on. It is walked as ``"recurse"`` is, and the walk is
|
|
831
|
+
then the only judge of the pair, where under ``"recurse"`` the pair is already known to differ.
|
|
832
|
+
"""
|
|
833
|
+
if config is not None:
|
|
834
|
+
if config.ignore_null and field is not None and as_held(expected) is None:
|
|
835
|
+
return "equal" # a named field the expected side leaves None is not compared
|
|
836
|
+
comparator = _resolve_comparator(actual, config, field=field)
|
|
837
|
+
if comparator is not None:
|
|
838
|
+
agreed = verdict(comparator(as_held(actual), as_held(expected)), subject="the comparator")
|
|
839
|
+
return "equal" if agreed else "leaf"
|
|
840
|
+
if config.strict_types:
|
|
841
|
+
if actual is expected and not at_root:
|
|
842
|
+
# identity, free from `PyObject_RichCompareBool`. Not at the root, where it made `strict_types` weaker
|
|
843
|
+
return "equal"
|
|
844
|
+
if _types_differ(actual, expected):
|
|
845
|
+
# ahead of tolerance: how far apart is not the same as may differ in type
|
|
846
|
+
return "leaf"
|
|
847
|
+
if type(actual) not in _EQ_ATOMIC and _walked_equal(actual, expected):
|
|
848
|
+
# `[True] == [1]`: a container says nothing about the types inside it, so the walk keeps going.
|
|
849
|
+
# Its keys first: the walk descends keys into values, so `True` and `1` keys present the same
|
|
850
|
+
# values. Asked of an equal pair alone: two that differ may differ only in a key left out.
|
|
851
|
+
# Not where a path leaves keys of this pair out: the walk asks the types of the keys it keeps
|
|
852
|
+
return "leaf" if not keys_left_out and _keyed_types_differ(actual, expected) else "strict"
|
|
853
|
+
if (config.tolerance is not None or config.rel is not None) and (
|
|
854
|
+
numpy_duration(as_held(actual)) and numpy_duration(as_held(expected))
|
|
855
|
+
if config.duration
|
|
856
|
+
else _is_real_number(as_held(actual)) and _is_real_number(as_held(expected))
|
|
857
|
+
):
|
|
858
|
+
return _tolerance_decision(actual, expected, config, at_root=at_root)
|
|
859
|
+
if (
|
|
860
|
+
config.comparators
|
|
861
|
+
and actual is not expected
|
|
862
|
+
and type(actual) not in _EQ_ATOMIC
|
|
863
|
+
and _walked_equal(actual, expected)
|
|
864
|
+
):
|
|
865
|
+
# a container's `==` says nothing of what a comparator says of the leaves inside it, so the walk goes on
|
|
866
|
+
return "strict"
|
|
867
|
+
return _plain_decision(actual, expected, config, at_root=at_root)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def held_equal_decision(actual, expected, config: _CompareConfig, *, field) -> str:
|
|
871
|
+
"""`_node_decision` for a field of two objects their own ``==`` holds equal, where ``"equal"`` is also not judged.
|
|
872
|
+
|
|
873
|
+
That ``==`` has judged what the two hold, so a field ``==`` holds apart is not the walk's to fail: it is one
|
|
874
|
+
the object leaves out of its ``==`` or reads another way, and which of the two cannot be told. A field
|
|
875
|
+
``==`` holds equal is asked what the options add to that: a comparator that owns it, under ``strict_types``
|
|
876
|
+
its type, and either of what it holds. A field ``==`` cannot answer for, a graph it runs out of stack
|
|
877
|
+
on, is ``"unanswered"`` here as anywhere, and the walk is its judge.
|
|
878
|
+
"""
|
|
879
|
+
if actual is not expected and _walked_equal(actual, expected) is False:
|
|
880
|
+
return "equal"
|
|
881
|
+
decision = _node_decision(actual, expected, config, field=field)
|
|
882
|
+
return decision if decision in ("leaf", "strict", "unanswered") else "equal"
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _tolerance_decision(actual, expected, config: _CompareConfig, *, at_root: bool) -> str:
|
|
886
|
+
"""A pair of real numbers under a tolerance, which only widens ``==``: equal if ``==`` says so, else measured.
|
|
887
|
+
|
|
888
|
+
Identity counts as ``==`` below the root, as it does inside a container, so the very NaN both sides hold is
|
|
889
|
+
equal in a dict as in a list, and a root NaN still is not.
|
|
890
|
+
"""
|
|
891
|
+
if (actual is expected and not at_root) or _guarded_equal(actual, expected):
|
|
892
|
+
return "equal"
|
|
893
|
+
try:
|
|
894
|
+
within = within_either(as_held(actual), as_held(expected), config.tolerance, config.rel)
|
|
895
|
+
except UnorderableError:
|
|
896
|
+
return _plain_decision(actual, expected, config, at_root=at_root)
|
|
897
|
+
return "equal" if within else "leaf"
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _plain_decision(actual, expected, config: _CompareConfig | None, *, at_root: bool = False) -> str:
|
|
901
|
+
"""``==``'s answer as a decision, where under a config a pair ruled out by its kind alone is a leaf."""
|
|
902
|
+
if actual is expected and not at_root:
|
|
903
|
+
# the same rule a container's own `==` applies to its members, and the verdict came from that `==`:
|
|
904
|
+
# without it the diff listed a NaN both sides hold as differing, and the hint blamed it
|
|
905
|
+
return "equal"
|
|
906
|
+
equal = _walked_equal(actual, expected)
|
|
907
|
+
if equal:
|
|
908
|
+
return "equal"
|
|
909
|
+
if equal is None:
|
|
910
|
+
return "unanswered"
|
|
911
|
+
return "leaf" if config is not None and _kinds_never_equal(actual, expected) else "recurse"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _walked_equal(actual, expected) -> bool | None:
|
|
915
|
+
"""`_guarded_equal` for a walk, which has its own answer where ``==`` has none: ``None`` for a graph ``==``
|
|
916
|
+
runs out of stack on, which the walk then enters, counting a pair it meets again as equal.
|
|
917
|
+
|
|
918
|
+
It asks ``==`` itself: a walk asks this of every pair, and asked through `_guarded_equal` the frame between
|
|
919
|
+
cost 11 ns a pair, a tenth of a failing comparison of two lists of 500.
|
|
920
|
+
"""
|
|
921
|
+
try:
|
|
922
|
+
try:
|
|
923
|
+
equal = actual == expected
|
|
924
|
+
if type(equal) is bool or type(actual) is type(expected):
|
|
925
|
+
return bool(equal)
|
|
926
|
+
return not broadcasts(actual, expected, answer=equal) and bool(equal)
|
|
927
|
+
except REFUSALS as refusal:
|
|
928
|
+
return _equal_past_refusal(actual, expected, refusal, "is_equal_to")
|
|
929
|
+
except RecursionError:
|
|
930
|
+
return None
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _spec_matches(key, value, specs) -> bool:
|
|
934
|
+
"""Return whether a dict ``key``/``value`` matches any ignore/include ``spec``.
|
|
935
|
+
|
|
936
|
+
A spec matches by exact key equality (today's behavior), by a compiled `re.Pattern` searched
|
|
937
|
+
against ``str(key)``, or by a ``type`` the ``value`` is an instance of. Nested-path tuples never match
|
|
938
|
+
here - they are expanded by the recursion in `HelpersMixin._dict_not_equal()`.
|
|
939
|
+
"""
|
|
940
|
+
for spec in specs:
|
|
941
|
+
if isinstance(spec, re.Pattern):
|
|
942
|
+
if spec.search(str(key)):
|
|
943
|
+
return True
|
|
944
|
+
elif isinstance(spec, type):
|
|
945
|
+
if isinstance(value, spec):
|
|
946
|
+
return True
|
|
947
|
+
else:
|
|
948
|
+
try:
|
|
949
|
+
if spec == key:
|
|
950
|
+
return True
|
|
951
|
+
except REFUSALS as refusal:
|
|
952
|
+
if equal_past(spec, key, refusal):
|
|
953
|
+
return True
|
|
954
|
+
return False
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _config_note(config: _CompareConfig | None) -> str:
|
|
958
|
+
"""A newline plus an echo of the comparison settings that were in force, or ``""`` when none were.
|
|
959
|
+
|
|
960
|
+
``is_equal_to`` already names ``ignore`` and ``include`` inside its sentence, but nothing reports
|
|
961
|
+
the rest, and they are what a reader is questioning when a field they thought was tolerated still
|
|
962
|
+
failed. Rendered only for a non-default config: `_build_compare_config()` returns ``None`` when
|
|
963
|
+
every setting is at its default, so the check costs nothing and the line never appears on the
|
|
964
|
+
ordinary failure.
|
|
965
|
+
|
|
966
|
+
It goes on its own line rather than into the sentence, so the original message stays a prefix and
|
|
967
|
+
a `match=` or ``startswith`` written against it keeps working.
|
|
968
|
+
"""
|
|
969
|
+
if config is None:
|
|
970
|
+
return ""
|
|
971
|
+
parts = []
|
|
972
|
+
if config.tolerance is not None:
|
|
973
|
+
parts.append(f"tolerance={config.tolerance!r}")
|
|
974
|
+
if config.rel is not None:
|
|
975
|
+
parts.append(f"rel={config.rel!r}")
|
|
976
|
+
if config.comparators:
|
|
977
|
+
keys = ", ".join(sorted(key.__name__ if isinstance(key, type) else str(key) for key in config.comparators))
|
|
978
|
+
parts.append(f"comparators for {keys}")
|
|
979
|
+
if config.ignore_null:
|
|
980
|
+
parts.append("ignore_null=True")
|
|
981
|
+
if config.strict_types:
|
|
982
|
+
parts.append("strict_types=True")
|
|
983
|
+
return "\ncompared with " + ", ".join(parts) if parts else ""
|