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
assertpy/_hints.py
ADDED
|
@@ -0,0 +1,1213 @@
|
|
|
1
|
+
"""One line naming *why* two values differ, when the whole difference has a single explanation.
|
|
2
|
+
|
|
3
|
+
The diff says what differs, which is where every assertion library stops. It leaves the reader to
|
|
4
|
+
work out why, and there are failures where that is genuinely hard: two strings that render
|
|
5
|
+
identically and differ in a trailing space, a comparison that can never pass because a NaN is in it.
|
|
6
|
+
|
|
7
|
+
A hint is stated only when it accounts for **every** entry in the diff. A partial explanation is
|
|
8
|
+
worse than none, because the reader acts on it and lands back at the same failure. That rule is what
|
|
9
|
+
keeps this from becoming the kind of advice people learn to scroll past, and it is measurable: over a
|
|
10
|
+
corpus of 20000 ordinary failures (a wrong leaf somewhere in a generated structure) the checks below
|
|
11
|
+
stay silent on all of them, and over a set of near misses built to resemble each form without being
|
|
12
|
+
it, likewise.
|
|
13
|
+
|
|
14
|
+
Everything here reads the diff rather than the values. That is not only cheaper, though it is: the
|
|
15
|
+
diff of a failure over 500 records holds one entry, so the cost does not grow with the data. It also
|
|
16
|
+
means a difference nested six levels down is examined exactly like a top-level one, with no separate
|
|
17
|
+
code path.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import dataclasses
|
|
23
|
+
import datetime
|
|
24
|
+
import decimal
|
|
25
|
+
import difflib
|
|
26
|
+
import enum
|
|
27
|
+
import fractions
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from collections import Counter
|
|
32
|
+
from typing import TYPE_CHECKING, Final, NamedTuple, cast
|
|
33
|
+
|
|
34
|
+
from ._engine._compare import _non_finite
|
|
35
|
+
from ._engine._equality import comparable_fields
|
|
36
|
+
from ._engine._introspection import (
|
|
37
|
+
class_name,
|
|
38
|
+
class_namespace,
|
|
39
|
+
class_tree,
|
|
40
|
+
is_attrs_instance,
|
|
41
|
+
is_mapping_like,
|
|
42
|
+
is_model_dump_object,
|
|
43
|
+
kind_of,
|
|
44
|
+
)
|
|
45
|
+
from ._engine._ordering import equals, nan_operand
|
|
46
|
+
from .errors import _capped, _class_names, _safe_repr, _safe_str
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
50
|
+
|
|
51
|
+
from .errors import DiffEntry, DiffResult, Step
|
|
52
|
+
|
|
53
|
+
# a step's wording: fixed, or decided from the shape of the pairs it is describing
|
|
54
|
+
_Label = str | Callable[[Sequence[tuple[object, object]]], str]
|
|
55
|
+
|
|
56
|
+
_OBJECT_EQUALITY: Final = object.__dict__["__eq__"]
|
|
57
|
+
_NAN_FACT = "a NaN takes part in this comparison, and a NaN is equal to nothing, not even itself"
|
|
58
|
+
_IDENTITY_FACT = (
|
|
59
|
+
"these values compare with object's __eq__, so equality is identity and no two separate instances are equal"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_UNSEEN_FACT = "a difference here prints the same on both sides, so what holds the two apart is not in their repr"
|
|
63
|
+
_UNSEEN_IDENTITY_FACT = (
|
|
64
|
+
"a difference here prints the same on both sides, and the class of the two leaves __eq__ to object,"
|
|
65
|
+
" so two separate instances are never equal"
|
|
66
|
+
)
|
|
67
|
+
_ORDER_FACT = "both sides hold the same elements, in a different order"
|
|
68
|
+
_UNSEEN_ORDER_FACT = f"{_ORDER_FACT}, and their repr does not show which is which"
|
|
69
|
+
# exact types whose repr differs wherever their values do, so ``==`` answers for the repr at a fraction of its cost
|
|
70
|
+
_PRINTED_AS_HELD: Final = frozenset({int, str, bytes, bool})
|
|
71
|
+
# the plain values a payload spells one way and a test another: an id as text, a flag as a word
|
|
72
|
+
_READ_AS_TEXT: Final = frozenset({int, float, str, bool})
|
|
73
|
+
|
|
74
|
+
_VALUE_KINDS: Final = frozenset(
|
|
75
|
+
{"dict", "sequence", "dataclass", "namedtuple", "model", "attrs", "set", "string", "scalar"}
|
|
76
|
+
)
|
|
77
|
+
"""Diff kinds whose entries hold two values compared for equality.
|
|
78
|
+
|
|
79
|
+
Everything here reasons about a pair as two values, so it only applies where both sides are one. A
|
|
80
|
+
``match`` entry holds the actual value against a *description* of a predicate ("a value equal to
|
|
81
|
+
<guest>"), and an ``openapi`` one against a schema violation. Treating a description as a value is
|
|
82
|
+
not a false positive waiting to happen so much as a category error, and one that would surface as an
|
|
83
|
+
authoritative-sounding line about whitespace in a predicate.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _newlines(value: object) -> object:
|
|
88
|
+
if isinstance(value, bytes):
|
|
89
|
+
return bytes.replace(value, b"\r\n", b"\n")
|
|
90
|
+
return str.replace(value, "\r\n", "\n") if isinstance(value, str) else value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _stripped(value: object) -> object:
|
|
94
|
+
if isinstance(value, bytes):
|
|
95
|
+
return bytes.strip(value)
|
|
96
|
+
return str.strip(value) if isinstance(value, str) else value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _spaced(value: object) -> object:
|
|
100
|
+
if isinstance(value, bytes):
|
|
101
|
+
return b" ".join(bytes.split(value))
|
|
102
|
+
return " ".join(str.split(value)) if isinstance(value, str) else value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parsed_json(value: object) -> object:
|
|
106
|
+
"""A string that is a JSON document, parsed. Anything else passes through."""
|
|
107
|
+
if isinstance(value, str) and value[:1] in "{[":
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(value)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return value
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _decoded(value: object) -> object:
|
|
116
|
+
if isinstance(value, bytes):
|
|
117
|
+
try:
|
|
118
|
+
return value.decode("utf-8")
|
|
119
|
+
except UnicodeDecodeError:
|
|
120
|
+
return value
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _enum_value(value: object) -> object:
|
|
125
|
+
return value.value if isinstance(value, enum.Enum) else value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _json_label(pairs: Sequence[tuple[object, object]]) -> str:
|
|
129
|
+
"""What parsing actually resolved, which is not the same thing on both shapes.
|
|
130
|
+
|
|
131
|
+
One side a string and the other a container is the familiar forgotten ``.json()``. Two strings
|
|
132
|
+
that parse to the same document differ in their formatting instead, and calling either of them
|
|
133
|
+
unparsed would be plainly wrong about both.
|
|
134
|
+
"""
|
|
135
|
+
if all(isinstance(left, str) and isinstance(right, str) for left, right in pairs):
|
|
136
|
+
return "the same JSON written differently"
|
|
137
|
+
return "unparsed JSON text"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_OF_SPACE: list[tuple[Callable[[object], object], _Label]] = [
|
|
141
|
+
(_newlines, "line endings"),
|
|
142
|
+
(_stripped, "surrounding whitespace"),
|
|
143
|
+
]
|
|
144
|
+
# ordered, narrower first: a step explains a pair only if the pair differed before it ran
|
|
145
|
+
_STEPS: list[tuple[Callable[[object], object], _Label]] = [
|
|
146
|
+
(_parsed_json, _json_label),
|
|
147
|
+
(_decoded, "bytes against decoded text"),
|
|
148
|
+
(_enum_value, "enum members against their values"),
|
|
149
|
+
*_OF_SPACE,
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _explains(pairs: Sequence[tuple[object, object]], steps: Sequence[Callable[[object], object]]) -> bool:
|
|
154
|
+
"""Whether applying ``steps`` to both sides of every pair leaves nothing differing.
|
|
155
|
+
|
|
156
|
+
A pair that already matches counts against every step rather than for it. Under
|
|
157
|
+
``strict_types=True`` the two sides of an entry can compare equal and still be a difference,
|
|
158
|
+
because what differs is their type - and then any normalisation "explains" them by doing nothing,
|
|
159
|
+
so the first step in the ladder took the credit and the reader was told that a comparison holding
|
|
160
|
+
no JSON at all was one of unparsed JSON text.
|
|
161
|
+
|
|
162
|
+
A pair the steps left as it was is not accounted for either, and is not asked again: an ``==`` that answered
|
|
163
|
+
otherwise the second time credited a step that had done nothing.
|
|
164
|
+
|
|
165
|
+
Comparison failures count as "not explained" rather than propagating. This runs while a failure
|
|
166
|
+
is already being raised, on values the caller wrote, and a numpy array or any object with an
|
|
167
|
+
opinionated ``__eq__`` can raise from ``==``. Letting that out would replace the assertion error
|
|
168
|
+
the reader needs with a crash from the line that was only trying to be helpful.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
for left, right in pairs:
|
|
172
|
+
if equals(left, right):
|
|
173
|
+
return False
|
|
174
|
+
read_left, read_right = left, right
|
|
175
|
+
for step in steps:
|
|
176
|
+
read_left, read_right = step(read_left), step(read_right)
|
|
177
|
+
if (read_left is left and read_right is right) or not equals(read_left, read_right):
|
|
178
|
+
return False
|
|
179
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _typed(pairs: Sequence[tuple[object, object]], kind: str) -> str | None:
|
|
185
|
+
"""Whether every pair differs in its type, and what kind of type difference it is.
|
|
186
|
+
|
|
187
|
+
The leaf twin of the DTO-against-payload claim above, and the case a REST payload produces more
|
|
188
|
+
than any other: the ids came back as ``"7"`` where the test expects ``7``. Two facts live here
|
|
189
|
+
rather than one, because they are not the same news. Values that compare equal differ only in
|
|
190
|
+
their type, which is a difference at all only under ``strict_types``. Values that do not compare
|
|
191
|
+
equal but read alike are the same text on one side and a parsed value on the other.
|
|
192
|
+
|
|
193
|
+
Silent where the headline already said it. A scalar failure whose two sides render alike is
|
|
194
|
+
tagged by `_disambiguated` as ``<1:str>`` / ``<1:int>``, which names the two types outright, and
|
|
195
|
+
a line under it restating that in general terms is worse than nothing.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
if not all(kind_of(left) is not kind_of(right) for left, right in pairs):
|
|
199
|
+
return None
|
|
200
|
+
if kind == "scalar" and all(str(left) == str(right) for left, right in pairs):
|
|
201
|
+
return None
|
|
202
|
+
if all(equals(left, right) for left, right in pairs):
|
|
203
|
+
return "the values on both sides are equal, and only their types differ"
|
|
204
|
+
if all(str(left) == str(right) for left, right in pairs):
|
|
205
|
+
return "every difference here is the same text against a value of another type"
|
|
206
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
207
|
+
return None
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def diagnose(
|
|
212
|
+
diff: DiffResult | None,
|
|
213
|
+
actual: object = None,
|
|
214
|
+
expected: object = None,
|
|
215
|
+
*,
|
|
216
|
+
identity: bool = False,
|
|
217
|
+
comparators: bool = False,
|
|
218
|
+
) -> str | None:
|
|
219
|
+
"""The one line to add to a failure message, or ``None`` when nothing can be said.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
diff: the structured diff already built for this failure.
|
|
223
|
+
actual: the value asserted on, needed only for the string case below.
|
|
224
|
+
expected: the value it was compared against.
|
|
225
|
+
identity: whether the comparison was an unconfigured equality one that `identity_candidate`
|
|
226
|
+
found identity-bound *before* it ran. Only then does identity account for the failure.
|
|
227
|
+
comparators: whether the comparison was given ``comparators=``. Identity is then not said of a pair
|
|
228
|
+
inside the diff, whose verdict may be the predicate's.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
A lowercase clause to put on its own line under the message, or ``None``.
|
|
232
|
+
"""
|
|
233
|
+
if diff is None or diff.kind not in _VALUE_KINDS:
|
|
234
|
+
return None
|
|
235
|
+
# asked of the two values compared, never of a pair inside the diff: identity equality can never pass
|
|
236
|
+
if identity:
|
|
237
|
+
return _IDENTITY_FACT
|
|
238
|
+
if not diff.entries:
|
|
239
|
+
return None
|
|
240
|
+
entries = diff.entries
|
|
241
|
+
|
|
242
|
+
# one flat pass: this is the only part whose cost grows with the entry count
|
|
243
|
+
pairs: list[tuple[object, object]] = []
|
|
244
|
+
# by the container each pair sits in: values swapped between two lists are not one list rearranged
|
|
245
|
+
per_container: dict[tuple[object, ...], list[tuple[object, object]]] = {}
|
|
246
|
+
absent_seen = False
|
|
247
|
+
absent_expected_only = True
|
|
248
|
+
positional = True
|
|
249
|
+
for entry in entries:
|
|
250
|
+
left, right = entry.actual, entry.expected
|
|
251
|
+
if nan_operand(left) or nan_operand(right):
|
|
252
|
+
# with a NaN in the comparison no other value would make it pass, so it comes before anything else
|
|
253
|
+
return _beside_a_nan(entries, comparators=comparators)
|
|
254
|
+
absent = entry.absent
|
|
255
|
+
if absent is None:
|
|
256
|
+
pairs.append((left, right))
|
|
257
|
+
per_container.setdefault(entry.steps[:-1], []).append((left, right))
|
|
258
|
+
absent_expected_only = False
|
|
259
|
+
else:
|
|
260
|
+
absent_seen = True
|
|
261
|
+
if absent != "expected":
|
|
262
|
+
absent_expected_only = False
|
|
263
|
+
# a step and not the rendered text: a mapping key ending in a bracket used to read as an index
|
|
264
|
+
if positional and not (entry.steps and entry.steps[-1].kind == "index"):
|
|
265
|
+
positional = False
|
|
266
|
+
|
|
267
|
+
if diff.kind == "string":
|
|
268
|
+
# the whole strings: `splitlines()` reads "\r\n" as "\n", so a text differing in both yields one entry
|
|
269
|
+
if not isinstance(actual, (str, bytes)) or not isinstance(expected, (str, bytes)):
|
|
270
|
+
return None
|
|
271
|
+
return _named([(actual, expected)])
|
|
272
|
+
|
|
273
|
+
# restricted to plain mappings, since telling someone their list carries extra keys helps nobody
|
|
274
|
+
if diff.kind == "dict" and absent_seen and absent_expected_only:
|
|
275
|
+
return "every shared key matches, and actual carries keys the expected side does not"
|
|
276
|
+
|
|
277
|
+
# a mixture of absent sides and differing values has no single statement that covers it
|
|
278
|
+
if absent_seen:
|
|
279
|
+
return _unseen(pairs, comparators=comparators)
|
|
280
|
+
|
|
281
|
+
# a DTO against the payload it was built from, said before the leaf steps because the shape is the narrower claim
|
|
282
|
+
if all(type(left) is not type(right) and _fields_match(left, right) for left, right in pairs):
|
|
283
|
+
return "the contents match field for field, and only the type of the two sides differs"
|
|
284
|
+
|
|
285
|
+
named = _named(pairs)
|
|
286
|
+
if named is not None:
|
|
287
|
+
return named
|
|
288
|
+
|
|
289
|
+
# after the ladder: a step that resolves the pairs has named the encoding they differ in, which is narrower
|
|
290
|
+
typed = _typed(pairs, diff.kind)
|
|
291
|
+
if typed is not None:
|
|
292
|
+
return typed
|
|
293
|
+
|
|
294
|
+
# last, as the broadest thing that can be said; one differing value can never be a rearrangement
|
|
295
|
+
if (
|
|
296
|
+
len(pairs) >= 2
|
|
297
|
+
and positional
|
|
298
|
+
and all(len(group) >= 2 and _same_values(group) for group in per_container.values())
|
|
299
|
+
):
|
|
300
|
+
# the elements moved, and where two of them print the same no row shows which went where
|
|
301
|
+
return _UNSEEN_ORDER_FACT if any(_prints_alike(left, right) for left, right in pairs) else _ORDER_FACT
|
|
302
|
+
return _unseen(pairs, comparators=comparators)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def placed(diff: DiffResult | None) -> str | None:
|
|
306
|
+
"""Where the differences of a diff sit, when they repeat at one to three places down a sequence, or ``None``.
|
|
307
|
+
|
|
308
|
+
Forty rows that differ in one field are forty rows of diff to read before that can be said, and past the
|
|
309
|
+
fiftieth the rows are not printed at all: a single difference of another field among them went unseen.
|
|
310
|
+
A place is the path with every position replaced by ``[*]``. It is a fact about where, said beside
|
|
311
|
+
whatever is said about why, and it recommends nothing.
|
|
312
|
+
"""
|
|
313
|
+
if diff is None or diff.kind not in _VALUE_KINDS or len(diff.entries) < 3:
|
|
314
|
+
return None
|
|
315
|
+
counted: dict[tuple[tuple[str, str], ...], int] = {}
|
|
316
|
+
for entry in diff.entries:
|
|
317
|
+
place = _place(entry.steps)
|
|
318
|
+
if place is None:
|
|
319
|
+
return None
|
|
320
|
+
counted[place] = counted.get(place, 0) + 1
|
|
321
|
+
if len(counted) > 3:
|
|
322
|
+
return None
|
|
323
|
+
total = len(diff.entries)
|
|
324
|
+
spelled = {place: _spelled(place) for place in counted}
|
|
325
|
+
# nothing repeats, two places read the same (a key and a field of one name), or one is too long to be a line
|
|
326
|
+
if (
|
|
327
|
+
len(counted) == total
|
|
328
|
+
or len(set(spelled.values())) != len(spelled)
|
|
329
|
+
or any(len(text) > 200 for text in spelled.values())
|
|
330
|
+
):
|
|
331
|
+
return None
|
|
332
|
+
if len(counted) == 1:
|
|
333
|
+
return f"all {total} differences here are at <{next(iter(spelled.values()))}>"
|
|
334
|
+
# by how many, and among equals by which came first, which is the order a dict keeps and a stable sort leaves
|
|
335
|
+
ranked = [f"<{spelled[place]}> ({count})" for place, count in sorted(counted.items(), key=lambda item: -item[1])]
|
|
336
|
+
return f"the {total} differences here are at {', '.join(ranked[:-1])} and {ranked[-1]}"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _place(steps: Sequence[Step]) -> tuple[tuple[str, str], ...] | None:
|
|
340
|
+
"""*steps* with every position made one, or ``None`` where they name no field.
|
|
341
|
+
|
|
342
|
+
The kind of each hop is kept, so a key and a field of one name are two places. Only a name that is
|
|
343
|
+
exactly a `str` counts: ``1`` and ``"1"`` are two keys and read as one. A place with no position in it
|
|
344
|
+
is met once and so never repeats, which is why none is asked for.
|
|
345
|
+
"""
|
|
346
|
+
place: list[tuple[str, str]] = []
|
|
347
|
+
named = False
|
|
348
|
+
for step in steps:
|
|
349
|
+
kind = step.kind
|
|
350
|
+
if kind == "index":
|
|
351
|
+
place.append(("index", "*"))
|
|
352
|
+
elif kind in ("key", "attr") and type(step.value) is str:
|
|
353
|
+
place.append((kind, step.value))
|
|
354
|
+
named = True
|
|
355
|
+
elif kind != "json":
|
|
356
|
+
return None
|
|
357
|
+
return tuple(place) if named else None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _spelled(place: tuple[tuple[str, str], ...]) -> str:
|
|
361
|
+
"""A place as a path. A name that is no identifier is quoted: ``[*]['a.b']`` is one key, ``[*].a.b`` two."""
|
|
362
|
+
parts: list[str] = []
|
|
363
|
+
for kind, name in place:
|
|
364
|
+
if kind == "index":
|
|
365
|
+
parts.append("[*]")
|
|
366
|
+
elif not name.isidentifier():
|
|
367
|
+
parts.append(f"[{name!r}]")
|
|
368
|
+
else:
|
|
369
|
+
parts.append(f".{name}" if parts else name)
|
|
370
|
+
return "".join(parts)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def reads_as(one: object, other: object) -> bool:
|
|
374
|
+
"""Whether two plain values of two types read the same: ``7`` and ``"7"``."""
|
|
375
|
+
kind, other_kind = type(one), type(other)
|
|
376
|
+
return kind is not other_kind and kind in _READ_AS_TEXT and other_kind in _READ_AS_TEXT and str(one) == str(other)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class Roles(NamedTuple):
|
|
380
|
+
"""What the assertion asking calls the item it looked for and a candidate it looked among.
|
|
381
|
+
|
|
382
|
+
``is_in`` looks for the value among the items given, a subset for its own element in the superset.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
sought: str = "the item not found"
|
|
386
|
+
held: str = "an element"
|
|
387
|
+
named: bool = False
|
|
388
|
+
"""Whether what it looked among are names, keys or attributes: one spelled almost the same is then said."""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
_OF_AN_ITEM: Final = Roles()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def not_found(item: object, searched: Iterable[object], roles: Roles = _OF_AN_ITEM) -> str | None:
|
|
395
|
+
"""One line on why *item* was not found in *searched*, where a fact about the item says it, or ``None``.
|
|
396
|
+
|
|
397
|
+
Said of the item sought and never of the collection: a NaN somewhere in a list does not explain a ``2`` that
|
|
398
|
+
is missing from it. Three facts are looked for. The item is a NaN. An element reads the same and is of
|
|
399
|
+
another plain type. An element prints the same, is of the item's class, and that class leaves ``==`` to
|
|
400
|
+
`object`. Each states what is so and leaves the reader to draw the rest. Among names a fourth: one that
|
|
401
|
+
reads almost the same (`_near_name`).
|
|
402
|
+
"""
|
|
403
|
+
sought, held = roles.sought, roles.held
|
|
404
|
+
names: list[str] = []
|
|
405
|
+
try:
|
|
406
|
+
if nan_operand(item):
|
|
407
|
+
return f"{sought} is a NaN, and one NaN is not equal to another"
|
|
408
|
+
kind = type(item)
|
|
409
|
+
identity: bool | None = None
|
|
410
|
+
for element in searched:
|
|
411
|
+
if type(element) is not kind:
|
|
412
|
+
if reads_as(element, item):
|
|
413
|
+
types = f"{class_name(type(element))}, not {class_name(kind)}"
|
|
414
|
+
return f"{held} reads the same as {sought} and is of another type: {types}"
|
|
415
|
+
continue
|
|
416
|
+
if roles.named and kind is str and isinstance(element, str):
|
|
417
|
+
names.append(element)
|
|
418
|
+
if identity is None:
|
|
419
|
+
# a fact about the class, so asked of the first element of it and not of each
|
|
420
|
+
identity = identity_candidate(item, element)
|
|
421
|
+
# asked again past the two reprs, which are code of the class and may change what it compares by
|
|
422
|
+
if identity and _safe_repr(element) == _safe_repr(item) and identity_candidate(item, element):
|
|
423
|
+
return (
|
|
424
|
+
f"{held} prints the same as {sought}, and their class leaves __eq__ to object,"
|
|
425
|
+
" which compares by identity"
|
|
426
|
+
)
|
|
427
|
+
if names and isinstance(item, str):
|
|
428
|
+
return _near_name(item, names, held)
|
|
429
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
430
|
+
return None
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
_RUNS: Final = re.compile(r"\s+")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def under(line: str | None) -> str:
|
|
438
|
+
"""*line* on a line of its own under a sentence, or nothing."""
|
|
439
|
+
return "" if line is None else f"\n{line}"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
_INSTANCE_MATCHER: Final = "the matcher for an instance of a class is match.is_instance_of"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def matcher_for_a_class(given: object) -> str:
|
|
446
|
+
"""What follows the refusal of *given* where a matcher was wanted, where it is a class `type` itself built.
|
|
447
|
+
|
|
448
|
+
Such a class takes an instance check and cannot refuse one, which a typed dict or a plain protocol does.
|
|
449
|
+
"""
|
|
450
|
+
return f": {_INSTANCE_MATCHER}" if type(given) is type else ""
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def class_in_spec(leaves: Iterable[tuple[str, object, object]]) -> str | None:
|
|
454
|
+
"""One line for the first place where a spec holds a class and the value there is an instance of it.
|
|
455
|
+
|
|
456
|
+
A class in a spec is a value like any other, compared with ``==``. Said where `match.is_instance_of` takes the
|
|
457
|
+
value on the class tree alone: the class is built by `type` itself, so its instance check is the base one, and
|
|
458
|
+
it stands in the tree of the value's class, so neither is asked anything.
|
|
459
|
+
"""
|
|
460
|
+
for where, value, literal in leaves:
|
|
461
|
+
if _an_instance_by_the_tree(value, literal):
|
|
462
|
+
return f"at <{where}> the spec holds a class itself and the value is an instance of it: {_INSTANCE_MATCHER}"
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _an_instance_by_the_tree(value: object, literal: object) -> bool:
|
|
467
|
+
return type(literal) is type and any(base is literal for base in class_tree(type(value)))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def type_beside_a_class(value: object, literal: object) -> str:
|
|
471
|
+
"""What follows a value held against a class it was not found to be an instance of: its type, as the matcher for
|
|
472
|
+
an instance words it.
|
|
473
|
+
|
|
474
|
+
A sentence prints a value by its text, so the text ``'7'`` beside the class `int` read ``<7>``. Nothing where
|
|
475
|
+
`class_in_spec` speaks of the same pair.
|
|
476
|
+
"""
|
|
477
|
+
if not issubclass(type(literal), type) or _an_instance_by_the_tree(value, literal):
|
|
478
|
+
return ""
|
|
479
|
+
return f" of type <{class_name(type(value))}>"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def not_at_an_end(value: object, piece: object, *, start: bool, cased: bool = True) -> str | None:
|
|
483
|
+
"""One line on why a text does not start with another, or end with it, where one fact says it.
|
|
484
|
+
|
|
485
|
+
Four are looked for, the narrowest first, each the relation itself holding once one thing is set aside:
|
|
486
|
+
whitespace at that end of the value, case, whitespace round the text given, and the text held somewhere
|
|
487
|
+
else in the value. *cased* is whether the assertion minded case: where it did not, the two are read
|
|
488
|
+
lowercased, so case is never the fact found. Read off exact `str` alone, which runs no code of the values.
|
|
489
|
+
"""
|
|
490
|
+
if type(value) is not str or type(piece) is not str:
|
|
491
|
+
return None
|
|
492
|
+
named, edge, side = ("prefix", "starts", "past") if start else ("suffix", "ends", "before")
|
|
493
|
+
holds = str.startswith if start else str.endswith
|
|
494
|
+
text, wanted = (value, piece) if cased else (value.lower(), piece.lower())
|
|
495
|
+
if holds(text, wanted):
|
|
496
|
+
return None
|
|
497
|
+
bare = text.lstrip() if start else text.rstrip()
|
|
498
|
+
if bare != text and holds(bare, wanted):
|
|
499
|
+
gap = text[: len(text) - len(bare)] if start else text[len(bare) :]
|
|
500
|
+
what = "a line break" if gap in ("\n", "\r\n", "\r") else "whitespace"
|
|
501
|
+
return f"the value {edge} with {what}, and {edge} with the {named} {side} it"
|
|
502
|
+
if holds(text.lower(), wanted.lower()):
|
|
503
|
+
return f"the value {edge} with the {named} once case is ignored"
|
|
504
|
+
trimmed = wanted.strip()
|
|
505
|
+
if trimmed and trimmed != wanted and holds(text, trimmed):
|
|
506
|
+
return f"the {named} given starts or ends with whitespace, and the value {edge} with it without that"
|
|
507
|
+
if wanted in text:
|
|
508
|
+
return f"the value holds the {named}, and not at its {'start' if start else 'end'}"
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def not_in_text(value: object, piece: object, *, cased: bool = True) -> str | None:
|
|
513
|
+
"""One line on why a text does not hold another, where one fact says it.
|
|
514
|
+
|
|
515
|
+
It holds it once case is ignored, or once every run of whitespace is read as one space, which is what a
|
|
516
|
+
line break, a tab, a no-break space and two spaces in a row all come to, or once both are. *cased* as in
|
|
517
|
+
`not_at_an_end`. Read off exact `str` alone.
|
|
518
|
+
"""
|
|
519
|
+
if type(value) is not str or type(piece) is not str:
|
|
520
|
+
return None
|
|
521
|
+
text, wanted = (value, piece) if cased else (value.lower(), piece.lower())
|
|
522
|
+
if wanted in text:
|
|
523
|
+
return None
|
|
524
|
+
if wanted.lower() in text.lower():
|
|
525
|
+
return "the value holds it once case is ignored"
|
|
526
|
+
# a run is read as one space and not taken out: stripped, a text given with a space before it was held
|
|
527
|
+
spaced_text, spaced = _RUNS.sub(" ", text), _RUNS.sub(" ", wanted)
|
|
528
|
+
if spaced in spaced_text:
|
|
529
|
+
return "the value holds it once every run of whitespace is read as one space"
|
|
530
|
+
if spaced.lower() in spaced_text.lower():
|
|
531
|
+
return "the value holds it once case is ignored and every run of whitespace is read as one space"
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def but_for_whitespace(value: object, other: object) -> str | None:
|
|
536
|
+
"""One line on two texts that are not equal without regard to case, where whitespace alone holds them apart.
|
|
537
|
+
|
|
538
|
+
It names the whitespace as `is_equal_to` does for two texts. Read off exact `str` alone.
|
|
539
|
+
"""
|
|
540
|
+
if type(value) is not str or type(other) is not str:
|
|
541
|
+
return None
|
|
542
|
+
apart = _accounted([(value.lower(), other.lower())], _OF_SPACE)
|
|
543
|
+
return None if apart is None else f"the two are equal without regard to case but for {apart}"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def but_for_case(value: object, other: object) -> str | None:
|
|
547
|
+
"""One line on two texts that are not equal ignoring whitespace, where case alone holds them apart.
|
|
548
|
+
|
|
549
|
+
Read off exact `str` alone.
|
|
550
|
+
"""
|
|
551
|
+
if type(value) is not str or type(other) is not str:
|
|
552
|
+
return None
|
|
553
|
+
if "".join(value.split()).lower() == "".join(other.split()).lower():
|
|
554
|
+
return "the two are equal ignoring whitespace once case is ignored too"
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _own_datetime(standing: type) -> type[datetime.datetime]:
|
|
559
|
+
"""The standard library's datetime under the class that stands at `datetime.datetime`.
|
|
560
|
+
|
|
561
|
+
A library that freezes time puts a class of its own at that name, made of the real one. The real one is
|
|
562
|
+
the last to write `utcoffset` on the line of bases the class takes its layout from (`__base__`). Not the
|
|
563
|
+
last of the whole tree: a mixin that writes one comes after it there. And not the class of an instance
|
|
564
|
+
the class hands out: its `min` may be an instance of the class itself.
|
|
565
|
+
"""
|
|
566
|
+
found, base = standing, standing
|
|
567
|
+
while base is not None:
|
|
568
|
+
if "utcoffset" in class_namespace(base):
|
|
569
|
+
found = base
|
|
570
|
+
base = type.__dict__["__base__"].__get__(base)
|
|
571
|
+
return cast("type[datetime.datetime]", found)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# in a tuple: such a library also rewrites each attribute of a module that is the real class
|
|
575
|
+
_MOMENT: Final = (_own_datetime(datetime.datetime),)
|
|
576
|
+
_ORDERED_BY: Final = ("__lt__", "__le__", "__gt__", "__ge__")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def out_of_order(value: object, other: object, *, before: bool, strict: bool) -> str | None:
|
|
580
|
+
"""One line on a moment that is not before another, or not after it: how far on the other side it is.
|
|
581
|
+
|
|
582
|
+
Said by the four assertions of dates and by the four single relations of numbers, which take two datetimes
|
|
583
|
+
too. *before* is the side the assertion asked for, and *strict* whether the same moment fails it as well. The
|
|
584
|
+
distance is `datetime.datetime`'s own subtraction, exact to the microsecond. Where that is only the
|
|
585
|
+
distance between two readings of one clock (`_time_between`), the line says so.
|
|
586
|
+
"""
|
|
587
|
+
measured = _time_between(value, other, _ORDERED_BY)
|
|
588
|
+
if measured is None:
|
|
589
|
+
return None
|
|
590
|
+
apart, moments = measured
|
|
591
|
+
if not apart:
|
|
592
|
+
return _same(moments) if strict else None
|
|
593
|
+
late = apart > datetime.timedelta()
|
|
594
|
+
if late is not before:
|
|
595
|
+
return None
|
|
596
|
+
side = "after" if late else "before"
|
|
597
|
+
if moments:
|
|
598
|
+
return f"the value is {abs(apart)} {side} the moment given"
|
|
599
|
+
return f"the value reads {abs(apart)} {side} the moment given on the clock the two share"
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def apart_in_time(value: object, other: object, tolerance: object, *, close: bool) -> str | None:
|
|
603
|
+
"""One line on two moments held against a tolerance: how far apart they are, and how far that is from it.
|
|
604
|
+
|
|
605
|
+
*close* is what the assertion asked for. Measured as `out_of_order` measures. Nothing is said of a pair
|
|
606
|
+
found close whose distance is past the tolerance: a window round either moment reached the other where the
|
|
607
|
+
distance did not. The tolerance is read where it is exactly a `datetime.timedelta`, and the assertion has
|
|
608
|
+
refused one below nothing.
|
|
609
|
+
"""
|
|
610
|
+
measured = _time_between(
|
|
611
|
+
value, other, (*_ORDERED_BY, "__eq__", "__sub__", "__rsub__", "__add__", "__radd__", "__float__")
|
|
612
|
+
)
|
|
613
|
+
if measured is None or type(tolerance) is not datetime.timedelta:
|
|
614
|
+
return None
|
|
615
|
+
apart, moments = measured
|
|
616
|
+
distance = abs(apart)
|
|
617
|
+
if not distance:
|
|
618
|
+
return None if close else _same(moments)
|
|
619
|
+
told = f"the two are {distance} apart" if moments else f"the two read {distance} apart on the clock they share"
|
|
620
|
+
if close:
|
|
621
|
+
return f"{told}, {distance - tolerance} more than the tolerance" if distance > tolerance else None
|
|
622
|
+
if distance > tolerance:
|
|
623
|
+
return None
|
|
624
|
+
if distance == tolerance:
|
|
625
|
+
return f"{told}, which is the tolerance"
|
|
626
|
+
return f"{told}, {tolerance - distance} less than the tolerance"
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _same(moments: bool) -> str:
|
|
630
|
+
return "the two are the same moment" if moments else "the two read the same on the clock they share"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# every setting named: one left out is copied from `decimal.DefaultContext`, which a caller may have changed
|
|
634
|
+
_SHOWN_EXACTLY: Final = decimal.Context(
|
|
635
|
+
prec=34,
|
|
636
|
+
rounding=decimal.ROUND_HALF_EVEN,
|
|
637
|
+
Emin=decimal.MIN_EMIN,
|
|
638
|
+
Emax=decimal.MAX_EMAX,
|
|
639
|
+
capitals=1,
|
|
640
|
+
clamp=0,
|
|
641
|
+
flags=[],
|
|
642
|
+
traps=[decimal.Inexact, decimal.Overflow, decimal.InvalidOperation],
|
|
643
|
+
)
|
|
644
|
+
_SHOWN_ROUGHLY: Final = decimal.Context(
|
|
645
|
+
prec=12,
|
|
646
|
+
rounding=decimal.ROUND_HALF_EVEN,
|
|
647
|
+
Emin=decimal.MIN_EMIN,
|
|
648
|
+
Emax=decimal.MAX_EMAX,
|
|
649
|
+
capitals=1,
|
|
650
|
+
clamp=0,
|
|
651
|
+
flags=[],
|
|
652
|
+
traps=[],
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
_SIDE: Final = {
|
|
656
|
+
"is_less_than": "before",
|
|
657
|
+
"is_less_than_or_equal_to": "before",
|
|
658
|
+
"is_greater_than": "after",
|
|
659
|
+
"is_greater_than_or_equal_to": "after",
|
|
660
|
+
"is_before": "before",
|
|
661
|
+
"is_before_or_equal_to": "before",
|
|
662
|
+
"is_after": "after",
|
|
663
|
+
"is_after_or_equal_to": "after",
|
|
664
|
+
}
|
|
665
|
+
"""The single bounds, by the side of the bound given each asks for."""
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def passed_by(operation: str, value: object, parameters: Mapping[str, object], *, negated: bool) -> str | None:
|
|
669
|
+
"""How far a value that passed was from the bound it was held against, or ``None`` where that is no distance.
|
|
670
|
+
|
|
671
|
+
Said for the bounds a threshold is written with: the four single relations of numbers and of dates, a range,
|
|
672
|
+
and closeness. The distance is taken between the values the comparison compared, exactly: a float is the
|
|
673
|
+
binary number it holds, so ``5 - 4.9`` is ``0.0999999999999996447...``, shown as ``about 0.1``. A distance
|
|
674
|
+
is printed exactly where 34 digits hold it and rounded to twelve, with ``about``, where they do not.
|
|
675
|
+
|
|
676
|
+
Only operands whose class is exactly a number of the standard library, a ``datetime``, a ``date`` or a
|
|
677
|
+
``timedelta`` are measured, so no code of anybody's runs. Python orders those exactly, so the side a distance
|
|
678
|
+
falls on is the side the verdict found. A number whose exact ratio has a numerator or a denominator longer than
|
|
679
|
+
3322 bits is not measured, so neither is one larger than about ``10**1000`` or smaller than about ``10**-1000``,
|
|
680
|
+
nor a `Decimal` written with more than a thousand digits: a pure-Python `decimal` refuses to read so many.
|
|
681
|
+
"""
|
|
682
|
+
if operation in _SIDE:
|
|
683
|
+
return _from_bound(value, parameters.get("other"), _SIDE[operation])
|
|
684
|
+
if operation in ("is_between", "is_not_between"):
|
|
685
|
+
return _in_range(value, parameters.get("low"), parameters.get("high"))
|
|
686
|
+
if operation in ("is_close_to", "is_not_close_to"):
|
|
687
|
+
close = (operation == "is_close_to") is not negated
|
|
688
|
+
return _near(value, parameters.get("other"), parameters.get("tolerance"), parameters.get("rel"), close=close)
|
|
689
|
+
return None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _exact(number: object) -> fractions.Fraction | None:
|
|
693
|
+
"""*number* as the exact rational it is, or ``None`` for anything else, a non-finite or an outsized number."""
|
|
694
|
+
kind = type(number)
|
|
695
|
+
if kind is int:
|
|
696
|
+
whole = cast("int", number)
|
|
697
|
+
return fractions.Fraction(whole) if int.bit_length(whole) <= 3322 else None
|
|
698
|
+
if kind is float:
|
|
699
|
+
return None if _non_finite(number) else fractions.Fraction(cast("float", number))
|
|
700
|
+
if kind is decimal.Decimal:
|
|
701
|
+
held = cast("decimal.Decimal", number)
|
|
702
|
+
if _non_finite(held) or not -1000 <= decimal.Decimal.adjusted(held) <= 1000:
|
|
703
|
+
return None
|
|
704
|
+
if len(decimal.Decimal.as_tuple(held).digits) > 1000:
|
|
705
|
+
return None
|
|
706
|
+
return _bounded(fractions.Fraction(held))
|
|
707
|
+
if kind is fractions.Fraction:
|
|
708
|
+
return _bounded(cast("fractions.Fraction", number))
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _bounded(ratio: fractions.Fraction) -> fractions.Fraction | None:
|
|
713
|
+
"""*ratio*, unless its numerator or denominator is longer than 3322 bits, past about ``10**1000``."""
|
|
714
|
+
return ratio if max(int.bit_length(ratio.numerator), int.bit_length(ratio.denominator)) <= 3322 else None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _minus(left: object, right: object) -> fractions.Fraction | None:
|
|
718
|
+
"""*left* minus *right*, exactly, where both are numbers `_exact` reads."""
|
|
719
|
+
first, second = _exact(left), _exact(right)
|
|
720
|
+
if first is None or second is None:
|
|
721
|
+
return None
|
|
722
|
+
return first - second
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _durations(left: object, right: object) -> tuple[int, datetime.timedelta] | None:
|
|
726
|
+
"""Which way *left* lies from *right*, and how far, for two durations or two dates of exactly the standard classes.
|
|
727
|
+
|
|
728
|
+
The size is the larger less the smaller: `-timedelta.max` is past the range though `timedelta.max` is not.
|
|
729
|
+
"""
|
|
730
|
+
kinds = (type(left), type(right))
|
|
731
|
+
if kinds != (datetime.timedelta, datetime.timedelta) and kinds != (datetime.date, datetime.date):
|
|
732
|
+
return None
|
|
733
|
+
first, second = cast("datetime.timedelta", left), cast("datetime.timedelta", right)
|
|
734
|
+
larger, smaller, sign = (first, second, 1) if first >= second else (second, first, -1)
|
|
735
|
+
try:
|
|
736
|
+
return (sign if larger != smaller else 0), larger - smaller
|
|
737
|
+
except OverflowError:
|
|
738
|
+
# two durations at the two ends of the range compare, and their distance is past it
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _from_bound(value: object, bound: object, side: str) -> str | None:
|
|
743
|
+
"""Where *value* stands against one bound: how far below or above it, or before or after it.
|
|
744
|
+
|
|
745
|
+
*side* is the one the assertion asked for, which `out_of_order` takes as the side to say nothing on. Numbers
|
|
746
|
+
are asked first, being what a threshold usually is: the moments' reading looks at the classes, which cost a
|
|
747
|
+
passing line half its time when asked first.
|
|
748
|
+
"""
|
|
749
|
+
difference = _minus(value, bound)
|
|
750
|
+
words = ("above", "below")
|
|
751
|
+
if difference is None:
|
|
752
|
+
moment = out_of_order(value, bound, before=side == "after", strict=True) or out_of_order(
|
|
753
|
+
value, bound, before=side != "after", strict=True
|
|
754
|
+
)
|
|
755
|
+
if moment is not None:
|
|
756
|
+
return moment
|
|
757
|
+
apart = _durations(value, bound)
|
|
758
|
+
if apart is None:
|
|
759
|
+
return None
|
|
760
|
+
sign, size = apart
|
|
761
|
+
if type(bound) is datetime.date:
|
|
762
|
+
given, shown, words = "date", f"{size.days} day{'s' if size.days != 1 else ''}", ("after", "before")
|
|
763
|
+
else:
|
|
764
|
+
given, shown = "duration", str(size)
|
|
765
|
+
else:
|
|
766
|
+
sign, given, shown = (difference > 0) - (difference < 0), "number", _shown(abs(difference))
|
|
767
|
+
if not sign:
|
|
768
|
+
return f"the value is the {given} given"
|
|
769
|
+
return f"the value is {shown} {words[0] if sign > 0 else words[1]} the {given} given"
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _shown(number: fractions.Fraction) -> str:
|
|
773
|
+
"""*number* in decimal: exactly where 34 digits hold it, else rounded to twelve and said to be."""
|
|
774
|
+
numerator, denominator = decimal.Decimal(number.numerator), decimal.Decimal(number.denominator)
|
|
775
|
+
try:
|
|
776
|
+
written, about = _SHOWN_EXACTLY.divide(numerator, denominator), ""
|
|
777
|
+
except decimal.Inexact:
|
|
778
|
+
written, about = _SHOWN_ROUGHLY.divide(numerator, denominator), "about "
|
|
779
|
+
plain = written.normalize(_SHOWN_ROUGHLY if about else _SHOWN_EXACTLY)
|
|
780
|
+
# asked before formatting: a positional `1E+999` is a thousand characters. `E`, as `str()` reads the ambient context
|
|
781
|
+
return about + format(plain, "f" if -12 <= plain.adjusted() <= 20 else "E")
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _in_range(value: object, low: object, high: object) -> str | None:
|
|
785
|
+
"""Where *value* stands against a range of numbers: how far within each end, or how far past the nearer."""
|
|
786
|
+
from_low, to_high = _minus(value, low), _minus(high, value)
|
|
787
|
+
if from_low is None or to_high is None:
|
|
788
|
+
return None
|
|
789
|
+
if from_low < 0 or to_high < 0:
|
|
790
|
+
if from_low < 0:
|
|
791
|
+
return f"the value is {_shown(-from_low)} below the low end"
|
|
792
|
+
return f"the value is {_shown(-to_high)} above the high end"
|
|
793
|
+
if not from_low:
|
|
794
|
+
return "the value is the low end"
|
|
795
|
+
if not to_high:
|
|
796
|
+
return "the value is the high end"
|
|
797
|
+
return f"the value is {_shown(from_low)} above the low end and {_shown(to_high)} below the high end"
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _near(value: object, other: object, tolerance: object, rel: object, *, close: bool) -> str | None:
|
|
801
|
+
"""How far apart two values that passed a closeness are, against what they were allowed.
|
|
802
|
+
|
|
803
|
+
Either allowance holding is enough, so the larger is the one measured against. Nothing is said where the
|
|
804
|
+
exact distance disagrees with the verdict: a window round either value reached the other where it did not.
|
|
805
|
+
"""
|
|
806
|
+
if isinstance(tolerance, datetime.timedelta) and rel is None:
|
|
807
|
+
return apart_in_time(value, other, tolerance, close=not close)
|
|
808
|
+
difference, allowed = _minus(value, other), _allowance(other, tolerance, rel)
|
|
809
|
+
if difference is None or allowed is None:
|
|
810
|
+
return None
|
|
811
|
+
distance = abs(difference)
|
|
812
|
+
spare = allowed - distance
|
|
813
|
+
if (spare >= 0) is not close:
|
|
814
|
+
return None
|
|
815
|
+
if not distance:
|
|
816
|
+
return "the two are equal"
|
|
817
|
+
told = "the tolerance" if rel is None else f"the allowance of {_shown(allowed)}"
|
|
818
|
+
if not spare:
|
|
819
|
+
return f"the two are {_shown(distance)} apart, which is {told}"
|
|
820
|
+
if close:
|
|
821
|
+
return f"the two are {_shown(distance)} apart, {_shown(spare)} less than {told}"
|
|
822
|
+
return f"the two are {_shown(distance)} apart, {_shown(-spare)} more than {told}"
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _allowance(other: object, tolerance: object, rel: object) -> fractions.Fraction | None:
|
|
826
|
+
"""The larger of the two allowances asked, exactly, or ``None`` where either given is past measuring.
|
|
827
|
+
|
|
828
|
+
One past measuring, an infinite one among them, would be the larger, so no smaller one stands in for it.
|
|
829
|
+
"""
|
|
830
|
+
absolute = _exact(tolerance) if tolerance is not None else None
|
|
831
|
+
if tolerance is not None and absolute is None:
|
|
832
|
+
return None
|
|
833
|
+
if rel is None:
|
|
834
|
+
return absolute
|
|
835
|
+
scale, size = _exact(rel), _exact(other)
|
|
836
|
+
if scale is None or size is None:
|
|
837
|
+
return None
|
|
838
|
+
relative = scale * abs(size)
|
|
839
|
+
return relative if absolute is None or relative > absolute else absolute
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _time_between(value: object, other: object, asked: tuple[str, ...]) -> tuple[datetime.timedelta, bool] | None:
|
|
843
|
+
"""*value* minus *other* by `datetime.datetime`'s own subtraction, and whether that is the time between them.
|
|
844
|
+
|
|
845
|
+
Read only for two whose classes leave *asked* to the base type: a class that writes one of those operators
|
|
846
|
+
made the verdict its own way, on what it holds past the microsecond or anywhere else.
|
|
847
|
+
|
|
848
|
+
Two that share one zone object, or have none, the base type compares and subtracts on their wall clocks,
|
|
849
|
+
asking the zone nothing. That is the time between the two where there is no zone, or where the zone gives
|
|
850
|
+
both one offset. Across a change of its clocks it is not: four hours read off a clock that went back for
|
|
851
|
+
five that passed, and the two readings of the repeated hour found equal. Then, and for a zone that is not
|
|
852
|
+
asked, the answer is ``False`` and the line speaks of the clock the two share.
|
|
853
|
+
|
|
854
|
+
A zone is asked only where it is one of the standard library's own (`_own_zone`), since a line read past the
|
|
855
|
+
verdict cannot know what another zone's code told the verdict. Two in different zones are measured as
|
|
856
|
+
moments, which asks both, so a pair with a zone from elsewhere among them gets nothing.
|
|
857
|
+
"""
|
|
858
|
+
moment = _MOMENT[0]
|
|
859
|
+
if not isinstance(value, moment) or not isinstance(other, moment):
|
|
860
|
+
return None
|
|
861
|
+
try:
|
|
862
|
+
if not (_left_to_datetime(type(value), asked) and _left_to_datetime(type(other), asked)):
|
|
863
|
+
return None
|
|
864
|
+
zone, other_zone = moment.tzinfo.__get__(value), moment.tzinfo.__get__(other)
|
|
865
|
+
if zone is not other_zone:
|
|
866
|
+
if not (_own_zone(zone) and _own_zone(other_zone)):
|
|
867
|
+
return None
|
|
868
|
+
return moment.__sub__(value, other), True
|
|
869
|
+
apart = moment.__sub__(value, other)
|
|
870
|
+
if zone is None:
|
|
871
|
+
return apart, True
|
|
872
|
+
if not _own_zone(zone):
|
|
873
|
+
return apart, False
|
|
874
|
+
offset = moment.utcoffset(value)
|
|
875
|
+
one = offset is not None and datetime.timedelta.__eq__(offset, moment.utcoffset(other)) is True
|
|
876
|
+
return apart, one
|
|
877
|
+
except Exception: # a naive moment against an aware one refuses to subtract, and no line outranks the failure
|
|
878
|
+
return None
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _own_zone(zone: object) -> bool:
|
|
882
|
+
"""Whether *zone* is none, or one of the standard library's own, whose offset its code alone answers.
|
|
883
|
+
|
|
884
|
+
The class of `zoneinfo` is read where it is loaded and not imported: no zone of its class exists without
|
|
885
|
+
it, and the import costs 4.9 ms, measured, which every importer of the library would pay. It is read off
|
|
886
|
+
the two modules that implement it and not off `zoneinfo.ZoneInfo`: that is a name a test may put a class
|
|
887
|
+
of its own at, and what such a class says of itself, its name and its module, is the class's to say.
|
|
888
|
+
"""
|
|
889
|
+
kind = type(zone)
|
|
890
|
+
if zone is None or kind is datetime.timezone:
|
|
891
|
+
return True
|
|
892
|
+
implemented_in = ("_zoneinfo", "zoneinfo._zoneinfo")
|
|
893
|
+
return any(kind is getattr(sys.modules.get(name), "ZoneInfo", None) for name in implemented_in)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _left_to_datetime(klass: type, asked: tuple[str, ...]) -> bool:
|
|
897
|
+
"""Whether *klass* is `datetime.datetime`, or leaves every operator in *asked* to it, read off its tree.
|
|
898
|
+
|
|
899
|
+
The keys are read before any name is looked up: a key of a class of its own answers a lookup with its code.
|
|
900
|
+
"""
|
|
901
|
+
if klass is _MOMENT[0]:
|
|
902
|
+
return True
|
|
903
|
+
if not _plainly_keyed(klass):
|
|
904
|
+
return False
|
|
905
|
+
for base in class_tree(klass):
|
|
906
|
+
if base is _MOMENT[0]:
|
|
907
|
+
return True
|
|
908
|
+
written = class_namespace(base)
|
|
909
|
+
if any(name in written for name in asked):
|
|
910
|
+
return False
|
|
911
|
+
return False
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
_JOINTS: Final = re.compile(r"[\s_.\-]+")
|
|
915
|
+
"""What joins the words of a name: a space, an underscore, a dot, a hyphen."""
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _near_name(name: str, names: Sequence[str], held: str) -> str | None:
|
|
919
|
+
"""One line on a name among *names* that reads almost as *name*, which was asked for and is not there.
|
|
920
|
+
|
|
921
|
+
Two facts and one likeness, the narrowest first. A name is the same once both are lowercased. A name is
|
|
922
|
+
the same once both are lowercased and what joins their words is taken out, ``userId`` beside ``user_id``.
|
|
923
|
+
One spelling alone is almost the same (`_almost`), and the names of that spelling are named, the first
|
|
924
|
+
three of them and how many more: two that differ only in case or joints are one spelling. The last is a
|
|
925
|
+
likeness and is worded as one: which name was meant is the reader's to say, and where two spellings come
|
|
926
|
+
that close nothing is said.
|
|
927
|
+
|
|
928
|
+
Read off exact `str` alone, which runs no code of the values. By ``lower()`` and not ``casefold()``, which
|
|
929
|
+
calls ``ß`` and ``ss`` one text, a difference that is not of case. The likeness is looked for only where
|
|
930
|
+
the name asked for and the name held are of a hundred characters at most, as they were written: past that
|
|
931
|
+
it is no name, and the search is quadratic.
|
|
932
|
+
"""
|
|
933
|
+
others = [each for each in names if each != name]
|
|
934
|
+
lowered = name.lower()
|
|
935
|
+
alike = [each for each in others if each.lower() == lowered]
|
|
936
|
+
if alike:
|
|
937
|
+
return f"{held} reads the same but for its case: {_listed(alike)}"
|
|
938
|
+
plain = _JOINTS.sub("", lowered)
|
|
939
|
+
if not plain:
|
|
940
|
+
return None
|
|
941
|
+
joined: dict[str, list[str]] = {}
|
|
942
|
+
for each in others:
|
|
943
|
+
joined.setdefault(_JOINTS.sub("", each.lower()), []).append(each)
|
|
944
|
+
if plain in joined:
|
|
945
|
+
return f"{held} reads the same but for how its words are joined: {_listed(joined[plain])}"
|
|
946
|
+
if len(name) > 100:
|
|
947
|
+
return None
|
|
948
|
+
# one matcher for the name: built for every key, the ratio cost a failure over 200 keys 0.8 ms
|
|
949
|
+
texts = difflib.SequenceMatcher(None, b=plain, autojunk=False)
|
|
950
|
+
short = (each for each, held_as in joined.items() if each and all(len(key) <= 100 for key in held_as))
|
|
951
|
+
close = [each for each in short if _almost(plain, each, texts)]
|
|
952
|
+
if len(close) == 1:
|
|
953
|
+
return f"{held} is spelled almost the same: {_listed(joined[close[0]])}"
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _almost(one: str, other: str, texts: difflib.SequenceMatcher[str]) -> bool:
|
|
958
|
+
"""Whether two names are one slip apart, or nearly one text.
|
|
959
|
+
|
|
960
|
+
A slip is two neighbouring letters swapped, one letter too many, or one too few. A letter in place of
|
|
961
|
+
another is not one: that is how two words differ, ``date`` and ``data``. Nearly one text is a `difflib`
|
|
962
|
+
ratio of 0.85, which a long name reaches with a few letters dropped or replaced, ``created`` beside
|
|
963
|
+
``created_at`` and seventeen of one letter beside twenty. Measured on 54 labelled typos and 46 pairs of
|
|
964
|
+
unrelated key names: 52 named and 6, four of the six a singular beside its plural. The ratio alone named
|
|
965
|
+
49 and 5 at this floor, and 52 and 9 at 0.8. *texts* holds *one* and is asked the ratio last, past its
|
|
966
|
+
two upper bounds.
|
|
967
|
+
"""
|
|
968
|
+
if len(one) == len(other):
|
|
969
|
+
apart = [index for index in range(len(one)) if one[index] != other[index]]
|
|
970
|
+
if (
|
|
971
|
+
len(apart) == 2
|
|
972
|
+
and apart[1] == apart[0] + 1
|
|
973
|
+
and one[apart[0]] == other[apart[1]]
|
|
974
|
+
and one[apart[1]] == other[apart[0]]
|
|
975
|
+
):
|
|
976
|
+
return True
|
|
977
|
+
elif abs(len(one) - len(other)) == 1:
|
|
978
|
+
shorter, longer = sorted((one, other), key=len)
|
|
979
|
+
parted = next((index for index in range(len(shorter)) if shorter[index] != longer[index]), len(shorter))
|
|
980
|
+
if longer[parted + 1 :] == shorter[parted:]:
|
|
981
|
+
return True
|
|
982
|
+
texts.set_seq1(other)
|
|
983
|
+
return texts.real_quick_ratio() >= 0.85 and texts.quick_ratio() >= 0.85 and texts.ratio() >= 0.85
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _listed(names: Sequence[str]) -> str:
|
|
987
|
+
shown = ", ".join(f"<{_capped(each)}>" for each in names[:3])
|
|
988
|
+
return shown if len(names) <= 3 else f"{shown} and {len(names) - 3} more"
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _beside_a_nan(entries: Sequence[DiffEntry], *, comparators: bool) -> str:
|
|
992
|
+
"""The NaN fact, and under it what is said of a pair no NaN takes part in that prints the same on both sides."""
|
|
993
|
+
others = [
|
|
994
|
+
(entry.actual, entry.expected)
|
|
995
|
+
for entry in entries
|
|
996
|
+
if entry.absent is None and not (nan_operand(entry.actual) or nan_operand(entry.expected))
|
|
997
|
+
]
|
|
998
|
+
unseen = _unseen(others, comparators=comparators)
|
|
999
|
+
return _NAN_FACT if unseen is None else f"{_NAN_FACT}\n{unseen}"
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _prints_alike(left: object, right: object) -> bool:
|
|
1003
|
+
"""Whether a row of these two reads the same on both sides: one class, or two nothing tells apart, one repr."""
|
|
1004
|
+
kind = type(left)
|
|
1005
|
+
if kind is type(right) and kind in _PRINTED_AS_HELD:
|
|
1006
|
+
return left == right
|
|
1007
|
+
return _class_names(left, right) is None and _safe_repr(left) == _safe_repr(right)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _unseen(pairs: Sequence[tuple[object, object]], *, comparators: bool) -> str | None:
|
|
1011
|
+
"""What to say of pairs that print the same on both sides and are one class, or ``None`` where none does.
|
|
1012
|
+
|
|
1013
|
+
Such a row names no difference a reader can see, and nothing above accounted for it. Where no comparator
|
|
1014
|
+
took part and every one of them is two instances of a class that leaves ``==`` to `object`, that is the
|
|
1015
|
+
reason. Otherwise the reason is in the comparison itself, a comparator or an ``__eq__`` of the value's
|
|
1016
|
+
own, and the line says what is known: the repr does not show it, and which attributes of the two differ
|
|
1017
|
+
where they hold any. Asked of every pair: how many rows a diff prints is the renderer's to decide.
|
|
1018
|
+
|
|
1019
|
+
The identity line here is not the top pair's. That one speaks of the two values compared, and is asked
|
|
1020
|
+
before they are. A pair inside has no before short of walking both values on every passing comparison
|
|
1021
|
+
(23 times the cost of one over 200 rows), so its line speaks of the row and of the class as it stands now.
|
|
1022
|
+
"""
|
|
1023
|
+
alike = [(left, right) for left, right in pairs if _prints_alike(left, right)]
|
|
1024
|
+
if not alike:
|
|
1025
|
+
return None
|
|
1026
|
+
if not comparators and all(identity_candidate(left, right) for left, right in alike):
|
|
1027
|
+
return _UNSEEN_IDENTITY_FACT
|
|
1028
|
+
names: dict[str, None] = {}
|
|
1029
|
+
for left, right in alike:
|
|
1030
|
+
_attributes_apart(left, right, names)
|
|
1031
|
+
if len(names) > 5:
|
|
1032
|
+
break
|
|
1033
|
+
if not names:
|
|
1034
|
+
return _UNSEEN_FACT
|
|
1035
|
+
shown = list(names)
|
|
1036
|
+
return f"{_UNSEEN_FACT} (attributes that differ: {', '.join(shown[:5])}{', ..' if len(shown) > 5 else ''})"
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _attributes_apart(left: object, right: object, known: dict[str, None]) -> None:
|
|
1040
|
+
"""Add to *known* the attributes two values hold that differ between them, up to the six a line can use.
|
|
1041
|
+
|
|
1042
|
+
An attribute whose comparison raises is left out: nothing is known of it.
|
|
1043
|
+
"""
|
|
1044
|
+
try:
|
|
1045
|
+
held, other = vars(left), vars(right)
|
|
1046
|
+
for name in (*held, *(name for name in other if name not in held)):
|
|
1047
|
+
if name not in held or name not in other or _held_apart(held[name], other[name]):
|
|
1048
|
+
known[_safe_str(name)] = None
|
|
1049
|
+
if len(known) > 5:
|
|
1050
|
+
return
|
|
1051
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
1052
|
+
return
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _held_apart(one: object, other: object) -> bool:
|
|
1056
|
+
try:
|
|
1057
|
+
return not equals(one, other)
|
|
1058
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
1059
|
+
return False
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _fields_of(value: object) -> dict | None:
|
|
1063
|
+
"""A dataclass, attrs instance or pydantic-style model as its fields, read as its ``==`` reads them.
|
|
1064
|
+
|
|
1065
|
+
Read field by field, a field ``==`` leaves out kept the hint away from a payload that matched, and so
|
|
1066
|
+
did a nested object against the nested mapping it was built from.
|
|
1067
|
+
"""
|
|
1068
|
+
if (
|
|
1069
|
+
is_model_dump_object(value)
|
|
1070
|
+
or is_attrs_instance(value)
|
|
1071
|
+
or (dataclasses.is_dataclass(value) and not isinstance(value, type))
|
|
1072
|
+
):
|
|
1073
|
+
return comparable_fields(value)
|
|
1074
|
+
return None
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def identity_candidate(left: object, right: object) -> bool:
|
|
1078
|
+
"""Whether ``==`` between these two comes down to identity, asked *before* the comparison runs.
|
|
1079
|
+
|
|
1080
|
+
Two separate instances of a type that leaves ``__eq__`` to ``object`` are never equal, whatever they
|
|
1081
|
+
hold. That is a fact about the type rather than about what the two hold, which is the only claim worth
|
|
1082
|
+
making: state can live in a slot, in a descriptor's own table or in a C field, and a line that promised
|
|
1083
|
+
to have read all of it would be promising more than any reading can deliver. It is said of two of one
|
|
1084
|
+
type only: a value of another type may answer the reflected comparison its own way.
|
|
1085
|
+
|
|
1086
|
+
Three details make the answer trustworthy, and each was put here by a case that defeated the one
|
|
1087
|
+
before it. It is asked before the comparison, because a type may rewrite its own ``__eq__`` while
|
|
1088
|
+
answering one, and a question asked afterwards would be answered by the type it left behind. It
|
|
1089
|
+
reads no attribute of the class, not even through `type.__getattribute__`: that runs a descriptor
|
|
1090
|
+
the class holds under the name, and one that installs an equality while being asked failed a pair
|
|
1091
|
+
its own ``==`` held equal. And what it reads is the class tree's own namespaces, in the order the
|
|
1092
|
+
tree has them, the way `slot_tp_richcompare` reads them, off the slots of `type`, which a metaclass
|
|
1093
|
+
cannot spell. The tree is the real one: a metaclass's own ``mro()`` can put another class first, and
|
|
1094
|
+
that class's equality is then the one ``==`` runs.
|
|
1095
|
+
|
|
1096
|
+
``None`` is an answer: a class that writes ``__eq__ = None`` has said what its equality is. A tree in
|
|
1097
|
+
which no class writes one, which reassigning ``__bases__`` under such a metaclass can leave behind, is
|
|
1098
|
+
no claim of identity. A list and a tuple are answered before any namespace is read. Scalars reach
|
|
1099
|
+
none of it: their comparison returns before this is asked.
|
|
1100
|
+
|
|
1101
|
+
One thing a namespace can still run: a key of a class of its own, a `str` subclass, that compares itself
|
|
1102
|
+
and may answer one lookup one way and the next another. Identity is therefore claimed only of a tree whose
|
|
1103
|
+
namespaces hold exact `str` keys alone (`_plainly_keyed`), which is read where the claim is about to be
|
|
1104
|
+
made and nowhere else: a tree that writes an equality of its own never pays for it.
|
|
1105
|
+
|
|
1106
|
+
The first class of the tree answers for a type that writes its equality itself, at 105 ns where the
|
|
1107
|
+
look through the class it replaces took 76. One that inherits it pays about 50 ns more a level.
|
|
1108
|
+
|
|
1109
|
+
``__ne__`` is not asked about: the comparison a failing assertion runs is ``actual == expected``, so a
|
|
1110
|
+
type defining only ``__ne__`` still compares by identity.
|
|
1111
|
+
"""
|
|
1112
|
+
try:
|
|
1113
|
+
klass = type(left)
|
|
1114
|
+
# one object against itself is equal under identity too, so a failure can never be about that
|
|
1115
|
+
if left is right or klass is not type(right) or klass is list or klass is tuple:
|
|
1116
|
+
return False
|
|
1117
|
+
for base in class_tree(klass):
|
|
1118
|
+
written = class_namespace(base)
|
|
1119
|
+
if "__eq__" in written:
|
|
1120
|
+
return written["__eq__"] is _OBJECT_EQUALITY and _plainly_keyed(klass)
|
|
1121
|
+
except Exception: # a key of a class of its own raised from its ``__eq__``: no claim, and a failure is on the way
|
|
1122
|
+
return False
|
|
1123
|
+
return False
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _plainly_keyed(klass: type) -> bool:
|
|
1127
|
+
"""Whether every namespace of *klass*'s tree holds exact `str` keys alone, so a lookup in it ran no code."""
|
|
1128
|
+
return all(type(key) is str for base in class_tree(klass) for key in class_namespace(base))
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _fields_match(left: object, right: object) -> bool:
|
|
1132
|
+
"""Whether one side is an object whose fields are exactly the other side's mapping."""
|
|
1133
|
+
try:
|
|
1134
|
+
for obj, other in ((left, right), (right, left)):
|
|
1135
|
+
fields = _fields_of(obj)
|
|
1136
|
+
if fields is not None and is_mapping_like(other) and fields == dict(other):
|
|
1137
|
+
return True
|
|
1138
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
1139
|
+
return False
|
|
1140
|
+
return False
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _same_values(pairs: Sequence[tuple[object, object]]) -> bool:
|
|
1144
|
+
"""Whether the differing values are the same on both sides, sitting in different places.
|
|
1145
|
+
|
|
1146
|
+
Counted rather than sorted wherever the values are hashable, which is the common case and the one
|
|
1147
|
+
that used to be expensive: sorting by ``repr`` calls ``repr`` on every value on both sides, and a
|
|
1148
|
+
failure over a two-thousand element sequence paid four thousand of them for an answer that is
|
|
1149
|
+
almost always no. The sort stays as the fallback, since it is what handles values that are
|
|
1150
|
+
unhashable or not orderable against each other.
|
|
1151
|
+
"""
|
|
1152
|
+
try:
|
|
1153
|
+
return Counter(value for value, _ in pairs) == Counter(value for _, value in pairs)
|
|
1154
|
+
except TypeError: # unhashable values, which only the slower route can compare
|
|
1155
|
+
pass
|
|
1156
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
1157
|
+
return False
|
|
1158
|
+
try:
|
|
1159
|
+
return sorted((value for value, _ in pairs), key=repr) == sorted((value for _, value in pairs), key=repr)
|
|
1160
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
1161
|
+
return False
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _named(pairs: Sequence[tuple[object, object]]) -> str | None:
|
|
1165
|
+
"""The narrowest set of steps that accounts for every pair, worded, or ``None``."""
|
|
1166
|
+
accounted = _accounted(pairs, _STEPS)
|
|
1167
|
+
return None if accounted is None else f"every difference here is one of {accounted}"
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _accounted(
|
|
1171
|
+
pairs: Sequence[tuple[object, object]], steps: Sequence[tuple[Callable[[object], object], _Label]]
|
|
1172
|
+
) -> str | None:
|
|
1173
|
+
"""What accounts for every pair, of *steps* and of whitespace of any kind and amount, the narrowest first.
|
|
1174
|
+
|
|
1175
|
+
One step, then two. Then the kind or amount of whitespace, alone and beside one step: it takes in line
|
|
1176
|
+
endings and surrounding whitespace, so said first it would stand in for the two narrower facts.
|
|
1177
|
+
|
|
1178
|
+
Nothing is asked where no step reads the first pair (`_unread_by_any_step`): every step has to account for
|
|
1179
|
+
it, and asking all nineteen was most of what a failure of two numbers spent here.
|
|
1180
|
+
"""
|
|
1181
|
+
if _unread_by_any_step(pairs[0]):
|
|
1182
|
+
return None
|
|
1183
|
+
for step, label in steps:
|
|
1184
|
+
if _explains(pairs, (step,)):
|
|
1185
|
+
return _worded(label, pairs)
|
|
1186
|
+
# only now pairs of steps: `"a\r\nb "` against `"a\nb"` needs both, and neither alone equalises it
|
|
1187
|
+
for index, (first_step, first_label) in enumerate(steps):
|
|
1188
|
+
for second_step, second_label in steps[index + 1 :]:
|
|
1189
|
+
if _explains(pairs, (first_step, second_step)):
|
|
1190
|
+
return f"{_worded(first_label, pairs)} and {_worded(second_label, pairs)}"
|
|
1191
|
+
if _explains(pairs, (_spaced,)):
|
|
1192
|
+
return "the kind or amount of whitespace"
|
|
1193
|
+
for step, label in steps:
|
|
1194
|
+
if step not in (_newlines, _stripped) and _explains(pairs, (step, _spaced)):
|
|
1195
|
+
return f"{_worded(label, pairs)} and the kind or amount of whitespace"
|
|
1196
|
+
return None
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _unread_by_any_step(pair: tuple[object, object]) -> bool:
|
|
1200
|
+
"""Whether neither side of *pair* is a text, a ``bytes`` or an enum member, which is all a step reads.
|
|
1201
|
+
|
|
1202
|
+
Asked as each step asks it. Where a value's ``__class__`` is code of its own that raises, the pair is unread
|
|
1203
|
+
too: every step would have ended in that same error, and `_explains` counts it as no.
|
|
1204
|
+
"""
|
|
1205
|
+
try:
|
|
1206
|
+
return not any(isinstance(side, (str, bytes, enum.Enum)) for side in pair)
|
|
1207
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
1208
|
+
return True
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _worded(label: _Label, pairs: Sequence[tuple[object, object]]) -> str:
|
|
1212
|
+
"""A step's label, letting one that depends on the shape of the pairs decide for itself."""
|
|
1213
|
+
return label if isinstance(label, str) else label(pairs)
|