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.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. 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