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
assertpy/helpers.py
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import collections.abc
|
|
3
|
+
import datetime
|
|
4
|
+
import decimal
|
|
5
|
+
import numbers
|
|
6
|
+
from typing import Any, Final, cast
|
|
7
|
+
|
|
8
|
+
from assertpy.errors import (
|
|
9
|
+
DiffResult,
|
|
10
|
+
_capped,
|
|
11
|
+
_capped_format,
|
|
12
|
+
_capped_repr,
|
|
13
|
+
_ends_kept,
|
|
14
|
+
_safe_repr,
|
|
15
|
+
_truncated,
|
|
16
|
+
_windowed,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from ._engine._compare import (
|
|
20
|
+
_CompareConfig,
|
|
21
|
+
_config_note,
|
|
22
|
+
_is_nan,
|
|
23
|
+
_node_decision,
|
|
24
|
+
_spec_matches,
|
|
25
|
+
_walked_equal,
|
|
26
|
+
require_rel,
|
|
27
|
+
zero_of,
|
|
28
|
+
)
|
|
29
|
+
from ._engine._diff import _aligned_match_indices, _graphs_differ, _sub_diff_entries, readable, run_nested
|
|
30
|
+
from ._engine._equality import (
|
|
31
|
+
IncludeKeysMissingError,
|
|
32
|
+
as_fields,
|
|
33
|
+
carries_callable,
|
|
34
|
+
comparable_fields,
|
|
35
|
+
fields_pair,
|
|
36
|
+
ignore_specs,
|
|
37
|
+
include_specs,
|
|
38
|
+
key_specs_given,
|
|
39
|
+
mapping_differs,
|
|
40
|
+
mapping_shaped,
|
|
41
|
+
normalize_key_specs,
|
|
42
|
+
supports_subscript,
|
|
43
|
+
)
|
|
44
|
+
from ._engine._introspection import (
|
|
45
|
+
MappingLike,
|
|
46
|
+
TakenApart,
|
|
47
|
+
class_name,
|
|
48
|
+
is_namedtuple,
|
|
49
|
+
keyed_names,
|
|
50
|
+
keyed_pair,
|
|
51
|
+
keyed_snapshot,
|
|
52
|
+
)
|
|
53
|
+
from ._engine._mixin_base import _MixinBase
|
|
54
|
+
from ._engine._ordering import (
|
|
55
|
+
UnorderableError,
|
|
56
|
+
holds,
|
|
57
|
+
lookup,
|
|
58
|
+
nan_operand,
|
|
59
|
+
numpy_duration,
|
|
60
|
+
python_numbers,
|
|
61
|
+
rational_overflow,
|
|
62
|
+
)
|
|
63
|
+
from ._engine._path import _ROOT
|
|
64
|
+
from ._engine._require import _shown, argument, raised_inside, refuse, require_type
|
|
65
|
+
|
|
66
|
+
__tracebackhide__ = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _both_list_like(left: object, right: object) -> bool:
|
|
70
|
+
"""Whether both values are plain sequences, so they compare element-by-element.
|
|
71
|
+
|
|
72
|
+
Namedtuples are excluded on purpose: they carry field names and are compared field-wise.
|
|
73
|
+
"""
|
|
74
|
+
return (
|
|
75
|
+
isinstance(left, (list, tuple))
|
|
76
|
+
and isinstance(right, (list, tuple))
|
|
77
|
+
and not is_namedtuple(left)
|
|
78
|
+
and not is_namedtuple(right)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Elided:
|
|
83
|
+
"""Marker standing in a parts list for a run of elements equal to their counterpart.
|
|
84
|
+
|
|
85
|
+
Handed to the join in its place among the parts rather than as one flag for the whole value, because
|
|
86
|
+
such a flag can only put the ``..`` in front: a value differing from its counterpart by one extra
|
|
87
|
+
leading element then printed as ``[.., 0]``, which is the shape of a changed *tail*.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
__slots__ = ()
|
|
91
|
+
|
|
92
|
+
def __repr__(self) -> str:
|
|
93
|
+
return ".."
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_ELIDED = _Elided()
|
|
97
|
+
_Part = str | _Elided
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _joined_parts(parts: list[_Part], *, opener: str = "", closer: str = "") -> str:
|
|
101
|
+
"""Assemble a collapsed repr, capping how many differing parts are spelled out.
|
|
102
|
+
|
|
103
|
+
Collapsing only removes what matched, so a value where nearly everything differs still prints in
|
|
104
|
+
full. The cap is what keeps that case from becoming a wall of text on one line. `_ELIDED` entries
|
|
105
|
+
mark where the matched runs were and never count against the cap. Past the cap the count stands
|
|
106
|
+
for everything that follows, so no marker is kept after it and none is left just before it.
|
|
107
|
+
"""
|
|
108
|
+
kept: list[_Part] = []
|
|
109
|
+
spelled = hidden = 0
|
|
110
|
+
for part in parts:
|
|
111
|
+
if part is _ELIDED:
|
|
112
|
+
if not hidden:
|
|
113
|
+
kept.append(part)
|
|
114
|
+
continue
|
|
115
|
+
spelled += 1
|
|
116
|
+
if spelled > 5:
|
|
117
|
+
hidden += 1
|
|
118
|
+
else:
|
|
119
|
+
kept.append(part)
|
|
120
|
+
if hidden:
|
|
121
|
+
if kept[-1] is _ELIDED:
|
|
122
|
+
kept.pop()
|
|
123
|
+
kept.append(f"... and {hidden} more")
|
|
124
|
+
return f"{opener}{', '.join(str(part) for part in kept)}{closer}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _elided_text_repr(text: str, counterpart: str) -> str:
|
|
128
|
+
"""Collapse lines equal to their counterpart into ``..`` so only the changed ones are printed.
|
|
129
|
+
|
|
130
|
+
Mirrors `_elided_seq_repr` for multi-line values: a one-line change in a long block should not put
|
|
131
|
+
the whole block into the message twice.
|
|
132
|
+
"""
|
|
133
|
+
# the cost of a multi-line value is vertical, and the message prints it twice, so this counts rows
|
|
134
|
+
if len(text.splitlines()) <= 3:
|
|
135
|
+
# a long line has no rows to collapse, and capping from the start printed 96% of a five-kilobyte
|
|
136
|
+
# string with none of it near the change
|
|
137
|
+
return _windowed(text, counterpart, width=320)[0] if len(text) > 320 else text
|
|
138
|
+
other_lines = counterpart.splitlines()
|
|
139
|
+
parts: list[_Part] = []
|
|
140
|
+
pending = False
|
|
141
|
+
for index, line in enumerate(text.splitlines()):
|
|
142
|
+
if index < len(other_lines) and line == other_lines[index]:
|
|
143
|
+
pending = True
|
|
144
|
+
continue
|
|
145
|
+
if pending:
|
|
146
|
+
parts.append(_ELIDED)
|
|
147
|
+
pending = False
|
|
148
|
+
parts.append(f"line {index + 1}: {line}")
|
|
149
|
+
if pending:
|
|
150
|
+
parts.append(_ELIDED)
|
|
151
|
+
return _joined_parts(parts)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _elided_seq_repr(seq, counterpart) -> str:
|
|
155
|
+
"""Collapse elements equal to their counterpart into ``..`` so only the differing ones are printed.
|
|
156
|
+
|
|
157
|
+
A one-element change in a forty-element list reads as ``[.., 999, ..]`` instead of dumping the list
|
|
158
|
+
twice into a message the reader then has to diff by eye.
|
|
159
|
+
|
|
160
|
+
Matched elements are found by the alignment the diff uses
|
|
161
|
+
(`assertpy._engine._diff._aligned_match_indices()`), so a shifted sequence collapses in the message
|
|
162
|
+
the same way it collapses in the diff. Position is the fallback, for the pairs no alignment
|
|
163
|
+
improves on.
|
|
164
|
+
"""
|
|
165
|
+
items, others = readable(seq), readable(counterpart)
|
|
166
|
+
# past 20 elements the rendering is over budget, so the value is never rendered just to be measured
|
|
167
|
+
if len(items) <= 20:
|
|
168
|
+
rendered = _safe_repr(seq)
|
|
169
|
+
if len(rendered) <= 60:
|
|
170
|
+
# on a two-element list the ".." form is the longer of the two
|
|
171
|
+
return rendered
|
|
172
|
+
aligned = _aligned_match_indices(items, others)
|
|
173
|
+
parts: list[_Part] = []
|
|
174
|
+
pending = False
|
|
175
|
+
for index, value in enumerate(items):
|
|
176
|
+
if aligned is not None:
|
|
177
|
+
matched = index in aligned
|
|
178
|
+
else:
|
|
179
|
+
matched = index < len(others) and (
|
|
180
|
+
(equal := _walked_equal(value, others[index]))
|
|
181
|
+
or (equal is None and not _graphs_differ(value, others[index]))
|
|
182
|
+
)
|
|
183
|
+
if matched:
|
|
184
|
+
pending = True
|
|
185
|
+
continue
|
|
186
|
+
if pending:
|
|
187
|
+
parts.append(_ELIDED)
|
|
188
|
+
pending = False
|
|
189
|
+
parts.append(_safe_repr(value))
|
|
190
|
+
if pending:
|
|
191
|
+
parts.append(_ELIDED)
|
|
192
|
+
if isinstance(seq, tuple):
|
|
193
|
+
# a tuple of one takes its comma, or the text reads as that one element in brackets
|
|
194
|
+
return _joined_parts(parts, opener="(", closer=",)" if len(items) == 1 else ")")
|
|
195
|
+
return _joined_parts(parts, opener="[", closer="]")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _spelling(mapping: object) -> tuple[str, str, collections.abc.Callable[[object, str], str]]:
|
|
199
|
+
"""How a mapping is written in a message: what opens it, what closes it, and one part of it.
|
|
200
|
+
|
|
201
|
+
A dict is written as a dict. A value taken apart is written as the class it was read from, as its own
|
|
202
|
+
repr writes it: ``Row(name='a')`` for a record, ``Kept({'x': 1})`` for a mapping of a class of its own.
|
|
203
|
+
"""
|
|
204
|
+
if type(mapping) is not TakenApart:
|
|
205
|
+
return "{", "}", _entry_part
|
|
206
|
+
name = class_name(mapping.kind)
|
|
207
|
+
return (f"{name}({{", "})", _entry_part) if mapping.keyed else (f"{name}(", ")", _field_part)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _entry_part(key: object, text: str) -> str:
|
|
211
|
+
return f"{_capped_repr(key)}: {text}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _field_part(key: object, text: str) -> str:
|
|
215
|
+
return f"{_capped(key)}={text}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _read_by_fields(value: object) -> object:
|
|
219
|
+
"""*value* as the mapping a key path reads it through, a snapshot of its keys or its fields, or ``None``."""
|
|
220
|
+
return keyed_snapshot(value) if mapping_shaped(value, check_values=False) else comparable_fields(value)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _keyed_pair(value: object, other: object) -> tuple[MappingLike, MappingLike] | None:
|
|
224
|
+
"""Both sides as something safe to walk by key, or `None` when either cannot be walked that way.
|
|
225
|
+
|
|
226
|
+
Asked at every descent a message walks, not once at the top: a value that answers `keys` and cannot
|
|
227
|
+
be indexed by what it yields can sit nested inside a pair whose outer halves are ordinary dicts.
|
|
228
|
+
"""
|
|
229
|
+
if not (mapping_shaped(value, check_values=False) and mapping_shaped(other, check_values=False)):
|
|
230
|
+
return None
|
|
231
|
+
kept, kept_other = keyed_snapshot(value), keyed_snapshot(other)
|
|
232
|
+
return None if kept is None or kept_other is None else (kept, kept_other)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _informative(val: object, other: object, val_repr: str, other_repr: str, *, whole: bool) -> tuple[str, str]:
|
|
236
|
+
"""The two shape-aware renderings, or plain reprs when the walk rendered both sides the same.
|
|
237
|
+
|
|
238
|
+
A value answering `keys()` and iterating empty, which is what a `MagicMock` does, made an unequal
|
|
239
|
+
pair read `<{}>` against `<{}>`, and at the top level the diff was empty too, so the failure said
|
|
240
|
+
nothing at all. The shape guess is given up there the way `_dict_err` already gives it up for a
|
|
241
|
+
value it cannot walk by key.
|
|
242
|
+
|
|
243
|
+
Only when the halves rendered were the *whole* values. Under `ignore`, `include` or a compare
|
|
244
|
+
config they are a projection, and printing the originals put an ignored key back into the message:
|
|
245
|
+
two values differing in a leaf whose repr is the same on both sides render alike, and the fallback
|
|
246
|
+
then printed the key the caller had asked to keep out of the log.
|
|
247
|
+
|
|
248
|
+
Compared before truncating and truncated after, since two values agreeing for the first 4000
|
|
249
|
+
characters collide once cut, and the untruncated fallback then printed both in full.
|
|
250
|
+
"""
|
|
251
|
+
if whole and val_repr == other_repr:
|
|
252
|
+
return _truncated(_safe_repr(val)), _truncated(_safe_repr(other))
|
|
253
|
+
return _truncated(val_repr), _truncated(other_repr)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _zero_for(val, other, tolerance):
|
|
257
|
+
"""The zero *tolerance* is signed against, once *other* and it are numbers of *val*'s measure.
|
|
258
|
+
|
|
259
|
+
A bool is refused, and so is a `numpy` duration measured against a number, either way round, since `numpy`
|
|
260
|
+
registers one as an integer. Asked of three plain numbers too, the duration check cost `is_close_to` 20%.
|
|
261
|
+
"""
|
|
262
|
+
if isinstance(other, bool):
|
|
263
|
+
refuse(other, "a number other than a bool", subject=argument("other"))
|
|
264
|
+
require_type(other, numbers.Number, "a number", subject=argument("other"))
|
|
265
|
+
if isinstance(tolerance, bool):
|
|
266
|
+
refuse(tolerance, "a number other than a bool", subject=argument("tolerance"))
|
|
267
|
+
require_type(tolerance, numbers.Number, "a number", subject=argument("tolerance"))
|
|
268
|
+
duration = numpy_duration(val)
|
|
269
|
+
for operand, name in ((other, "other"), (tolerance, "tolerance")):
|
|
270
|
+
if numpy_duration(operand) != duration:
|
|
271
|
+
kind = "a numpy timedelta64" if duration else "a number"
|
|
272
|
+
refuse(operand, f"{kind}, to match val", subject=argument(name))
|
|
273
|
+
return zero_of(tolerance)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _swapped_as_ordered(low, high, refusal: Exception) -> bool:
|
|
277
|
+
"""Whether the bounds are the wrong way round as the ordering engine reads them, once ``>`` raised *refusal*.
|
|
278
|
+
|
|
279
|
+
A `Decimal` NaN signals at ``>``, where the engine reads it as unordered. Bounds with no ordering between
|
|
280
|
+
them are refused here, under the name of the bound the engine stopped at: left to `_within`, a value below
|
|
281
|
+
the low bound failed before the high one was ever asked. A `TypeError` or an overflow raised inside a
|
|
282
|
+
comparison of the value's own is a bug in the value and is handed on, unless `rational_overflow` names it,
|
|
283
|
+
and so is a signal with no NaN among the bounds, which the operands decide as the engine does, since the
|
|
284
|
+
traceback's depth differs between the C and the pure-Python `decimal`. A signal is a verdict only where the
|
|
285
|
+
engine reads the pair as a NaN it can answer.
|
|
286
|
+
"""
|
|
287
|
+
if isinstance(refusal, (TypeError, OverflowError)):
|
|
288
|
+
handed_on = raised_inside(refusal) and not rational_overflow(refusal)
|
|
289
|
+
else:
|
|
290
|
+
handed_on = not (nan_operand(low) or nan_operand(high))
|
|
291
|
+
if handed_on:
|
|
292
|
+
raise refusal
|
|
293
|
+
try:
|
|
294
|
+
return holds(low, high, "gt")
|
|
295
|
+
except UnorderableError as unordered:
|
|
296
|
+
failure = unordered
|
|
297
|
+
if isinstance(refusal, decimal.InvalidOperation):
|
|
298
|
+
raise refusal
|
|
299
|
+
if failure.kind == "value":
|
|
300
|
+
refuse(low, "a number", subject=argument("low"))
|
|
301
|
+
if failure.kind == "kind":
|
|
302
|
+
refuse(high, "a number", subject=argument("high"))
|
|
303
|
+
refuse(high, f"comparable with given low arg {_shown(low)}", subject=argument("high"))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_ElidedWalk = collections.abc.Callable[[Any, Any], collections.abc.Generator[Any, Any, str]]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _elided_walks(config: _CompareConfig | None) -> tuple[_ElidedWalk, _ElidedWalk]:
|
|
310
|
+
"""The two walks that render a pair with what matched left out: one of two mappings, one of two sequences.
|
|
311
|
+
|
|
312
|
+
Both are walks for `run_nested()`, which sends each nested rendering back. Made as a pair, for one failure:
|
|
313
|
+
the two share the containers on the path, which is where a value that holds itself stops.
|
|
314
|
+
"""
|
|
315
|
+
on_path: set[int] = set()
|
|
316
|
+
|
|
317
|
+
def _dict_repr(mapping, counterpart):
|
|
318
|
+
opener, closer, part_of = _spelling(mapping)
|
|
319
|
+
if id(mapping) in on_path:
|
|
320
|
+
return f"{opener}<circular ref>{closer}"
|
|
321
|
+
on_path.add(id(mapping))
|
|
322
|
+
keyed_fields = keyed_names(mapping, counterpart)
|
|
323
|
+
parts: list[_Part] = []
|
|
324
|
+
pending = False
|
|
325
|
+
# left in the mapping's order, which the diff prints: sorting here made the two halves disagree
|
|
326
|
+
for key, value in ((key, mapping[key]) for key in mapping):
|
|
327
|
+
found, other_value = lookup(counterpart, key)
|
|
328
|
+
if not found:
|
|
329
|
+
part = part_of(key, _safe_repr(value))
|
|
330
|
+
else:
|
|
331
|
+
decision = (
|
|
332
|
+
_node_decision(*keyed_pair(mapping, counterpart, key), config, field=key)
|
|
333
|
+
if key in keyed_fields
|
|
334
|
+
else _node_decision(value, other_value, config, field=key)
|
|
335
|
+
)
|
|
336
|
+
if decision == "equal":
|
|
337
|
+
pending = True
|
|
338
|
+
continue
|
|
339
|
+
if decision == "leaf":
|
|
340
|
+
part = part_of(key, _safe_repr(value))
|
|
341
|
+
else: # recurse
|
|
342
|
+
if (keyed := _keyed_pair(value, other_value)) is not None:
|
|
343
|
+
value_repr = yield _dict_repr(*keyed)
|
|
344
|
+
elif _both_list_like(value, other_value):
|
|
345
|
+
value_repr = yield _list_repr(value, other_value)
|
|
346
|
+
else:
|
|
347
|
+
value_repr = _safe_repr(value)
|
|
348
|
+
part = part_of(key, value_repr)
|
|
349
|
+
if pending:
|
|
350
|
+
parts.append(_ELIDED)
|
|
351
|
+
pending = False
|
|
352
|
+
parts.append(part)
|
|
353
|
+
if pending:
|
|
354
|
+
parts.append(_ELIDED)
|
|
355
|
+
on_path.discard(id(mapping))
|
|
356
|
+
return _joined_parts(parts, opener=opener, closer=closer)
|
|
357
|
+
|
|
358
|
+
def _list_repr(seq, counterpart):
|
|
359
|
+
"""List counterpart of ``_dict_repr``: collapse equal elements to ``..`` and drill only into
|
|
360
|
+
the differing ones, so a one-element change in a long list reads as ``[.., {.., 'v': 'y'}, ..]``
|
|
361
|
+
instead of dumping the whole list. Both are walks for `run_nested()`, which sends back each nested
|
|
362
|
+
repr."""
|
|
363
|
+
if id(seq) in on_path:
|
|
364
|
+
return "[<circular ref>]"
|
|
365
|
+
on_path.add(id(seq))
|
|
366
|
+
parts: list[_Part] = []
|
|
367
|
+
pending = False
|
|
368
|
+
for index, value in enumerate(seq):
|
|
369
|
+
if index >= len(counterpart):
|
|
370
|
+
part = _safe_repr(value) # extra element beyond the counterpart's length
|
|
371
|
+
else:
|
|
372
|
+
other_value = counterpart[index]
|
|
373
|
+
decision = _node_decision(value, other_value, config, field=None)
|
|
374
|
+
if decision == "equal":
|
|
375
|
+
pending = True
|
|
376
|
+
continue
|
|
377
|
+
if decision == "leaf":
|
|
378
|
+
part = _safe_repr(value)
|
|
379
|
+
elif (keyed := _keyed_pair(value, other_value)) is not None:
|
|
380
|
+
part = yield _dict_repr(*keyed)
|
|
381
|
+
elif _both_list_like(value, other_value):
|
|
382
|
+
part = yield _list_repr(value, other_value)
|
|
383
|
+
else:
|
|
384
|
+
part = _safe_repr(value)
|
|
385
|
+
if pending:
|
|
386
|
+
parts.append(_ELIDED)
|
|
387
|
+
pending = False
|
|
388
|
+
parts.append(part)
|
|
389
|
+
if pending:
|
|
390
|
+
parts.append(_ELIDED)
|
|
391
|
+
on_path.discard(id(seq))
|
|
392
|
+
# keep tuples looking like tuples, a tuple of one with its comma
|
|
393
|
+
opener, closer = ("(", ",)" if len(seq) == 1 else ")") if isinstance(seq, tuple) else ("[", "]")
|
|
394
|
+
return _joined_parts(parts, opener=opener, closer=closer)
|
|
395
|
+
|
|
396
|
+
return _dict_repr, _list_repr
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# built once: a passing `is_close_to` on datetimes paid 76 ns for the check with it built each time, 29 ns kept
|
|
400
|
+
_NO_SPAN: Final = datetime.timedelta()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _validate_moments_close(other, tolerance, rel) -> None:
|
|
404
|
+
"""The arguments of a closeness of two datetimes: a datetime and a timedelta, and no `rel`."""
|
|
405
|
+
require_type(other, datetime.datetime, "a datetime, to match val", subject=argument("other"))
|
|
406
|
+
if rel is not None:
|
|
407
|
+
refuse(rel, "None for a datetime, which has no size to take a fraction of", subject=argument("rel"))
|
|
408
|
+
span = require_type(tolerance, datetime.timedelta, "a timedelta, to match val", subject=argument("tolerance"))
|
|
409
|
+
if datetime.timedelta.__lt__(span, _NO_SPAN):
|
|
410
|
+
raise ValueError("given tolerance arg must not be negative")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _validate_numbers_close(val, other, tolerance, rel) -> None:
|
|
414
|
+
"""The arguments of a closeness of two numbers: a tolerance, a `rel` or both, neither negative nor NaN."""
|
|
415
|
+
if tolerance is None and rel is None:
|
|
416
|
+
refuse(tolerance, "a number, or a rel given instead", subject=argument("tolerance"))
|
|
417
|
+
if rel is not None:
|
|
418
|
+
require_rel(rel)
|
|
419
|
+
if numpy_duration(val):
|
|
420
|
+
refuse(rel, "None for a numpy duration, which has no size to take a fraction of", subject=argument("rel"))
|
|
421
|
+
if tolerance is not None:
|
|
422
|
+
plain = type(val) in (int, float) and type(other) in (int, float) and type(tolerance) in (int, float)
|
|
423
|
+
zero = 0 if plain else _zero_for(val, other, tolerance)
|
|
424
|
+
if _is_nan(tolerance):
|
|
425
|
+
raise ValueError("given tolerance arg must not be NaN")
|
|
426
|
+
if tolerance < zero:
|
|
427
|
+
raise ValueError("given tolerance arg must not be negative")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class HelpersMixin(_MixinBase):
|
|
431
|
+
"""Helpers mixin. For internal use only."""
|
|
432
|
+
|
|
433
|
+
def _fmt_items(self, items):
|
|
434
|
+
"""Helper to format the given items."""
|
|
435
|
+
if len(items) == 0:
|
|
436
|
+
return "<>"
|
|
437
|
+
elif len(items) == 1 and hasattr(items, "__getitem__"):
|
|
438
|
+
return f"<{_capped_format(items[0])}>"
|
|
439
|
+
elif type(items) is tuple or type(items) is list:
|
|
440
|
+
try:
|
|
441
|
+
return f"<{_ends_kept(str.__str__(str(items))[1:-1])}>"
|
|
442
|
+
except Exception:
|
|
443
|
+
# one element at a time, so one bad `__repr__` spoils only itself
|
|
444
|
+
return f"<{_ends_kept(', '.join(map(_safe_repr, items)))}>"
|
|
445
|
+
return f"<{_capped(items)}>"
|
|
446
|
+
|
|
447
|
+
def _fmt_args_kwargs(self, *some_args, **some_kwargs):
|
|
448
|
+
"""Helper to convert the given args and kwargs into a string."""
|
|
449
|
+
out_args = out_kwargs = ""
|
|
450
|
+
if some_args:
|
|
451
|
+
out_args = str(some_args).lstrip("(").rstrip(",)")
|
|
452
|
+
if some_kwargs:
|
|
453
|
+
out_kwargs = ", ".join(
|
|
454
|
+
[
|
|
455
|
+
str(pair).lstrip("(").rstrip(")").replace(", ", ": ")
|
|
456
|
+
for pair in [(key, some_kwargs[key]) for key in sorted(some_kwargs.keys())]
|
|
457
|
+
]
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if some_args and some_kwargs:
|
|
461
|
+
return out_args + ", " + out_kwargs
|
|
462
|
+
elif some_args:
|
|
463
|
+
return out_args
|
|
464
|
+
elif some_kwargs:
|
|
465
|
+
return out_kwargs
|
|
466
|
+
else:
|
|
467
|
+
return ""
|
|
468
|
+
|
|
469
|
+
def _validate_between_args(self, val_type, low, high):
|
|
470
|
+
"""Helper to validate given range args."""
|
|
471
|
+
low_type = type(low)
|
|
472
|
+
high_type = type(high)
|
|
473
|
+
|
|
474
|
+
if val_type in self._NUMERIC_NON_COMPAREABLE:
|
|
475
|
+
refuse(self.val, "a value with an ordering (complex numbers have none)")
|
|
476
|
+
|
|
477
|
+
if val_type in self._NUMERIC_COMPAREABLE:
|
|
478
|
+
if low_type is not val_type:
|
|
479
|
+
refuse(low, f"a {val_type.__name__}, to match val", subject=argument("low"))
|
|
480
|
+
if high_type is not val_type:
|
|
481
|
+
refuse(high, f"a {val_type.__name__}, to match val", subject=argument("high"))
|
|
482
|
+
elif isinstance(self.val, numbers.Number):
|
|
483
|
+
require_type(low, numbers.Number, "a number", subject=argument("low"))
|
|
484
|
+
require_type(high, numbers.Number, "a number", subject=argument("high"))
|
|
485
|
+
else:
|
|
486
|
+
refuse(self.val, "a number or a date, which is what an ordering is defined for")
|
|
487
|
+
|
|
488
|
+
if low_type is not high_type:
|
|
489
|
+
low, high = python_numbers(low, high)
|
|
490
|
+
try:
|
|
491
|
+
swapped = low > high
|
|
492
|
+
except (TypeError, OverflowError, decimal.InvalidOperation) as refusal:
|
|
493
|
+
swapped = _swapped_as_ordered(low, high, refusal)
|
|
494
|
+
if swapped:
|
|
495
|
+
raise ValueError("given low arg must be less than given high arg")
|
|
496
|
+
|
|
497
|
+
def _validate_close_to_args(self, val, other, tolerance, rel=None):
|
|
498
|
+
"""Helper for validate given arg and delta."""
|
|
499
|
+
for operand in (val, other, tolerance):
|
|
500
|
+
if type(operand) is complex:
|
|
501
|
+
refuse(operand, "a value with an ordering (complex numbers have none)")
|
|
502
|
+
|
|
503
|
+
if isinstance(val, bool):
|
|
504
|
+
refuse(val, "a number other than a bool, or a datetime")
|
|
505
|
+
if not isinstance(val, (numbers.Number, datetime.datetime)):
|
|
506
|
+
refuse(val, "a number or a datetime")
|
|
507
|
+
|
|
508
|
+
if isinstance(val, datetime.datetime):
|
|
509
|
+
_validate_moments_close(other, tolerance, rel)
|
|
510
|
+
else:
|
|
511
|
+
_validate_numbers_close(val, other, tolerance, rel)
|
|
512
|
+
|
|
513
|
+
def _is_dict_like(self, candidate, check_keys=True, check_values=True, check_getitem=True):
|
|
514
|
+
"""Return whether *candidate* has the requested dict-like attributes."""
|
|
515
|
+
return mapping_shaped(candidate, check_keys=check_keys, check_values=check_values, check_getitem=check_getitem)
|
|
516
|
+
|
|
517
|
+
def _require_dict_like(self, candidate, check_keys=True, check_values=True, check_getitem=True, name="val"):
|
|
518
|
+
"""Raise ``TypeError`` unless *candidate* has the requested dict-like attributes.
|
|
519
|
+
|
|
520
|
+
The same reading as `mapping_shaped`, one check at a time so each refusal can name what is
|
|
521
|
+
missing. Reading it any other way here would accept a value the renderer then crashes on.
|
|
522
|
+
"""
|
|
523
|
+
if not isinstance(candidate, collections.abc.Iterable):
|
|
524
|
+
refuse(candidate, "dict-like (this one is not iterable)", subject=name)
|
|
525
|
+
if check_keys and not carries_callable(candidate, "keys"):
|
|
526
|
+
refuse(candidate, "dict-like (this one has no keys())", subject=name)
|
|
527
|
+
if check_values and not carries_callable(candidate, "values"):
|
|
528
|
+
refuse(candidate, "dict-like (this one has no values())", subject=name)
|
|
529
|
+
if check_getitem and not supports_subscript(candidate):
|
|
530
|
+
refuse(candidate, "dict-like (this one has no [] accessor)", subject=name)
|
|
531
|
+
|
|
532
|
+
def _check_iterable(self, val, check_getitem=True, name="val"):
|
|
533
|
+
"""Helper to check if given val is iterable with optional item access."""
|
|
534
|
+
if not isinstance(val, collections.abc.Iterable):
|
|
535
|
+
refuse(val, "iterable", subject=name)
|
|
536
|
+
if check_getitem and not supports_subscript(val):
|
|
537
|
+
refuse(val, "a value with a [] accessor", subject=name)
|
|
538
|
+
|
|
539
|
+
def _dict_not_equal(self, val, other, ignore=None, include=None, config: _CompareConfig | None = None):
|
|
540
|
+
"""Whether two dict-like values differ, under optional ignore/include specs and a compare config.
|
|
541
|
+
|
|
542
|
+
The decision lives in `_engine._equality`, where a matcher reaches it too. What stays here is
|
|
543
|
+
the one part that is not a comparison: an `include` naming a key the mapping does not have is a
|
|
544
|
+
mistake in the call, and it is reported as a failure in the builder's own wording. Reported as
|
|
545
|
+
a prerequisite, since a comparison of keys that are not there has no answer to invert, and
|
|
546
|
+
``not_.is_equal_to(other, include="typo")`` passed on it.
|
|
547
|
+
|
|
548
|
+
Returns ``None`` once that is reported rather than a verdict.
|
|
549
|
+
"""
|
|
550
|
+
try:
|
|
551
|
+
return mapping_differs(val, other, ignore=ignore, include=include, config=config)
|
|
552
|
+
except IncludeKeysMissingError as found:
|
|
553
|
+
absent = found
|
|
554
|
+
# reported outside the except block: a failure raised inside carries the signal as its `__context__`
|
|
555
|
+
keys_suffix = "" if len(absent.includes) == 1 else "s"
|
|
556
|
+
missing_suffix = "" if len(absent.missing) == 1 else "s"
|
|
557
|
+
includes_fmt = self._fmt_items(
|
|
558
|
+
[".".join([str(segment) for segment in key]) if type(key) is tuple else key for key in absent.includes]
|
|
559
|
+
)
|
|
560
|
+
self._unmet(
|
|
561
|
+
f"Expected <{_capped_format(absent.mapping)}> to include key{keys_suffix} {includes_fmt},"
|
|
562
|
+
f" but did not include key{missing_suffix} {self._fmt_items(absent.missing)}."
|
|
563
|
+
)
|
|
564
|
+
# reported: falsy, so no second failure follows it, and `None` so a caller walking items stops there
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
@staticmethod
|
|
568
|
+
def _normalize_key_specs(specs, param):
|
|
569
|
+
"""An ``ignore``/``include`` kwarg as a flat list of key-specs; see `_engine._equality`."""
|
|
570
|
+
return normalize_key_specs(specs, param)
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _dict_ignore(ignore):
|
|
574
|
+
"""Ignore-specs for one comparison; see `_engine._equality`."""
|
|
575
|
+
return ignore_specs(ignore)
|
|
576
|
+
|
|
577
|
+
@staticmethod
|
|
578
|
+
def _dict_include(include):
|
|
579
|
+
"""Include-specs for one comparison; see `_engine._equality`."""
|
|
580
|
+
return include_specs(include)
|
|
581
|
+
|
|
582
|
+
def _selected_keys_only(
|
|
583
|
+
self, mapping: object, ignore: object, include: object, held_as: type = dict, beside: object = None
|
|
584
|
+
) -> object:
|
|
585
|
+
"""A copy of ``mapping`` holding only the keys the comparison actually looked at.
|
|
586
|
+
|
|
587
|
+
`_dict_not_equal()` picks those keys to reach its verdict, and both the repr and the diff used
|
|
588
|
+
to be built from the unfiltered pair. A failure under ``include="b"`` then printed a diff
|
|
589
|
+
whose first entry was a key that had never been compared, and one under ``ignore="a"`` named
|
|
590
|
+
``a`` as a difference in the same breath as saying it was ignored.
|
|
591
|
+
|
|
592
|
+
Returns ``mapping`` itself when no filter is set, so the ordinary path allocates nothing.
|
|
593
|
+
|
|
594
|
+
*held_as* is the class of the value ``mapping`` was read from. The copy carries it, as a `TakenApart`,
|
|
595
|
+
where it is not a plain dict: copied as one, a dict of a class of its own lost the class ``strict_types``
|
|
596
|
+
had held it apart by, and the diff then had no entry where the comparison failed.
|
|
597
|
+
|
|
598
|
+
*beside* is the mapping this one was compared with. A value a key path goes on into is copied with its
|
|
599
|
+
keys left out only where the value beside it was read by its fields too: compared whole, it is kept whole,
|
|
600
|
+
or the copy differed from a counterpart the value itself equals.
|
|
601
|
+
"""
|
|
602
|
+
ignoring, including = key_specs_given(ignore), key_specs_given(include)
|
|
603
|
+
if not (ignoring or including):
|
|
604
|
+
return mapping
|
|
605
|
+
ignores = self._dict_ignore(ignore) if ignoring else []
|
|
606
|
+
includes = self._dict_include(include) if including else []
|
|
607
|
+
# an OrderedDict keeps its type and a TakenApart its class, both part of what was compared
|
|
608
|
+
if isinstance(mapping, TakenApart):
|
|
609
|
+
kept: dict = TakenApart(mapping.kind, {}, mapping.compared_by)
|
|
610
|
+
elif isinstance(mapping, collections.OrderedDict):
|
|
611
|
+
ordered = held_as if issubclass(held_as, collections.OrderedDict) else collections.OrderedDict
|
|
612
|
+
kept = collections.OrderedDict.__new__(ordered)
|
|
613
|
+
else:
|
|
614
|
+
kept = {} if held_as is dict else TakenApart(held_as, {})
|
|
615
|
+
for key in mapping: # ty: ignore[not-iterable] # only ever called on the dict-like branch
|
|
616
|
+
value = mapping[key] # ty: ignore[not-subscriptable] # same
|
|
617
|
+
if ignoring and _spec_matches(key, value, ignores):
|
|
618
|
+
continue
|
|
619
|
+
if including and not _spec_matches(key, value, includes):
|
|
620
|
+
continue
|
|
621
|
+
nested_ignore = [entry[1:] for entry in ignores if type(entry) is tuple and entry[0] == key] or None
|
|
622
|
+
nested_include = [
|
|
623
|
+
entry[1:] for entry in self._dict_ignore(include) if type(entry) is tuple and entry[0] == key
|
|
624
|
+
] or None
|
|
625
|
+
if nested_ignore or nested_include:
|
|
626
|
+
# the snapshot and not the value: recursing into the original would read it a third time
|
|
627
|
+
kept_value = _read_by_fields(value)
|
|
628
|
+
found, counterpart = (False, None) if beside is None else lookup(beside, key)
|
|
629
|
+
other_value = _read_by_fields(counterpart) if found else None
|
|
630
|
+
if kept_value is not None and (other_value is not None or not found):
|
|
631
|
+
value = self._selected_keys_only(
|
|
632
|
+
kept_value, nested_ignore, nested_include, type(value), other_value
|
|
633
|
+
)
|
|
634
|
+
kept[key] = value
|
|
635
|
+
return kept
|
|
636
|
+
|
|
637
|
+
def _failure_views(
|
|
638
|
+
self, val, other, ignore, include, config, dict_repr, list_repr
|
|
639
|
+
) -> tuple[str, list, str, str, tuple[object, object] | None]:
|
|
640
|
+
"""What a failed comparison shows, by the shape of the pair: the kind of diff, its entries, the two reprs,
|
|
641
|
+
and under a key option the two copies the diff is of, without the keys left out.
|
|
642
|
+
|
|
643
|
+
*dict_repr* and *list_repr* are `_dict_err`'s own walks, which render a pair with what matched left out.
|
|
644
|
+
"""
|
|
645
|
+
filtered = key_specs_given(ignore) or key_specs_given(include)
|
|
646
|
+
if (keyed := _keyed_pair(val, other)) is not None:
|
|
647
|
+
reported_val = self._selected_keys_only(keyed[0], ignore, include, beside=keyed[1])
|
|
648
|
+
reported_other = self._selected_keys_only(keyed[1], ignore, include, beside=keyed[0])
|
|
649
|
+
val_repr, other_repr = _informative(
|
|
650
|
+
val,
|
|
651
|
+
other,
|
|
652
|
+
run_nested(dict_repr(reported_val, reported_other)),
|
|
653
|
+
run_nested(dict_repr(reported_other, reported_val)),
|
|
654
|
+
# a compare config hides no key, so only a key filter keeps the whole values out of the message
|
|
655
|
+
whole=not filtered,
|
|
656
|
+
)
|
|
657
|
+
entries = _sub_diff_entries(reported_val, reported_other, _ROOT, config=config) or []
|
|
658
|
+
return "dict", entries, val_repr, other_repr, (reported_val, reported_other) if filtered else None
|
|
659
|
+
if filtered and _both_list_like(val, other):
|
|
660
|
+
actual_items, expected_items = cast("list | tuple", val), cast("list | tuple", other)
|
|
661
|
+
reported_val = self._selected_items_only(actual_items, expected_items, ignore, include)
|
|
662
|
+
reported_other = self._selected_items_only(expected_items, actual_items, ignore, include)
|
|
663
|
+
entries = _sub_diff_entries(reported_val, reported_other, _ROOT, config=config) or []
|
|
664
|
+
if _aligned_match_indices(reported_val, reported_other) is None:
|
|
665
|
+
val_repr = run_nested(list_repr(reported_val, reported_other))
|
|
666
|
+
other_repr = run_nested(list_repr(reported_other, reported_val))
|
|
667
|
+
else:
|
|
668
|
+
# two sequences that shifted apart are paired by alignment in the diff, so in the message too
|
|
669
|
+
val_repr = _elided_seq_repr(reported_val, reported_other)
|
|
670
|
+
other_repr = _elided_seq_repr(reported_other, reported_val)
|
|
671
|
+
return "sequence", entries, val_repr, other_repr, (reported_val, reported_other)
|
|
672
|
+
# the shape said keyed and the value is not, so the richer message is the thing given up here
|
|
673
|
+
return "scalar", [], _safe_repr(val), _safe_repr(other), None
|
|
674
|
+
|
|
675
|
+
def _selected_items_only(
|
|
676
|
+
self, items: list | tuple, others: list | tuple, ignore: object, include: object
|
|
677
|
+
) -> list | tuple:
|
|
678
|
+
"""A copy of a sequence whose elements compared by their fields hold only the keys that were looked at.
|
|
679
|
+
|
|
680
|
+
An element is compared by its fields where its counterpart in *others* has fields too, and whole where it
|
|
681
|
+
has none. Copied with keys left out all the same, a value its counterpart equals was shown as differing.
|
|
682
|
+
Two sequences of different lengths have no counterparts: no element of them was compared, and the diff
|
|
683
|
+
pairs them by alignment, so there every element with fields is copied without the keys left out.
|
|
684
|
+
"""
|
|
685
|
+
paired = len(items) == len(others)
|
|
686
|
+
kept = []
|
|
687
|
+
for index, item in enumerate(items):
|
|
688
|
+
fields, beside = fields_pair(item, others[index]) if paired else (as_fields(item), None)
|
|
689
|
+
if fields is None or (paired and beside is None):
|
|
690
|
+
kept.append(item)
|
|
691
|
+
else:
|
|
692
|
+
kept.append(self._selected_keys_only(fields, ignore, include, type(item), beside))
|
|
693
|
+
return tuple(kept) if isinstance(items, tuple) else kept
|
|
694
|
+
|
|
695
|
+
def _key_filter_note(self, ignore: object, include: object) -> str:
|
|
696
|
+
"""The ` ignoring keys ...` / ` including keys ...` tail of a dict failure, or an empty string."""
|
|
697
|
+
note = ""
|
|
698
|
+
for label, specs in (("ignoring", ignore), ("including", include)):
|
|
699
|
+
if key_specs_given(specs):
|
|
700
|
+
spelled = [
|
|
701
|
+
".".join([str(segment) for segment in entry]) if type(entry) is tuple else entry
|
|
702
|
+
for entry in self._dict_ignore(specs)
|
|
703
|
+
]
|
|
704
|
+
note += f" {label} keys {self._fmt_items(spelled)}"
|
|
705
|
+
return note
|
|
706
|
+
|
|
707
|
+
def _dict_err(
|
|
708
|
+
self,
|
|
709
|
+
val: object,
|
|
710
|
+
other: object,
|
|
711
|
+
ignore: object = None,
|
|
712
|
+
include: object = None,
|
|
713
|
+
config: _CompareConfig | None = None,
|
|
714
|
+
held: tuple[object, object] | None = None,
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Helper to construct error message for dict comparison, and for two sequences under a key option.
|
|
717
|
+
|
|
718
|
+
A compare ``config`` is routed through both the textual repr (a tolerated / comparator-equal leaf is
|
|
719
|
+
ellipsized, never shown) and the structured diff, so the message and diff agree on what differs.
|
|
720
|
+
``ignore`` / ``include`` are applied to both for the same reason.
|
|
721
|
+
|
|
722
|
+
*held* is what the failure hands back as ``actual`` and ``expected`` where that is not what was walked:
|
|
723
|
+
a dict taken apart to be read beside a record is a copy, and the failure holds the caller's own.
|
|
724
|
+
"""
|
|
725
|
+
|
|
726
|
+
_dict_repr, _list_repr = _elided_walks(config)
|
|
727
|
+
kind, diff_entries, val_repr, other_repr, copies = self._failure_views(
|
|
728
|
+
val, other, ignore, include, config, _dict_repr, _list_repr
|
|
729
|
+
)
|
|
730
|
+
# the comparison has failed, so where the walk shows nothing under the pair, the pair is the entry
|
|
731
|
+
diff = DiffResult(kind=kind, entries=diff_entries or [_ROOT.leaf_entry(actual=val, expected=other)])
|
|
732
|
+
actual, expected = held or (val, other)
|
|
733
|
+
message = (
|
|
734
|
+
f"Expected <{val_repr}> to be equal to <{other_repr}>{self._key_filter_note(ignore, include)}, but was not."
|
|
735
|
+
f"{_config_note(config)}"
|
|
736
|
+
)
|
|
737
|
+
# under a compare config `==` did not decide alone: a tolerated leaf differs in the two and held
|
|
738
|
+
self._compared = None if config is not None else copies or (actual, expected)
|
|
739
|
+
self.error(message, actual=actual, expected=expected, diff=diff)
|
|
740
|
+
|
|
741
|
+
@staticmethod
|
|
742
|
+
def _to_comparable_dict(obj):
|
|
743
|
+
"""Convert an object with introspectable fields to a dict for comparison; see `_engine._equality`.
|
|
744
|
+
|
|
745
|
+
Returns None if the object cannot be converted.
|
|
746
|
+
"""
|
|
747
|
+
return comparable_fields(obj)
|