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,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime
|
|
6
|
+
import decimal
|
|
7
|
+
import enum
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import types
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
14
|
+
|
|
15
|
+
from ._engine._introspection import (
|
|
16
|
+
is_attrs_instance,
|
|
17
|
+
is_pydantic_model,
|
|
18
|
+
is_pydantic_model_class,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
__tracebackhide__ = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Serializer(NamedTuple):
|
|
28
|
+
cls: type
|
|
29
|
+
encode: Callable[[object], object]
|
|
30
|
+
decode: Callable[[object], object]
|
|
31
|
+
tag: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# user-registered serializers, checked before the built-in codec (last registered wins)
|
|
35
|
+
_SERIALIZERS: list[_Serializer] = []
|
|
36
|
+
|
|
37
|
+
# a user dict carrying any of them is escaped so it is not mistaken for an envelope on load
|
|
38
|
+
_RESERVED_MARKERS = frozenset({"__type__", "__data__", "__tag__", "__class__", "__module__"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _prepare(value, _seen: frozenset[int] = frozenset()):
|
|
42
|
+
"""Pre-process a value so ``json`` round-trips dicts that it otherwise mangles: dicts with a
|
|
43
|
+
non-string key (which ``json`` coerces to a string) and dicts that collide with the codec's markers
|
|
44
|
+
(which the decoder would misread). Both are wrapped in a ``{"__type__": "dict", "__data__": [[k,
|
|
45
|
+
v], ...]}`` envelope; a plain string-keyed dict without markers is left as an ordinary object so
|
|
46
|
+
existing snapshots stay byte-identical."""
|
|
47
|
+
if isinstance(value, (dict, list, tuple)):
|
|
48
|
+
if id(value) in _seen:
|
|
49
|
+
# json cannot represent a cycle, so naming it beats a thousand frames of RecursionError
|
|
50
|
+
raise ValueError("cannot snapshot a value that contains a circular reference")
|
|
51
|
+
nested = _seen | {id(value)}
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
if all(isinstance(key, str) for key in value) and not _RESERVED_MARKERS.intersection(value):
|
|
54
|
+
return {key: _prepare(item, nested) for key, item in value.items()}
|
|
55
|
+
items = sorted(value.items(), key=lambda pair: repr(pair[0])) # deterministic for mixed keys
|
|
56
|
+
return {
|
|
57
|
+
"__type__": "dict",
|
|
58
|
+
"__data__": [[_prepare(key, nested), _prepare(item, nested)] for key, item in items],
|
|
59
|
+
}
|
|
60
|
+
# a tuple comes back a list: `_rehash_key` restores one only where it was a dict key
|
|
61
|
+
return [_prepare(item, nested) for item in value]
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hashable(item):
|
|
66
|
+
"""An element as it was written: JSON has no tuple, and a list cannot have been one, since it cannot hash."""
|
|
67
|
+
return tuple(_hashable(inner) for inner in item) if isinstance(item, list) else item
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rehash_key(key):
|
|
71
|
+
"""Restore a hashable dict key from its json form: a list can only be a re-encoded tuple (a list is
|
|
72
|
+
not hashable and so could never have been a key), so convert it back to a tuple recursively."""
|
|
73
|
+
if isinstance(key, list):
|
|
74
|
+
return tuple(_rehash_key(item) for item in key)
|
|
75
|
+
return key
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Encoder(json.JSONEncoder):
|
|
79
|
+
# `o` is what `JSONEncoder` names it, and pyright reads an override by parameter name
|
|
80
|
+
def default(self, o):
|
|
81
|
+
for entry in _SERIALIZERS:
|
|
82
|
+
if isinstance(o, entry.cls):
|
|
83
|
+
return {"__type__": "custom", "__tag__": entry.tag, "__data__": entry.encode(o)}
|
|
84
|
+
if isinstance(o, set):
|
|
85
|
+
return {"__type__": "set", "__data__": list(o)}
|
|
86
|
+
elif isinstance(o, complex):
|
|
87
|
+
return {"__type__": "complex", "__data__": [o.real, o.imag]}
|
|
88
|
+
elif isinstance(o, datetime.datetime):
|
|
89
|
+
# each suffix is added only when the value carries it, so a snapshot with neither microseconds nor
|
|
90
|
+
# `tzinfo` keeps the historical format and stays readable by an older version
|
|
91
|
+
fmt = "%Y-%m-%d %H:%M:%S.%f" if o.microsecond else "%Y-%m-%d %H:%M:%S"
|
|
92
|
+
if o.tzinfo is not None:
|
|
93
|
+
fmt += "%z"
|
|
94
|
+
return {"__type__": "datetime", "__data__": o.strftime(fmt)}
|
|
95
|
+
elif isinstance(o, datetime.date):
|
|
96
|
+
return {"__type__": "date", "__data__": o.isoformat()}
|
|
97
|
+
elif isinstance(o, datetime.time):
|
|
98
|
+
return {"__type__": "time", "__data__": o.isoformat()}
|
|
99
|
+
elif isinstance(o, decimal.Decimal):
|
|
100
|
+
return {"__type__": "decimal", "__data__": str(o)}
|
|
101
|
+
elif isinstance(o, (bytes, bytearray)):
|
|
102
|
+
return {"__type__": "bytes", "__data__": base64.b64encode(bytes(o)).decode("ascii")}
|
|
103
|
+
elif isinstance(o, uuid.UUID):
|
|
104
|
+
return {"__type__": "uuid", "__data__": str(o)}
|
|
105
|
+
elif isinstance(o, enum.Enum):
|
|
106
|
+
return {
|
|
107
|
+
"__type__": "enum",
|
|
108
|
+
"__class__": o.__class__.__name__,
|
|
109
|
+
"__module__": o.__class__.__module__,
|
|
110
|
+
"__data__": o.value,
|
|
111
|
+
}
|
|
112
|
+
elif _slot_aware(o) or is_pydantic_model(o) or ("__dict__" in dir(o) and type(o) is not type):
|
|
113
|
+
return {
|
|
114
|
+
"__type__": "instance",
|
|
115
|
+
"__class__": o.__class__.__name__,
|
|
116
|
+
"__module__": o.__class__.__module__,
|
|
117
|
+
# prepared like any other mapping: a non-string key inside an attribute came back a string
|
|
118
|
+
"__data__": _prepare(_held(o)),
|
|
119
|
+
**_model_state(o),
|
|
120
|
+
}
|
|
121
|
+
return json.JSONEncoder.default(self, o)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Decoder(json.JSONDecoder):
|
|
125
|
+
def __init__(self):
|
|
126
|
+
json.JSONDecoder.__init__(self, object_hook=self._object_hook)
|
|
127
|
+
|
|
128
|
+
def _object_hook(self, decoded):
|
|
129
|
+
if "__type__" in decoded and "__data__" in decoded:
|
|
130
|
+
if decoded["__type__"] == "dict":
|
|
131
|
+
return {_rehash_key(key): item for key, item in decoded["__data__"]}
|
|
132
|
+
elif decoded["__type__"] == "set":
|
|
133
|
+
return {_hashable(item) for item in decoded["__data__"]}
|
|
134
|
+
elif decoded["__type__"] == "complex":
|
|
135
|
+
return complex(decoded["__data__"][0], decoded["__data__"][1])
|
|
136
|
+
elif decoded["__type__"] == "datetime":
|
|
137
|
+
raw = decoded["__data__"]
|
|
138
|
+
tail = raw[len("0000-00-00 00:00:00") :] # the date part contains "-", so probe past the seconds
|
|
139
|
+
fmt = "%Y-%m-%d %H:%M:%S"
|
|
140
|
+
if "." in tail:
|
|
141
|
+
fmt += ".%f"
|
|
142
|
+
if "+" in tail or "-" in tail:
|
|
143
|
+
fmt += "%z"
|
|
144
|
+
return datetime.datetime.strptime(raw, fmt)
|
|
145
|
+
elif decoded["__type__"] == "date":
|
|
146
|
+
return datetime.date.fromisoformat(decoded["__data__"])
|
|
147
|
+
elif decoded["__type__"] == "time":
|
|
148
|
+
return datetime.time.fromisoformat(decoded["__data__"])
|
|
149
|
+
elif decoded["__type__"] == "decimal":
|
|
150
|
+
return decimal.Decimal(decoded["__data__"])
|
|
151
|
+
elif decoded["__type__"] == "bytes":
|
|
152
|
+
return base64.b64decode(decoded["__data__"])
|
|
153
|
+
elif decoded["__type__"] == "uuid":
|
|
154
|
+
return uuid.UUID(decoded["__data__"])
|
|
155
|
+
elif decoded["__type__"] == "custom":
|
|
156
|
+
tag = decoded.get("__tag__")
|
|
157
|
+
for entry in _SERIALIZERS:
|
|
158
|
+
if entry.tag == tag:
|
|
159
|
+
return entry.decode(decoded["__data__"])
|
|
160
|
+
return decoded # no serializer registered for this tag this run - leave the marker as-is
|
|
161
|
+
elif decoded["__type__"] == "enum":
|
|
162
|
+
target_class = _resolve_class(decoded["__module__"], decoded["__class__"])
|
|
163
|
+
if target_class is None:
|
|
164
|
+
return decoded
|
|
165
|
+
raw = decoded["__data__"]
|
|
166
|
+
try:
|
|
167
|
+
return target_class(raw)
|
|
168
|
+
except ValueError:
|
|
169
|
+
if not isinstance(raw, list):
|
|
170
|
+
raise
|
|
171
|
+
return target_class(_hashable(raw))
|
|
172
|
+
elif decoded["__type__"] == "instance":
|
|
173
|
+
target_class = _resolve_class(decoded["__module__"], decoded["__class__"])
|
|
174
|
+
if target_class is None:
|
|
175
|
+
return decoded
|
|
176
|
+
return _rebuilt(target_class, decoded)
|
|
177
|
+
return decoded
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _slot_aware(value: object) -> bool:
|
|
181
|
+
"""An attrs instance or a dataclass, the two kinds this reads through slots as well as a ``__dict__``.
|
|
182
|
+
|
|
183
|
+
Slotted by default in attrs and on request in dataclasses, and without a ``__dict__`` either raised
|
|
184
|
+
`Object of type ... is not JSON serializable`. Other slotted objects stay refused: a slot of the
|
|
185
|
+
standard library's is state nobody vouched for rebuilding.
|
|
186
|
+
"""
|
|
187
|
+
return is_attrs_instance(value) or (dataclasses.is_dataclass(value) and not isinstance(value, type))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _slot_names(target_class: type) -> set[str]:
|
|
191
|
+
"""Every slot the class and its bases declare, under the name it is stored by, mangled or not."""
|
|
192
|
+
return {
|
|
193
|
+
name
|
|
194
|
+
for klass in target_class.__mro__
|
|
195
|
+
for name, member in vars(klass).items()
|
|
196
|
+
if isinstance(member, types.MemberDescriptorType)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _held(value: object) -> dict:
|
|
201
|
+
"""What an instance holds: its ``__dict__`` and any slot set on it.
|
|
202
|
+
|
|
203
|
+
A slot is read past ``__getattr__``, so one never set stays unwritten: attrs computes a
|
|
204
|
+
``cached_property`` of a slotted class there, and writing a snapshot would have run it.
|
|
205
|
+
"""
|
|
206
|
+
held = dict(vars(value)) if hasattr(value, "__dict__") else {}
|
|
207
|
+
for name in _slot_names(type(value)) if _slot_aware(value) else ():
|
|
208
|
+
try:
|
|
209
|
+
held[name] = object.__getattribute__(value, name)
|
|
210
|
+
except AttributeError: # noqa: PERF203 # a slot never set holds nothing to write
|
|
211
|
+
continue
|
|
212
|
+
return held
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _model_state(value: object) -> dict:
|
|
216
|
+
"""What a pydantic model holds beside its ``__dict__``: the extras and the private state its ``==``
|
|
217
|
+
compares, and the fields set, which ``model_dump(exclude_unset=True)`` reads. Nothing for any other value."""
|
|
218
|
+
if not is_pydantic_model(value):
|
|
219
|
+
return {}
|
|
220
|
+
extra, private = getattr(value, "__pydantic_extra__", None), getattr(value, "__pydantic_private__", None)
|
|
221
|
+
return {
|
|
222
|
+
"__extra__": None if extra is None else _prepare(dict(extra)),
|
|
223
|
+
"__private__": None if private is None else _prepare(dict(private)),
|
|
224
|
+
"__fields_set__": sorted(getattr(value, "model_fields_set", ())),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _rebuilt(target_class: Any, written: dict) -> object:
|
|
229
|
+
"""An instance of *target_class* holding what was written, built without running its ``__init__``.
|
|
230
|
+
|
|
231
|
+
A slot is set attribute by attribute, since a ``__dict__`` cannot hold it.
|
|
232
|
+
"""
|
|
233
|
+
if is_pydantic_model_class(target_class):
|
|
234
|
+
return _rebuilt_model(target_class, written)
|
|
235
|
+
held = written["__data__"]
|
|
236
|
+
instance = target_class.__new__(target_class)
|
|
237
|
+
slots = _slot_names(target_class)
|
|
238
|
+
if hasattr(instance, "__dict__"):
|
|
239
|
+
instance.__dict__ = {name: value for name, value in held.items() if name not in slots}
|
|
240
|
+
for name in slots & held.keys():
|
|
241
|
+
object.__setattr__(instance, name, held[name])
|
|
242
|
+
return instance
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _rebuilt_model(target_class: Any, written: dict) -> object:
|
|
246
|
+
"""A pydantic model in the state it was written in, through pydantic's own ``__setstate__``.
|
|
247
|
+
|
|
248
|
+
Assigned a ``__dict__`` alone it had none of the state its ``==`` reads, and every snapshot of a model
|
|
249
|
+
failed on the run after it was written. `model_construct` runs `model_post_init`, and a subclass may
|
|
250
|
+
override ``__new__`` or ``__setstate__``: reading a snapshot runs none of the caller's code. A file
|
|
251
|
+
written before the rest of the state was stored holds the fields alone, and reads as a model holding
|
|
252
|
+
them and nothing else.
|
|
253
|
+
"""
|
|
254
|
+
model = object.__new__(target_class)
|
|
255
|
+
sys.modules["pydantic"].BaseModel.__setstate__(
|
|
256
|
+
model,
|
|
257
|
+
{
|
|
258
|
+
"__dict__": written["__data__"],
|
|
259
|
+
"__pydantic_extra__": written.get("__extra__"),
|
|
260
|
+
"__pydantic_fields_set__": set(written.get("__fields_set__", written["__data__"])),
|
|
261
|
+
"__pydantic_private__": written.get("__private__"),
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
return model
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _resolve_class(module_name, class_name):
|
|
268
|
+
"""Resolve a class by module+name without importing anything (never runs arbitrary imports, per the
|
|
269
|
+
snapshot security model); returns ``None`` if the module is not already imported or the name is absent."""
|
|
270
|
+
if module_name not in sys.modules:
|
|
271
|
+
return None
|
|
272
|
+
return getattr(sys.modules[module_name], class_name, None)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _save(name, val):
|
|
276
|
+
# rendered whole first: `json.dump` calls `write` once per fragment where `dumps` joins them once, and
|
|
277
|
+
# with `indent` neither reaches the C encoder. Five thousand records: rendering to a string 1.4 ms
|
|
278
|
+
# against 10.1 ms for the same rendering written fragment by fragment, 20.8 ms against 8.0 ms end to
|
|
279
|
+
# end. A value the encoder refuses now leaves no file behind either
|
|
280
|
+
text = json.dumps(_prepare(val), indent=2, separators=(",", ": "), sort_keys=True, cls=_Encoder)
|
|
281
|
+
tmp = f"{name}.{os.getpid()}.tmp"
|
|
282
|
+
with open(tmp, "w", encoding="utf-8") as file_handle:
|
|
283
|
+
file_handle.write(text)
|
|
284
|
+
os.replace(tmp, name)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _load(name):
|
|
288
|
+
# as UTF-8 and not as the platform's default: a file a formatter left unescaped read as mojibake under cp1252
|
|
289
|
+
with open(name, encoding="utf-8") as file_handle:
|
|
290
|
+
return json.load(file_handle, cls=_Decoder)
|