assertpy 2.35.0__py3-none-any.whl

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