Skip to content

Commit 3d296e1

Browse files
authored
refactor: simplify data serializer for ambr (#676)
* refactor: simplify data serializer for ambr * feat: introduce concept of a tainted snapshot BREAKING CHANGE: Serializers may now throw a TaintedSnapshotError which will tell the user to regenerate the snapshot even if the underlying data has not changed. This is to support rolling out more subtle changes to the serializers, such as the introduction of serializer metadata. BREAKING CHANGE: Renamed DataSerializer to AmberDataSerializer.
1 parent 69f04ab commit 3d296e1

20 files changed

+209
-87
lines changed

src/syrupy/assertion.py

Lines changed: 62 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212
Dict,
1313
List,
1414
Optional,
15+
Tuple,
1516
Type,
1617
)
1718

18-
from .exceptions import SnapshotDoesNotExist
19+
from .exceptions import (
20+
SnapshotDoesNotExist,
21+
TaintedSnapshotError,
22+
)
1923
from .extensions.amber.serializer import Repr
2024

2125
if TYPE_CHECKING:
@@ -125,13 +129,15 @@ def __repr(self) -> "SerializableData":
125129
SnapshotAssertionRepr = namedtuple( # type: ignore
126130
"SnapshotAssertion", ["name", "num_executions"]
127131
)
128-
assertion_result = self.executions.get(
129-
(self._custom_index and self._execution_name_index.get(self._custom_index))
130-
or self.num_executions - 1
131-
)
132+
execution_index = (
133+
self._custom_index and self._execution_name_index.get(self._custom_index)
134+
) or self.num_executions - 1
135+
assertion_result = self.executions.get(execution_index)
132136
return (
133137
Repr(str(assertion_result.final_data))
134-
if assertion_result
138+
if execution_index in self.executions
139+
and assertion_result
140+
and assertion_result.final_data is not None
135141
else SnapshotAssertionRepr(
136142
name=self.name,
137143
num_executions=self.num_executions,
@@ -179,15 +185,23 @@ def _serialize(self, data: "SerializableData") -> "SerializedData":
179185
def get_assert_diff(self) -> List[str]:
180186
assertion_result = self._execution_results[self.num_executions - 1]
181187
if assertion_result.exception:
182-
lines = [
183-
line
184-
for lines in traceback.format_exception(
185-
assertion_result.exception.__class__,
186-
assertion_result.exception,
187-
assertion_result.exception.__traceback__,
188-
)
189-
for line in lines.splitlines()
190-
]
188+
if isinstance(assertion_result.exception, (TaintedSnapshotError,)):
189+
lines = [
190+
gettext(
191+
"This snapshot needs to be regenerated. "
192+
"This is typically due to a major Syrupy update."
193+
)
194+
]
195+
else:
196+
lines = [
197+
line
198+
for lines in traceback.format_exception(
199+
assertion_result.exception.__class__,
200+
assertion_result.exception,
201+
assertion_result.exception.__traceback__,
202+
)
203+
for line in lines.splitlines()
204+
]
191205
# Rotate to place exception with message at first line
192206
return lines[-1:] + lines[:-1]
193207
snapshot_data = assertion_result.recalled_data
@@ -232,7 +246,7 @@ def __call__(
232246
return self
233247

234248
def __repr__(self) -> str:
235-
return str(self._serialize(self.__repr))
249+
return str(self.__repr)
236250

237251
def __eq__(self, other: "SerializableData") -> bool:
238252
return self._assert(other)
@@ -250,29 +264,36 @@ def _assert(self, data: "SerializableData") -> bool:
250264
assertion_success = False
251265
assertion_exception = None
252266
try:
253-
snapshot_data = self._recall_data(index=self.index)
267+
snapshot_data, tainted = self._recall_data(index=self.index)
254268
serialized_data = self._serialize(data)
255269
snapshot_diff = getattr(self, "_snapshot_diff", None)
256270
if snapshot_diff is not None:
257-
snapshot_data_diff = self._recall_data(index=snapshot_diff)
271+
snapshot_data_diff, _ = self._recall_data(index=snapshot_diff)
258272
if snapshot_data_diff is None:
259273
raise SnapshotDoesNotExist()
260274
serialized_data = self.extension.diff_snapshots(
261275
serialized_data=serialized_data,
262276
snapshot_data=snapshot_data_diff,
263277
)
264-
matches = snapshot_data is not None and self.extension.matches(
265-
serialized_data=serialized_data, snapshot_data=snapshot_data
278+
matches = (
279+
not tainted
280+
and snapshot_data is not None
281+
and self.extension.matches(
282+
serialized_data=serialized_data, snapshot_data=snapshot_data
283+
)
266284
)
267285
assertion_success = matches
268-
if not matches and self.update_snapshots:
269-
self.session.queue_snapshot_write(
270-
extension=self.extension,
271-
test_location=self.test_location,
272-
data=serialized_data,
273-
index=self.index,
274-
)
275-
assertion_success = True
286+
if not matches:
287+
if self.update_snapshots:
288+
self.session.queue_snapshot_write(
289+
extension=self.extension,
290+
test_location=self.test_location,
291+
data=serialized_data,
292+
index=self.index,
293+
)
294+
assertion_success = True
295+
elif tainted:
296+
raise TaintedSnapshotError
276297
return assertion_success
277298
except Exception as e:
278299
assertion_exception = e
@@ -301,12 +322,19 @@ def _post_assert(self) -> None:
301322
while self._post_assert_actions:
302323
self._post_assert_actions.pop()()
303324

304-
def _recall_data(self, index: "SnapshotIndex") -> Optional["SerializableData"]:
325+
def _recall_data(
326+
self, index: "SnapshotIndex"
327+
) -> Tuple[Optional["SerializableData"], bool]:
305328
try:
306-
return self.extension.read_snapshot(
307-
test_location=self.test_location,
308-
index=index,
309-
session_id=str(id(self.session)),
329+
return (
330+
self.extension.read_snapshot(
331+
test_location=self.test_location,
332+
index=index,
333+
session_id=str(id(self.session)),
334+
),
335+
False,
310336
)
311337
except SnapshotDoesNotExist:
312-
return None
338+
return None, False
339+
except TaintedSnapshotError as e:
340+
return e.snapshot_data, True

src/syrupy/data.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
class Snapshot:
2424
name: str
2525
data: Optional["SerializedData"] = None
26+
# A tainted snapshot needs to be regenerated
27+
tainted: Optional[bool] = field(default=None)
2628

2729

2830
@dataclass(frozen=True)
@@ -42,6 +44,9 @@ class SnapshotCollection:
4244
location: str
4345
_snapshots: Dict[str, "Snapshot"] = field(default_factory=dict)
4446

47+
# A tainted collection needs to be regenerated
48+
tainted: Optional[bool] = field(default=None)
49+
4550
@property
4651
def has_snapshots(self) -> bool:
4752
return bool(self._snapshots)

src/syrupy/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1+
from typing import Optional
2+
3+
from syrupy.types import SerializedData
4+
5+
16
class SnapshotDoesNotExist(Exception):
27
"""Snapshot does not exist"""
38

49

510
class FailedToLoadModuleMember(Exception):
611
"""Failed to load specific member in a module"""
12+
13+
14+
class TaintedSnapshotError(Exception):
15+
"""The snapshot needs to be regenerated."""
16+
17+
snapshot_data: Optional["SerializedData"]
18+
19+
def __init__(self, snapshot_data: Optional["SerializedData"] = None) -> None:
20+
super().__init__()
21+
self.snapshot_data = snapshot_data

src/syrupy/extensions/amber/__init__.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
)
99

1010
from syrupy.data import SnapshotCollection
11+
from syrupy.exceptions import TaintedSnapshotError
1112
from syrupy.extensions.base import AbstractSyrupyExtension
1213

13-
from .serializer import DataSerializer
14+
from .serializer import AmberDataSerializer
1415

1516
if TYPE_CHECKING:
1617
from syrupy.types import SerializableData
@@ -28,29 +29,29 @@ def serialize(self, data: "SerializableData", **kwargs: Any) -> str:
2829
Returns the serialized form of 'data' to be compared
2930
with the snapshot data written to disk.
3031
"""
31-
return DataSerializer.serialize(data, **kwargs)
32+
return AmberDataSerializer.serialize(data, **kwargs)
3233

3334
def delete_snapshots(
3435
self, snapshot_location: str, snapshot_names: Set[str]
3536
) -> None:
36-
snapshot_collection_to_update = DataSerializer.read_file(snapshot_location)
37+
snapshot_collection_to_update = AmberDataSerializer.read_file(snapshot_location)
3738
for snapshot_name in snapshot_names:
3839
snapshot_collection_to_update.remove(snapshot_name)
3940

4041
if snapshot_collection_to_update.has_snapshots:
41-
DataSerializer.write_file(snapshot_collection_to_update)
42+
AmberDataSerializer.write_file(snapshot_collection_to_update)
4243
else:
4344
Path(snapshot_location).unlink()
4445

4546
def _read_snapshot_collection(self, snapshot_location: str) -> "SnapshotCollection":
46-
return DataSerializer.read_file(snapshot_location)
47+
return AmberDataSerializer.read_file(snapshot_location)
4748

4849
@staticmethod
4950
@lru_cache()
5051
def __cacheable_read_snapshot(
5152
snapshot_location: str, cache_key: str
5253
) -> "SnapshotCollection":
53-
return DataSerializer.read_file(snapshot_location)
54+
return AmberDataSerializer.read_file(snapshot_location)
5455

5556
def _read_snapshot_data_from_location(
5657
self, snapshot_location: str, snapshot_name: str, session_id: str
@@ -59,13 +60,17 @@ def _read_snapshot_data_from_location(
5960
snapshot_location=snapshot_location, cache_key=session_id
6061
)
6162
snapshot = snapshots.get(snapshot_name)
62-
return snapshot.data if snapshot else None
63+
tainted = bool(snapshots.tainted or (snapshot and snapshot.tainted))
64+
data = snapshot.data if snapshot else None
65+
if tainted:
66+
raise TaintedSnapshotError(snapshot_data=data)
67+
return data
6368

6469
@classmethod
6570
def _write_snapshot_collection(
6671
cls, *, snapshot_collection: "SnapshotCollection"
6772
) -> None:
68-
DataSerializer.write_file(snapshot_collection, merge=True)
73+
AmberDataSerializer.write_file(snapshot_collection, merge=True)
6974

7075

71-
__all__ = ["AmberSnapshotExtension", "DataSerializer"]
76+
__all__ = ["AmberSnapshotExtension", "AmberDataSerializer"]

0 commit comments

Comments
 (0)