assertpy 2.35.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
"""Recursive diff engine shared by the equality assertions and the dict error path.
|
|
2
|
+
|
|
3
|
+
Three walkers coexist on purpose and must not be merged: `_build_equality_diff()` dispatches a
|
|
4
|
+
top-level pair (top-level dicts are handled by `HelpersMixin._dict_err()` instead, so its ladder
|
|
5
|
+
starts at namedtuples), `_Walk.children()` decomposes nested values (mappings first), and
|
|
6
|
+
`_walk_leaves()` iterates scalar leaves for the recursive leaf assertions. Their dispatch orders
|
|
7
|
+
differ deliberately; a shared type classifier was investigated and rejected, because a single global
|
|
8
|
+
precedence changes behavior for values that quack like several container shapes at once.
|
|
9
|
+
The per-shape walks of `_Walk` are the pieces genuinely shared by the two diff ladders.
|
|
10
|
+
|
|
11
|
+
The three ladders are not the same width, and the reason is worth stating because it has already cost
|
|
12
|
+
two bugs. They answer different questions. `_build_equality_diff()` asks *how should a difference
|
|
13
|
+
here be shown*, so it carries steps that are renderers rather than decompositions: a set diffs by
|
|
14
|
+
membership, a string or bytes goes through ``difflib``. `_sub_diff_entries()` asks *does this value
|
|
15
|
+
break into path-addressed entries*, which a set does not, because its members have no stable position
|
|
16
|
+
to name. They agree on mappings and sequences and disagree at both ends: the top has sets, strings and
|
|
17
|
+
bytes that the nested walker refuses, and the nested one has mappings that the top never sees, since a
|
|
18
|
+
top-level dict is routed to `HelpersMixin._dict_err()` before it gets here.
|
|
19
|
+
|
|
20
|
+
So do **not** write a predicate that answers "will this decompose". Two attempts have been made and
|
|
21
|
+
both produced a false failure on values that were equal, because the predicate drifted from one ladder
|
|
22
|
+
or answered for the wrong one. Ask the walker and read its answer instead: ``None`` from
|
|
23
|
+
`_sub_diff_entries()`, or the ladder falling through to the scalar case in `_build_equality_diff()`.
|
|
24
|
+
`_Walk.descend()` is where that reading is interpreted.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import collections
|
|
30
|
+
import dataclasses
|
|
31
|
+
import decimal
|
|
32
|
+
import difflib
|
|
33
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
34
|
+
|
|
35
|
+
from ..errors import DiffEntry, DiffResult, _safe_repr
|
|
36
|
+
from ._compare import _EQ_ATOMIC, _node_decision, _walked_equal, held_equal_decision, of_one_atom, typed_apart
|
|
37
|
+
from ._introspection import (
|
|
38
|
+
TakenApart,
|
|
39
|
+
compares_by_parts,
|
|
40
|
+
is_attrs_instance,
|
|
41
|
+
is_mapping_like,
|
|
42
|
+
is_model_dump_object,
|
|
43
|
+
is_namedtuple,
|
|
44
|
+
keyed_names,
|
|
45
|
+
keyed_pair,
|
|
46
|
+
keyed_snapshot,
|
|
47
|
+
model_field_values,
|
|
48
|
+
own_fields,
|
|
49
|
+
)
|
|
50
|
+
from ._ordering import equals, held_key, lookup, member
|
|
51
|
+
from ._path import _ROOT, _Path
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from collections.abc import Callable, Generator, Hashable, Iterable, Iterator
|
|
55
|
+
|
|
56
|
+
from ._compare import _CompareConfig
|
|
57
|
+
|
|
58
|
+
_Owed = tuple[_Path, object, object, int]
|
|
59
|
+
"""A pair ``==`` held apart: where it is, its two sides, and how many entries there were before it."""
|
|
60
|
+
|
|
61
|
+
_Frame = tuple[Iterator[Any], tuple[int, int], _Owed | None]
|
|
62
|
+
"""A container's children still to walk, the ids of the pair it put on the path, and the pair if it is owed."""
|
|
63
|
+
|
|
64
|
+
_K = TypeVar("_K", bound="Hashable") # a mapping key or a field name, kept as itself through the walk
|
|
65
|
+
_T = TypeVar("_T")
|
|
66
|
+
|
|
67
|
+
__tracebackhide__ = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _field_dict(obj, is_model):
|
|
71
|
+
"""Field mapping of a pydantic-style model or an attrs instance (shallow), as the values its fields hold.
|
|
72
|
+
|
|
73
|
+
An attrs instance's carries the keys its fields are compared through, so the walk decides a field by the
|
|
74
|
+
key both sides declare and still shows the values held: ``str.lower`` would otherwise print a value
|
|
75
|
+
neither side has, and listed a field ``==`` holds equal.
|
|
76
|
+
"""
|
|
77
|
+
if is_model:
|
|
78
|
+
return model_field_values(obj)
|
|
79
|
+
compared = [field for field in obj.__attrs_attrs__ if field.eq is not False]
|
|
80
|
+
return TakenApart(
|
|
81
|
+
type(obj),
|
|
82
|
+
{field.name: getattr(obj, field.name) for field in compared},
|
|
83
|
+
{field.name: field.eq_key for field in compared if getattr(field, "eq_key", None) is not None},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _child_entries(actual, expected, path: _Path, *, descended_for, config=None, aligns=True) -> list[DiffEntry]:
|
|
88
|
+
"""The entries of one child, walked on its own by a caller outside a walk.
|
|
89
|
+
|
|
90
|
+
`_Walk.descend()` is where the walker's answer for the child is read, given why it was descended into.
|
|
91
|
+
"""
|
|
92
|
+
walk = _Walk(config, aligns=aligns)
|
|
93
|
+
frame = walk.descend(actual, expected, path, descended_for)
|
|
94
|
+
return walk.entries if frame is None else walk.run(*frame)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_ALIGN_MAX_ELEMENTS = 1000
|
|
98
|
+
"""Longest sequence `_alignment_opcodes()` will align.
|
|
99
|
+
|
|
100
|
+
difflib's search is quadratic, and the alignment buys nothing a reader of a thousand-element failure
|
|
101
|
+
was going to use anyway, so past this the diff stays positional: never wrong, only longer.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _alignment_opcodes(actual, expected):
|
|
106
|
+
"""difflib opcodes pairing two sequences, or ``None`` when only positions are available.
|
|
107
|
+
|
|
108
|
+
Alignment decides *which elements to pair*, never whether a pair is equal - that stays with
|
|
109
|
+
`_node_decision()`, which is what keeps a comparator, a tolerance and ``strict_types`` in charge of
|
|
110
|
+
the verdict no matter how the pairing was found.
|
|
111
|
+
|
|
112
|
+
Elements difflib cannot hash - dicts, lists, arrays, which is the shape of most API payloads - are
|
|
113
|
+
aligned on their reprs instead. A repr stands in for structural identity here, and standing in
|
|
114
|
+
badly costs only a worse pairing, not a wrong answer. ``autojunk`` is off because the heuristic
|
|
115
|
+
calls any value filling more than 1% of a 200+ element sequence junk, which is exactly the repeated
|
|
116
|
+
value an alignment has to match on.
|
|
117
|
+
|
|
118
|
+
`_rechecked_equal_runs()` is what makes the first paragraph true rather than merely intended, and
|
|
119
|
+
both branches go through it: neither of difflib's two notions of a match is this library's. The
|
|
120
|
+
repr keying matches values that print alike, and the hashable keying matches through a dict lookup,
|
|
121
|
+
while every verdict here is reached by `_node_decision()`.
|
|
122
|
+
|
|
123
|
+
The length cap lives in the caller, which reaches it before paying for anything here.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
opcodes = difflib.SequenceMatcher(None, actual, expected, autojunk=False).get_opcodes()
|
|
127
|
+
except (TypeError, ValueError, decimal.InvalidOperation, OverflowError): # `difflib` asks `==` itself
|
|
128
|
+
pass
|
|
129
|
+
else:
|
|
130
|
+
return _rechecked_equal_runs(opcodes, actual, expected)
|
|
131
|
+
try:
|
|
132
|
+
keyed_actual = [_safe_repr(item) for item in actual]
|
|
133
|
+
keyed_expected = [_safe_repr(item) for item in expected]
|
|
134
|
+
opcodes = difflib.SequenceMatcher(None, keyed_actual, keyed_expected, autojunk=False).get_opcodes()
|
|
135
|
+
# pragma: no cover - `_safe_repr` swallows everything; what is left degrades to a positional diff
|
|
136
|
+
except (TypeError, ValueError): # pragma: no cover
|
|
137
|
+
return None
|
|
138
|
+
return _rechecked_equal_runs(opcodes, actual, expected)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _rechecked_equal_runs(opcodes, actual, expected):
|
|
142
|
+
"""Opcodes whose ``equal`` runs survive the comparison this library reaches its verdicts with.
|
|
143
|
+
|
|
144
|
+
A run difflib calls equal was matched on whatever it was keyed with, and neither key is the
|
|
145
|
+
verdict. Keyed on reprs, the run is only known to *print* the same, and a shared repr is not
|
|
146
|
+
exotic: `_safe_repr()` renders every value of a type whose ``__repr__`` raises as the same string.
|
|
147
|
+
Kept, the pair would drop out of the diff and out of the message's elision, and the failure would name
|
|
148
|
+
a smaller difference than the one that caused it. Keyed on the values, the run matched by identity or
|
|
149
|
+
``==``, which is already the walk's own rule for an element.
|
|
150
|
+
|
|
151
|
+
Compared through `_walked_equal()`, the same question `_node_decision()` reaches its verdict with, and
|
|
152
|
+
through `_graphs_differ()` where that has no answer, so a run split back into a substitution is exactly a
|
|
153
|
+
pair the walk will then report. That
|
|
154
|
+
costs one comparison per matched element, on the failing path only and under the caller's length
|
|
155
|
+
cap. Measured on 200 records with one inserted at the head: 0.38 ms to 0.48 ms for unhashable rows,
|
|
156
|
+
and 0.15 ms to 0.20 ms for hashable ones, which is the path most sequences take.
|
|
157
|
+
"""
|
|
158
|
+
revalidated = []
|
|
159
|
+
for tag, actual_start, actual_stop, expected_start, expected_stop in opcodes:
|
|
160
|
+
if tag != "equal":
|
|
161
|
+
revalidated.append((tag, actual_start, actual_stop, expected_start, expected_stop))
|
|
162
|
+
continue
|
|
163
|
+
holds = [
|
|
164
|
+
(equal := _walked_equal(actual[actual_start + offset], expected[expected_start + offset]))
|
|
165
|
+
or (equal is None and not _graphs_differ(actual[actual_start + offset], expected[expected_start + offset]))
|
|
166
|
+
for offset in range(actual_stop - actual_start)
|
|
167
|
+
]
|
|
168
|
+
run_start = 0
|
|
169
|
+
for offset in range(1, len(holds) + 1):
|
|
170
|
+
if offset < len(holds) and holds[offset] == holds[run_start]:
|
|
171
|
+
continue
|
|
172
|
+
revalidated.append(
|
|
173
|
+
(
|
|
174
|
+
"equal" if holds[run_start] else "replace",
|
|
175
|
+
actual_start + run_start,
|
|
176
|
+
actual_start + offset,
|
|
177
|
+
expected_start + run_start,
|
|
178
|
+
expected_start + offset,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
run_start = offset
|
|
182
|
+
return revalidated
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _aligned_match_indices(seq, counterpart) -> set[int] | None:
|
|
186
|
+
"""Indices of ``seq`` that align with an equal element of ``counterpart``, or ``None`` if unaligned.
|
|
187
|
+
|
|
188
|
+
Lets the failure message collapse a matched run the way the diff collapses it: without this the
|
|
189
|
+
message elides on position and an element inserted at the head shifts every later element out of
|
|
190
|
+
the elision, so the message dumps both sequences whole while the diff below it shows one entry.
|
|
191
|
+
"""
|
|
192
|
+
opcodes = _alignment_opcodes_if_useful(seq, counterpart)
|
|
193
|
+
if opcodes is None:
|
|
194
|
+
return None
|
|
195
|
+
matched: set[int] = set()
|
|
196
|
+
for tag, start, stop, _, _ in opcodes:
|
|
197
|
+
if tag == "equal":
|
|
198
|
+
matched.update(range(start, stop))
|
|
199
|
+
return matched
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _Reads:
|
|
203
|
+
"""One side of a sequence pair, read as the walk always read it, and through its storage once a read refuses.
|
|
204
|
+
|
|
205
|
+
The verdict is in before the walk starts, and `==` of a `list` or `tuple` may never have asked the value's own
|
|
206
|
+
`__len__`, `__getitem__` or `__iter__`: the base one compares the stored items. A read that refuses there cannot
|
|
207
|
+
be allowed to turn the failure into the value's exception. Reads that answer pass through untouched, in the
|
|
208
|
+
order and number the walk makes them; from the first refusal on, the stored items answer. A value with no storage
|
|
209
|
+
behind it, one only posing as a list, keeps its refusal.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
__slots__ = ("sequence",)
|
|
213
|
+
|
|
214
|
+
def __init__(self, sequence: Any) -> None:
|
|
215
|
+
self.sequence = sequence
|
|
216
|
+
|
|
217
|
+
def _stored(self) -> bool:
|
|
218
|
+
"""Whether the stored items now answer in place of the value's own reads."""
|
|
219
|
+
held: Any = self.sequence
|
|
220
|
+
if issubclass(type(held), list):
|
|
221
|
+
self.sequence = list(list.__iter__(held))
|
|
222
|
+
elif issubclass(type(held), tuple):
|
|
223
|
+
self.sequence = list(tuple.__iter__(held))
|
|
224
|
+
else:
|
|
225
|
+
return False
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
def __len__(self) -> int:
|
|
229
|
+
try:
|
|
230
|
+
return len(self.sequence)
|
|
231
|
+
except Exception:
|
|
232
|
+
if not self._stored():
|
|
233
|
+
raise
|
|
234
|
+
return len(self.sequence)
|
|
235
|
+
|
|
236
|
+
def __getitem__(self, index: Any) -> Any:
|
|
237
|
+
try:
|
|
238
|
+
return self.sequence[index]
|
|
239
|
+
except Exception:
|
|
240
|
+
if not self._stored():
|
|
241
|
+
raise
|
|
242
|
+
return self.sequence[index]
|
|
243
|
+
|
|
244
|
+
def __iter__(self) -> Iterator[Any]:
|
|
245
|
+
# asked out here: a `StopIteration` refusing the iterator inside a generator would become `RuntimeError`
|
|
246
|
+
try:
|
|
247
|
+
iterator = iter(self.sequence)
|
|
248
|
+
except Exception:
|
|
249
|
+
if not self._stored():
|
|
250
|
+
raise
|
|
251
|
+
return iter(self.sequence)
|
|
252
|
+
return self._continued(iterator)
|
|
253
|
+
|
|
254
|
+
def _continued(self, iterator: Iterator[Any]) -> Iterator[Any]:
|
|
255
|
+
# `next()` and not a `for`, which would ask the iterator for an iterator of its own first
|
|
256
|
+
read = 0
|
|
257
|
+
while True:
|
|
258
|
+
try:
|
|
259
|
+
item = next(iterator)
|
|
260
|
+
except StopIteration:
|
|
261
|
+
return
|
|
262
|
+
except Exception:
|
|
263
|
+
if not self._stored():
|
|
264
|
+
raise
|
|
265
|
+
yield from self.sequence[read:]
|
|
266
|
+
return
|
|
267
|
+
read += 1
|
|
268
|
+
yield item
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def readable(sequence: Any) -> Any:
|
|
272
|
+
"""*sequence*, or for anything but an exact `list` or `tuple` the `_Reads` of it."""
|
|
273
|
+
kind = type(sequence)
|
|
274
|
+
return sequence if kind is list or kind is tuple else _Reads(sequence)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _stored_members(held: set | frozenset) -> set | frozenset:
|
|
278
|
+
"""What a `set` or `frozenset` stores, as the base type: copied with the hashes it holds, so no item is hashed
|
|
279
|
+
again and no read of its own is made."""
|
|
280
|
+
return set.copy(held) if isinstance(held, set) else frozenset.copy(held)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _members(held: set | frozenset) -> Iterator[Any]:
|
|
284
|
+
"""*held*'s items as its own iteration gives them, and from its storage once that refuses.
|
|
285
|
+
|
|
286
|
+
`==` of a `set` or `frozenset` compares the stored items and never asks the value's own `__iter__`, so an
|
|
287
|
+
iteration refusing there cannot turn the failure into the value's exception. An item the own iteration gave
|
|
288
|
+
before refusing is not given twice.
|
|
289
|
+
"""
|
|
290
|
+
if type(held) in (set, frozenset):
|
|
291
|
+
return iter(held)
|
|
292
|
+
# asked out here: a `StopIteration` refusing the iterator inside a generator would become `RuntimeError`
|
|
293
|
+
try:
|
|
294
|
+
iterator = iter(held)
|
|
295
|
+
except Exception:
|
|
296
|
+
return iter(_stored_members(held))
|
|
297
|
+
return _members_on(held, iterator)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _members_on(held: set | frozenset, iterator: Iterator[Any]) -> Iterator[Any]:
|
|
301
|
+
given: dict[int, Any] = {}
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
item = next(iterator)
|
|
305
|
+
except StopIteration:
|
|
306
|
+
return
|
|
307
|
+
except Exception:
|
|
308
|
+
yield from (item for item in _stored_members(held) if id(item) not in given)
|
|
309
|
+
return
|
|
310
|
+
given[id(item)] = item
|
|
311
|
+
yield item
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _absent_from(items: Iterator[Any], container: set | frozenset) -> list[Any]:
|
|
315
|
+
"""The *items* `member` does not find in *container*, asked of its storage from its first lookup that refuses.
|
|
316
|
+
|
|
317
|
+
A refusal the storage repeats, an item's own comparison, is handed on.
|
|
318
|
+
"""
|
|
319
|
+
if type(container) in (set, frozenset):
|
|
320
|
+
return [item for item in items if not member(item, container)]
|
|
321
|
+
stored: set | frozenset | None = None
|
|
322
|
+
absent = []
|
|
323
|
+
for item in items:
|
|
324
|
+
if stored is None:
|
|
325
|
+
try:
|
|
326
|
+
found = member(item, container)
|
|
327
|
+
except Exception:
|
|
328
|
+
stored = _stored_members(container)
|
|
329
|
+
found = member(item, stored)
|
|
330
|
+
else:
|
|
331
|
+
found = member(item, stored)
|
|
332
|
+
if not found:
|
|
333
|
+
absent.append(item)
|
|
334
|
+
return absent
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _positional_difference_count(actual, expected) -> int:
|
|
338
|
+
"""How many positions the two sequences differ at when paired by index.
|
|
339
|
+
|
|
340
|
+
Guarded rather than bare ``==``: an array member reached here has an element-wise ``==`` with no
|
|
341
|
+
single truth value, and the operand gate on the assertion never saw it - the top-level ``==`` that
|
|
342
|
+
admitted the failure short-circuited on an earlier element. Without the guard numpy's own
|
|
343
|
+
``ValueError`` leaves the library in place of the actionable ``TypeError`` it promises.
|
|
344
|
+
"""
|
|
345
|
+
return sum(
|
|
346
|
+
1
|
|
347
|
+
for index in range(max(len(actual), len(expected)))
|
|
348
|
+
if index >= len(actual)
|
|
349
|
+
or index >= len(expected)
|
|
350
|
+
or not (
|
|
351
|
+
(equal := _walked_equal(actual[index], expected[index]))
|
|
352
|
+
or (equal is None and not _graphs_differ(actual[index], expected[index]))
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _graphs_differ(actual, expected) -> bool:
|
|
358
|
+
"""Whether two values ``==`` cannot finish on differ, as the walk finds.
|
|
359
|
+
|
|
360
|
+
Counted as a difference unasked, two equal values that hold themselves tipped a pairing towards alignment,
|
|
361
|
+
which then listed one as removed and the other as added. Asked only of such a pair: as a wrapper around every
|
|
362
|
+
``==`` it cost a failing comparison of 200 rows 8%.
|
|
363
|
+
|
|
364
|
+
Answered by a walk that pairs every two sequences by index. Choosing a pairing is what asks this, so a
|
|
365
|
+
walk that chose one under an asking asked again from inside it, each asking a walk of its own that
|
|
366
|
+
remembers none above it, and two graphs holding sequences of different lengths asked it of each other
|
|
367
|
+
until the stack ran out. The answer is the same either way: two sequences of one length are never
|
|
368
|
+
aligned, and two of different lengths leave an entry however they are paired.
|
|
369
|
+
"""
|
|
370
|
+
return bool(_child_entries(actual, expected, _ROOT, descended_for="unanswered", aligns=False))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _aligned_difference_count(opcodes) -> int:
|
|
374
|
+
"""How many positions the alignment reports, which is what an aligned walk would emit."""
|
|
375
|
+
return sum(
|
|
376
|
+
max(actual_stop - actual_start, expected_stop - expected_start)
|
|
377
|
+
for tag, actual_start, actual_stop, expected_start, expected_stop in opcodes
|
|
378
|
+
if tag != "equal"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _alignment_opcodes_if_useful(actual, expected):
|
|
383
|
+
"""Alignment opcodes, or ``None`` when pairing by index already reads at least as short.
|
|
384
|
+
|
|
385
|
+
The order matters for cost, not just for the answer. A long list of records with one field changed
|
|
386
|
+
is the common failure, and pairing it by index already yields the one entry an alignment could -
|
|
387
|
+
but the elements are unhashable, so asking difflib means rendering every element's repr first. One
|
|
388
|
+
differing position cannot be beaten, so that case never asks: measured on 200 records, it is the
|
|
389
|
+
difference between 0.09 ms and 0.75 ms.
|
|
390
|
+
|
|
391
|
+
Alignment is a large win when a sequence shifted and a loss when it did not: a reversal reads as
|
|
392
|
+
two substitutions positionally and as four insertions and deletions aligned. Counting both, and
|
|
393
|
+
keeping the index reading on a tie, is what lets one rule serve both - and it answers whether a
|
|
394
|
+
tuple should align without a special case, since a coordinate pair is never shorter aligned.
|
|
395
|
+
|
|
396
|
+
Counted on ``==`` alone rather than on the built entries: the walkers recurse, so building both to
|
|
397
|
+
compare them would double the work at every level of nesting. Measured over 13 600 random pairs,
|
|
398
|
+
this count picks the same winner as the exact one every time.
|
|
399
|
+
"""
|
|
400
|
+
if len(actual) == len(expected):
|
|
401
|
+
# equal lengths hide a rotation, worth 8% of the wins over 13 640 pairs against a doubled comparison
|
|
402
|
+
return None
|
|
403
|
+
if max(len(actual), len(expected)) > _ALIGN_MAX_ELEMENTS:
|
|
404
|
+
return None # over the cap nothing here can be used anyway
|
|
405
|
+
positional = _positional_difference_count(actual, expected)
|
|
406
|
+
if positional <= 1:
|
|
407
|
+
return None # nothing to win: an alignment would have to report zero positions to beat it
|
|
408
|
+
opcodes = _alignment_opcodes(actual, expected)
|
|
409
|
+
if opcodes is None or _aligned_difference_count(opcodes) >= positional:
|
|
410
|
+
return None
|
|
411
|
+
return opcodes
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _ordered_keys(actual: Iterable[_K], expected: Iterable[_K]) -> list[_K]:
|
|
415
|
+
"""Every key of both sides, in the order a reader wrote them.
|
|
416
|
+
|
|
417
|
+
A union of two sets loses insertion order, which is why this used to be sorted: without an order
|
|
418
|
+
imposed, the report varied with the hash seed. Sorting bought determinism at the price of the one
|
|
419
|
+
ordering that carries meaning - a JSON response reads in the order its fields arrived, and `k0, k1,
|
|
420
|
+
k10, k100` reads as no order at all. Walking the actual side and then the keys only the expected
|
|
421
|
+
side has is just as deterministic, and it is the order pytest shows.
|
|
422
|
+
"""
|
|
423
|
+
seen = set(actual)
|
|
424
|
+
return [*actual, *(key for key in expected if not member(key, seen))]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _order_entries(actual, expected, kept, kept_expected, prefix: _Path) -> list[DiffEntry]:
|
|
428
|
+
"""The key order of two `OrderedDict` values holding the same keys in different places, which their `==` reads."""
|
|
429
|
+
if not (isinstance(actual, collections.OrderedDict) and isinstance(expected, collections.OrderedDict)):
|
|
430
|
+
return []
|
|
431
|
+
if not equals(set(kept), set(kept_expected)) or equals(list(kept), list(kept_expected)):
|
|
432
|
+
return []
|
|
433
|
+
return [prefix.leaf_entry(actual=list(kept), expected=list(kept_expected))]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _differs_of_itself(actual: object, expected: object) -> bool:
|
|
437
|
+
"""Whether ``==`` may hold apart two values the walk takes apart together, whatever they hold.
|
|
438
|
+
|
|
439
|
+
It may where either class has an ``==`` not known to read the parts the walk reads (`compares_by_parts`): one
|
|
440
|
+
left to `object`, which holds any two apart, or one written by hand, which answers for reasons of its own.
|
|
441
|
+
Two of different classes differ where the class is part of the comparison: a list against a tuple, and two
|
|
442
|
+
dataclasses, attrs classes or models, whose own ``==`` asks for the same class. Two mappings of different
|
|
443
|
+
classes do not, and neither do a tuple and a namedtuple, nor a list and a subclass of it: each is compared by
|
|
444
|
+
what it holds.
|
|
445
|
+
"""
|
|
446
|
+
kind = type(actual)
|
|
447
|
+
if kind is type(expected):
|
|
448
|
+
return not compares_by_parts(kind)
|
|
449
|
+
if not (compares_by_parts(kind) and compares_by_parts(type(expected))):
|
|
450
|
+
return True
|
|
451
|
+
if isinstance(actual, (list, tuple)):
|
|
452
|
+
return isinstance(actual, list) is not isinstance(expected, list)
|
|
453
|
+
return not is_mapping_like(actual)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class _Walk:
|
|
457
|
+
"""One structural diff, walked on a list of frames rather than on the interpreter's stack.
|
|
458
|
+
|
|
459
|
+
A frame is the generator over one container's children. It appends what it finds to ``entries``, yields the
|
|
460
|
+
frame of each child it has to descend into, and is resumed once that child is done. Walked by recursion,
|
|
461
|
+
every level cost three Python calls, and a pair nested four hundred levels deep raised `RecursionError` in
|
|
462
|
+
place of its failure.
|
|
463
|
+
|
|
464
|
+
``paired`` holds the current node's ancestors and nothing else, as the pairs they were opened as, one
|
|
465
|
+
mapping for the whole walk: a pair goes on when its frame opens and comes off when the frame is done. A copy
|
|
466
|
+
per level made memory quadratic in the depth. Held by id, and the id holds its two values, so no ancestor
|
|
467
|
+
is freed for a new value to take its id.
|
|
468
|
+
|
|
469
|
+
The walk reads a graph as the mapping core does: a pair it meets again while still inside it is equal as far
|
|
470
|
+
as that pair goes, so it adds nothing, and one side met again beside another partner is a new pair, walked
|
|
471
|
+
like any other. There are only so many pairs, so that ends. A value two siblings share is walked for each.
|
|
472
|
+
|
|
473
|
+
Under a config the walk is the verdict. Without one it renders a failure already decided, and every pair
|
|
474
|
+
it enters because ``==`` held the two apart owes an entry: where nothing under the pair accounts for that
|
|
475
|
+
answer, an ``==`` of the value's own or of a container's kind, the pair itself is the entry. A pair that
|
|
476
|
+
leads back to one it is inside differs as that one does, and owes nothing of its own, unless it differs
|
|
477
|
+
whatever it holds (`_differs_of_itself()`).
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
__slots__ = ("aligns", "config", "entries", "paired", "stack")
|
|
481
|
+
|
|
482
|
+
def __init__(self, config: _CompareConfig | None, *, aligns: bool = True) -> None:
|
|
483
|
+
self.config = config
|
|
484
|
+
self.aligns = aligns
|
|
485
|
+
"""Whether two sequences may be paired by alignment: not in a walk `_graphs_differ` runs."""
|
|
486
|
+
self.entries: list[DiffEntry] = []
|
|
487
|
+
self.paired: dict[tuple[int, int], tuple[object, object]] = {}
|
|
488
|
+
self.stack: list[_Frame] = []
|
|
489
|
+
|
|
490
|
+
def run(self, children: Iterator[_Frame], pair: tuple[int, int], owed: _Owed | None = None) -> list[DiffEntry]:
|
|
491
|
+
"""Walk *children* to the end, and every frame they open, and answer the entries found."""
|
|
492
|
+
stack = self.stack
|
|
493
|
+
stack.append((children, pair, owed))
|
|
494
|
+
entries, paired = self.entries, self.paired
|
|
495
|
+
while stack:
|
|
496
|
+
walking, pair, _ = stack[-1]
|
|
497
|
+
try:
|
|
498
|
+
nested = next(walking, None)
|
|
499
|
+
except RuntimeError as error:
|
|
500
|
+
escaped = _escaped_stop(error)
|
|
501
|
+
if escaped is None:
|
|
502
|
+
raise
|
|
503
|
+
else:
|
|
504
|
+
if nested is None:
|
|
505
|
+
owed = stack.pop()[2]
|
|
506
|
+
del paired[pair]
|
|
507
|
+
if owed is not None and len(entries) == owed[3]:
|
|
508
|
+
entries.append(owed[0].leaf_entry(actual=owed[1], expected=owed[2]))
|
|
509
|
+
else:
|
|
510
|
+
stack.append(nested)
|
|
511
|
+
continue
|
|
512
|
+
raise escaped
|
|
513
|
+
return self.entries
|
|
514
|
+
|
|
515
|
+
def opened(self, actual, expected, prefix: _Path, *, held_equal: bool = False) -> _Frame | None:
|
|
516
|
+
"""The frame over a pair's children with the pair now on the path, or ``None`` for a pair not taken apart.
|
|
517
|
+
|
|
518
|
+
A pair *held_equal* by its own ``==``, entered for what it holds, is taken apart as two objects as well.
|
|
519
|
+
"""
|
|
520
|
+
children = self.children(actual, expected, prefix)
|
|
521
|
+
if children is None and held_equal:
|
|
522
|
+
children = self.objects(actual, expected, prefix)
|
|
523
|
+
if children is None:
|
|
524
|
+
return None
|
|
525
|
+
pair = (id(actual), id(expected))
|
|
526
|
+
self.paired[pair] = (actual, expected)
|
|
527
|
+
return children, pair, None
|
|
528
|
+
|
|
529
|
+
def descend(self, actual, expected, path: _Path, descended_for) -> _Frame | None:
|
|
530
|
+
"""Walk into a child, given *why* it was descended into: its entries, or the frame still to walk.
|
|
531
|
+
|
|
532
|
+
`_Walk.children()` answers ``None`` for a value it does not take apart, and that answer means
|
|
533
|
+
two different things depending on the reason for the descent. Descending because the two sides
|
|
534
|
+
differ, ``None`` is a differing leaf and must be reported. Descending because ``strict_types`` has
|
|
535
|
+
to look past a container whose own ``==`` was true, ``None`` is a value that is already equal and
|
|
536
|
+
must not be. Reading it wrong is where the false failure on two equal sets came from, so the two
|
|
537
|
+
readings live here and nowhere else: every caller names its reason.
|
|
538
|
+
|
|
539
|
+
Scope, because this is easy to over-read: it owns ``None`` for *building entries*, not for every
|
|
540
|
+
use of the walker. Two other readings exist and are both correct for their own job -
|
|
541
|
+
`assertpy._engine._equality.values_differ()` treats ``None`` as "ask ``==`` instead", and
|
|
542
|
+
`HelpersMixin._dict_err()` treats it as "nothing to render". A new caller still has to decide what
|
|
543
|
+
``None`` means for what it is doing; it just must not invent a fourth answer for this one.
|
|
544
|
+
"""
|
|
545
|
+
pair = (id(actual), id(expected))
|
|
546
|
+
if pair in self.paired:
|
|
547
|
+
if self.config is None:
|
|
548
|
+
self.excuse(pair)
|
|
549
|
+
return None
|
|
550
|
+
found = len(self.entries)
|
|
551
|
+
frame = self.opened(actual, expected, path, held_equal=descended_for == "strict")
|
|
552
|
+
if frame is None:
|
|
553
|
+
if descended_for != "strict":
|
|
554
|
+
self.entries.append(path.entry(actual=actual, expected=expected))
|
|
555
|
+
elif descended_for == "recurse" and self.config is None:
|
|
556
|
+
return frame[0], frame[1], (path, actual, expected, found)
|
|
557
|
+
return frame
|
|
558
|
+
|
|
559
|
+
def excuse(self, pair: tuple[int, int]) -> None:
|
|
560
|
+
"""Take what they owe off the frames inside *pair*, which one of them has just led back to."""
|
|
561
|
+
stack = self.stack
|
|
562
|
+
depth = len(stack) - 1
|
|
563
|
+
while stack[depth][1] != pair:
|
|
564
|
+
children, opened, owed = stack[depth]
|
|
565
|
+
if owed is not None and not _differs_of_itself(owed[1], owed[2]):
|
|
566
|
+
stack[depth] = (children, opened, None)
|
|
567
|
+
depth -= 1
|
|
568
|
+
|
|
569
|
+
def children(self, actual, expected, prefix: _Path) -> Iterator[_Frame] | None:
|
|
570
|
+
"""The walk over a pair's children, in the nested ladder, or ``None`` for a pair it does not take apart.
|
|
571
|
+
|
|
572
|
+
Mappings, dataclasses, namedtuples, model-dump objects, attrs instances and sequences are taken
|
|
573
|
+
apart. The ladder starts at mappings, where `_build_equality_diff()` starts at namedtuples.
|
|
574
|
+
|
|
575
|
+
Two deques are taken apart under a config, where the walk is the verdict: left whole, ``[1]`` against
|
|
576
|
+
``[True]`` in one passed ``strict_types``. With no config a failed pair of them prints as it did, and a
|
|
577
|
+
deque of a class of its own is left to the ``==`` of that class.
|
|
578
|
+
"""
|
|
579
|
+
if is_mapping_like(actual) and is_mapping_like(expected):
|
|
580
|
+
return self.mapping(actual, expected, prefix)
|
|
581
|
+
if (
|
|
582
|
+
dataclasses.is_dataclass(actual)
|
|
583
|
+
and not isinstance(actual, type)
|
|
584
|
+
and dataclasses.is_dataclass(expected)
|
|
585
|
+
and not isinstance(expected, type)
|
|
586
|
+
):
|
|
587
|
+
return self.dataclass(actual, expected, prefix)
|
|
588
|
+
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
589
|
+
return self.namedtuple(actual, expected, prefix)
|
|
590
|
+
both_model = is_model_dump_object(actual) and is_model_dump_object(expected)
|
|
591
|
+
both_attrs = is_attrs_instance(actual) and is_attrs_instance(expected)
|
|
592
|
+
if both_model or both_attrs:
|
|
593
|
+
return self.fields(actual, expected, prefix, both_model=both_model)
|
|
594
|
+
read = _sequences(actual, expected, self.config)
|
|
595
|
+
return None if read is None else self.sequence(*read, prefix)
|
|
596
|
+
|
|
597
|
+
def objects(self, actual, expected, prefix: _Path) -> Iterator[_Frame] | None:
|
|
598
|
+
"""The walk over two objects of one class by their fields, or ``None`` for a pair that is no such two.
|
|
599
|
+
|
|
600
|
+
For a pair its own ``==`` holds equal and ``strict_types`` or a comparator still has to look into: a
|
|
601
|
+
container says nothing of the types it holds, and an object with fields is one. The fields are the
|
|
602
|
+
``__dict__`` of an object that holds nothing else (`own_fields`), the reading ``ignore=`` has. Two that
|
|
603
|
+
differ by their ``==`` are not read this way: taken apart, two objects with no ``==`` of their own would
|
|
604
|
+
be equal.
|
|
605
|
+
"""
|
|
606
|
+
if type(actual) is not type(expected):
|
|
607
|
+
return None
|
|
608
|
+
held, held_expected = own_fields(actual), own_fields(expected)
|
|
609
|
+
if held is None or held_expected is None:
|
|
610
|
+
return None
|
|
611
|
+
return self.held_equal(dict(held), dict(held_expected), prefix)
|
|
612
|
+
|
|
613
|
+
def held_equal(self, held: dict, held_expected: dict, prefix: _Path) -> Iterator[_Frame]:
|
|
614
|
+
"""The fields both of two equal objects hold, each asked what the options add to ``==`` and nothing more.
|
|
615
|
+
|
|
616
|
+
A field one side lacks is outside the ``==`` that held the two equal, as one the two hold apart is
|
|
617
|
+
(`held_equal_decision`).
|
|
618
|
+
"""
|
|
619
|
+
config = cast("_CompareConfig", self.config)
|
|
620
|
+
for name, value in held.items():
|
|
621
|
+
if name in held_expected:
|
|
622
|
+
counterpart = held_expected[name]
|
|
623
|
+
decision = held_equal_decision(value, counterpart, config, field=name)
|
|
624
|
+
if decision == "leaf":
|
|
625
|
+
self.entries.append(prefix.attr(name).entry(actual=value, expected=counterpart))
|
|
626
|
+
elif decision != "equal":
|
|
627
|
+
frame = self.descend(value, counterpart, prefix.attr(name), decision)
|
|
628
|
+
if frame is not None:
|
|
629
|
+
yield frame
|
|
630
|
+
|
|
631
|
+
def mapping(self, actual, expected, prefix: _Path) -> Iterator[_Frame] | None:
|
|
632
|
+
"""The walk over two mappings, or ``None`` when either cannot be walked by key.
|
|
633
|
+
|
|
634
|
+
Both sides are snapshotted first, so the keys walked below are the ones that were proved readable
|
|
635
|
+
rather than a second reading of a value free to answer differently between passes.
|
|
636
|
+
"""
|
|
637
|
+
kept, kept_expected = keyed_snapshot(actual), keyed_snapshot(expected)
|
|
638
|
+
if kept is None or kept_expected is None:
|
|
639
|
+
return None
|
|
640
|
+
actual_keys = set(kept)
|
|
641
|
+
expected_keys = set(kept_expected)
|
|
642
|
+
self.entries.extend(_order_entries(actual, expected, kept, kept_expected, prefix))
|
|
643
|
+
keyed = keyed_names(kept, kept_expected)
|
|
644
|
+
if self.config is not None and self.config.strict_types and not of_one_atom(kept, kept_expected):
|
|
645
|
+
# `{True} & {1}` hands back whichever side the set drew from, losing the type that differs
|
|
646
|
+
stored = {key: key for key in kept_expected}
|
|
647
|
+
for key in kept:
|
|
648
|
+
found, counterpart = lookup(stored, key)
|
|
649
|
+
counterpart = counterpart if found else key
|
|
650
|
+
kind = type(key)
|
|
651
|
+
if kind is not type(counterpart) or (kind not in _EQ_ATOMIC and typed_apart(key, counterpart)):
|
|
652
|
+
self.entries.append(prefix.key(key).entry(actual=key, expected=counterpart))
|
|
653
|
+
return self.keys(kept, kept_expected, actual_keys, expected_keys, keyed, prefix)
|
|
654
|
+
|
|
655
|
+
def keys(self, kept, kept_expected, actual_keys, expected_keys, keyed, prefix: _Path) -> Iterator[_Frame]:
|
|
656
|
+
"""The children of two snapshotted mappings, key by key in the order a reader wrote them."""
|
|
657
|
+
for key in _ordered_keys(kept, kept_expected):
|
|
658
|
+
# the side that holds an equal key under another object is read by that object, which no lookup compares
|
|
659
|
+
in_expected, expected_key = held_key(expected_keys, key)
|
|
660
|
+
in_actual, actual_key = held_key(actual_keys, key)
|
|
661
|
+
if not in_expected:
|
|
662
|
+
self.entries.append(prefix.key(key).entry(actual=kept[key], expected=None, absent="expected"))
|
|
663
|
+
elif not in_actual:
|
|
664
|
+
self.entries.append(prefix.key(key).entry(actual=None, absent="actual", expected=kept_expected[key]))
|
|
665
|
+
else:
|
|
666
|
+
decision = (
|
|
667
|
+
_node_decision(*keyed_pair(kept, kept_expected, key), self.config, field=key)
|
|
668
|
+
if key in keyed
|
|
669
|
+
else _node_decision(kept[actual_key], kept_expected[expected_key], self.config, field=key)
|
|
670
|
+
)
|
|
671
|
+
if decision == "leaf":
|
|
672
|
+
path = prefix.key(key)
|
|
673
|
+
self.entries.append(path.entry(actual=kept[actual_key], expected=kept_expected[expected_key]))
|
|
674
|
+
elif decision != "equal":
|
|
675
|
+
frame = self.descend(kept[actual_key], kept_expected[expected_key], prefix.key(key), decision)
|
|
676
|
+
if frame is not None:
|
|
677
|
+
yield frame
|
|
678
|
+
|
|
679
|
+
def dataclass(self, actual, expected, prefix: _Path) -> Iterator[_Frame]:
|
|
680
|
+
"""The children of two dataclasses, over both sides' field names in declaration order.
|
|
681
|
+
|
|
682
|
+
A field present on one side only is reported as absent from the other. Shared by the top-level
|
|
683
|
+
and nested ladders, so both report dataclass fields identically.
|
|
684
|
+
"""
|
|
685
|
+
# a field declared `compare=False` is outside the dataclass's own `==`, so it stays outside here
|
|
686
|
+
actual_names = [field.name for field in dataclasses.fields(actual) if field.compare]
|
|
687
|
+
expected_names = [field.name for field in dataclasses.fields(expected) if field.compare]
|
|
688
|
+
in_actual, in_expected = set(actual_names), set(expected_names)
|
|
689
|
+
for field in _ordered_keys(actual_names, expected_names):
|
|
690
|
+
if field not in in_expected:
|
|
691
|
+
self.entries.append(
|
|
692
|
+
prefix.attr(field).entry(actual=getattr(actual, field), expected=None, absent="expected")
|
|
693
|
+
)
|
|
694
|
+
elif field not in in_actual:
|
|
695
|
+
self.entries.append(
|
|
696
|
+
prefix.attr(field).entry(actual=None, absent="actual", expected=getattr(expected, field))
|
|
697
|
+
)
|
|
698
|
+
else:
|
|
699
|
+
actual_value = getattr(actual, field)
|
|
700
|
+
expected_value = getattr(expected, field)
|
|
701
|
+
decision = _node_decision(actual_value, expected_value, self.config, field=field)
|
|
702
|
+
if decision == "leaf":
|
|
703
|
+
self.entries.append(prefix.attr(field).entry(actual=actual_value, expected=expected_value))
|
|
704
|
+
elif decision != "equal":
|
|
705
|
+
frame = self.descend(actual_value, expected_value, prefix.attr(field), decision)
|
|
706
|
+
if frame is not None:
|
|
707
|
+
yield frame
|
|
708
|
+
|
|
709
|
+
def namedtuple(self, actual, expected, prefix: _Path) -> Iterator[_Frame]:
|
|
710
|
+
"""The children of two namedtuples, field by field, a field one side lacks reported as absent."""
|
|
711
|
+
for field in actual._fields:
|
|
712
|
+
actual_value = getattr(actual, field)
|
|
713
|
+
# `_fields` and not `getattr`: a field named like a tuple method resolves to the method, not to absent
|
|
714
|
+
if field not in expected._fields:
|
|
715
|
+
self.entries.append(prefix.attr(field).entry(actual=actual_value, expected=None, absent="expected"))
|
|
716
|
+
else:
|
|
717
|
+
expected_value = getattr(expected, field)
|
|
718
|
+
decision = _node_decision(actual_value, expected_value, self.config, field=field)
|
|
719
|
+
if decision == "leaf":
|
|
720
|
+
self.entries.append(prefix.attr(field).entry(actual=actual_value, expected=expected_value))
|
|
721
|
+
elif decision != "equal":
|
|
722
|
+
frame = self.descend(actual_value, expected_value, prefix.attr(field), decision)
|
|
723
|
+
if frame is not None:
|
|
724
|
+
yield frame
|
|
725
|
+
self.entries.extend(
|
|
726
|
+
prefix.attr(field).entry(actual=None, absent="actual", expected=getattr(expected, field))
|
|
727
|
+
for field in expected._fields
|
|
728
|
+
if field not in actual._fields
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
def fields(self, actual, expected, prefix: _Path, *, both_model: bool) -> Iterator[_Frame]:
|
|
732
|
+
"""The children of two pydantic-style models or two attrs instances, field by field."""
|
|
733
|
+
actual_dict, expected_dict = _field_dict(actual, both_model), _field_dict(expected, both_model)
|
|
734
|
+
keyed = keyed_names(actual_dict, expected_dict)
|
|
735
|
+
for key in _ordered_keys(actual_dict, expected_dict):
|
|
736
|
+
if key not in expected_dict:
|
|
737
|
+
self.entries.append(prefix.attr(key).entry(actual=actual_dict[key], expected=None, absent="expected"))
|
|
738
|
+
elif key not in actual_dict:
|
|
739
|
+
self.entries.append(prefix.attr(key).entry(actual=None, absent="actual", expected=expected_dict[key]))
|
|
740
|
+
else:
|
|
741
|
+
decision = (
|
|
742
|
+
_node_decision(*keyed_pair(actual_dict, expected_dict, key), self.config, field=key)
|
|
743
|
+
if key in keyed
|
|
744
|
+
else _node_decision(actual_dict[key], expected_dict[key], self.config, field=key)
|
|
745
|
+
)
|
|
746
|
+
if decision == "leaf":
|
|
747
|
+
self.entries.append(prefix.attr(key).entry(actual=actual_dict[key], expected=expected_dict[key]))
|
|
748
|
+
elif decision != "equal":
|
|
749
|
+
frame = self.descend(actual_dict[key], expected_dict[key], prefix.attr(key), decision)
|
|
750
|
+
if frame is not None:
|
|
751
|
+
yield frame
|
|
752
|
+
|
|
753
|
+
def sequence(self, actual, expected, prefix: _Path) -> Iterator[_Frame]:
|
|
754
|
+
"""The children of two sequences, paired by alignment where that reads shorter.
|
|
755
|
+
|
|
756
|
+
An element inserted or removed shifts everything after it, and pairing by index then calls every
|
|
757
|
+
later element different. Pairing by `difflib` alignment reports the one insertion instead.
|
|
758
|
+
Shared by the top-level and nested ladders, so both decompose sequences identically. Elements have
|
|
759
|
+
no field name, so a ``config`` applies only type comparators and tolerance to them.
|
|
760
|
+
|
|
761
|
+
The walk over the children is handed to the driver itself rather than delegated to, so a `StopIteration`
|
|
762
|
+
of the value's own that the walk turns into `RuntimeError` reaches `_escaped_stop()` with no frame between.
|
|
763
|
+
"""
|
|
764
|
+
actual, expected = readable(actual), readable(expected)
|
|
765
|
+
opcodes = _alignment_opcodes_if_useful(actual, expected) if self.aligns else None
|
|
766
|
+
if opcodes is not None:
|
|
767
|
+
return self.aligned(actual, expected, prefix, opcodes)
|
|
768
|
+
return self.positional(actual, expected, prefix)
|
|
769
|
+
|
|
770
|
+
def positional(self, actual, expected, prefix: _Path) -> Iterator[_Frame]:
|
|
771
|
+
"""The children of two sequences paired by index."""
|
|
772
|
+
max_len = max(len(actual), len(expected))
|
|
773
|
+
for i in range(max_len):
|
|
774
|
+
if i >= len(actual):
|
|
775
|
+
self.entries.append(prefix.index(i).entry(actual=None, absent="actual", expected=expected[i]))
|
|
776
|
+
elif i >= len(expected):
|
|
777
|
+
self.entries.append(prefix.index(i).entry(actual=actual[i], expected=None, absent="expected"))
|
|
778
|
+
else:
|
|
779
|
+
# the path is built after the decision: an empty list per equal element cost 15% of the walk
|
|
780
|
+
decision = _node_decision(actual[i], expected[i], self.config)
|
|
781
|
+
if decision == "leaf":
|
|
782
|
+
self.entries.append(prefix.index(i).entry(actual=actual[i], expected=expected[i]))
|
|
783
|
+
elif decision != "equal":
|
|
784
|
+
frame = self.descend(actual[i], expected[i], prefix.index(i), decision)
|
|
785
|
+
if frame is not None:
|
|
786
|
+
yield frame
|
|
787
|
+
|
|
788
|
+
def aligned(self, actual, expected, prefix: _Path, opcodes) -> Iterator[_Frame]:
|
|
789
|
+
"""The children of a pair the alignment reports as shifted.
|
|
790
|
+
|
|
791
|
+
A one-sided entry names the sequence its index belongs to (``actual[2]``, ``expected[1]``). Once
|
|
792
|
+
the two sides have shifted apart their index spaces no longer agree, and numbering both as ``[i]``
|
|
793
|
+
put two unrelated entries on one path - the reader cannot tell which sequence the number indexes,
|
|
794
|
+
and a consumer reading entries by path sees a collision.
|
|
795
|
+
"""
|
|
796
|
+
for tag, actual_start, actual_stop, expected_start, expected_stop in opcodes:
|
|
797
|
+
if tag == "equal" and self.config is None:
|
|
798
|
+
continue # `_alignment_opcodes()` holds these equal, the whole test when no config narrows it
|
|
799
|
+
for offset in range(max(actual_stop - actual_start, expected_stop - expected_start)):
|
|
800
|
+
actual_index, expected_index = actual_start + offset, expected_start + offset
|
|
801
|
+
if actual_index >= actual_stop:
|
|
802
|
+
path = prefix.side_index("expected", expected_index)
|
|
803
|
+
self.entries.append(path.entry(actual=None, absent="actual", expected=expected[expected_index]))
|
|
804
|
+
elif expected_index >= expected_stop:
|
|
805
|
+
path = prefix.side_index("actual", actual_index)
|
|
806
|
+
self.entries.append(path.entry(actual=actual[actual_index], expected=None, absent="expected"))
|
|
807
|
+
else:
|
|
808
|
+
actual_item, expected_item = actual[actual_index], expected[expected_index]
|
|
809
|
+
decision = _node_decision(actual_item, expected_item, self.config)
|
|
810
|
+
if decision == "leaf":
|
|
811
|
+
path = prefix.index(actual_index)
|
|
812
|
+
self.entries.append(path.entry(actual=actual_item, expected=expected_item))
|
|
813
|
+
elif decision != "equal":
|
|
814
|
+
frame = self.descend(actual_item, expected_item, prefix.index(actual_index), decision)
|
|
815
|
+
if frame is not None:
|
|
816
|
+
yield frame
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _sequences(actual: Any, expected: Any, config: _CompareConfig | None) -> tuple[Any, Any] | None:
|
|
820
|
+
"""A pair the walk reads by position, or ``None``: two lists or tuples, and under a config two deques as lists.
|
|
821
|
+
|
|
822
|
+
A deque of a class of its own is no such pair: it may compare by more than what it holds.
|
|
823
|
+
"""
|
|
824
|
+
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
|
|
825
|
+
return actual, expected
|
|
826
|
+
if config is not None and type(actual) is collections.deque and type(expected) is collections.deque:
|
|
827
|
+
return list(actual), list(expected)
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _pair_itself(actual: object, expected: object) -> DiffResult:
|
|
832
|
+
"""The diff of two values that differ and cannot be walked: one entry, the pair, which reads nothing again."""
|
|
833
|
+
return DiffResult(kind="scalar", entries=[_ROOT.leaf_entry(actual=actual, expected=expected)])
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _set_diff(actual: set | frozenset, expected: set | frozenset, prefix: _Path, *, found_unequal: bool) -> DiffResult:
|
|
837
|
+
"""The members only one of two sets holds.
|
|
838
|
+
|
|
839
|
+
Two sets *found_unequal* that hold the same members, as a set of a class of its own can be, differ as a
|
|
840
|
+
pair: a diff with no entry rendered as nothing under a failure saying the two were not equal.
|
|
841
|
+
"""
|
|
842
|
+
entries = [
|
|
843
|
+
prefix.member(item, "extra").entry(actual=item, expected=None, absent="expected")
|
|
844
|
+
for item in sorted(_absent_from(_members(actual), expected), key=_safe_repr)
|
|
845
|
+
]
|
|
846
|
+
entries.extend(
|
|
847
|
+
prefix.member(item, "missing").entry(actual=None, absent="actual", expected=item)
|
|
848
|
+
for item in sorted(_absent_from(_members(expected), actual), key=_safe_repr)
|
|
849
|
+
)
|
|
850
|
+
if not entries and found_unequal:
|
|
851
|
+
return DiffResult(kind="scalar", entries=[prefix.leaf_entry(actual=actual, expected=expected)])
|
|
852
|
+
return DiffResult(kind="set", entries=entries)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _build_equality_diff(actual: object, expected: object, *, _prefix: _Path = _ROOT, config=None) -> DiffResult:
|
|
856
|
+
"""The diff of a top-level pair. Without a config the caller has found the two unequal, so the root owes."""
|
|
857
|
+
walk = _Walk(config)
|
|
858
|
+
pair = (id(actual), id(expected))
|
|
859
|
+
walk.paired[pair] = (actual, expected)
|
|
860
|
+
owed = None if config is not None else (_prefix, actual, expected, 0)
|
|
861
|
+
|
|
862
|
+
strict_descent = False
|
|
863
|
+
if config is not None:
|
|
864
|
+
# the root, where identity does not stand in for equality
|
|
865
|
+
decision = _node_decision(actual, expected, config, at_root=_prefix is _ROOT)
|
|
866
|
+
if decision == "equal":
|
|
867
|
+
return DiffResult(kind="scalar", entries=[])
|
|
868
|
+
if decision == "leaf":
|
|
869
|
+
return DiffResult(kind="scalar", entries=[_prefix.leaf_entry(actual=actual, expected=expected)])
|
|
870
|
+
strict_descent = decision == "strict"
|
|
871
|
+
|
|
872
|
+
if is_namedtuple(actual) and is_namedtuple(expected):
|
|
873
|
+
return DiffResult(kind="namedtuple", entries=walk.run(walk.namedtuple(actual, expected, _prefix), pair, owed))
|
|
874
|
+
if (
|
|
875
|
+
dataclasses.is_dataclass(actual)
|
|
876
|
+
and not isinstance(actual, type)
|
|
877
|
+
and dataclasses.is_dataclass(expected)
|
|
878
|
+
and not isinstance(expected, type)
|
|
879
|
+
):
|
|
880
|
+
return DiffResult(kind="dataclass", entries=walk.run(walk.dataclass(actual, expected, _prefix), pair, owed))
|
|
881
|
+
both_model = is_model_dump_object(actual) and is_model_dump_object(expected)
|
|
882
|
+
both_attrs = is_attrs_instance(actual) and is_attrs_instance(expected)
|
|
883
|
+
if both_model or both_attrs:
|
|
884
|
+
fields = walk.fields(actual, expected, _prefix, both_model=both_model)
|
|
885
|
+
return DiffResult(kind="model" if both_model else "attrs", entries=walk.run(fields, pair, owed))
|
|
886
|
+
read = _sequences(actual, expected, config)
|
|
887
|
+
if read is not None:
|
|
888
|
+
return DiffResult(kind="sequence", entries=walk.run(walk.sequence(*read, _prefix), pair, owed))
|
|
889
|
+
if isinstance(actual, (set, frozenset)) and isinstance(expected, (set, frozenset)):
|
|
890
|
+
return _set_diff(actual, expected, _prefix, found_unequal=owed is not None)
|
|
891
|
+
# under a strict descent the two sides were already equal, so only what two objects hold is left to differ
|
|
892
|
+
if strict_descent:
|
|
893
|
+
fields = walk.objects(actual, expected, _prefix) or iter(())
|
|
894
|
+
return DiffResult(kind="scalar", entries=walk.run(fields, pair, owed))
|
|
895
|
+
# bytes render as `b'...'`, which difflib points into like text, and both expose `splitlines()`
|
|
896
|
+
both_text = isinstance(actual, str) and isinstance(expected, str)
|
|
897
|
+
both_bytes = isinstance(actual, (bytes, bytearray)) and isinstance(expected, (bytes, bytearray))
|
|
898
|
+
if both_text or both_bytes:
|
|
899
|
+
entries = []
|
|
900
|
+
actual_lines = actual.splitlines()
|
|
901
|
+
expected_lines = expected.splitlines()
|
|
902
|
+
max_len = max(len(actual_lines), len(expected_lines))
|
|
903
|
+
for i in range(max_len):
|
|
904
|
+
if i >= len(actual_lines):
|
|
905
|
+
entries.append(_prefix.line(i + 1).entry(actual=None, absent="actual", expected=expected_lines[i]))
|
|
906
|
+
elif i >= len(expected_lines):
|
|
907
|
+
entries.append(_prefix.line(i + 1).entry(actual=actual_lines[i], expected=None, absent="expected"))
|
|
908
|
+
elif actual_lines[i] != expected_lines[i]:
|
|
909
|
+
entries.append(_prefix.line(i + 1).entry(actual=actual_lines[i], expected=expected_lines[i]))
|
|
910
|
+
if not entries:
|
|
911
|
+
entries.append(DiffEntry(path=".", actual=actual, expected=expected))
|
|
912
|
+
return DiffResult(kind="string", entries=entries)
|
|
913
|
+
return DiffResult(kind="scalar", entries=[_prefix.leaf_entry(actual=actual, expected=expected)])
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _sub_diff_entries(
|
|
917
|
+
actual: object, expected: object, prefix: _Path = _ROOT, *, config=None
|
|
918
|
+
) -> list[DiffEntry] | None:
|
|
919
|
+
"""Canonical recursive diff for a value, returning path-level entries (or ``None`` for a leaf).
|
|
920
|
+
|
|
921
|
+
Recurses into mappings, dataclasses, namedtuples, model-dump objects and sequences, returning a
|
|
922
|
+
(possibly empty) list for those; anything else returns ``None`` so the caller renders a single
|
|
923
|
+
leaf entry. The empty-list-vs-``None`` distinction lets a caller tell a recursable value whose
|
|
924
|
+
children are all ``config``-tolerated (empty list, no entry) from a genuinely differing leaf
|
|
925
|
+
(``None``, one entry). This is the single nested engine shared by the top-level paths:
|
|
926
|
+
`_build_equality_diff()` (lists, dataclasses, ...) and the dict path
|
|
927
|
+
(`HelpersMixin._dict_err()`), which calls it with an empty ``prefix`` so the top-level dict
|
|
928
|
+
keys render bare (``b``) and nested keys render dotted (``u.b``).
|
|
929
|
+
"""
|
|
930
|
+
walk = _Walk(config)
|
|
931
|
+
frame = walk.opened(actual, expected, prefix)
|
|
932
|
+
return None if frame is None else walk.run(*frame)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _walk_leaves(value, prefix: _Path = _ROOT) -> Iterator[tuple[_Path, object]]:
|
|
936
|
+
"""Yield ``(path, leaf)`` for every scalar leaf of an object graph, depth-first.
|
|
937
|
+
|
|
938
|
+
Recurses into the same containers as the rich-diff engine (`_sub_diff_entries()`): mappings,
|
|
939
|
+
dataclasses, namedtuples, model-dump objects, attrs instances, lists and tuples. Anything else -
|
|
940
|
+
scalars, strings, sets, opaque objects - is yielded as a single leaf, so the paths match the diffs.
|
|
941
|
+
A value met again inside itself yields nothing: its leaves are the ones the walk is already giving, and a
|
|
942
|
+
leaf standing for it was a value the graph does not hold, which a predicate was then asked about.
|
|
943
|
+
|
|
944
|
+
A field of the value itself is named bare (``age``) where the diff walkers name it ``.age``: these
|
|
945
|
+
paths go into a message about the fields of the value under test, not into a diff between two of them.
|
|
946
|
+
|
|
947
|
+
Walked on a list of iterators with the ancestors held by id, as `_Walk` is and for the same reasons. An
|
|
948
|
+
exception, or the walk being closed, closes the iterators still open innermost first, as leaving nested
|
|
949
|
+
calls would have: an iterator of the value's own runs its cleanup before the caller sees the error.
|
|
950
|
+
"""
|
|
951
|
+
on_path: dict[int, object] = {}
|
|
952
|
+
root = ((part, prefix) for part in (value,))
|
|
953
|
+
stack: list[tuple[Generator[tuple[object, _Path], None, None], int | None]] = [(root, None)]
|
|
954
|
+
try:
|
|
955
|
+
while stack:
|
|
956
|
+
parts, owner = stack[-1]
|
|
957
|
+
for part, path in parts:
|
|
958
|
+
if type(part) in _EQ_ATOMIC:
|
|
959
|
+
yield (path, part)
|
|
960
|
+
continue
|
|
961
|
+
if id(part) in on_path:
|
|
962
|
+
continue
|
|
963
|
+
inner = _leaf_parts(part, path)
|
|
964
|
+
if inner is None:
|
|
965
|
+
yield (path, part)
|
|
966
|
+
continue
|
|
967
|
+
on_path[id(part)] = part
|
|
968
|
+
stack.append((inner, id(part)))
|
|
969
|
+
break
|
|
970
|
+
else:
|
|
971
|
+
stack.pop()
|
|
972
|
+
if owner is not None:
|
|
973
|
+
del on_path[owner]
|
|
974
|
+
except BaseException:
|
|
975
|
+
for parts, _ in reversed(stack):
|
|
976
|
+
parts.close()
|
|
977
|
+
raise
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _leaf_parts(value, prefix: _Path) -> Generator[tuple[object, _Path], None, None] | None:
|
|
981
|
+
"""The parts `_walk_leaves()` goes on into, each with its path, or ``None`` for a leaf."""
|
|
982
|
+
if is_mapping_like(value):
|
|
983
|
+
return ((value[key], prefix.key(key)) for key in value)
|
|
984
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
985
|
+
return _named_parts(value, dataclasses.fields(value), prefix, _field_name)
|
|
986
|
+
if is_namedtuple(value):
|
|
987
|
+
return _named_parts(value, value._fields, prefix, lambda name: name)
|
|
988
|
+
if is_model_dump_object(value):
|
|
989
|
+
held = model_field_values(value)
|
|
990
|
+
return ((held[key], prefix.attr(str(key), dotted_at_root=False)) for key in held)
|
|
991
|
+
if is_attrs_instance(value):
|
|
992
|
+
return _named_parts(value, value.__attrs_attrs__, prefix, _field_name)
|
|
993
|
+
if isinstance(value, (list, tuple)):
|
|
994
|
+
return ((item, prefix.index(index)) for index, item in enumerate(value))
|
|
995
|
+
return None
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _named_parts(
|
|
999
|
+
value, fields: Iterable[Any], prefix: _Path, name_of: Callable[[Any], str]
|
|
1000
|
+
) -> Generator[tuple[object, _Path], None, None]:
|
|
1001
|
+
"""Each field of *value* with its path, the name read for the path and read again for the value, as ever."""
|
|
1002
|
+
for field in fields:
|
|
1003
|
+
path = prefix.attr(name_of(field), dotted_at_root=False)
|
|
1004
|
+
yield getattr(value, name_of(field)), path
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _field_name(field: Any) -> str:
|
|
1008
|
+
return field.name
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def run_nested(walk: Generator[Any, Any, _T]) -> _T:
|
|
1012
|
+
"""Run a recursive walk written as a generator, which yields each nested walk it needs and is sent its result.
|
|
1013
|
+
|
|
1014
|
+
The nested walks wait on a list rather than on the interpreter's stack, so a value as deep as Python itself
|
|
1015
|
+
compares is walked, where a call per level ran into the recursion limit first. An exception closes the
|
|
1016
|
+
walks it leaves, innermost first, as returning through calls would have: an iterator of the value's own that
|
|
1017
|
+
a walk holds runs its cleanup before the caller sees the error.
|
|
1018
|
+
"""
|
|
1019
|
+
stack: list[Generator[Any, Any, Any]] = [walk]
|
|
1020
|
+
answer: Any = None
|
|
1021
|
+
try:
|
|
1022
|
+
while True:
|
|
1023
|
+
try:
|
|
1024
|
+
nested = stack[-1].send(answer)
|
|
1025
|
+
except StopIteration as done:
|
|
1026
|
+
stack.pop()
|
|
1027
|
+
if not stack:
|
|
1028
|
+
return done.value
|
|
1029
|
+
answer = done.value
|
|
1030
|
+
continue
|
|
1031
|
+
except RuntimeError as error:
|
|
1032
|
+
escaped = _escaped_stop(error)
|
|
1033
|
+
if escaped is None:
|
|
1034
|
+
raise
|
|
1035
|
+
else:
|
|
1036
|
+
stack.append(nested)
|
|
1037
|
+
answer = None
|
|
1038
|
+
continue
|
|
1039
|
+
raise escaped
|
|
1040
|
+
except BaseException:
|
|
1041
|
+
for pending in reversed(stack):
|
|
1042
|
+
pending.close()
|
|
1043
|
+
raise
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _escaped_stop(error: RuntimeError) -> StopIteration | None:
|
|
1047
|
+
"""The `StopIteration` a walk's own frame turned into *error*, to be raised as it was, or ``None``.
|
|
1048
|
+
|
|
1049
|
+
A `StopIteration` leaving a generator becomes a `RuntimeError`, where the same call in a plain function let it
|
|
1050
|
+
through, and the walks were plain functions. Turned by the walk itself, the error's traceback holds only the
|
|
1051
|
+
frame that resumed it; turned deeper, by a generator of the value's own, it passed through the walk's frame and
|
|
1052
|
+
was a `RuntimeError` then too.
|
|
1053
|
+
"""
|
|
1054
|
+
cause = error.__cause__
|
|
1055
|
+
trace = error.__traceback__
|
|
1056
|
+
if isinstance(cause, StopIteration) and trace is not None and trace.tb_next is None:
|
|
1057
|
+
return cause
|
|
1058
|
+
return None
|