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/snapshot.py
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Final, NoReturn
|
|
11
|
+
|
|
12
|
+
from . import _inline, _renamed
|
|
13
|
+
from ._engine._compare import _build_compare_config
|
|
14
|
+
from ._engine._contract import shape, shape_diff
|
|
15
|
+
from ._engine._introspection import ASSERTPY_FILES, caller_stacklevel
|
|
16
|
+
from ._engine._mixin_base import _MixinBase
|
|
17
|
+
from ._snapshot_codec import _SERIALIZERS, _load, _save, _Serializer
|
|
18
|
+
from .errors import AssertionFailure, _safe_repr, _safe_str, _truncated
|
|
19
|
+
from .matchers import _apply_matcher, _describe_matcher, _is_matcher
|
|
20
|
+
from .outcome import MISSING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import types
|
|
24
|
+
from collections.abc import Callable, Generator, Mapping
|
|
25
|
+
|
|
26
|
+
from ._engine._compat import Self
|
|
27
|
+
from .matchers import Matcher
|
|
28
|
+
|
|
29
|
+
__tracebackhide__ = True
|
|
30
|
+
|
|
31
|
+
_UNSET: Final = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _require_caller(frame: types.FrameType | None) -> types.FrameType:
|
|
35
|
+
"""The frame of the call as written: the first one outside this package, from ``frame`` outwards,
|
|
36
|
+
which is what keys a snapshot by line and drives an inline rewrite.
|
|
37
|
+
|
|
38
|
+
Read by file rather than by a count of hops. One hop up answered the wrapper `check()` and `not_`
|
|
39
|
+
run an assertion through, so a snapshot under either was keyed by a line of this package, two tests
|
|
40
|
+
sharing one key, and an inline one was written into this package's source. mutmut's trampoline
|
|
41
|
+
adds hops of its own too.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
RuntimeError: if the interpreter exposes no calling frame.
|
|
45
|
+
"""
|
|
46
|
+
caller = frame
|
|
47
|
+
while caller is not None and caller.f_code.co_filename in ASSERTPY_FILES:
|
|
48
|
+
caller = caller.f_back
|
|
49
|
+
if caller is None: # pragma: no cover - frame introspection always available in CPython
|
|
50
|
+
raise RuntimeError("cannot determine caller frame")
|
|
51
|
+
return caller
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _calls_back(state: object) -> bool:
|
|
55
|
+
"""Whether *state* holds something callable, which a copy hands back as itself, to answer as it likes later."""
|
|
56
|
+
if isinstance(state, dict):
|
|
57
|
+
return any(_calls_back(key) or _calls_back(item) for key, item in state.items())
|
|
58
|
+
if isinstance(state, (list, tuple, set, frozenset)):
|
|
59
|
+
return any(_calls_back(item) for item in state)
|
|
60
|
+
return callable(state)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _stale(builder: Callable[..., Any], value: object, stored: object, options: dict[str, Any]) -> bool:
|
|
64
|
+
"""Whether *stored* no longer matches *value* under the comparison *options*, decided via a strict
|
|
65
|
+
throwaway builder (under soft/warn kinds ``self.is_equal_to`` would not raise)."""
|
|
66
|
+
try:
|
|
67
|
+
builder(value, "")._compared_to_stored(stored, **options)
|
|
68
|
+
except AssertionError:
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def register_snapshot_serializer(
|
|
74
|
+
cls: type,
|
|
75
|
+
# `Any`, not `object`: a codec only sees an instance of `cls`, and `object` rejected `pathlib.PurePath`
|
|
76
|
+
encode: Callable[[Any], object],
|
|
77
|
+
decode: Callable[[Any], object],
|
|
78
|
+
*,
|
|
79
|
+
tag: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Register a custom (encode, decode) pair for snapshotting values of type ``cls``.
|
|
82
|
+
|
|
83
|
+
The typed codec covers common non-JSON types (``set``, ``complex``, ``datetime``/``date``/``time``,
|
|
84
|
+
``Decimal``, ``bytes``, ``uuid.UUID``, ``Enum``). Register a serializer for anything else - a
|
|
85
|
+
domain object, an ORM row, a ``pathlib.Path`` - so ``snapshot()`` stores and round-trips it instead
|
|
86
|
+
of raising ``TypeError``.
|
|
87
|
+
|
|
88
|
+
Matching is by ``isinstance`` (so subclasses are covered), the registry is consulted **before** the
|
|
89
|
+
built-ins, and a later registration wins over an earlier one for overlapping types.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
cls: the type (matched by ``isinstance``) the serializer applies to
|
|
93
|
+
encode: ``value -> json_safe`` (the returned object must itself be JSON-serializable or
|
|
94
|
+
handled by another serializer)
|
|
95
|
+
decode: ``json_safe -> value`` (the inverse; runs your own code on snapshot load, so it is a
|
|
96
|
+
trusted, explicit opt-in - unlike the automatic instance decode, which never imports)
|
|
97
|
+
tag: a stable identifier stored in the snapshot to route decoding (defaults to the type's
|
|
98
|
+
fully-qualified name); change it only deliberately, since existing snapshots key on it
|
|
99
|
+
|
|
100
|
+
Examples:
|
|
101
|
+
Usage:
|
|
102
|
+
|
|
103
|
+
import pathlib
|
|
104
|
+
|
|
105
|
+
register_snapshot_serializer(pathlib.PurePath, str, pathlib.PurePath)
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
TypeError: if ``cls`` is not a type, or ``encode`` / ``decode`` are not callable
|
|
109
|
+
"""
|
|
110
|
+
if not isinstance(cls, type):
|
|
111
|
+
raise TypeError("cls must be a type")
|
|
112
|
+
if not callable(encode) or not callable(decode):
|
|
113
|
+
raise TypeError("encode and decode must be callable")
|
|
114
|
+
_SERIALIZERS.insert(0, _Serializer(cls, encode, decode, tag or f"{cls.__module__}.{cls.__qualname__}"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SnapshotCreatedWarning(UserWarning):
|
|
118
|
+
"""Emitted when `snapshot()` writes a new snapshot instead of comparing.
|
|
119
|
+
|
|
120
|
+
The first run of a snapshot assertion captures the current value and passes without comparing
|
|
121
|
+
anything, so a wrong first capture would silently become the reference. This warning makes that
|
|
122
|
+
capture visible; suites running with ``-W error`` turn it into an explicit failure.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SnapshotKeyReusedWarning(UserWarning):
|
|
127
|
+
"""Emitted when one snapshot key was reached by more than one test.
|
|
128
|
+
|
|
129
|
+
The default key is the line of the ``snapshot()`` call, so every case of a parametrised test shares
|
|
130
|
+
it: the first case stores its value and the rest compare against that one. Where the values differ
|
|
131
|
+
the run fails with a message that names two unrelated cases, and where they agree it passes while
|
|
132
|
+
checking one case out of however many - the second is the reason this warning exists, because
|
|
133
|
+
nothing else reports it.
|
|
134
|
+
|
|
135
|
+
Two calls inside one test are not this: a helper that snapshots twice asserts both values, so the
|
|
136
|
+
metric is distinct tests rather than accesses.
|
|
137
|
+
|
|
138
|
+
Its own category, so it can be raised to an error with a single ``filterwarnings`` entry.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SnapshotUpdatedWarning(UserWarning):
|
|
143
|
+
"""Emitted when `snapshot()` overwrites a stored snapshot in update mode.
|
|
144
|
+
|
|
145
|
+
Update mode replaces failing snapshots with the current value instead of failing; turn it on with
|
|
146
|
+
the ``--assertpy-snapshot-update`` pytest flag or the ``ASSERTPY_SNAPSHOT_UPDATE`` environment
|
|
147
|
+
variable.
|
|
148
|
+
|
|
149
|
+
Each overwrite emits this warning, so an update run reports exactly which snapshots changed instead
|
|
150
|
+
of rewriting them silently.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# set by the pytest plugin when --assertpy-snapshot-update is given; the env var covers other runners
|
|
155
|
+
_UPDATE_ALL: bool = False
|
|
156
|
+
|
|
157
|
+
# tri-state CI mode set by the pytest plugin flags (None = not set by a flag)
|
|
158
|
+
_CI_MODE: bool | None = None
|
|
159
|
+
|
|
160
|
+
# key is the lineno for a default-id sub-snap, or "" for a whole-file custom-id snapshot
|
|
161
|
+
_TOUCHED: set[tuple[str, str]] = set()
|
|
162
|
+
|
|
163
|
+
_ACCESS_NODES: dict[tuple[str, str], set[str]] = {}
|
|
164
|
+
"""Which tests reached each ``(snapname, key)``, by node id.
|
|
165
|
+
|
|
166
|
+
Counting *tests* rather than accesses is what separates the defect from the legitimate case. Two
|
|
167
|
+
parametrised cases share a line, so they share the default key: the first stores its value and the
|
|
168
|
+
rest compare against it, and only one of them is ever asserted. A helper that snapshots twice inside
|
|
169
|
+
one test also reaches a key twice, and asserts both times - counting accesses cannot tell those apart
|
|
170
|
+
and accuses the second. Distinct node ids can.
|
|
171
|
+
|
|
172
|
+
Values, writes and differences are all useless as the metric: when the reused key happens to hold the
|
|
173
|
+
same value there is one write, no difference, and nothing fails.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
_CURRENT_NODE: str | None = None
|
|
177
|
+
"""Node id of the running test, set by the pytest plugin. ``None`` off pytest, where there is no test
|
|
178
|
+
to attribute a reuse to and the sweep that would report it does not run either."""
|
|
179
|
+
|
|
180
|
+
_SCOPE: str | None = None
|
|
181
|
+
"""Whose keys ``_SCOPE_SEEN`` currently holds, so they can be dropped when the test changes.
|
|
182
|
+
|
|
183
|
+
Off pytest ``_CURRENT_NODE`` never moves, which makes the whole process one scope - the right reading
|
|
184
|
+
where there are no test boundaries to draw.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
_SCOPE_SEEN: set[tuple[str, str]] = set()
|
|
188
|
+
"""Keys the running test has already reached, which is how a repeat is spotted."""
|
|
189
|
+
|
|
190
|
+
_SCOPE_REPEATS: set[tuple[str, str]] = set()
|
|
191
|
+
"""Keys the running test reached more than once.
|
|
192
|
+
|
|
193
|
+
Two reaches inside one test are legitimate and deliberately unwarned, but when the second comparison
|
|
194
|
+
*fails*, the reader is looking at two unrelated values with nothing saying why they were compared.
|
|
195
|
+
This is the diagnostic for that message, not a second detection metric: it is read only on failure.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
_ACCESS_SITES: dict[tuple[str, str], str] = {}
|
|
199
|
+
"""Where each key was first reached, so the warning can name a source location rather than a key."""
|
|
200
|
+
|
|
201
|
+
_WARNED: set[tuple[str, str]] = set()
|
|
202
|
+
"""Keys already reported from inside a test, so the end-of-session sweep does not report them twice."""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _reuse_message(snapname: str, key: str, site: str, tests: int | None = None) -> str:
|
|
206
|
+
"""Describe a reused key. ``tests`` is the total, and only the end-of-run sweep ever has it.
|
|
207
|
+
|
|
208
|
+
The warning raised from inside an assertion fires the moment a second test arrives, with a third
|
|
209
|
+
and fourth still possible, and it cannot see the other xdist workers at all. Any number it printed
|
|
210
|
+
would read as a total, and a reader who fixes the two they went looking for would leave the rest of
|
|
211
|
+
them sharing the key. So it states the fact, and the sweep supplies the count.
|
|
212
|
+
"""
|
|
213
|
+
reach = f" is shared by {tests} tests." if tests is not None else " is reached by more than one test."
|
|
214
|
+
return (
|
|
215
|
+
f"snapshot key <{snapname}::{key or '<whole file>'}>"
|
|
216
|
+
f"{f' from {site}' if site else ''}{reach}"
|
|
217
|
+
" Only the first value is stored, so the others are compared against it and their own values"
|
|
218
|
+
" are never asserted. Give each test its own snapshot(id=...)."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _rewrapped(mismatch: AssertionFailure, message: str) -> AssertionFailure:
|
|
223
|
+
"""The same failure with a snapshot-aware message, keeping everything the original carried.
|
|
224
|
+
|
|
225
|
+
A snapshot comparison is `is_equal_to` underneath, so the failure it raises already carries a
|
|
226
|
+
composed record. Building a fresh exception around a new message used to drop that record, which
|
|
227
|
+
made a snapshot mismatch the only comparison failure without one: the flat ``actual``/``expected``/
|
|
228
|
+
``diff`` came across, the diagnostic hint and the provenance of the values did not.
|
|
229
|
+
|
|
230
|
+
Assigned rather than guarded: both callers catch what `is_equal_to` raised, and every failure that
|
|
231
|
+
goes through the builder's delivery carries a record. A guard here would be dead, and would turn a
|
|
232
|
+
future break of that invariant into a silently record-less failure instead of a loud one.
|
|
233
|
+
"""
|
|
234
|
+
failure = AssertionFailure(
|
|
235
|
+
message,
|
|
236
|
+
actual=mismatch.actual,
|
|
237
|
+
expected=mismatch.expected,
|
|
238
|
+
diff=mismatch.diff,
|
|
239
|
+
trace=mismatch.trace,
|
|
240
|
+
requirement=mismatch.requirement,
|
|
241
|
+
)
|
|
242
|
+
# ty: ignore[invalid-argument-type] # never None here: both callers catch what `is_equal_to` raised
|
|
243
|
+
failure._outcome = replace(mismatch._outcome, message=message)
|
|
244
|
+
return failure
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _shared_key_hint(snapname: str, key: str) -> str:
|
|
248
|
+
"""Say that this test reached the key before, when a comparison against it fails.
|
|
249
|
+
|
|
250
|
+
Reuse inside one test is legitimate, so it raises no warning - but the failure it can produce
|
|
251
|
+
reads as two unrelated values compared for no reason, and the reason is that both calls landed on
|
|
252
|
+
one key. Nothing else in the message carries that.
|
|
253
|
+
"""
|
|
254
|
+
if (snapname, key) not in _SCOPE_REPEATS:
|
|
255
|
+
return ""
|
|
256
|
+
return (
|
|
257
|
+
f" This test reached <{f'{snapname}::{key}' if key else snapname}> more than once, so the value"
|
|
258
|
+
" above was compared against what an earlier call in the same test stored."
|
|
259
|
+
" Give each call its own snapshot(id=...)."
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _record_access(snapname: str, key: str, site: str) -> None:
|
|
264
|
+
"""Record which test reached ``key``, and report the second distinct one from here.
|
|
265
|
+
|
|
266
|
+
Warning from inside the assertion is what makes it usable: pytest attributes it to the test that
|
|
267
|
+
triggered it, it lands in the normal warnings summary, and under ``-W error`` it fails that test
|
|
268
|
+
with a traceback pointing at the test rather than aborting a session-finish hook. The end-of-run
|
|
269
|
+
sweep still exists for the case this cannot see - parametrised cases split across xdist workers,
|
|
270
|
+
where no single process sees two node ids and only the union does.
|
|
271
|
+
"""
|
|
272
|
+
global _SCOPE
|
|
273
|
+
_TOUCHED.add((snapname, key))
|
|
274
|
+
_ACCESS_SITES.setdefault((snapname, key), site)
|
|
275
|
+
if _SCOPE != _CURRENT_NODE: # a new test: its predecessor's repeats say nothing about this one
|
|
276
|
+
_SCOPE = _CURRENT_NODE
|
|
277
|
+
_SCOPE_REPEATS.clear()
|
|
278
|
+
_SCOPE_SEEN.clear()
|
|
279
|
+
if (snapname, key) in _SCOPE_SEEN:
|
|
280
|
+
_SCOPE_REPEATS.add((snapname, key))
|
|
281
|
+
_SCOPE_SEEN.add((snapname, key))
|
|
282
|
+
if _CURRENT_NODE is None: # off pytest: nothing to attribute a reuse to, and no sweep to report it
|
|
283
|
+
return
|
|
284
|
+
nodes = _ACCESS_NODES.setdefault((snapname, key), set())
|
|
285
|
+
nodes.add(_CURRENT_NODE)
|
|
286
|
+
# `>= 2` with `_WARNED` doing the once-only work: `== 2` would drop warnings if anything repopulated the registry
|
|
287
|
+
if len(nodes) >= 2 and (snapname, key) not in _WARNED:
|
|
288
|
+
_WARNED.add((snapname, key))
|
|
289
|
+
warnings.warn(
|
|
290
|
+
_reuse_message(snapname, key, _ACCESS_SITES[snapname, key]),
|
|
291
|
+
SnapshotKeyReusedWarning,
|
|
292
|
+
stacklevel=caller_stacklevel(),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
_TRUTHY: Final = frozenset({"1", "true", "yes", "on"})
|
|
297
|
+
_FALSY: Final = frozenset({"0", "false", "no", "off"})
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _update_enabled() -> bool:
|
|
301
|
+
return _UPDATE_ALL or _renamed.environ("ASSERTPY_SNAPSHOT_UPDATE").strip().lower() in _TRUTHY
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _ci_mode_enabled() -> bool:
|
|
305
|
+
"""Whether snapshot creation is forbidden (a missing snapshot fails instead of being created).
|
|
306
|
+
|
|
307
|
+
Precedence: the pytest ``--assertpy-snapshot-ci`` / ``--assertpy-snapshot-no-ci`` flags, then the
|
|
308
|
+
``ASSERTPY_SNAPSHOT_CI`` env var (explicit on/off), then autodetection of a CI environment (a
|
|
309
|
+
truthy ``CI`` var, set by GitHub Actions, GitLab CI, CircleCI, and most others).
|
|
310
|
+
"""
|
|
311
|
+
if _CI_MODE is not None:
|
|
312
|
+
return _CI_MODE
|
|
313
|
+
explicit = _renamed.environ("ASSERTPY_SNAPSHOT_CI").strip().lower()
|
|
314
|
+
if explicit in _FALSY:
|
|
315
|
+
return False
|
|
316
|
+
if explicit in _TRUTHY:
|
|
317
|
+
return True
|
|
318
|
+
return os.environ.get("CI", "").strip().lower() in _TRUTHY
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _inline_literal_or_raise(value: object) -> None:
|
|
322
|
+
"""Reject values that cannot round-trip as a source literal (an inline snapshot rewrites source)."""
|
|
323
|
+
if not _inline.is_literalable(value):
|
|
324
|
+
raise TypeError(
|
|
325
|
+
"an inline snapshot literal must be a dict/list/tuple/set of scalars, not"
|
|
326
|
+
f" {type(value).__name__} - use snapshot() to store it in a file instead"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _combine_ignore(ignore, placeholders):
|
|
331
|
+
"""Merge the placeholder keys into the caller's ``ignore`` spec, so token fields are skipped by the
|
|
332
|
+
equality comparison (their matcher is asserted separately)."""
|
|
333
|
+
if not placeholders:
|
|
334
|
+
return ignore
|
|
335
|
+
keys = list(placeholders)
|
|
336
|
+
if ignore is None:
|
|
337
|
+
return keys
|
|
338
|
+
if isinstance(ignore, (list, set, frozenset)):
|
|
339
|
+
return [*keys, *ignore]
|
|
340
|
+
return [*keys, ignore] # a single key or a nested-path tuple
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _find_orphans(touched):
|
|
344
|
+
"""Given the ``(snapname, key)`` pairs touched this session, return obsolete snapshots as
|
|
345
|
+
``(sub_key_orphans, whole_file_orphans)``.
|
|
346
|
+
|
|
347
|
+
A sub-key orphan is ``(snapname, lineno)`` still on disk in a *touched* default-id file whose test
|
|
348
|
+
line was not exercised this run (that specific test was deleted). A whole-file orphan is a snapshot
|
|
349
|
+
file in a touched directory that was not touched at all (its test/module is gone). Only directories
|
|
350
|
+
that had at least one live snapshot this run are scanned, so an unrelated directory is never judged.
|
|
351
|
+
"""
|
|
352
|
+
touched_files = {snapname for snapname, _ in touched}
|
|
353
|
+
custom_touched = {snapname for snapname, key in touched if key == ""}
|
|
354
|
+
touched_keys: dict[str, set[str]] = {}
|
|
355
|
+
for snapname, key in touched:
|
|
356
|
+
if key != "":
|
|
357
|
+
touched_keys.setdefault(snapname, set()).add(key)
|
|
358
|
+
|
|
359
|
+
sub_orphans: list[tuple[str, str]] = []
|
|
360
|
+
whole_orphans: list[str] = []
|
|
361
|
+
for directory in sorted({os.path.dirname(snapname) for snapname, _ in touched}):
|
|
362
|
+
if not os.path.isdir(directory):
|
|
363
|
+
continue
|
|
364
|
+
for fname in sorted(os.listdir(directory)):
|
|
365
|
+
if not (fname.startswith("snap-") and fname.endswith(".json")):
|
|
366
|
+
continue
|
|
367
|
+
snapname = os.path.join(directory, fname)
|
|
368
|
+
if snapname not in touched_files:
|
|
369
|
+
whole_orphans.append(snapname)
|
|
370
|
+
continue
|
|
371
|
+
if snapname in custom_touched:
|
|
372
|
+
continue # a touched whole-file custom-id snapshot is live
|
|
373
|
+
data = _load(snapname)
|
|
374
|
+
live = touched_keys.get(snapname, set())
|
|
375
|
+
sub_orphans.extend((snapname, key) for key in sorted(data) if key not in live)
|
|
376
|
+
return sub_orphans, whole_orphans
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _prune_sub_key_orphans(sub_orphans):
|
|
380
|
+
"""Remove obsolete sub-snap keys from their files, deleting a file that becomes empty."""
|
|
381
|
+
by_file: dict[str, set[str]] = {}
|
|
382
|
+
for snapname, key in sub_orphans:
|
|
383
|
+
by_file.setdefault(snapname, set()).add(key)
|
|
384
|
+
for snapname, keys in by_file.items():
|
|
385
|
+
with _file_lock(snapname):
|
|
386
|
+
data = _load(snapname)
|
|
387
|
+
for key in keys:
|
|
388
|
+
data.pop(key, None)
|
|
389
|
+
if data:
|
|
390
|
+
_save(snapname, data)
|
|
391
|
+
else:
|
|
392
|
+
os.unlink(snapname)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@contextlib.contextmanager
|
|
396
|
+
def _file_lock(target: str, *, timeout: float = 10.0, poll: float = 0.05) -> Generator[None]:
|
|
397
|
+
"""Serialize snapshot read-modify-write across processes via an ``O_EXCL`` lock file.
|
|
398
|
+
|
|
399
|
+
Not crash-safe: a process that dies while holding the lock leaves a stale lock file and other
|
|
400
|
+
writers time out. Accepted: snapshots are development-time files and crashes mid-write are rare.
|
|
401
|
+
"""
|
|
402
|
+
lockpath = f"{target}.lock"
|
|
403
|
+
deadline = time.monotonic() + timeout
|
|
404
|
+
lock_fd = None
|
|
405
|
+
while lock_fd is None:
|
|
406
|
+
try:
|
|
407
|
+
lock_fd = os.open(lockpath, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
408
|
+
except FileExistsError: # noqa: PERF203 # retry the O_EXCL acquire each poll until the lock frees
|
|
409
|
+
if time.monotonic() >= deadline:
|
|
410
|
+
raise TimeoutError(f"could not acquire snapshot lock <{lockpath}> within {timeout}s") from None
|
|
411
|
+
time.sleep(poll)
|
|
412
|
+
except PermissionError:
|
|
413
|
+
# Windows refuses to create a lock file its last holder is still deleting, as access denied
|
|
414
|
+
if sys.platform != "win32" or time.monotonic() >= deadline:
|
|
415
|
+
raise
|
|
416
|
+
time.sleep(poll)
|
|
417
|
+
try:
|
|
418
|
+
yield
|
|
419
|
+
finally:
|
|
420
|
+
os.close(lock_fd)
|
|
421
|
+
os.unlink(lockpath)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _name(path, name):
|
|
425
|
+
try:
|
|
426
|
+
cleaned = name.replace(" ", "_").lower()
|
|
427
|
+
if os.sep in cleaned or (os.altsep and os.altsep in cleaned):
|
|
428
|
+
# it reached the lock file first, and the reader met a FileNotFoundError naming a directory nobody chose
|
|
429
|
+
raise ValueError(f"snapshot id cannot contain a path separator, but was {name!r}")
|
|
430
|
+
return os.path.join(path, f"snap-{cleaned}.json")
|
|
431
|
+
except (TypeError, AttributeError):
|
|
432
|
+
raise ValueError("failed to create snapshot filename, either bad path or bad name") from None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _format_shape_drift(drift):
|
|
436
|
+
"""Render `shape_diff` entries as an aligned added/removed/retyped report."""
|
|
437
|
+
glyph = {"added": "+", "removed": "-", "retyped": "~"}
|
|
438
|
+
return "\n".join(f" {glyph[kind]} {where} {detail}".rstrip() for kind, where, detail in drift)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _update_hint(descriptor: str, action: str) -> str:
|
|
442
|
+
"""One sentence naming the snapshot and how to accept the new value, shared by both kinds."""
|
|
443
|
+
return f"{descriptor}; rerun with --assertpy-snapshot-update to {action}."
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class SnapshotMixin(_MixinBase):
|
|
447
|
+
"""Assertions that compare a value against a stored snapshot, recording it on the first run.
|
|
448
|
+
|
|
449
|
+
Capture a python data structure to disk as JSON and compare the current value against it on every
|
|
450
|
+
later run - a few well-placed snapshot tests cover a lot of surface with very little code.
|
|
451
|
+
|
|
452
|
+
**On-disk format**
|
|
453
|
+
|
|
454
|
+
Snapshots are stored as readable JSON. For example:
|
|
455
|
+
|
|
456
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot()
|
|
457
|
+
|
|
458
|
+
is stored as:
|
|
459
|
+
|
|
460
|
+
{
|
|
461
|
+
"a": 1,
|
|
462
|
+
"b": 2,
|
|
463
|
+
"c": 3
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
Most python structures are supported (dict, list, object, and so on). Values without a native
|
|
467
|
+
JSON form round-trip through typed markers: ``set``, ``complex``, ``datetime``/``date``/``time``
|
|
468
|
+
(timezone-aware included), ``decimal.Decimal``, ``bytes`` (base64-encoded), ``uuid.UUID``, and
|
|
469
|
+
``Enum`` members. Any other type can be handled by registering a serializer with
|
|
470
|
+
[`register_snapshot_serializer()`][assertpy.snapshot.register_snapshot_serializer].
|
|
471
|
+
|
|
472
|
+
**Updating**
|
|
473
|
+
|
|
474
|
+
Run pytest with ``--assertpy-snapshot-update`` (or set ``ASSERTPY_SNAPSHOT_UPDATE`` for other
|
|
475
|
+
runners) and every failing comparison overwrites the stored value instead of failing.
|
|
476
|
+
|
|
477
|
+
Each overwrite emits a [`SnapshotUpdatedWarning`][assertpy.snapshot.SnapshotUpdatedWarning], and
|
|
478
|
+
matching snapshots are left untouched. Deleting the snapshot files and re-running works too - each
|
|
479
|
+
fresh capture emits a [`SnapshotCreatedWarning`][assertpy.snapshot.SnapshotCreatedWarning]. So
|
|
480
|
+
neither a first run nor an update run is ever silent, and both fail explicitly under ``-W error``.
|
|
481
|
+
|
|
482
|
+
**CI mode**
|
|
483
|
+
|
|
484
|
+
On a first run a missing snapshot is *created* and the test passes. That is convenient locally,
|
|
485
|
+
but a hazard in CI: a snapshot test whose golden was never committed would create it in the
|
|
486
|
+
ephemeral workspace, pass, and silently disable drift detection for that test.
|
|
487
|
+
|
|
488
|
+
In CI mode a missing snapshot is instead a hard failure. Enable it with the
|
|
489
|
+
``--assertpy-snapshot-ci`` pytest flag or the ``ASSERTPY_SNAPSHOT_CI`` environment variable; it
|
|
490
|
+
is also auto-enabled when a ``CI`` environment variable is set (the near-universal CI marker).
|
|
491
|
+
Disable the autodetection with ``--assertpy-snapshot-no-ci`` or ``ASSERTPY_SNAPSHOT_CI=0``.
|
|
492
|
+
Local runs are unaffected.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
def _refuse(self, message: str) -> NoReturn:
|
|
496
|
+
"""Raise a composed failure, without offering it to the modes that collect one.
|
|
497
|
+
|
|
498
|
+
These refusals are about the run's configuration rather than about the value, so they carry no
|
|
499
|
+
expectation and `has_expected` reads false on them. They still go through the composer, which
|
|
500
|
+
is what puts the value under test on them and makes them readable the way every other failure
|
|
501
|
+
this library raises is.
|
|
502
|
+
|
|
503
|
+
Raising rather than collecting is the point. Each of these guards a line that writes a
|
|
504
|
+
snapshot file, so a soft block that collected one and carried on would write the file the guard
|
|
505
|
+
exists to prevent.
|
|
506
|
+
"""
|
|
507
|
+
raise self._failure(self._compose(message, actual=MISSING, expected=MISSING, diff=None, trace=None))
|
|
508
|
+
|
|
509
|
+
def _forbid_creation(self, snapname: str) -> None:
|
|
510
|
+
"""In CI mode a missing snapshot is a hard failure, not a silent create - the golden was never
|
|
511
|
+
committed, so drift detection for this test would be silently off.
|
|
512
|
+
|
|
513
|
+
Under `not_` as well, whose verdict is inverted: a snapshot created there holds the very value it
|
|
514
|
+
is asked to differ from, so the negation failed on the first run and on every run after it.
|
|
515
|
+
"""
|
|
516
|
+
if _ci_mode_enabled():
|
|
517
|
+
self._refuse(
|
|
518
|
+
f"snapshot <{snapname}> does not exist and CI mode forbids creating it - commit the"
|
|
519
|
+
" snapshot to source control, or run without CI mode (--assertpy-snapshot-no-ci, or"
|
|
520
|
+
" unset CI / ASSERTPY_SNAPSHOT_CI)"
|
|
521
|
+
)
|
|
522
|
+
if self._answering_another:
|
|
523
|
+
self._refuse(f"snapshot <{snapname}> does not exist, and not_ cannot create one; create it without not_")
|
|
524
|
+
|
|
525
|
+
def _rewriting(self) -> bool:
|
|
526
|
+
"""Whether a stale snapshot is overwritten: under update mode, and never under `not_`, for the same reason."""
|
|
527
|
+
return _update_enabled() and not self._answering_another
|
|
528
|
+
|
|
529
|
+
def _with_placeholder_tokens(self, placeholders):
|
|
530
|
+
"""A shallow copy of the dict-like val with placeholder keys replaced by descriptive tokens, so
|
|
531
|
+
the stored snapshot documents the expected shape instead of a volatile captured value."""
|
|
532
|
+
stored = dict(self.val)
|
|
533
|
+
for key, matcher in placeholders.items():
|
|
534
|
+
stored[key] = {"__placeholder__": _describe_matcher(matcher)}
|
|
535
|
+
return stored
|
|
536
|
+
|
|
537
|
+
def _recorded(self, placeholders, record: Callable[[], None]) -> bool:
|
|
538
|
+
"""Record through *record* where the placeholders hold, checked as every other run checks them.
|
|
539
|
+
|
|
540
|
+
Answers whether it recorded. It does not where a soft block collected a placeholder that failed: a
|
|
541
|
+
value a placeholder refused is never written, on the run that creates a snapshot or on an update.
|
|
542
|
+
"""
|
|
543
|
+
if placeholders and not self._check_placeholders(placeholders):
|
|
544
|
+
return False
|
|
545
|
+
record()
|
|
546
|
+
return True
|
|
547
|
+
|
|
548
|
+
def _check_placeholders(self, placeholders) -> bool:
|
|
549
|
+
"""Assert each placeholder field of val is present and satisfies its matcher (shape, not value).
|
|
550
|
+
|
|
551
|
+
Answers whether every one held, for a soft block, where a failure is collected and the caller
|
|
552
|
+
must still not record the value it failed on.
|
|
553
|
+
"""
|
|
554
|
+
held = True
|
|
555
|
+
for key, matcher in placeholders.items():
|
|
556
|
+
present = key in self.val
|
|
557
|
+
if not present or not _apply_matcher(matcher, self.val[key]):
|
|
558
|
+
held = False
|
|
559
|
+
actual = _safe_repr(self.val[key]) if present else "missing"
|
|
560
|
+
self.error(
|
|
561
|
+
f"Expected snapshot placeholder <{key}> to satisfy {_describe_matcher(matcher)}, but was {actual}."
|
|
562
|
+
)
|
|
563
|
+
return held
|
|
564
|
+
|
|
565
|
+
def _compared_to_stored(self, stored: object, **options: object) -> Self:
|
|
566
|
+
"""`is_equal_to()` against what a snapshot stored, as an answer to the snapshot rather than the caller.
|
|
567
|
+
|
|
568
|
+
The keys a snapshot ignores include the placeholders it checks with matchers of their own, so a
|
|
569
|
+
comparison left with nothing to compare still checked them, and no word on it is due.
|
|
570
|
+
"""
|
|
571
|
+
answering = self._answering_another
|
|
572
|
+
self._answering_another = True
|
|
573
|
+
try:
|
|
574
|
+
return self.is_equal_to(stored, **options)
|
|
575
|
+
finally:
|
|
576
|
+
self._answering_another = answering
|
|
577
|
+
|
|
578
|
+
def _inline_recorded(self, expected: object, placeholders: object, options: dict[str, Any]) -> bool:
|
|
579
|
+
"""Whether update mode took this run of `matches_inline()` as a value to write rather than to compare.
|
|
580
|
+
|
|
581
|
+
Every run under update mode is handed to the recorder, the ones that still match included, since
|
|
582
|
+
what one case asks to write has to suit every case reaching the call. One whose placeholder fails
|
|
583
|
+
is taken too, with the failure reported and nothing recorded: it is no value to write.
|
|
584
|
+
"""
|
|
585
|
+
if expected is _UNSET:
|
|
586
|
+
if _ci_mode_enabled():
|
|
587
|
+
self._refuse(
|
|
588
|
+
"inline snapshot is empty and CI mode forbids recording it - record it locally with"
|
|
589
|
+
" --assertpy-snapshot-update and commit the source"
|
|
590
|
+
)
|
|
591
|
+
if not _update_enabled():
|
|
592
|
+
self._refuse("inline snapshot is empty; run --assertpy-snapshot-update to record it")
|
|
593
|
+
if not self._rewriting():
|
|
594
|
+
self._refuse("inline snapshot is empty, and not_ cannot record one; record it without not_")
|
|
595
|
+
stale = True
|
|
596
|
+
elif self._rewriting():
|
|
597
|
+
stale = self._snapshot_stale(expected, **options)
|
|
598
|
+
else:
|
|
599
|
+
return False
|
|
600
|
+
if stale:
|
|
601
|
+
_inline_literal_or_raise(self.val)
|
|
602
|
+
if placeholders and not self._check_placeholders(placeholders):
|
|
603
|
+
return True
|
|
604
|
+
self._observed(creating=expected is _UNSET, stale=stale, options=options)
|
|
605
|
+
if stale:
|
|
606
|
+
warnings.warn(
|
|
607
|
+
"recorded inline snapshot: this run captured the value into the test source;"
|
|
608
|
+
" subsequent runs compare against it"
|
|
609
|
+
if expected is _UNSET
|
|
610
|
+
else "updated inline snapshot: this run overwrote the stored literal instead of comparing;"
|
|
611
|
+
" subsequent runs compare against it",
|
|
612
|
+
SnapshotCreatedWarning if expected is _UNSET else SnapshotUpdatedWarning,
|
|
613
|
+
stacklevel=caller_stacklevel(),
|
|
614
|
+
)
|
|
615
|
+
return stale
|
|
616
|
+
|
|
617
|
+
def _observed(self, *, creating: bool, stale: bool, options: dict[str, Any]) -> None:
|
|
618
|
+
"""Hand this run of `matches_inline()` to the recorder, with the comparison it would pass by.
|
|
619
|
+
|
|
620
|
+
Options of plain data are copied as they are now, so the run can be asked again at the end of the
|
|
621
|
+
session. Options holding a function, or ones that cannot be copied, are used as they are, and the
|
|
622
|
+
recorder asks that run only while it runs.
|
|
623
|
+
"""
|
|
624
|
+
held = _inline.captured(options)
|
|
625
|
+
pure = not isinstance(held, _inline.Uncaptured) and not _calls_back(options)
|
|
626
|
+
compared = held if not isinstance(held, _inline.Uncaptured) and pure else options
|
|
627
|
+
_inline.observe(
|
|
628
|
+
_require_caller(inspect.currentframe()),
|
|
629
|
+
self.val,
|
|
630
|
+
creating=creating,
|
|
631
|
+
stale=stale,
|
|
632
|
+
accepts=lambda value, candidate: not _stale(self.builder, value, candidate, compared),
|
|
633
|
+
pure=pure,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def _snapshot_stale(self, snapshot_value, *, ignore, include, tolerance, comparators, rel=None) -> bool:
|
|
637
|
+
"""Whether the stored snapshot no longer matches val."""
|
|
638
|
+
options = {"ignore": ignore, "include": include, "tolerance": tolerance, "rel": rel, "comparators": comparators}
|
|
639
|
+
return _stale(self.builder, self.val, snapshot_value, options)
|
|
640
|
+
|
|
641
|
+
def snapshot(
|
|
642
|
+
self,
|
|
643
|
+
id: str | None = None, # noqa: A002 # `id` is the public snapshot-identifier parameter
|
|
644
|
+
path: str = "__snapshots",
|
|
645
|
+
*,
|
|
646
|
+
ignore: object = None,
|
|
647
|
+
include: object = None,
|
|
648
|
+
tolerance: float | None = None,
|
|
649
|
+
rel: float | None = None,
|
|
650
|
+
comparators: dict[Any, Callable[[Any, Any], Any]] | None = None,
|
|
651
|
+
placeholders: Mapping[Any, Matcher[Any] | Callable[[Any], object]] | None = None,
|
|
652
|
+
) -> Self:
|
|
653
|
+
"""Asserts that val is identical to the on-disk snapshot stored previously.
|
|
654
|
+
|
|
655
|
+
On the first run, before the snapshot file exists, the value is captured to disk, a
|
|
656
|
+
[`SnapshotCreatedWarning`][assertpy.snapshot.SnapshotCreatedWarning] is emitted, and the test
|
|
657
|
+
passes. On every later run the value is compared to the stored snapshot and the test fails on
|
|
658
|
+
any mismatch.
|
|
659
|
+
|
|
660
|
+
Snapshots live in the ``__snapshots`` directory by default (commit them to source control) and
|
|
661
|
+
are identified by test filename plus line number, unless you pass ``id`` or ``path``.
|
|
662
|
+
|
|
663
|
+
In **update mode** (``--assertpy-snapshot-update``, or the ``ASSERTPY_SNAPSHOT_UPDATE`` env
|
|
664
|
+
var) a failing comparison overwrites the stored snapshot and passes, emitting a
|
|
665
|
+
[`SnapshotUpdatedWarning`][assertpy.snapshot.SnapshotUpdatedWarning]; a matching snapshot is
|
|
666
|
+
left untouched.
|
|
667
|
+
|
|
668
|
+
In **CI mode** (``--assertpy-snapshot-ci``, ``ASSERTPY_SNAPSHOT_CI``, or an auto-detected
|
|
669
|
+
``CI`` environment) a *missing* snapshot is a hard ``AssertionError`` instead of being created,
|
|
670
|
+
so an uncommitted golden fails the build rather than silently disabling drift detection.
|
|
671
|
+
|
|
672
|
+
The comparison accepts the same selective options as
|
|
673
|
+
[`is_equal_to()`][assertpy.base.BaseMixin.is_equal_to], so volatile fields (timestamps,
|
|
674
|
+
generated ids) or float noise don't break snapshots. The snapshot file always stores the
|
|
675
|
+
**full** value; the options only shape the comparison.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
id: a custom snapshot identifier (defaults to test filename plus line number)
|
|
679
|
+
path: the directory where snapshots are stored (defaults to ``__snapshots``)
|
|
680
|
+
|
|
681
|
+
Keyword Args:
|
|
682
|
+
ignore (Hashable | list | set | frozenset | None): the key/field (or collection of
|
|
683
|
+
keys/fields) to ignore when comparing; accepts the same nested-path tuples,
|
|
684
|
+
``re.Pattern`` and ``type`` specs as ``is_equal_to()``.
|
|
685
|
+
include (Hashable | list | set | frozenset | None): the key/field (or collection of
|
|
686
|
+
keys/fields) to compare, everything else ignored.
|
|
687
|
+
tolerance (float | None): an absolute tolerance that widens ``==`` for every pair of real-number
|
|
688
|
+
leaves, as in ``is_equal_to()``.
|
|
689
|
+
rel (float | None): a tolerance relative to the stored leaf of each pair of real-number leaves,
|
|
690
|
+
either of the two holding being enough, as in ``is_equal_to()``.
|
|
691
|
+
comparators (dict | None): a dict mapping a ``type`` or a field name to an
|
|
692
|
+
``(actual, expected) -> bool`` predicate that decides every leaf it matches, as in
|
|
693
|
+
``is_equal_to()``.
|
|
694
|
+
placeholders (dict | None): a dict mapping a top-level key of a *dict-like* val to a
|
|
695
|
+
``Matcher`` (or callable predicate). The stored snapshot records a descriptive token
|
|
696
|
+
(``Any<...>``) for that field instead of the volatile value; each run then asserts the
|
|
697
|
+
field is present and satisfies the matcher, not that it equals a fixed value - so a
|
|
698
|
+
generated id or timestamp reads as its shape and never breaks the snapshot.
|
|
699
|
+
|
|
700
|
+
Examples:
|
|
701
|
+
Usage:
|
|
702
|
+
|
|
703
|
+
assert_that(None).snapshot()
|
|
704
|
+
assert_that(True).snapshot()
|
|
705
|
+
assert_that(1).snapshot()
|
|
706
|
+
assert_that(123.4).snapshot()
|
|
707
|
+
assert_that('foo').snapshot()
|
|
708
|
+
assert_that([1, 2, 3]).snapshot()
|
|
709
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot()
|
|
710
|
+
assert_that({'a', 'b', 'c'}).snapshot()
|
|
711
|
+
assert_that(1 + 2j).snapshot()
|
|
712
|
+
assert_that(someobj).snapshot()
|
|
713
|
+
|
|
714
|
+
By default, snapshots are identified by test filename plus line number.
|
|
715
|
+
Alternately, you can specify a custom identifier using the ``id`` arg:
|
|
716
|
+
|
|
717
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot(id='foo-id')
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
By default, snapshots are stored in the ``__snapshots`` directory.
|
|
721
|
+
Alternately, you can specify a custom path using the ``path`` arg:
|
|
722
|
+
|
|
723
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot(path='my-custom-folder')
|
|
724
|
+
|
|
725
|
+
Ignore volatile fields, or tolerate float noise, without touching the stored snapshot:
|
|
726
|
+
|
|
727
|
+
assert_that(api_response).snapshot(id='order', ignore=['created_at', ('user', 'session_id')])
|
|
728
|
+
assert_that(metrics).snapshot(id='latency', tolerance=0.001)
|
|
729
|
+
|
|
730
|
+
Store a shape token for a volatile field, and assert its shape on every run:
|
|
731
|
+
|
|
732
|
+
from assertpy import match
|
|
733
|
+
|
|
734
|
+
assert_that(response).snapshot(id='order', placeholders={'id': match.is_uuid()})
|
|
735
|
+
# stored as {"id": {"__placeholder__": "a valid UUID string"}, ...}
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
739
|
+
|
|
740
|
+
Raises:
|
|
741
|
+
AssertionError: if val does **not** equal to on-disk snapshot
|
|
742
|
+
TypeError: if ``tolerance`` is not a real number, or ``comparators`` is not a dict of
|
|
743
|
+
callables (validated on every run, including the capturing first one); or if
|
|
744
|
+
``placeholders`` is given for a non-dict-like val, or maps to a non-matcher value
|
|
745
|
+
ValueError: if ``tolerance`` is ``NaN`` or negative
|
|
746
|
+
|
|
747
|
+
Warns:
|
|
748
|
+
SnapshotCreatedWarning: when this run captured a new snapshot instead of comparing
|
|
749
|
+
SnapshotUpdatedWarning: when update mode overwrote a stale snapshot instead of failing
|
|
750
|
+
"""
|
|
751
|
+
# a bad tolerance must fail the capturing first run too
|
|
752
|
+
_build_compare_config(tolerance, comparators, rel=rel)
|
|
753
|
+
if placeholders:
|
|
754
|
+
self._require_dict_like(self.val, name="val") # placeholders address keys of a dict-like value
|
|
755
|
+
for matcher in placeholders.values():
|
|
756
|
+
if not _is_matcher(matcher) and not callable(matcher):
|
|
757
|
+
raise TypeError("placeholder values must be Matcher instances or callables")
|
|
758
|
+
# the stored snapshot documents placeholders as tokens, so a volatile field never breaks it
|
|
759
|
+
stored_val = self._with_placeholder_tokens(placeholders) if placeholders else self.val
|
|
760
|
+
effective_ignore = _combine_ignore(ignore, placeholders)
|
|
761
|
+
lineno = ""
|
|
762
|
+
if id:
|
|
763
|
+
snapname = _name(path, id)
|
|
764
|
+
else:
|
|
765
|
+
caller = _require_caller(inspect.currentframe())
|
|
766
|
+
file_path = os.path.basename(caller.f_code.co_filename)
|
|
767
|
+
file_name = os.path.splitext(file_path)[0]
|
|
768
|
+
lineno = str(caller.f_lineno)
|
|
769
|
+
snapname = _name(path, file_name)
|
|
770
|
+
|
|
771
|
+
_record_access(snapname, "" if id else lineno, f"id={id!r}" if id else f"{file_path}:{lineno}")
|
|
772
|
+
os.makedirs(path, exist_ok=True)
|
|
773
|
+
|
|
774
|
+
# serialised so parallel workers do not lose each other's entries; the rewrite decision stays inside the lock
|
|
775
|
+
snapshot_value = _UNSET
|
|
776
|
+
updated = False
|
|
777
|
+
held = True
|
|
778
|
+
with _file_lock(snapname):
|
|
779
|
+
if os.path.isfile(snapname):
|
|
780
|
+
snap = _load(snapname)
|
|
781
|
+
if id:
|
|
782
|
+
snapshot_value = snap
|
|
783
|
+
elif lineno in snap:
|
|
784
|
+
snapshot_value = snap[lineno]
|
|
785
|
+
else:
|
|
786
|
+
self._forbid_creation(snapname)
|
|
787
|
+
held = self._recorded(placeholders, lambda: _save(snapname, {**snap, lineno: stored_val}))
|
|
788
|
+
|
|
789
|
+
if (
|
|
790
|
+
snapshot_value is not _UNSET
|
|
791
|
+
and self._rewriting()
|
|
792
|
+
and self._snapshot_stale(
|
|
793
|
+
snapshot_value,
|
|
794
|
+
ignore=effective_ignore,
|
|
795
|
+
include=include,
|
|
796
|
+
tolerance=tolerance,
|
|
797
|
+
rel=rel,
|
|
798
|
+
comparators=comparators,
|
|
799
|
+
)
|
|
800
|
+
):
|
|
801
|
+
held = updated = self._recorded(
|
|
802
|
+
placeholders, lambda: _save(snapname, stored_val if id else {**snap, lineno: stored_val})
|
|
803
|
+
)
|
|
804
|
+
else:
|
|
805
|
+
self._forbid_creation(snapname)
|
|
806
|
+
held = self._recorded(placeholders, lambda: _save(snapname, stored_val if id else {lineno: stored_val}))
|
|
807
|
+
|
|
808
|
+
if not held:
|
|
809
|
+
# a soft block collected the placeholder that failed, and a value it failed on is never recorded
|
|
810
|
+
return self
|
|
811
|
+
if updated:
|
|
812
|
+
warnings.warn(
|
|
813
|
+
f"updated snapshot <{snapname}>: this run overwrote the stored value instead of comparing;"
|
|
814
|
+
" subsequent runs compare against it",
|
|
815
|
+
SnapshotUpdatedWarning,
|
|
816
|
+
stacklevel=caller_stacklevel(),
|
|
817
|
+
)
|
|
818
|
+
return self
|
|
819
|
+
if snapshot_value is not _UNSET:
|
|
820
|
+
if placeholders:
|
|
821
|
+
self._check_placeholders(placeholders)
|
|
822
|
+
try:
|
|
823
|
+
return self._compared_to_stored(
|
|
824
|
+
snapshot_value,
|
|
825
|
+
ignore=effective_ignore,
|
|
826
|
+
include=include,
|
|
827
|
+
tolerance=tolerance,
|
|
828
|
+
rel=rel,
|
|
829
|
+
comparators=comparators,
|
|
830
|
+
)
|
|
831
|
+
except AssertionFailure as mismatch:
|
|
832
|
+
# name the snapshot, or the failure reads as a plain `is_equal_to` with no file to open
|
|
833
|
+
located = snapname if id else f"{snapname}::{lineno}"
|
|
834
|
+
raise _rewrapped(
|
|
835
|
+
mismatch,
|
|
836
|
+
f"{mismatch._message}{_shared_key_hint(snapname, '' if id else lineno)}"
|
|
837
|
+
f" {_update_hint(f'Snapshot <{located}>', 'accept the new value')}",
|
|
838
|
+
) from None
|
|
839
|
+
warnings.warn(
|
|
840
|
+
f"created snapshot <{snapname}>: this run captured the value instead of comparing;"
|
|
841
|
+
" subsequent runs compare against it (delete the file to re-capture)",
|
|
842
|
+
SnapshotCreatedWarning,
|
|
843
|
+
stacklevel=caller_stacklevel(),
|
|
844
|
+
)
|
|
845
|
+
return self
|
|
846
|
+
|
|
847
|
+
def matches_inline(
|
|
848
|
+
self,
|
|
849
|
+
expected: object = _UNSET,
|
|
850
|
+
*,
|
|
851
|
+
ignore: object = None,
|
|
852
|
+
include: object = None,
|
|
853
|
+
tolerance: float | None = None,
|
|
854
|
+
rel: float | None = None,
|
|
855
|
+
comparators: dict[Any, Callable[[Any, Any], Any]] | None = None,
|
|
856
|
+
placeholders: Mapping[Any, Matcher[Any] | Callable[[Any], object]] | None = None,
|
|
857
|
+
) -> Self:
|
|
858
|
+
"""Asserts that val equals an inline snapshot literal written at the call site.
|
|
859
|
+
|
|
860
|
+
Unlike [`snapshot()`][assertpy.snapshot.SnapshotMixin.snapshot], which stores the value in a
|
|
861
|
+
separate file, an inline snapshot lives as a literal argument in the test source.
|
|
862
|
+
|
|
863
|
+
Call it empty the first time and run with ``--assertpy-snapshot-update`` to record the value
|
|
864
|
+
into the source; later runs compare against it. The same selective knobs as ``snapshot()``
|
|
865
|
+
apply, so volatile fields never make the snapshot brittle.
|
|
866
|
+
|
|
867
|
+
The comparison itself is an ordinary equality check with no source introspection, so it works
|
|
868
|
+
under ``pytest-xdist`` and needs neither the ``[inline]`` extra nor any assertion rewriting;
|
|
869
|
+
only recording (empty call under update mode) reads the source.
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
expected: the recorded literal; omit it to record on the next update run.
|
|
873
|
+
ignore: key(s)/path(s) to skip in the comparison (as in ``is_equal_to``).
|
|
874
|
+
include: restrict the comparison to these key(s)/path(s).
|
|
875
|
+
tolerance: absolute numeric tolerance applied at every depth.
|
|
876
|
+
rel: numeric tolerance relative to the leaf the literal holds, applied at every depth, either
|
|
877
|
+
of the two holding being enough.
|
|
878
|
+
comparators: per-type custom equality callables.
|
|
879
|
+
placeholders: ``{key: matcher}`` for volatile fields - the key is ignored by the equality
|
|
880
|
+
comparison and its matcher asserted separately.
|
|
881
|
+
|
|
882
|
+
Examples:
|
|
883
|
+
Usage:
|
|
884
|
+
|
|
885
|
+
assert_that({"id": 1, "name": "Alice"}).matches_inline({"id": 1, "name": "Alice"})
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
889
|
+
|
|
890
|
+
One call holds one literal: every case that reaches it under update mode has to pass against
|
|
891
|
+
the literal written there, or nothing is written and the run fails naming the call. Under
|
|
892
|
+
``not_`` nothing is recorded, since the literal would be the value the negation differs from.
|
|
893
|
+
|
|
894
|
+
Raises:
|
|
895
|
+
AssertionError: if val does not equal the recorded literal, or the snapshot is still empty
|
|
896
|
+
TypeError: if update mode has to rewrite an expected value that is not a literal written in
|
|
897
|
+
the call, such as a name or an unpacked argument
|
|
898
|
+
"""
|
|
899
|
+
if placeholders:
|
|
900
|
+
self._require_dict_like(self.val, name="val") # placeholders address keys of a dict-like value
|
|
901
|
+
for matcher in placeholders.values():
|
|
902
|
+
if not _is_matcher(matcher) and not callable(matcher):
|
|
903
|
+
raise TypeError("placeholder values must be Matcher instances or callables")
|
|
904
|
+
effective_ignore = _combine_ignore(ignore, placeholders)
|
|
905
|
+
options: dict[str, Any] = {
|
|
906
|
+
"ignore": effective_ignore,
|
|
907
|
+
"include": include,
|
|
908
|
+
"tolerance": tolerance,
|
|
909
|
+
"rel": rel,
|
|
910
|
+
"comparators": comparators,
|
|
911
|
+
}
|
|
912
|
+
if self._inline_recorded(expected, placeholders, options):
|
|
913
|
+
return self
|
|
914
|
+
if placeholders:
|
|
915
|
+
self._check_placeholders(placeholders)
|
|
916
|
+
try:
|
|
917
|
+
return self._compared_to_stored(expected, **options)
|
|
918
|
+
except AssertionFailure as mismatch:
|
|
919
|
+
# the stored snapshot lives in this very call, and updating rewrites it in place
|
|
920
|
+
raise _rewrapped(
|
|
921
|
+
mismatch, f"{mismatch._message} {_update_hint('Inline snapshot', 'rewrite the literal here')}"
|
|
922
|
+
) from None
|
|
923
|
+
|
|
924
|
+
def matches_contract_snapshot(self, id: str | None = None, path: str = "__snapshots") -> Self: # noqa: A002 # `id` is the public snapshot-identifier parameter
|
|
925
|
+
"""Asserts that val's *structure* matches a contract snapshot stored previously.
|
|
926
|
+
|
|
927
|
+
Records the shape - paths and type categories, never values - on the first run, then on later
|
|
928
|
+
runs fails only on **structural** drift: a field added, removed, or retyped.
|
|
929
|
+
|
|
930
|
+
It is value-tolerant by construction, so dynamic ids, timestamps, and amounts change freely
|
|
931
|
+
without breaking the snapshot, and it needs no hand-written model - the contract is inferred
|
|
932
|
+
from the first response. Numbers are one category (``5`` and ``5.0`` do not drift), and a
|
|
933
|
+
``null`` sample is a nullable wildcard.
|
|
934
|
+
|
|
935
|
+
The model-driven counterpart is
|
|
936
|
+
[`assert_conforms(..., exact=True)`][assertpy.assertpy.assert_conforms]: reach for that when you
|
|
937
|
+
already have a pydantic model, and for this when you would rather capture the shape from a real
|
|
938
|
+
response.
|
|
939
|
+
|
|
940
|
+
Honors the same update mode (``--assertpy-snapshot-update``), CI mode
|
|
941
|
+
(``--assertpy-snapshot-ci``), and storage layout as
|
|
942
|
+
[`snapshot()`][assertpy.snapshot.SnapshotMixin.snapshot].
|
|
943
|
+
|
|
944
|
+
Because a contract is inferred from a single observation it cannot know which fields are
|
|
945
|
+
optional, so a legitimately sometimes-absent field reads as ``removed``; re-record with update
|
|
946
|
+
mode when the contract really changed.
|
|
947
|
+
|
|
948
|
+
Args:
|
|
949
|
+
id: a custom snapshot identifier (defaults to test filename plus line number)
|
|
950
|
+
path: the directory where snapshots are stored (defaults to ``__snapshots``)
|
|
951
|
+
|
|
952
|
+
Examples:
|
|
953
|
+
Usage:
|
|
954
|
+
|
|
955
|
+
assert_that(response.json()).matches_contract_snapshot()
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
959
|
+
|
|
960
|
+
Raises:
|
|
961
|
+
AssertionError: if val's structure drifts from the stored contract snapshot
|
|
962
|
+
|
|
963
|
+
Warns:
|
|
964
|
+
SnapshotCreatedWarning: when this run captured a new contract instead of comparing
|
|
965
|
+
SnapshotUpdatedWarning: when update mode overwrote a drifted contract instead of failing
|
|
966
|
+
"""
|
|
967
|
+
contract = shape(self.val)
|
|
968
|
+
lineno = ""
|
|
969
|
+
if id:
|
|
970
|
+
snapname = _name(path, id)
|
|
971
|
+
else:
|
|
972
|
+
caller = _require_caller(inspect.currentframe())
|
|
973
|
+
file_path = os.path.basename(caller.f_code.co_filename)
|
|
974
|
+
file_name = os.path.splitext(file_path)[0]
|
|
975
|
+
lineno = str(caller.f_lineno)
|
|
976
|
+
snapname = _name(path, file_name)
|
|
977
|
+
|
|
978
|
+
_record_access(snapname, "" if id else lineno, f"id={id!r}" if id else f"{file_path}:{lineno}")
|
|
979
|
+
os.makedirs(path, exist_ok=True)
|
|
980
|
+
|
|
981
|
+
stored = _UNSET
|
|
982
|
+
updated = False
|
|
983
|
+
with _file_lock(snapname):
|
|
984
|
+
if os.path.isfile(snapname):
|
|
985
|
+
snap = _load(snapname)
|
|
986
|
+
if id:
|
|
987
|
+
stored = snap
|
|
988
|
+
elif lineno in snap:
|
|
989
|
+
stored = snap[lineno]
|
|
990
|
+
else:
|
|
991
|
+
self._forbid_creation(snapname)
|
|
992
|
+
snap[lineno] = contract
|
|
993
|
+
_save(snapname, snap)
|
|
994
|
+
|
|
995
|
+
if stored is not _UNSET and self._rewriting() and shape_diff(stored, contract):
|
|
996
|
+
if id:
|
|
997
|
+
_save(snapname, contract)
|
|
998
|
+
else:
|
|
999
|
+
snap[lineno] = contract
|
|
1000
|
+
_save(snapname, snap)
|
|
1001
|
+
updated = True
|
|
1002
|
+
else:
|
|
1003
|
+
self._forbid_creation(snapname)
|
|
1004
|
+
_save(snapname, contract if id else {lineno: contract})
|
|
1005
|
+
|
|
1006
|
+
if updated:
|
|
1007
|
+
warnings.warn(
|
|
1008
|
+
f"updated contract snapshot <{snapname}>: this run overwrote the stored shape instead of"
|
|
1009
|
+
" comparing; subsequent runs compare against it",
|
|
1010
|
+
SnapshotUpdatedWarning,
|
|
1011
|
+
stacklevel=caller_stacklevel(),
|
|
1012
|
+
)
|
|
1013
|
+
return self
|
|
1014
|
+
if stored is not _UNSET:
|
|
1015
|
+
drift = shape_diff(stored, contract)
|
|
1016
|
+
if drift:
|
|
1017
|
+
located = snapname if id else f"{snapname}::{lineno}"
|
|
1018
|
+
return self.error(
|
|
1019
|
+
f"Expected <{_truncated(_safe_str(self.val))}> to match contract snapshot, but the structure"
|
|
1020
|
+
f" drifted:\n{_format_shape_drift(drift)}\n"
|
|
1021
|
+
f"{_update_hint(f'Contract snapshot <{located}>', 'accept the new shape')}",
|
|
1022
|
+
actual=self.val,
|
|
1023
|
+
)
|
|
1024
|
+
return self
|
|
1025
|
+
warnings.warn(
|
|
1026
|
+
f"created contract snapshot <{snapname}>: this run captured the shape instead of comparing;"
|
|
1027
|
+
" subsequent runs compare against it (delete the file to re-capture)",
|
|
1028
|
+
SnapshotCreatedWarning,
|
|
1029
|
+
stacklevel=caller_stacklevel(),
|
|
1030
|
+
)
|
|
1031
|
+
return self
|