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