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,843 @@
1
+ """The equality decision itself, with nothing around it.
2
+
3
+ Equality was already computed in one place structurally: `_sub_diff_entries` walks both sides and the
4
+ compare config travels with it. What was not in one place was the *entry* to that walk. The recursive
5
+ mapping comparison, the ignore/include filtering and the dict-shape test lived on `HelpersMixin`, which
6
+ made them reachable from a builder and from nowhere else. A matcher therefore could not offer
7
+ `tolerance`, `ignore`, `include`, `comparators` or `ignore_null` at all: `match.equal_to(x, tolerance=0.1)`
8
+ raised `TypeError` while `assert_that(v).is_equal_to(x, tolerance=0.1)` worked, and the two spellings of
9
+ one relation answered different questions.
10
+
11
+ Everything here is a free function over values. The mixin keeps thin wrappers so that an extension
12
+ calling `self._dict_not_equal(...)` still works, and the matcher calls the same functions directly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import collections
18
+ import collections.abc
19
+ import dataclasses
20
+ import re
21
+ import types
22
+ from typing import TYPE_CHECKING, Any, cast
23
+
24
+ from ..errors import _capped_format
25
+ from ._compare import (
26
+ _EQ_ATOMIC,
27
+ _guarded_equal,
28
+ _keyed_types_differ,
29
+ _kinds_never_equal,
30
+ _node_decision,
31
+ _resolve_comparator,
32
+ _spec_matches,
33
+ _types_differ,
34
+ _walked_equal,
35
+ )
36
+ from ._diff import _child_entries, _escaped_stop, _sub_diff_entries
37
+ from ._introspection import (
38
+ TakenApart,
39
+ basic_size,
40
+ is_attrs_instance,
41
+ is_model_dump_object,
42
+ is_namedtuple,
43
+ keyed_names,
44
+ keyed_pair,
45
+ model_field_values,
46
+ own_fields,
47
+ weakref_offset,
48
+ )
49
+ from ._ordering import REFUSALS, equals, lookup, member
50
+ from ._path import _ROOT
51
+ from ._require import refuse
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Callable, Iterator
55
+
56
+ from ._compare import _CompareConfig
57
+ from ._introspection import MappingLike
58
+
59
+ _KeysFrame = tuple[Iterator[Any], tuple[int, int] | None]
60
+ """A mapping's keys still to compare, and the pair it put on the path, which under key specs it does not."""
61
+
62
+
63
+ def normalize_key_specs(specs: object, param: str) -> list:
64
+ """An ``ignore``/``include`` argument as a flat list of key-specs.
65
+
66
+ A ``list``/``set``/``frozenset`` is a collection of specs and is expanded. A ``str``/``bytes``/
67
+ ``tuple`` (a single key, or a nested-path key) or any non-iterable hashable key is one spec. Any
68
+ other iterable is refused: it is one-shot or ambiguous, and would otherwise be mishandled in silence
69
+ as a single opaque key. A path of no keys names nothing and is left out.
70
+ """
71
+ if isinstance(specs, (list, set, frozenset)):
72
+ return [spec for spec in specs if type(spec) is not tuple or spec]
73
+ if isinstance(specs, (str, bytes, tuple)) or not isinstance(specs, collections.abc.Iterable):
74
+ return [specs] if type(specs) is not tuple or specs else []
75
+ refuse(specs, "a key, a nested-path tuple, or a list/set/frozenset of them", subject=param)
76
+
77
+
78
+ def key_specs_given(specs: object) -> bool:
79
+ """Whether an ``ignore``/``include`` argument asks for anything, a falsy key such as ``0`` or ``""`` included.
80
+
81
+ Truthiness answered this and dropped a single falsy key in silence. An empty collection still asks
82
+ for nothing, as it always did, and so does a path of no keys, which is an exact empty tuple: a tuple of a
83
+ class of its own is a key. A collection holding nothing but such paths is an option given all the same, with
84
+ nothing left out of it once it is read.
85
+ """
86
+ if type(specs) is tuple:
87
+ return bool(specs)
88
+ return specs is not None and not (isinstance(specs, (list, set, frozenset)) and not specs)
89
+
90
+
91
+ def comparable_fields(obj: object) -> dict | None:
92
+ """An object with introspectable fields as a dict of them, or ``None`` when it has no fields to compare.
93
+
94
+ Dataclasses are converted by reference rather than through `dataclasses.asdict`, which deep-copies and
95
+ crashes on a field that cannot be copied, and a field declared ``compare=False`` is left out, as the
96
+ dataclass's own ``==`` leaves it out. An attrs field declared ``eq=False`` is left out the same way, and
97
+ one declared with a key, ``eq=str.lower``, is read through it: `attrs.asdict` read the raw value, and
98
+ ``ignore=`` failed on two instances ``==`` holds equal.
99
+
100
+ Any other object is read by its ``__dict__`` where it holds nothing else (`own_fields`): two lists of a
101
+ class of the caller's own were equal under ``ignore=`` whatever they held, and so were two exceptions,
102
+ whose ``args`` are not in it.
103
+
104
+ An exception is read as its class, its ``args`` and the attributes in its ``__dict__``, where its class
105
+ has no room in the instance for more (`_holds_only_its_args`): an `OSError` keeps a ``filename`` in
106
+ neither, and stays with its own ``==``. The class is a field of its own, ``__class__``, because what an
107
+ error is starts with its class: read as a record is, a `ValueError` and a `TypeError` of one message were
108
+ equal. The two names are the reader's: an entry of the ``__dict__`` under either does not replace them.
109
+ An exception that is a dataclass, an attrs class or a model is read as that record, by the fields it
110
+ declares, as its own generated ``==`` reads it.
111
+ """
112
+ through = _read_through(obj)
113
+ if through is not None:
114
+ return cast("dict", _fields_through(obj, through))
115
+ if is_namedtuple(obj):
116
+ return TakenApart(type(obj), obj._asdict())
117
+ if issubclass(type(obj), BaseException):
118
+ if not _holds_only_its_args(type(obj)):
119
+ return None
120
+ # off the slots of `BaseException`: a class may give itself an ``args`` or a ``__dict__`` of its own
121
+ slots = BaseException.__dict__
122
+ read = {"__class__": type(obj), "args": slots["args"].__get__(obj)}
123
+ return TakenApart(type(obj), {**read, **slots["__dict__"].__get__(obj), **read})
124
+ held = own_fields(obj)
125
+ return None if held is None else TakenApart(type(obj), held)
126
+
127
+
128
+ def fields_held(obj: object) -> collections.abc.Mapping | None:
129
+ """The fields of a record as it holds them, one level deep, or ``None`` for a value that is no record.
130
+
131
+ For a question asked of many rows about their own fields: taking each apart all the way down, as
132
+ `comparable_fields` does, cost a failed ``contains`` over 300 records 0.72 ms against 0.58 ms read this way.
133
+ """
134
+ held = _held(obj, frozenset({"dataclass", "attrs", "model"}))
135
+ if held is not None:
136
+ return held[0]
137
+ return obj._asdict() if is_namedtuple(obj) else None
138
+
139
+
140
+ def _holds_only_its_args(kind: type) -> bool:
141
+ """Whether an exception of *kind* has no room in the instance past what `BaseException` has, by its layout.
142
+
143
+ `BaseException` has the ``__dict__`` in it already, so past it a class of the caller's own adds a weak
144
+ reference list and nothing else. `OSError`, `ImportError`, `SyntaxError`, `StopIteration`, an exception
145
+ group and a class with a slot add room, and what is in it is in neither ``args`` nor ``__dict__``.
146
+ State a class keeps outside the instance, a property over a table of its own, is not seen by this or by
147
+ any reading of the instance.
148
+ """
149
+ spare = basic_size(kind) - BaseException.__basicsize__
150
+ return spare == tuple.__itemsize__ * (weakref_offset(kind) > 0)
151
+
152
+
153
+ class _NestedTooDeepError(Exception):
154
+ """`_flattened` went deeper than a value that ends is expected to: it may lead back to itself."""
155
+
156
+
157
+ def _flattened(node: Any, through: frozenset[str], depth: int = 0) -> Any:
158
+ """Fields by reference, as each value's ``==`` reads them, taken apart through the kinds *through* names.
159
+
160
+ A nested value is taken apart where the conversion each kind used to go through took it apart, so a
161
+ nested value is judged as it was: `dataclasses.asdict` went through dataclasses, `attrs.asdict` through
162
+ attrs instances, and `model_dump()` through models and the dataclasses inside them. What is read is
163
+ what is held, though, not what a serialiser or a copy makes of it.
164
+
165
+ Nothing met is remembered, so a value that leads back to itself would never end: past 64 levels this gives
166
+ up with `_NestedTooDeepError`. The number is where remembering starts to pay, not a promise about the stack:
167
+ a ring of three records took 2.25 ms found by `RecursionError` and 0.9 ms found here.
168
+ """
169
+ if type(node) in _EQ_ATOMIC:
170
+ return node
171
+ deeper = depth + 1
172
+ if deeper > 64:
173
+ raise _NestedTooDeepError
174
+ if "dataclass" in through and dataclasses.is_dataclass(node) and not isinstance(node, type):
175
+ return TakenApart(
176
+ type(node),
177
+ {
178
+ field.name: _flattened(getattr(node, field.name), through, deeper)
179
+ for field in dataclasses.fields(node)
180
+ if field.compare
181
+ },
182
+ )
183
+ if "attrs" in through and is_attrs_instance(node):
184
+ compared = [attribute for attribute in node.__attrs_attrs__ if attribute.eq is not False]
185
+ keys = {attribute.name: _key_of(attribute) for attribute in compared}
186
+ return TakenApart(
187
+ type(node),
188
+ {
189
+ attribute.name: getattr(node, attribute.name)
190
+ if keys[attribute.name] is not None
191
+ else _flattened(getattr(node, attribute.name), through, deeper)
192
+ for attribute in compared
193
+ },
194
+ {name: key for name, key in keys.items() if key is not None},
195
+ )
196
+ if "model" in through and is_model_dump_object(node):
197
+ return TakenApart(
198
+ type(node),
199
+ {name: _flattened(value, through, deeper) for name, value in model_field_values(node).items()},
200
+ )
201
+ # what a value is, is asked of its class from here on: a value may answer `isinstance` itself
202
+ held: Any = node
203
+ kind = type(held)
204
+ if issubclass(kind, tuple) and hasattr(held, "_fields"):
205
+ return kind(*[_flattened(item, through, deeper) for item in held])
206
+ if issubclass(kind, (list, tuple)):
207
+ return kind(_flattened(item, through, deeper) for item in held)
208
+ if issubclass(kind, dict):
209
+ # a key stays as held: taken apart, a record used as one could not be hashed
210
+ rebuilt = {key: _flattened(value, through, deeper) for key, value in held.items()}
211
+ return rebuilt if kind is dict else _as_its_class(held, rebuilt)
212
+ return node
213
+
214
+
215
+ def _as_its_class(node: dict, rebuilt: dict) -> dict:
216
+ """*rebuilt* as a dict of the class *node* is of (`_of_its_class`)."""
217
+ kept = _of_its_class(node)
218
+ put = _put_into(kept)
219
+ for key, value in rebuilt.items():
220
+ put(kept, key, value)
221
+ return kept
222
+
223
+
224
+ def _put_into(kept: dict) -> Any:
225
+ """What puts an item into a dict being rebuilt, past a ``__setitem__`` its class may have of its own."""
226
+ ordered = issubclass(type(kept), collections.OrderedDict)
227
+ return collections.OrderedDict.__setitem__ if ordered else dict.__setitem__
228
+
229
+
230
+ def _of_its_class(node: dict) -> dict:
231
+ """An empty dict to rebuild *node* into, which keeps what its class means for equality.
232
+
233
+ An `OrderedDict` stays one: its ``==`` reads the order, and taken apart into a plain mapping two that hold
234
+ the same items in another order were equal under a record. Any other dict of a class of its own keeps the
235
+ class as a `TakenApart`: rebuilt plain, ``strict_types`` held it equal to a dict.
236
+ """
237
+ if issubclass(type(node), collections.OrderedDict):
238
+ return collections.OrderedDict.__new__(type(node))
239
+ return TakenApart(type(node), {})
240
+
241
+
242
+ def _fields_through(node: object, through: frozenset[str]) -> Any:
243
+ """*node* taken apart (`_flattened`), and where that goes too deep to be a value that ends, taken apart once
244
+ per value met (`_flattened_once`).
245
+
246
+ The second way is not the first one's price: remembering every value cost a fifth of a comparison under
247
+ ``ignore``, which a value that holds itself, or one nested deeper than the first way goes, now pays alone.
248
+ It also takes a frame a level where the first takes two, so it is tried where the first ran out of them.
249
+ """
250
+ try:
251
+ return _flattened(node, through)
252
+ except (_NestedTooDeepError, RecursionError):
253
+ memo: dict[int, Any] = {}
254
+ taken = _flattened_once(node, through, memo)
255
+ _mend(memo)
256
+ return taken
257
+
258
+
259
+ def _mend(memo: dict[int, Any]) -> None:
260
+ """Put the rebuilt value where `_flattened_once` left one as it was, met again while still being rebuilt.
261
+
262
+ Left there, a tuple led back to the values not taken apart, and a record behind it was compared whole. A
263
+ dict or a list is the place to put it. A tuple holding one is made again, and tuples alone lead back to
264
+ nothing, so that ends. A field attrs compares through a key is held raw, and stays so.
265
+ """
266
+ for rebuilt in list(memo.values()):
267
+ if issubclass(type(rebuilt), dict):
268
+ raw = rebuilt.compared_by if isinstance(rebuilt, TakenApart) else ()
269
+ for key, value in rebuilt.items():
270
+ if key not in raw:
271
+ rebuilt[key] = _mended(value, memo)
272
+ elif issubclass(type(rebuilt), list):
273
+ rebuilt[:] = [_mended(item, memo) for item in rebuilt]
274
+
275
+
276
+ def _mended(value: Any, memo: dict[int, Any]) -> Any:
277
+ """*value* as it was rebuilt where it was left as it was, or made again where it is a tuple holding such a one."""
278
+ rebuilt = memo.get(id(value), value)
279
+ if rebuilt is not value or not issubclass(type(value), tuple):
280
+ return rebuilt
281
+ items = [_mended(item, memo) for item in value]
282
+ if all(new is old for new, old in zip(items, value, strict=True)):
283
+ return value
284
+ return _of_its_kind(value, items)
285
+
286
+
287
+ def _of_its_kind(node: Any, items: list) -> Any:
288
+ """A sequence of the class *node* is of over *items*, handed to its constructor as `_flattened` hands them.
289
+
290
+ A named tuple takes them as arguments. Any other takes a generator and not the list: a class may be built
291
+ differently from the two, and one value taken apart the two ways then came out as two.
292
+ """
293
+ return type(node)(*items) if hasattr(node, "_fields") else type(node)(item for item in items)
294
+
295
+
296
+ def _flattened_once(node: Any, through: frozenset[str], memo: dict[int, Any]) -> Any:
297
+ """`_flattened`, with each value taken apart once: met again, it is what it came apart into the first time.
298
+
299
+ So a value that holds itself comes apart into fields that hold themselves, and the walk over them meets the
300
+ pair again as it does in a graph of dicts. A dict or a list is on record before its items are rebuilt, so one
301
+ met again inside itself is the rebuilt one: left as it was, it led back to the values not taken apart, and a
302
+ record behind it was compared whole. A tuple cannot be, having no items until it is made, so one met again
303
+ while it is still being rebuilt stays as it is here, and `_mend` puts the rebuilt one in its place afterwards.
304
+ Every value met is held by the one being taken apart, so its id stays its own.
305
+ """
306
+ if type(node) in _EQ_ATOMIC:
307
+ return node
308
+ if id(node) in memo:
309
+ return memo[id(node)]
310
+ held = _held(node, through)
311
+ if held is not None:
312
+ fields, compared_by = held
313
+ taken = memo[id(node)] = TakenApart(type(node), {}, compared_by)
314
+ for name, value in fields.items():
315
+ taken[name] = value if name in taken.compared_by else _flattened_once(value, through, memo)
316
+ return taken
317
+ if not issubclass(type(node), (list, tuple, dict)):
318
+ return node
319
+ # no generator: through one a level was three frames on Python 3.10, 331 levels deep against 496
320
+ rebuilt: Any
321
+ if issubclass(type(node), dict):
322
+ rebuilt = memo[id(node)] = {} if type(node) is dict else _of_its_class(node)
323
+ put = _put_into(rebuilt)
324
+ for key, value in node.items():
325
+ put(rebuilt, key, _flattened_once(value, through, memo))
326
+ return rebuilt
327
+ if type(node) is list:
328
+ rebuilt = memo[id(node)] = []
329
+ for item in node:
330
+ rebuilt.append(_flattened_once(item, through, memo))
331
+ return rebuilt
332
+ memo[id(node)] = node
333
+ items = [_flattened_once(item, through, memo) for item in node]
334
+ rebuilt = memo[id(node)] = _of_its_kind(node, items)
335
+ return rebuilt
336
+
337
+
338
+ def _held(node: Any, through: frozenset[str]) -> tuple[collections.abc.Mapping, collections.abc.Mapping | None] | None:
339
+ """The fields `_flattened` takes *node* apart into, as *node* holds them, and the keys attrs compares some of
340
+ them through; ``None`` for a value it does not take apart."""
341
+ if "dataclass" in through and dataclasses.is_dataclass(node) and not isinstance(node, type):
342
+ return {field.name: getattr(node, field.name) for field in dataclasses.fields(node) if field.compare}, None
343
+ if "attrs" in through and is_attrs_instance(node):
344
+ compared = [attribute for attribute in node.__attrs_attrs__ if attribute.eq is not False]
345
+ keys = {attribute.name: _key_of(attribute) for attribute in compared}
346
+ held = {attribute.name: getattr(node, attribute.name) for attribute in compared}
347
+ return held, {name: key for name, key in keys.items() if key is not None}
348
+ if "model" in through and is_model_dump_object(node):
349
+ return model_field_values(node), None
350
+ return None
351
+
352
+
353
+ def _key_of(attribute: Any) -> Any:
354
+ """The key an attrs field is compared through, given as ``eq=``, or ``None``: its value is then held raw."""
355
+ return getattr(attribute, "eq_key", None)
356
+
357
+
358
+ def _read_through(obj: object) -> frozenset[str] | None:
359
+ """The kinds of record `comparable_fields` takes *obj* apart through, all the way down, or ``None``.
360
+
361
+ ``None`` for a value read one level deep or not at all: a named tuple, an object through its ``__dict__``.
362
+ """
363
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
364
+ return frozenset({"dataclass"})
365
+ if is_namedtuple(obj):
366
+ return None
367
+ if is_model_dump_object(obj):
368
+ return frozenset({"model", "dataclass"})
369
+ if is_attrs_instance(obj):
370
+ return frozenset({"attrs"})
371
+ return None
372
+
373
+
374
+ def as_fields(value: object, beside: object = None) -> Any:
375
+ """A dict or another mapping by its keys, anything else through `comparable_fields`.
376
+
377
+ A dict *beside* a record taken apart all the way down is taken apart as deep. Left as held, a record the
378
+ dict holds was compared whole against the fields of the equal one the record holds, and the two differed.
379
+ A dict that is a record as well stays as held: taken apart it was read by its fields, and a key it held
380
+ beside them was gone.
381
+
382
+ What is a dict is asked of the class, since a value may answer `isinstance` itself. A mapping that is no
383
+ dict and no record is its keys too: refused, a `MappingProxyType` beside a record was called not dict-like,
384
+ and a `ChainMap` was read through the attribute it keeps its maps in.
385
+ """
386
+ if not issubclass(type(value), dict):
387
+ own = _read_through(value)
388
+ if own is not None:
389
+ return _fields_through(value, own)
390
+ return value if mapping_shaped(value, check_values=False) else comparable_fields(value)
391
+ through = None if issubclass(type(beside), dict) else _read_through(beside)
392
+ if through is None or _read_through(value) is not None:
393
+ return value
394
+ return _fields_through(value, through)
395
+
396
+
397
+ def fields_pair(value: object, other: object) -> tuple[Any, Any]:
398
+ """Two values through their fields, each read beside the other (`as_fields`)."""
399
+ return as_fields(value, other), as_fields(other, value)
400
+
401
+
402
+ def _plain_sequence(value: object) -> bool:
403
+ return isinstance(value, (list, tuple)) and not is_namedtuple(value)
404
+
405
+
406
+ def ignore_specs(ignore: object) -> list:
407
+ """Ignore-specs, keeping nested paths whole: a one-element tuple is just that key."""
408
+ return [
409
+ entry[0] if type(entry) is tuple and len(entry) == 1 else entry
410
+ for entry in normalize_key_specs(ignore, "ignore")
411
+ ]
412
+
413
+
414
+ def include_specs(include: object) -> list:
415
+ """Include-specs for one level: a nested path selects its first segment here, the rest deeper down."""
416
+ return [entry[0] if type(entry) is tuple else entry for entry in normalize_key_specs(include, "include")]
417
+
418
+
419
+ _NOT_DEFINED = object()
420
+
421
+
422
+ def _defined_on(cls: type, name: str) -> object:
423
+ """What the interpreter's own type lookup finds for *name*, or `_NOT_DEFINED`.
424
+
425
+ A walk of the MRO namespaces, which is what `_PyType_Lookup` does and what `getattr` on the class
426
+ does not: the ordinary one runs through the metaclass, so a `__getattr__` there can fabricate a
427
+ member the C slot never sees, and a `__getattribute__` there can hide one it does. Reached through
428
+ `type.__getattribute__` for the same reason.
429
+
430
+ A `None` found this way is a definition and not an absence. It is how a subclass takes a special
431
+ member away, and the lookup stops on it exactly as it stops on a real one.
432
+ """
433
+ for base in type.__getattribute__(cls, "__mro__"):
434
+ namespace = type.__getattribute__(base, "__dict__")
435
+ if name in namespace:
436
+ return namespace[name]
437
+ return _NOT_DEFINED
438
+
439
+
440
+ _DIRECTLY_CALLABLE = (
441
+ types.FunctionType,
442
+ types.BuiltinFunctionType,
443
+ types.MethodType,
444
+ types.MethodWrapperType,
445
+ types.WrapperDescriptorType,
446
+ types.BuiltinMethodType,
447
+ )
448
+ """Callables answered without resolving anything. A class is deliberately not one: a metaclass can null
449
+ `__call__` out, and then `callable()` says yes and instantiating raises."""
450
+
451
+
452
+ def _bound_special(candidate: object, name: str, hops: int) -> object:
453
+ """What the interpreter would call for *name* as a special member, or `_NOT_DEFINED`.
454
+
455
+ The type lookup plus the descriptor step, which together are `_PyObject_LookupSpecial`. A
456
+ definition that cannot be resolved comes back as `None`, because that is what calling it amounts
457
+ to: not callable, and the caller says so in its own words.
458
+ """
459
+ raw = _defined_on(type(candidate), name)
460
+ if raw is _NOT_DEFINED:
461
+ return _NOT_DEFINED
462
+ binding = _defined_on(type(raw), "__get__")
463
+ if binding is _NOT_DEFINED:
464
+ return raw # nothing to resolve through, so the definition found is the one called
465
+ if not _reachable_call(binding, hops):
466
+ return None # a defined `__get__` is reached whatever it holds, so `__get__ = None` raises
467
+ resolve = cast("Callable[[object, object, type], object]", binding)
468
+ try:
469
+ return resolve(raw, candidate, type(candidate))
470
+ except Exception:
471
+ # a binding that raises would raise on the real lookup too, and this question is asked while
472
+ # rendering a failure, where a crash replaces the failure the reader came for
473
+ return None
474
+
475
+
476
+ def _reachable_call(value: object, hops: int = 3) -> bool:
477
+ """Whether calling *value* reaches an implementation rather than raising `TypeError`.
478
+
479
+ `callable()` answers one level: it says the type has a call slot, and Python's own documentation
480
+ says that is not a promise the call succeeds. `__call__ = None` fills the slot and fails when
481
+ reached, which is the same trick as `__getitem__ = None` one level down.
482
+
483
+ Bounded rather than fully recursive, and the bound is not arbitrary: almost every callable is a
484
+ function, a method or a class and is answered by the first test, so a chain deep enough to exhaust
485
+ the hops is one built on purpose. A value that does is called what `callable()` calls it.
486
+ """
487
+ if not callable(value):
488
+ return False
489
+ if isinstance(value, _DIRECTLY_CALLABLE) or hops == 0:
490
+ return True
491
+ return _reachable_call(_bound_special(value, "__call__", hops - 1), hops - 1)
492
+
493
+
494
+ def carries_callable(candidate: object, name: str) -> bool:
495
+ """Whether calling `candidate.name()` reaches an implementation rather than raising `TypeError`.
496
+
497
+ Deliberately no test for where the attribute came from. Forwarding through `__getattr__` is how a
498
+ proxy delegates, and a rule against it refuses every wrapper over a mapping. Two attempts at such a
499
+ rule were made and both were unsound: fabrication is not the offence, and no nominal test tells a
500
+ `unittest.mock` attribute from a delegated one.
501
+
502
+ A value that answers this and is then unreadable by key is handled where it shows, in `_dict_err`,
503
+ which falls back to a plain repr rather than letting the shape guess replace the failure.
504
+ """
505
+ return _reachable_call(getattr(candidate, name, None))
506
+
507
+
508
+ def supports_subscript(candidate: object) -> bool:
509
+ """Whether `candidate[key]` will reach an implementation instead of raising `TypeError`.
510
+
511
+ Neither `hasattr` nor plain presence on the MRO answers this. The operator is looked up on the
512
+ type, so a `__getattr__` on the instance answers `hasattr` for a subscript the object does not
513
+ have, and `__getitem__ = None` in a subclass shadows a working parent while still being present.
514
+
515
+ The descriptor step is not decoration: a `__getitem__` written as a `property` really is resolved
516
+ and really does work, measured against CPython.
517
+ """
518
+ return _reachable_call(_bound_special(candidate, "__getitem__", 3))
519
+
520
+
521
+ def mapping_shaped(
522
+ candidate: object, *, check_keys: bool = True, check_values: bool = True, check_getitem: bool = True
523
+ ) -> bool:
524
+ """Whether *candidate* has the requested dict-like attributes.
525
+
526
+ Deliberately structural rather than `isinstance(..., Mapping)`: the package accepts anything that
527
+ answers `keys()`/`values()`/`[]`, which is what a config object or a lightweight row wrapper does.
528
+ """
529
+ if type(candidate) is dict: # fast path: a real dict satisfies every check, skip the ABC isinstance
530
+ return True
531
+ if not isinstance(candidate, collections.abc.Iterable):
532
+ return False
533
+ if check_keys and not carries_callable(candidate, "keys"):
534
+ return False
535
+ if check_values and not carries_callable(candidate, "values"):
536
+ return False
537
+ return not check_getitem or supports_subscript(candidate)
538
+
539
+
540
+ def values_differ(value: object, other: object, config: _CompareConfig | None, *, at_root: bool = False) -> bool:
541
+ """Whether two non-mapping values differ, delegating to the shared structural walker.
542
+
543
+ Asking the walker keeps the equality *decision* and the rendered *diff* in agreement: every shape it
544
+ can decompose (dataclass, attrs, namedtuple, model, sequence) is then compared under the compare
545
+ config, instead of falling back to plain equality and silently dropping that config. Without a
546
+ config there is nothing to honour, so the plain check is kept exactly as it was.
547
+ """
548
+ if value is other and not at_root:
549
+ # identity first, as Python's containers do. Not at the root, where `nan` made `strict_types` the weaker rule
550
+ return False
551
+ if config is None:
552
+ equal = _walked_equal(value, other)
553
+ if equal is None:
554
+ # a graph `==` cannot finish, reached under a key option: the walk reads it, a pair met again being equal
555
+ return bool(_child_entries(value, other, _ROOT, descended_for="unanswered"))
556
+ return not equal
557
+ if at_root and config.comparators and _resolve_comparator(value, config, field=None) is not None:
558
+ # a comparator owns the root too, where the walk below starts at the children
559
+ return _node_decision(value, other, config, at_root=True) == "leaf"
560
+ if _kinds_never_equal(value, other) and not _guarded_equal(value, other):
561
+ return True
562
+ entries = _sub_diff_entries(value, other, _ROOT, config=config)
563
+ if entries is None:
564
+ decision = _node_decision(value, other, config, at_root=at_root)
565
+ if decision == "strict":
566
+ # equal by its own `==`, with what it holds still to look at: an object by its fields, a leaf by nothing
567
+ return bool(_child_entries(value, other, _ROOT, descended_for="strict", config=config))
568
+ return decision != "equal"
569
+ return bool(entries)
570
+
571
+
572
+ class IncludeKeysMissingError(LookupError):
573
+ """An ``include`` naming a key the mapping does not have, at whatever depth it was found.
574
+
575
+ Not a difference and not a refusal of types, so neither answer fits: the builder reports it as a
576
+ failure in its own wording, and a matcher treats it as a non-match. Carried as an exception because
577
+ the recursion finds it several levels down, where the verdict `True`/`False` has no room for it.
578
+ """
579
+
580
+ def __init__(self, mapping: object, includes: list, missing: list) -> None:
581
+ super().__init__(f"include names {_capped_format(missing)}, which {_capped_format(mapping)} does not have")
582
+ self.mapping = mapping
583
+ self.includes = includes
584
+ self.missing = missing
585
+
586
+
587
+ def filtered_to_nothing(actual: object, expected: object, *, ignore: object, include: object) -> bool:
588
+ """Whether ``ignore`` and ``include`` left none of the keys two mappings had to compare.
589
+
590
+ Asked once a comparison under a key filter has passed, so both sides kept the same keys and reading
591
+ one is enough. Two empty mappings still checked that both are empty, and an include naming a key the
592
+ actual side lacks is a failure of its own, so neither counts. Only `dict` values are read a second
593
+ time: another mapping may answer differently.
594
+ """
595
+ if not isinstance(actual, dict) or not isinstance(expected, dict) or not (actual or expected):
596
+ return False
597
+ ignores = ignore_specs(ignore) if key_specs_given(ignore) else []
598
+ includes = include_specs(include) if key_specs_given(include) else []
599
+ return not _kept_keys(actual, ignores, includes) and not missing_include_keys(actual, includes)
600
+
601
+
602
+ def missing_include_keys(mapping: MappingLike, includes: list) -> list:
603
+ """Include-keys naming something the mapping does not have."""
604
+ return [key for key in includes if not isinstance(key, (re.Pattern, type)) and not member(key, mapping)]
605
+
606
+
607
+ def mapping_differs(
608
+ actual: object,
609
+ expected: object,
610
+ *,
611
+ ignore: object = None,
612
+ include: object = None,
613
+ config: _CompareConfig | None = None,
614
+ ) -> bool:
615
+ """Whether two dict-like values differ under the given filtering and compare config.
616
+
617
+ Normalization happens here rather than at the call sites, because the two spellings differ and the
618
+ difference is easy to get wrong: at one level an `include` of `("user", "session")` selects `user`,
619
+ while the recursion into `user` needs the whole path to strip its first segment from.
620
+
621
+ Walked on a list of frames as the structural diff is, `_Walk`, and for the same reasons: a mapping nested
622
+ past Python's recursion limit is answered, and the pairs on the path are one set rather than a copy a level.
623
+
624
+ A pair met again inside itself is equal as far as that pair goes, where it is the same question: the pair
625
+ compared whole. Under key specs still to apply it is another question each time, since a spec applies at its
626
+ own level and a path loses a segment a level, so such a pair is neither put on the path nor found there. The
627
+ specs run out after the longest path, which is where that ends.
628
+ """
629
+ on_path: set[tuple[int, int]] = set()
630
+ opened = _mapping_opened(actual, expected, ignore, include, config, on_path, inside=False)
631
+ if isinstance(opened, bool):
632
+ return opened
633
+ stack = [opened]
634
+ while stack:
635
+ keys, pair = stack[-1]
636
+ try:
637
+ nested = next(keys, None)
638
+ except RuntimeError as error:
639
+ escaped = _escaped_stop(error)
640
+ if escaped is None:
641
+ raise
642
+ else:
643
+ if nested is None:
644
+ stack.pop()
645
+ on_path.discard(pair)
646
+ elif nested is True:
647
+ return True
648
+ else:
649
+ stack.append(nested)
650
+ continue
651
+ raise escaped
652
+ return False
653
+
654
+
655
+ def _mapping_opened(
656
+ actual: object,
657
+ expected: object,
658
+ ignore: object,
659
+ include: object,
660
+ config: _CompareConfig | None,
661
+ on_path: set[tuple[int, int]],
662
+ *,
663
+ inside: bool,
664
+ ) -> bool | _KeysFrame:
665
+ """The verdict on two mappings where it is reached before their keys, else the frame over the keys.
666
+
667
+ *inside* says the pair was reached by a walk rather than handed to one.
668
+ """
669
+ # one cast at the top beats a suppression on each of the six lookups below
670
+ left = cast("MappingLike", actual)
671
+ right = cast("MappingLike", expected)
672
+ ignoring, including = key_specs_given(ignore), key_specs_given(include)
673
+ pair = None if ignoring or including else (id(actual), id(expected))
674
+ if pair in on_path:
675
+ return False
676
+
677
+ if pair is not None and config is None:
678
+ try:
679
+ return not _guarded_equal(actual, expected)
680
+ except RecursionError:
681
+ # inside a walk, a graph `==` cannot finish is taken key by key below, where a pair met again is equal
682
+ if not inside:
683
+ raise
684
+
685
+ ignores = ignore_specs(ignore) if ignoring else []
686
+ includes = include_specs(include) if including else []
687
+ # read once for the whole mapping: asked per key, a five-hundred path include normalised itself five
688
+ # hundred times. After `include_specs`, so a one-shot iterable is still refused under its own name
689
+ nested_paths = ignore_specs(include) if including else []
690
+ if includes:
691
+ missing = missing_include_keys(left, includes)
692
+ if missing:
693
+ raise IncludeKeysMissingError(left, includes, missing)
694
+ keys_in_actual = _kept_keys(left, ignores, includes)
695
+ keys_in_expected = _kept_keys(right, ignores, includes)
696
+ if not equals(keys_in_actual, keys_in_expected) or _order_differs(actual, expected, keys_in_actual):
697
+ return True
698
+ if (
699
+ config is not None
700
+ and config.strict_types
701
+ and (
702
+ # two elements taken apart reach here with no parent walk to have compared their classes
703
+ _types_differ(actual, expected)
704
+ or _keyed_types_differ(
705
+ dict.fromkeys(key for key in left if key in keys_in_actual),
706
+ dict.fromkeys(key for key in right if key in keys_in_expected),
707
+ )
708
+ )
709
+ ):
710
+ # `{True: "a"}` and `{1: "a"}` are equal to Python and not under strict types; only the keys still compared
711
+ return True
712
+ if pair is not None:
713
+ on_path.add(pair)
714
+ keys = _differing_keys(left, right, keys_in_actual, ignores, nested_paths, config, on_path, ignoring, including)
715
+ return keys, pair
716
+
717
+
718
+ def _differing_keys(
719
+ left: MappingLike,
720
+ right: MappingLike,
721
+ keys_in_actual: set,
722
+ ignores: list,
723
+ nested_paths: list,
724
+ config: _CompareConfig | None,
725
+ on_path: set[tuple[int, int]],
726
+ ignoring: bool,
727
+ including: bool,
728
+ ) -> Iterator[bool | _KeysFrame]:
729
+ """The keys of two mappings in turn: ``True`` where one differs, which ends the walk, or a mapping's frame."""
730
+ keyed = keyed_names(left, right)
731
+ # under strict types the decision has to know whether a path goes on into the pair it is asked of
732
+ strict = config is not None and config.strict_types and (ignoring or including)
733
+ paths = [entry for entry in (*ignores, *nested_paths) if type(entry) is tuple] if strict else ()
734
+ for key in keys_in_actual:
735
+ if key in keyed:
736
+ nested_left, nested_right = keyed_pair(left, right, key)
737
+ else:
738
+ try:
739
+ nested_left, nested_right = left[key], right[key]
740
+ except REFUSALS as refusal:
741
+ nested_left, nested_right = left[key], lookup(right, key, refusal)[1]
742
+ if config is not None:
743
+ keys_left_out = bool(paths) and any(equals(entry[0], key) for entry in paths)
744
+ decision = _node_decision(nested_left, nested_right, config, field=key, keys_left_out=keys_left_out)
745
+ if decision == "equal":
746
+ continue
747
+ if decision == "leaf":
748
+ yield True
749
+ nested_ignore = (
750
+ [entry[1:] for entry in ignores if type(entry) is tuple and equals(entry[0], key)] if ignoring else None
751
+ )
752
+ # the nested half of an include keeps whole paths, and the level above already consumed the first segment
753
+ nested_include = (
754
+ [entry[1:] for entry in nested_paths if type(entry) is tuple and equals(entry[0], key)]
755
+ if including
756
+ else None
757
+ )
758
+ nested = _nested_differs(
759
+ nested_left, nested_right, ignore=nested_ignore, include=nested_include, config=config, on_path=on_path
760
+ )
761
+ if nested is True:
762
+ yield True
763
+ elif nested is not False:
764
+ yield nested
765
+
766
+
767
+ def _kept_keys(mapping: MappingLike, ignores: list, includes: list) -> set:
768
+ """The keys a comparison looks at: those no ignore-spec names, and an include-spec does when there are any."""
769
+ return {
770
+ key
771
+ for key in mapping
772
+ if not (ignores and _spec_matches(key, mapping[key], ignores))
773
+ and (not includes or _spec_matches(key, mapping[key], includes))
774
+ }
775
+
776
+
777
+ def _order_differs(actual: object, expected: object, kept: set) -> bool:
778
+ """Whether two `OrderedDict` values hold the compared keys in different orders, which their `==` reads."""
779
+ if not (isinstance(actual, collections.OrderedDict) and isinstance(expected, collections.OrderedDict)):
780
+ return False
781
+ return not equals([key for key in actual if member(key, kept)], [key for key in expected if member(key, kept)])
782
+
783
+
784
+ def _nested_differs(
785
+ left: object,
786
+ right: object,
787
+ *,
788
+ ignore: object,
789
+ include: object,
790
+ config: _CompareConfig | None,
791
+ on_path: set[tuple[int, int]],
792
+ ) -> bool | _KeysFrame:
793
+ """One value under a key, compared with the rest of the key path that reaches into it.
794
+
795
+ A verdict, or the frame over the keys of a mapping still to compare, which `mapping_differs()` walks.
796
+ """
797
+ if mapping_shaped(left, check_values=False) and mapping_shaped(right, check_values=False):
798
+ return _mapping_opened(left, right, ignore, include, config, on_path, inside=True)
799
+ if key_specs_given(ignore) or key_specs_given(include):
800
+ # a path that goes on into a value is followed through its fields, which for a mapping are its keys
801
+ left_fields, right_fields = _keyed(left, right), _keyed(right, left)
802
+ if left_fields is not None and right_fields is not None:
803
+ return _mapping_opened(left_fields, right_fields, ignore, include, config, on_path, inside=True)
804
+ return values_differ(left, right, config)
805
+
806
+
807
+ def _keyed(value: object, beside: object) -> Any:
808
+ """A mapping as what a key path reads it through, its keys, and anything else through its fields."""
809
+ if mapping_shaped(value, check_values=False) and not issubclass(type(value), dict):
810
+ return value
811
+ return as_fields(value, beside)
812
+
813
+
814
+ def filtered_differs(
815
+ actual: object, expected: object, *, ignore: object, include: object, config: _CompareConfig | None
816
+ ) -> bool:
817
+ """`is_equal_to`'s verdict under ``ignore``/``include`` for a pair that is not two mappings.
818
+
819
+ The builder takes a sequence pair element by element and anything else through its fields. A matcher
820
+ answering the same question takes the same route, or `match.equal_to(user, ignore="updated_at")`
821
+ compared ``updated_at`` anyway. An element with no fields is compared whole, as the builder compares
822
+ it. At the top a value with no fields is one the builder refuses with a `TypeError`, and a matcher,
823
+ which must not raise, answers that it differs.
824
+ """
825
+ if _plain_sequence(actual) and _plain_sequence(expected):
826
+ sequence_actual = cast("list | tuple", actual)
827
+ sequence_expected = cast("list | tuple", expected)
828
+ if len(sequence_actual) != len(sequence_expected) or _kinds_never_equal(actual, expected):
829
+ return True
830
+ return any(
831
+ _filtered_pair_differs(item, counterpart, ignore=ignore, include=include, config=config, at_root=False)
832
+ for item, counterpart in zip(sequence_actual, sequence_expected, strict=True)
833
+ )
834
+ return _filtered_pair_differs(actual, expected, ignore=ignore, include=include, config=config, at_root=True)
835
+
836
+
837
+ def _filtered_pair_differs(
838
+ actual: object, expected: object, *, ignore: object, include: object, config: _CompareConfig | None, at_root: bool
839
+ ) -> bool:
840
+ left, right = fields_pair(actual, expected)
841
+ if left is None or right is None:
842
+ return at_root or values_differ(actual, expected, config)
843
+ return mapping_differs(left, right, ignore=ignore, include=include, config=config)