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,729 @@
|
|
|
1
|
+
"""Runtime-checkable protocols for duck-typed object introspection.
|
|
2
|
+
|
|
3
|
+
The comparison and diff code accepts arbitrary user objects, so it inspects them structurally: pydantic
|
|
4
|
+
models (``model_dump``), ``attrs`` classes (``__attrs_attrs__``) and namedtuples (``_fields`` /
|
|
5
|
+
``_asdict``). Expressing those shapes as ``@runtime_checkable`` protocols lets the diff code use
|
|
6
|
+
``isinstance`` checks the type checker can follow - narrowing the value instead of probing it with
|
|
7
|
+
``hasattr`` and then suppressing the attribute access.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import collections
|
|
13
|
+
import collections.abc
|
|
14
|
+
import dataclasses
|
|
15
|
+
import datetime
|
|
16
|
+
import enum
|
|
17
|
+
import itertools
|
|
18
|
+
import numbers
|
|
19
|
+
import os
|
|
20
|
+
import pathlib
|
|
21
|
+
import reprlib
|
|
22
|
+
import sys
|
|
23
|
+
import types
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import TYPE_CHECKING, Any, Final, Protocol, TypeGuard, TypeVar, cast, runtime_checkable
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
29
|
+
from types import CellType
|
|
30
|
+
|
|
31
|
+
_T = TypeVar("_T")
|
|
32
|
+
|
|
33
|
+
# absolute paths in a set: one hash lookup a frame, and a user file of the same name cannot shadow ours
|
|
34
|
+
ASSERTPY_FILES: Final = frozenset(
|
|
35
|
+
os.path.join(os.path.dirname(os.path.dirname(__file__)), name)
|
|
36
|
+
for name in os.listdir(os.path.dirname(os.path.dirname(__file__)))
|
|
37
|
+
if name.endswith(".py")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def caller_stacklevel() -> int:
|
|
42
|
+
"""The ``stacklevel`` that points a warning its caller raises at the first frame outside the package.
|
|
43
|
+
|
|
44
|
+
Counted, not fixed: a builder a `log_passes()` block made runs each assertion through frames of its own, and a
|
|
45
|
+
fixed count pointed into them.
|
|
46
|
+
"""
|
|
47
|
+
frame = sys._getframe(1) # the caller, which `warnings.warn(stacklevel=1)` names
|
|
48
|
+
level = 1
|
|
49
|
+
while frame is not None and frame.f_code.co_filename in ASSERTPY_FILES:
|
|
50
|
+
frame = frame.f_back
|
|
51
|
+
level += 1
|
|
52
|
+
return level
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# exact builtins carrying no `__dict__`, so the isinstance is skipped; a subclass takes the structural check
|
|
56
|
+
_ATOMIC_TYPES: Final = frozenset(
|
|
57
|
+
{type(None), bool, int, float, complex, str, bytes, bytearray, list, tuple, set, frozenset}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@runtime_checkable
|
|
62
|
+
class SupportsModelDump(Protocol):
|
|
63
|
+
"""A pydantic-style model exposing ``model_dump()``."""
|
|
64
|
+
|
|
65
|
+
def model_dump(self) -> dict[str, Any]: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@runtime_checkable
|
|
69
|
+
class NamedTupleLike(Protocol):
|
|
70
|
+
"""A ``collections.namedtuple`` / ``typing.NamedTuple`` instance."""
|
|
71
|
+
|
|
72
|
+
_fields: tuple[str, ...]
|
|
73
|
+
|
|
74
|
+
def _asdict(self) -> dict[str, Any]: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@runtime_checkable
|
|
78
|
+
class AttrsInstance(Protocol):
|
|
79
|
+
"""An ``attrs``-decorated instance exposing ``__attrs_attrs__``."""
|
|
80
|
+
|
|
81
|
+
__attrs_attrs__: tuple[Any, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class WarningLogger(Protocol):
|
|
85
|
+
"""What a warning-mode assertion asks of a logger: the one method it calls when an assertion fails.
|
|
86
|
+
|
|
87
|
+
Structural rather than either concrete type, because both are promised and both work. The
|
|
88
|
+
docstring of `assert_warn` says `Logger`, the default the builder installs is a `LoggerAdapter`,
|
|
89
|
+
and naming one of them in the signature refuses the other for no reason the runtime shares.
|
|
90
|
+
|
|
91
|
+
Widest on both sides. `str` because a logger declared to take only a string is a working one and
|
|
92
|
+
the message here is always a string, and `object` because a return this never reads is not a
|
|
93
|
+
reason to refuse a logger that has one.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def warning(self, msg: str) -> object: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@runtime_checkable
|
|
100
|
+
class Readable(Protocol):
|
|
101
|
+
"""A file-like object, recognised by the one method that reads it whole.
|
|
102
|
+
|
|
103
|
+
The return is spelled out because the caller promises a `str`: a text handle answers with one and a
|
|
104
|
+
binary handle with `bytes`, which is decoded, and a reader that answers with neither would be
|
|
105
|
+
handed straight back through a signature that says `str`.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def read(self) -> str | bytes: ...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@runtime_checkable
|
|
112
|
+
class MappingLike(Protocol):
|
|
113
|
+
"""A dict-like object that can be iterated over and subscripted by key."""
|
|
114
|
+
|
|
115
|
+
def keys(self) -> Any: ...
|
|
116
|
+
|
|
117
|
+
def __iter__(self) -> Any: ...
|
|
118
|
+
|
|
119
|
+
def __getitem__(self, key: Any, /) -> Any: ... # positional, so a plain `dict` satisfies it
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_model_dump_object(obj: object) -> TypeGuard[SupportsModelDump]:
|
|
123
|
+
"""Return whether ``obj``'s type exposes a callable ``model_dump()`` (e.g. a pydantic model)."""
|
|
124
|
+
if type(obj) is dict or type(obj) in _ATOMIC_TYPES:
|
|
125
|
+
return False
|
|
126
|
+
# on the type first, which a `unittest.mock` object's fabricated attribute never reaches; then on the
|
|
127
|
+
# value, because an ordinary method is a non-data descriptor and `obj.model_dump = None` shadows it
|
|
128
|
+
return hasattr(type(obj), "model_dump") and callable(getattr(obj, "model_dump", None))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_pydantic_model(obj: object) -> TypeGuard[SupportsModelDump]:
|
|
132
|
+
"""Whether *obj* is an instance of pydantic's ``BaseModel``, asked without importing pydantic.
|
|
133
|
+
|
|
134
|
+
No model exists unless pydantic is loaded already, so an absent module answers no.
|
|
135
|
+
"""
|
|
136
|
+
pydantic = sys.modules.get("pydantic")
|
|
137
|
+
return pydantic is not None and isinstance(obj, pydantic.BaseModel)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_pydantic_model_class(candidate: object) -> bool:
|
|
141
|
+
"""Whether *candidate* is a subclass of pydantic's ``BaseModel``, asked without importing pydantic."""
|
|
142
|
+
pydantic = sys.modules.get("pydantic")
|
|
143
|
+
return pydantic is not None and isinstance(candidate, type) and issubclass(candidate, pydantic.BaseModel)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TakenApart(dict):
|
|
147
|
+
"""The fields of one value as it holds them, with its class and the keys attrs compares fields through.
|
|
148
|
+
|
|
149
|
+
Read as a plain mapping, a nested value of another class with the same fields passed a comparison
|
|
150
|
+
under ``strict_types`` and ``ignore`` together, which the rule says holds at every depth. A field
|
|
151
|
+
declared with an ``eq=`` key is compared through it against the same field of another instance, as
|
|
152
|
+
attrs' own ``==`` does, and as held against anything else, a payload's value included.
|
|
153
|
+
|
|
154
|
+
Printed as the class it was read from, ``Row(name='a')`` for a record and ``Kept({'x': 1})`` for a dict of
|
|
155
|
+
a class of its own, so a failure names what was compared.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
__slots__ = ("compared_by", "kind")
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
kind: type,
|
|
163
|
+
fields: collections.abc.Mapping,
|
|
164
|
+
compared_by: collections.abc.Mapping[object, Callable[[object], object]] | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
super().__init__(fields)
|
|
167
|
+
self.kind = kind
|
|
168
|
+
self.compared_by = compared_by or {}
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def keyed(self) -> bool:
|
|
172
|
+
"""Whether the class this was read from is a dict's, whose parts are keys rather than fields."""
|
|
173
|
+
return issubclass(self.kind, dict)
|
|
174
|
+
|
|
175
|
+
@reprlib.recursive_repr()
|
|
176
|
+
def __repr__(self) -> str:
|
|
177
|
+
if self.keyed:
|
|
178
|
+
return f"{class_name(self.kind)}({dict.__repr__(self)})"
|
|
179
|
+
fields = ", ".join(f"{field}={value!r}" for field, value in self.items() if not self._says_its_class(field))
|
|
180
|
+
return f"{class_name(self.kind)}({fields})"
|
|
181
|
+
|
|
182
|
+
def _says_its_class(self, field: object) -> bool:
|
|
183
|
+
"""Whether *field* is the class an exception is read with, which the name ahead of the bracket has said."""
|
|
184
|
+
return field == "__class__" and self[field] is self.kind and issubclass(self.kind, BaseException)
|
|
185
|
+
|
|
186
|
+
def __eq__(self, other: object) -> bool:
|
|
187
|
+
if not isinstance(other, dict):
|
|
188
|
+
return NotImplemented
|
|
189
|
+
if not self.compared_by and not (isinstance(other, TakenApart) and other.compared_by):
|
|
190
|
+
return dict.__eq__(self, other)
|
|
191
|
+
keyed = keyed_names(self, other)
|
|
192
|
+
pairs = (keyed_pair(self, other, name) if name in keyed else (self[name], other[name]) for name in self)
|
|
193
|
+
return self.keys() == other.keys() and all(left is right or bool(left == right) for left, right in pairs)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def laid_out(value: object) -> object:
|
|
197
|
+
"""*value* with every record taken apart turned into a plain dict of its fields, for a reader that lays
|
|
198
|
+
containers out and prints anything else on one line.
|
|
199
|
+
|
|
200
|
+
Followed through the values of a ``dict`` and the items of a ``list`` or a ``tuple``, each of exactly that
|
|
201
|
+
class, told by identity so that no class is asked. A mapping is turned only where every key in it is one
|
|
202
|
+
the interpreter hashes and compares by code of its own: exactly a ``str``, an ``int``, a ``bool``, a
|
|
203
|
+
``float``, a ``bytes`` or ``None``. Putting a value back looks its key up, and beside a key of any other
|
|
204
|
+
class that runs the class's ``__hash__`` or ``__eq__``, so such a mapping stays as it is, a record among
|
|
205
|
+
them. A dict with nothing to turn comes back as the same object. A part two places share is turned once,
|
|
206
|
+
and a value that holds itself is left where the walk meets it again.
|
|
207
|
+
"""
|
|
208
|
+
return _laid_out(value, {}, set())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def plainly_hashed(key: object) -> bool:
|
|
212
|
+
"""Whether *key* is exactly one of the six plain kinds, whose own hash and equality are the interpreter's.
|
|
213
|
+
|
|
214
|
+
A lookup runs no other code only where every key the dict holds is one too: a key of another class that
|
|
215
|
+
hashes the same is asked its own ``__eq__``.
|
|
216
|
+
"""
|
|
217
|
+
kind = type(key)
|
|
218
|
+
return kind is str or kind is int or kind is bool or kind is float or kind is bytes or key is None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _laid_out(value: object, done: dict[int, object], above: set[int]) -> object:
|
|
222
|
+
kind = type(value)
|
|
223
|
+
if not (kind is TakenApart or kind is dict or kind is list or kind is tuple):
|
|
224
|
+
return value
|
|
225
|
+
mark = id(value)
|
|
226
|
+
if mark in above:
|
|
227
|
+
return value
|
|
228
|
+
if mark in done:
|
|
229
|
+
return done[mark]
|
|
230
|
+
above.add(mark)
|
|
231
|
+
made: object = value
|
|
232
|
+
if kind is list or kind is tuple:
|
|
233
|
+
items = cast("list[object] | tuple[object, ...]", value)
|
|
234
|
+
parts = [_laid_out(part, done, above) for part in items]
|
|
235
|
+
if any(new is not old for new, old in zip(parts, items, strict=True)):
|
|
236
|
+
made = kind(parts)
|
|
237
|
+
else:
|
|
238
|
+
held = list(dict.items(cast("dict[object, object]", value)))
|
|
239
|
+
if all(plainly_hashed(key) for key, _ in held):
|
|
240
|
+
# a copy keeps the hashes it was made with, and putting a value back compares plain keys alone
|
|
241
|
+
fields = dict.copy(cast("dict[object, object]", value))
|
|
242
|
+
for key, old in held:
|
|
243
|
+
if (new := _laid_out(old, done, above)) is not old:
|
|
244
|
+
fields[key] = new
|
|
245
|
+
if kind is TakenApart or any(fields[key] is not old for key, old in held):
|
|
246
|
+
made = fields
|
|
247
|
+
above.discard(mark)
|
|
248
|
+
done[mark] = made
|
|
249
|
+
return made
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_PART_READERS = tuple(
|
|
253
|
+
type.__getattribute__(kind, "__dict__")["__eq__"]
|
|
254
|
+
for kind in (
|
|
255
|
+
list,
|
|
256
|
+
tuple,
|
|
257
|
+
dict,
|
|
258
|
+
collections.OrderedDict,
|
|
259
|
+
collections.abc.Mapping,
|
|
260
|
+
types.MappingProxyType,
|
|
261
|
+
TakenApart,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
"""The ``__eq__`` of each kind whose values are equal exactly as what they hold is."""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class KeyedValue:
|
|
268
|
+
"""One of two fields declared with an ``eq=`` key, for the one comparison of them: equal when the keys are.
|
|
269
|
+
|
|
270
|
+
Built for the comparison and never kept, so what a failure hands out and prints is the value held.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
__slots__ = ("held", "key")
|
|
274
|
+
|
|
275
|
+
def __init__(self, held: object, key: Callable[[object], object]) -> None:
|
|
276
|
+
self.held = held
|
|
277
|
+
self.key = key
|
|
278
|
+
|
|
279
|
+
def __eq__(self, other: object) -> bool:
|
|
280
|
+
# read at the comparison: `ignore_null` looks at what is held first, and `str.lower(None)` raises
|
|
281
|
+
return self.key(self.held) == other.key(other.held) if isinstance(other, KeyedValue) else NotImplemented
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def keyed_names(fields: object, counterpart: object) -> collections.abc.Set[object]:
|
|
285
|
+
"""The fields two sides both compare through an attrs key, and none unless both are a `TakenApart`.
|
|
286
|
+
|
|
287
|
+
Asked once for a pair of mappings rather than once per field: asked per field, a 200-key dict diff
|
|
288
|
+
took 20% longer, on dicts that hold no key at all.
|
|
289
|
+
"""
|
|
290
|
+
if type(fields) is TakenApart and type(counterpart) is TakenApart:
|
|
291
|
+
return fields.compared_by.keys() & counterpart.compared_by.keys()
|
|
292
|
+
return frozenset()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def keyed_pair(fields: Any, counterpart: Any, name: object) -> tuple[KeyedValue, KeyedValue]:
|
|
296
|
+
"""Field *name* of two sides, each read through its own key, for a name `keyed_names` answered."""
|
|
297
|
+
return (
|
|
298
|
+
KeyedValue(fields[name], fields.compared_by[name]),
|
|
299
|
+
KeyedValue(counterpart[name], counterpart.compared_by[name]),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def class_name(kind: type) -> str:
|
|
304
|
+
"""The name of a class as an exact `str`, read off the slot: a metaclass can make the attribute raise."""
|
|
305
|
+
return str.__str__(type.__dict__["__name__"].__get__(kind))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def holds_only_its_dict(kind: type) -> bool:
|
|
309
|
+
"""Whether an instance of *kind* holds nothing but its ``__dict__``, read off the layout of its class.
|
|
310
|
+
|
|
311
|
+
A slot, the items of a builtin container and the fields of a type written in C are all room in the instance
|
|
312
|
+
past the object's own header, its ``__dict__`` and its weak reference list. A list of kinds was the first
|
|
313
|
+
answer, and each one found missing was a comparison that passed: `list`, `array.array`, an exception.
|
|
314
|
+
"""
|
|
315
|
+
spare = basic_size(kind) - object.__basicsize__
|
|
316
|
+
spare -= tuple.__itemsize__ * ((_dict_offset(kind) > 0) + (weakref_offset(kind) > 0))
|
|
317
|
+
return spare == 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# off the slots of `type`: a metaclass may spell a size or an offset of its own
|
|
321
|
+
basic_size, _dict_offset, weakref_offset = (
|
|
322
|
+
type.__dict__[name].__get__ for name in ("__basicsize__", "__dictoffset__", "__weakrefoffset__")
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def own_fields(obj: object) -> dict[str, Any] | None:
|
|
327
|
+
"""The ``__dict__`` of an object that holds nothing else, or ``None`` for a value that is no bag of fields.
|
|
328
|
+
|
|
329
|
+
A value of a builtin kind is none even when it carries a ``__dict__``: a subclass of `Decimal` or `str` has
|
|
330
|
+
an empty one, and reading it made every two such values compare equal. Nor is any value that holds
|
|
331
|
+
something outside its ``__dict__`` (`holds_only_its_dict`): two lists of a class of the caller's own were
|
|
332
|
+
equal under ``ignore=`` whatever they held.
|
|
333
|
+
"""
|
|
334
|
+
builtin_kinds = (
|
|
335
|
+
type,
|
|
336
|
+
numbers.Number,
|
|
337
|
+
str,
|
|
338
|
+
bytes,
|
|
339
|
+
bytearray,
|
|
340
|
+
datetime.date,
|
|
341
|
+
datetime.time,
|
|
342
|
+
datetime.timedelta,
|
|
343
|
+
enum.Enum,
|
|
344
|
+
uuid.UUID,
|
|
345
|
+
pathlib.PurePath,
|
|
346
|
+
)
|
|
347
|
+
if hasattr(obj, "__dict__") and not isinstance(obj, builtin_kinds) and holds_only_its_dict(type(obj)):
|
|
348
|
+
return vars(obj)
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def kind_of(value: object) -> type:
|
|
353
|
+
"""The class *value* was read from: its own, the one a `TakenApart` holds the fields of, or a keyed field's."""
|
|
354
|
+
if type(value) is KeyedValue:
|
|
355
|
+
return type(value.held)
|
|
356
|
+
# by the type itself: `isinstance` reads a ``__class__`` the value may answer with code of its own
|
|
357
|
+
return value.kind if type(value) is TakenApart else type(value)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def eq_keyed(attribute: Any, value: object) -> object:
|
|
361
|
+
"""An attrs field's value as its ``==`` compares it: through the key given as ``eq=``, when there is one."""
|
|
362
|
+
key = getattr(attribute, "eq_key", None)
|
|
363
|
+
return value if key is None else key(value)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def as_held(value: object) -> object:
|
|
367
|
+
"""*value* as the instance held it, past the `KeyedValue` one comparison reads it through."""
|
|
368
|
+
return value.held if type(value) is KeyedValue else value
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def model_field_values(model: SupportsModelDump) -> dict[Any, Any]:
|
|
372
|
+
"""A model's declared fields and extras as the values they hold, not what serialising makes of them.
|
|
373
|
+
|
|
374
|
+
`model_dump()` runs every `field_serializer`, leaves out a field declared ``exclude=True`` and adds the
|
|
375
|
+
computed ones, and pydantic's ``==`` does none of that. Read through it, `M(f=1)` and `M(f="1")`
|
|
376
|
+
compared equal under ``ignore=``, ``include=``, ``strict_types=`` and ``tolerance=`` while ``==`` told
|
|
377
|
+
them apart, and the structured diff showed serialised values beside a message showing the held ones.
|
|
378
|
+
|
|
379
|
+
Only a pydantic ``BaseModel`` is read this way. A duck type may carry a ``model_fields`` of its own
|
|
380
|
+
meaning, pydantic's own attribute names, or no ``__dict__`` at all, and is read through `model_dump()`,
|
|
381
|
+
which is all the duck type promises.
|
|
382
|
+
|
|
383
|
+
What pydantic's ``==`` compares beyond these is not read: the model's class, which a configured
|
|
384
|
+
comparison leaves to ``strict_types``, and private attributes, so a pair differing only there fails
|
|
385
|
+
plainly and passes a configured comparison, as it did through the dump.
|
|
386
|
+
|
|
387
|
+
An extra named like a declared field is refused. Validation never produces one, and the model then
|
|
388
|
+
holds two values under one name, which no reading field by field can show: `model_dump()` keeps the
|
|
389
|
+
extra's, ``==`` compares both.
|
|
390
|
+
"""
|
|
391
|
+
if not is_pydantic_model(model):
|
|
392
|
+
return model.model_dump()
|
|
393
|
+
# `Any` because pydantic comes off `sys.modules`, and `model_fields` because `__pydantic_fields__` is 2.10+
|
|
394
|
+
model_class: Any = type(model)
|
|
395
|
+
declared = model_class.model_fields
|
|
396
|
+
held = vars(model)
|
|
397
|
+
values = {name: held[name] for name in declared if name in held}
|
|
398
|
+
extra = getattr(model, "__pydantic_extra__", None)
|
|
399
|
+
if isinstance(extra, collections.abc.Mapping):
|
|
400
|
+
shadowing = next((name for name in extra if name in declared), None)
|
|
401
|
+
if shadowing is not None:
|
|
402
|
+
raise TypeError(
|
|
403
|
+
f"{type(model).__name__} holds an extra named {shadowing!r} beside the field of that name,"
|
|
404
|
+
" which validation never produces, so its fields cannot be read one by one"
|
|
405
|
+
)
|
|
406
|
+
values.update(extra)
|
|
407
|
+
return values
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def is_namedtuple(obj: object) -> TypeGuard[NamedTupleLike]:
|
|
411
|
+
"""Return whether ``obj`` is a namedtuple instance (a ``tuple`` whose type carries the whole surface).
|
|
412
|
+
|
|
413
|
+
Both ``_fields`` and ``_asdict``, because the callers use both: a ``tuple`` subclass with a bare
|
|
414
|
+
``_fields`` is not one, and asking for only that let it through.
|
|
415
|
+
|
|
416
|
+
Asked of the type and not the instance, as its two neighbours are and for the same reason. A
|
|
417
|
+
``unittest.mock`` object fabricates every attribute through ``__getattr__``, so it answered all three
|
|
418
|
+
of these and the diff walk read a call's own elements as field names, then as keys. Its class
|
|
419
|
+
carries none of them.
|
|
420
|
+
|
|
421
|
+
Cheaper as well as narrower: a ``runtime_checkable`` protocol was measured here at 876 ns against
|
|
422
|
+
41 ns for the attribute, on the walk that asks this about every differing element.
|
|
423
|
+
"""
|
|
424
|
+
return isinstance(obj, tuple) and hasattr(type(obj), "_fields") and hasattr(type(obj), "_asdict")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def is_attrs_instance(obj: object) -> TypeGuard[AttrsInstance]:
|
|
428
|
+
"""Return whether ``obj`` is an ``attrs``-decorated instance (not the class itself, which also
|
|
429
|
+
carries ``__attrs_attrs__`` but has no field values to read).
|
|
430
|
+
|
|
431
|
+
The atomic fast path is the same one its two neighbours above have, and it was the one predicate
|
|
432
|
+
missing it. That mattered out of proportion to the line: the diff walk asks this about every
|
|
433
|
+
differing element, and a profile of a 2000-element sequence diff put 39% of the whole walk inside
|
|
434
|
+
the ``runtime_checkable`` isinstance this skips.
|
|
435
|
+
"""
|
|
436
|
+
if type(obj) in _ATOMIC_TYPES:
|
|
437
|
+
return False
|
|
438
|
+
return not isinstance(obj, type) and hasattr(type(obj), "__attrs_attrs__")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def is_mapping_like(obj: object) -> TypeGuard[MappingLike]:
|
|
442
|
+
"""Return whether ``obj`` is dict-like: iterable with ``keys()`` and ``[]`` access."""
|
|
443
|
+
if type(obj) is dict:
|
|
444
|
+
return True
|
|
445
|
+
if type(obj) in _ATOMIC_TYPES:
|
|
446
|
+
return False
|
|
447
|
+
# read with a default: a value may say it is a dict through `__class__` and have no `keys` at all
|
|
448
|
+
return isinstance(obj, MappingLike) and callable(getattr(obj, "keys", None))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def keyed_snapshot(candidate: object) -> MappingLike | None:
|
|
452
|
+
"""*candidate* as something safe to walk by key, or `None` when it cannot be walked that way.
|
|
453
|
+
|
|
454
|
+
Iteration yields keys for a mapping and values for a sequence, and no test of the type tells which a
|
|
455
|
+
duck-typed value does. `unittest.mock.call_args` is a tuple subclass answering `keys`, so a renderer
|
|
456
|
+
walked it for keys, got its items and indexed the tuple with one.
|
|
457
|
+
|
|
458
|
+
A snapshot rather than a yes-or-no answer, because a probe and the walk that follows it are two
|
|
459
|
+
readings of the same value: an iterator that yields `0` on the first pass and `"bad"` on the second
|
|
460
|
+
passes the probe and raises in the walk. Reading once and rendering from what was read closes that.
|
|
461
|
+
|
|
462
|
+
Only a `dict` is handed back as it is, and a `TakenApart`, read once already and carrying the class
|
|
463
|
+
``strict_types`` compares. Registering as a `Mapping` promises an interface and not stability between
|
|
464
|
+
reads, so a custom one is snapshotted like anything else.
|
|
465
|
+
"""
|
|
466
|
+
if type(candidate) is dict or type(candidate) is TakenApart:
|
|
467
|
+
return cast("MappingLike", candidate)
|
|
468
|
+
keyed = cast("MappingLike", candidate)
|
|
469
|
+
try:
|
|
470
|
+
# an OrderedDict stays one, since its `==` reads the order a plain dict would forget
|
|
471
|
+
snapshot = collections.OrderedDict() if isinstance(candidate, collections.OrderedDict) else {}
|
|
472
|
+
snapshot.update((key, keyed[key]) for key in keyed)
|
|
473
|
+
except Exception:
|
|
474
|
+
return None
|
|
475
|
+
return snapshot
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def is_same_implementation(existing: object, candidate: object) -> bool:
|
|
479
|
+
"""Whether two registrations are the same implementation rather than two claiming one name.
|
|
480
|
+
|
|
481
|
+
Object identity is too strict. A ``def`` inside a pytest fixture builds a new function object
|
|
482
|
+
every time the fixture runs, and a module-scoped fixture in ``conftest.py`` is the documented way
|
|
483
|
+
to share an extension across test files, so that shape would otherwise look like a clash on the
|
|
484
|
+
second module. CPython reuses the enclosing code object across those rebuilds, and it differs the
|
|
485
|
+
moment the body does, which is exactly the line wanted here.
|
|
486
|
+
|
|
487
|
+
Callables without a ``__code__`` (instances with ``__call__``, builtins, partials) fall back to
|
|
488
|
+
identity, which is the strictest answer available for them.
|
|
489
|
+
|
|
490
|
+
A decorator's wrapper shares its code with everything that decorator wraps, so the functions a
|
|
491
|
+
wrapper holds, in its closure, its defaults or its ``__wrapped__``, are compared the same way: two
|
|
492
|
+
wrappers are one implementation only if what they wrap is. Anything else they hold is data and is
|
|
493
|
+
not compared, a callable without code (a builtin, a partial, a mock) included. A fixture rebuilding
|
|
494
|
+
its function carries fresh data every time, and the latest registration replacing the earlier one
|
|
495
|
+
is the point of accepting it.
|
|
496
|
+
"""
|
|
497
|
+
return _same_implementation(existing, candidate, set())
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _same_implementation(existing: object, candidate: object, seen: set[tuple[int, int]]) -> bool:
|
|
501
|
+
if existing is candidate:
|
|
502
|
+
return True
|
|
503
|
+
left = getattr(existing, "__code__", None)
|
|
504
|
+
if left is None or left is not getattr(candidate, "__code__", None):
|
|
505
|
+
return False
|
|
506
|
+
# a closure that holds its own function reaches this pair again
|
|
507
|
+
if (id(existing), id(candidate)) in seen:
|
|
508
|
+
return True
|
|
509
|
+
seen.add((id(existing), id(candidate)))
|
|
510
|
+
return all(
|
|
511
|
+
_same_implementation(inner, other, seen)
|
|
512
|
+
for inner, other in _held(existing, candidate)
|
|
513
|
+
if isinstance(inner, types.FunctionType) or isinstance(other, types.FunctionType)
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _held(existing: object, candidate: object) -> Iterator[tuple[object, object]]:
|
|
518
|
+
"""What two functions of one code object hold, side by side: closure cells, defaults, what they wrap."""
|
|
519
|
+
# one code object, so one set of free variables on both sides
|
|
520
|
+
cells = zip(
|
|
521
|
+
getattr(existing, "__closure__", None) or (), getattr(candidate, "__closure__", None) or (), strict=True
|
|
522
|
+
)
|
|
523
|
+
yield from ((_cell_contents(mine), _cell_contents(theirs)) for mine, theirs in cells)
|
|
524
|
+
yield from itertools.zip_longest(
|
|
525
|
+
getattr(existing, "__defaults__", None) or (), getattr(candidate, "__defaults__", None) or ()
|
|
526
|
+
)
|
|
527
|
+
mine, theirs = getattr(existing, "__kwdefaults__", None) or {}, getattr(candidate, "__kwdefaults__", None) or {}
|
|
528
|
+
yield from ((mine.get(name), theirs.get(name)) for name in mine.keys() | theirs.keys())
|
|
529
|
+
yield getattr(existing, "__wrapped__", None), getattr(candidate, "__wrapped__", None)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _cell_contents(cell: CellType) -> object:
|
|
533
|
+
"""What a closure cell holds, or ``None`` for one not yet filled."""
|
|
534
|
+
try:
|
|
535
|
+
return cell.cell_contents
|
|
536
|
+
except ValueError:
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def is_own_iterator(value: object) -> bool:
|
|
541
|
+
"""Whether *value* is an iterator, so the first pass over it is the only one there is.
|
|
542
|
+
|
|
543
|
+
Asked of its type rather than through `iter()`: this is asked while a failure is being described,
|
|
544
|
+
and a closed file answers `iter()` with `ValueError`.
|
|
545
|
+
"""
|
|
546
|
+
return issubclass(type(value), collections.abc.Iterator)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class Replay:
|
|
550
|
+
"""What a one-shot iterator has handed out so far, with the iterator for the rest.
|
|
551
|
+
|
|
552
|
+
A builder keeps one for a value that is its own iterator, so every link of a chain reads the same items:
|
|
553
|
+
read by the first link alone, the second passed or failed over nothing. Each walk starts at the first item
|
|
554
|
+
and takes one from the iterator only past what was handed out, so a walk that stops early leaves the rest
|
|
555
|
+
untaken and an endless generator is read no further than before. An item is kept before it is handed on:
|
|
556
|
+
code of the caller's that raises over it does not lose it for the next link. An error the iterator
|
|
557
|
+
itself raises is its own to repeat or not: asked again, a generator that raised is at its end, and an
|
|
558
|
+
iterator that goes on is read on.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
__slots__ = ("_rest", "_seen", "given_away", "source")
|
|
562
|
+
|
|
563
|
+
def __init__(self, source: Iterator[Any]) -> None:
|
|
564
|
+
self.source = source
|
|
565
|
+
self._rest: Iterator[Any] | None = source
|
|
566
|
+
self._seen: list[Any] = []
|
|
567
|
+
self.given_away = False
|
|
568
|
+
|
|
569
|
+
def give_away(self) -> None:
|
|
570
|
+
"""Note that code of the caller's is handed the iterator itself after a part of it was read here.
|
|
571
|
+
|
|
572
|
+
What that code takes of the rest then goes missing between what was read and what is left, and stays
|
|
573
|
+
missing: once noted, it is not taken back. With nothing read yet, or all of it, there is no such gap.
|
|
574
|
+
A walk still going when it is noted is refused at its next item past what was read, as the next link is.
|
|
575
|
+
"""
|
|
576
|
+
if self._rest is not None and self._seen:
|
|
577
|
+
self.given_away = True
|
|
578
|
+
|
|
579
|
+
def drained(self) -> list[Any]:
|
|
580
|
+
"""Every item in a list of its own, the rest taken in one go: through `__iter__`, 200 cost 7 times more."""
|
|
581
|
+
rest = self._rest
|
|
582
|
+
if rest is not None:
|
|
583
|
+
self._seen.extend(rest)
|
|
584
|
+
self._rest = None
|
|
585
|
+
return list(self._seen)
|
|
586
|
+
|
|
587
|
+
def __iter__(self) -> Iterator[Any]:
|
|
588
|
+
seen = self._seen
|
|
589
|
+
index = 0
|
|
590
|
+
while True:
|
|
591
|
+
if index == len(seen):
|
|
592
|
+
rest = self._rest
|
|
593
|
+
if rest is None:
|
|
594
|
+
return
|
|
595
|
+
if self.given_away:
|
|
596
|
+
raise handed_away()
|
|
597
|
+
try:
|
|
598
|
+
seen.append(next(rest))
|
|
599
|
+
except StopIteration:
|
|
600
|
+
self._rest = None
|
|
601
|
+
return
|
|
602
|
+
yield seen[index]
|
|
603
|
+
index += 1
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def handed_away() -> TypeError:
|
|
607
|
+
"""The refusal of a read of a one-shot iterator past the part read before a predicate was handed it."""
|
|
608
|
+
return TypeError(
|
|
609
|
+
"val is a one-shot iterator that a predicate was handed after a part of it was read, so what"
|
|
610
|
+
" is left of it cannot be told. Assert on list(...) of it instead"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def materialized(value: Iterable[_T]) -> Iterable[_T]:
|
|
615
|
+
"""``value``, drained into a list when it is a one-shot iterator, handed back untouched otherwise.
|
|
616
|
+
|
|
617
|
+
An object that is its own iterator (a generator, a file, ``map``/``filter``/``zip``) is consumed by
|
|
618
|
+
the first pass over it. Assertions that walk their subject more than once - once to decide and
|
|
619
|
+
again to describe, or once per argument - therefore see an empty value on the second pass, and
|
|
620
|
+
either report a wrong verdict or a message with the wrong contents in it.
|
|
621
|
+
|
|
622
|
+
Re-iterable values are returned as they are, so nothing is copied on the ordinary path. A value
|
|
623
|
+
that cannot be iterated at all is also returned as it is, leaving the caller's own guard to produce
|
|
624
|
+
the error message it wants.
|
|
625
|
+
|
|
626
|
+
Being its own iterator is not the only way to have one position: a wrapper handing out one shared
|
|
627
|
+
iterator answers no here and still loses what a first walk read, so ``satisfies()`` over one
|
|
628
|
+
reports the remainder rather than the value. That is left uncaught on purpose. Copying anything
|
|
629
|
+
that merely lacks a length instead turns a Pydantic model into a list of its field pairs, and
|
|
630
|
+
walking one to find out costs the position when the position is shared.
|
|
631
|
+
"""
|
|
632
|
+
try:
|
|
633
|
+
return list(value) if iter(value) is value else value
|
|
634
|
+
except TypeError:
|
|
635
|
+
return value
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def compares_by_parts(kind: type) -> bool:
|
|
639
|
+
"""Whether the ``==`` of *kind* is one known to read the parts a walk takes its values apart into.
|
|
640
|
+
|
|
641
|
+
Known by being the thing itself, never by where it says it came from: the method a builtin container, `Mapping`
|
|
642
|
+
or pydantic's ``BaseModel`` defines, or code that is what `dataclasses` or attrs writes for the fields the class
|
|
643
|
+
has (`_written_again`). Any other ``==`` may hold two values apart for a reason their parts do not show.
|
|
644
|
+
|
|
645
|
+
Kept once found, for at most 256 classes, with the ``__eq__`` it was found for and the code that ran: building
|
|
646
|
+
the twin cost a ring of a hundred records 15 ms, and a class given another ``__eq__``, or one given other code,
|
|
647
|
+
is asked again.
|
|
648
|
+
"""
|
|
649
|
+
owner, written = definition_of(kind, "__eq__") or (object, None)
|
|
650
|
+
ran = getattr(written, "__code__", None)
|
|
651
|
+
if kind in _COMPARED_BY_PARTS and _COMPARED_BY_PARTS[kind] == (id(written), id(ran)):
|
|
652
|
+
return True
|
|
653
|
+
pydantic = sys.modules.get("pydantic")
|
|
654
|
+
known = (
|
|
655
|
+
any(written is reader for reader in _PART_READERS)
|
|
656
|
+
or (pydantic is not None and written is type.__getattribute__(pydantic.BaseModel, "__dict__").get("__eq__"))
|
|
657
|
+
or _written_again(owner, written)
|
|
658
|
+
)
|
|
659
|
+
if known and len(_COMPARED_BY_PARTS) < 256:
|
|
660
|
+
_COMPARED_BY_PARTS[kind] = (id(written), id(ran))
|
|
661
|
+
return known
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
_COMPARED_BY_PARTS: dict[type, tuple[int, int]] = {}
|
|
665
|
+
"""What `compares_by_parts` has found: a class, and which ``__eq__`` and which code of it the answer was for. The
|
|
666
|
+
class holds both, so neither id is taken by another object while the class is here."""
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _written_again(owner: type, written: object) -> bool:
|
|
670
|
+
"""Whether *written*, the ``__eq__`` *owner* defines, is the code `dataclasses` or attrs writes for its fields.
|
|
671
|
+
|
|
672
|
+
Asked by having the same library write one for a twin with the same fields and comparing the two code objects.
|
|
673
|
+
A file name would not do: the method `dataclasses` writes names none, and neither does one put there by ``exec``.
|
|
674
|
+
Only a plain function is read: any other object can carry a ``__code__`` it does not run. The keys attrs
|
|
675
|
+
compares fields through are globals of the function it writes, so those are held to being the same objects.
|
|
676
|
+
"""
|
|
677
|
+
namespace = type.__getattribute__(owner, "__dict__")
|
|
678
|
+
attr = sys.modules.get("attr")
|
|
679
|
+
if type(written) is not types.FunctionType:
|
|
680
|
+
return False
|
|
681
|
+
if "__dataclass_fields__" in namespace:
|
|
682
|
+
fields = dataclasses.fields(cast("Any", owner))
|
|
683
|
+
twin = dataclasses.dataclass(
|
|
684
|
+
type(
|
|
685
|
+
"twin",
|
|
686
|
+
(),
|
|
687
|
+
{
|
|
688
|
+
"__annotations__": dict.fromkeys((field.name for field in fields), object),
|
|
689
|
+
**{field.name: dataclasses.field(default=None, compare=field.compare) for field in fields},
|
|
690
|
+
},
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
elif "__attrs_attrs__" in namespace and attr is not None:
|
|
694
|
+
twin = attr.make_class(
|
|
695
|
+
"twin",
|
|
696
|
+
{
|
|
697
|
+
attribute.name: attr.ib(default=None, eq=attribute.eq if attribute.eq_key is None else attribute.eq_key)
|
|
698
|
+
for attribute in namespace["__attrs_attrs__"]
|
|
699
|
+
},
|
|
700
|
+
)
|
|
701
|
+
else:
|
|
702
|
+
return False
|
|
703
|
+
again = type.__getattribute__(twin, "__dict__")["__eq__"]
|
|
704
|
+
code, ours = written.__code__, again.__code__
|
|
705
|
+
keys = again.__globals__ if "__attrs_attrs__" in namespace else {}
|
|
706
|
+
return (code.co_code, code.co_names, code.co_consts) == (ours.co_code, ours.co_names, ours.co_consts) and all(
|
|
707
|
+
written.__globals__.get(name) is key for name, key in keys.items() if name in ours.co_names
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# off the slots of `type`: asked through the class, a metaclass may spell a tree or a namespace of its own
|
|
712
|
+
class_tree, class_namespace = (type.__dict__[name].__get__ for name in ("__mro__", "__dict__"))
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def definition_of(klass: type, name: str) -> tuple[type, object] | None:
|
|
716
|
+
"""The class in *klass*'s tree that defines *name*, and the definition itself, or ``None``.
|
|
717
|
+
|
|
718
|
+
Walked and read raw, which is the reading the interpreter does for an operator: no attribute access
|
|
719
|
+
on the class, so neither a metaclass nor a descriptor of the class's own gets to answer for it.
|
|
720
|
+
|
|
721
|
+
A definition of ``None`` reads as no definition, so a class declaring `__hash__ = None` reports the
|
|
722
|
+
hashable base above it. Callers that care ask the class about `__hash__` directly, which is the
|
|
723
|
+
cheaper question anyway.
|
|
724
|
+
"""
|
|
725
|
+
for base in class_tree(klass):
|
|
726
|
+
found = class_namespace(base).get(name)
|
|
727
|
+
if found is not None:
|
|
728
|
+
return base, found
|
|
729
|
+
return None
|