assertpy 2.35.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,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)
|