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.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. 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 ""