Skip to content

Commit 57e02e1

Browse files
iamogbznoahnu
andauthored
feat: pretty diff of failed snapshot assertions (#86)
* refactor: move diff generation into serializer * wip: python difflib * wip: use colored library and show some diff context * wip: fix setup py * wip: simplify diff lines * wip: highlight deleted snapshots * wip: highlight changed snapshot lines * refactor: streamline some logic * refactor: fix single line * test: snapshot test diff output * test: snapshot color changing diff * test: make more robust Co-authored-by: Noah <[email protected]>
1 parent 5beaae7 commit 57e02e1

File tree

10 files changed

+234
-71
lines changed

10 files changed

+234
-71
lines changed

requirements.txt

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,33 @@
22
# This file is autogenerated by pip-compile
33
# To update, run:
44
#
5-
# pip-compile --allow-unsafe --no-emit-find-links --no-index requirements.in
5+
# pip-compile --allow-unsafe --no-emit-find-links --no-index --output-file=- -
66
#
77
appdirs==1.4.3 # via black
88
attrs==19.3.0 # via black, flake8-bugbear, pytest
99
black==19.10b0
1010
bleach==3.1.0 # via readme-renderer
1111
certifi==2019.11.28 # via requests
12+
cffi==1.13.2 # via cryptography
1213
chardet==3.0.4 # via requests
1314
click==7.0 # via black, pip-tools
1415
codecov==2.0.15
16+
colored==1.4.2
1517
configparser==4.0.2 # via py-githooks
16-
coverage[toml]==5.0.1
18+
coverage[toml]==5.0.1 # via codecov
19+
cryptography==2.8 # via secretstorage
1720
docutils==0.15.2 # via readme-renderer
1821
entrypoints==0.3 # via flake8
19-
flake8-bugbear==19.8.0
22+
flake8-bugbear==20.1.0
2023
flake8-builtins==1.4.2
2124
flake8-comprehensions==3.1.4
2225
flake8-i18n==0.1.0
23-
flake8==3.7.9
26+
flake8==3.7.9 # via flake8-bugbear, flake8-builtins, flake8-comprehensions, flake8-i18n
2427
idna==2.8 # via requests
2528
importlib-metadata==1.3.0 # via flake8-comprehensions, keyring, pluggy, pytest, twine
26-
invoke==1.3.0
29+
invoke==1.4.0
2730
isort==4.3.21
31+
jeepney==0.4.2 # via secretstorage
2832
keyring==21.0.0 # via twine
2933
mccabe==0.6.1 # via flake8
3034
more-itertools==8.0.2 # via pytest, zipp
@@ -38,6 +42,7 @@ pluggy==0.13.1 # via pytest
3842
py-githooks==1.1.0
3943
py==1.8.1 # via pytest
4044
pycodestyle==2.5.0 # via flake8
45+
pycparser==2.19 # via cffi
4146
pyflakes==2.1.1 # via flake8
4247
pygments==2.5.2 # via readme-renderer
4348
pyparsing==2.4.6 # via packaging
@@ -46,18 +51,19 @@ readme-renderer==24.0 # via twine
4651
regex==2019.12.20 # via black
4752
requests-toolbelt==0.9.1 # via twine
4853
requests==2.22.0 # via codecov, requests-toolbelt, twine
54+
secretstorage==3.1.1 # via keyring
4955
semver==2.9.0
50-
six==1.13.0 # via bleach, packaging, pip-tools, readme-renderer
56+
six==1.13.0 # via bleach, cryptography, packaging, pip-tools, readme-renderer
5157
toml==0.10.0 # via black, coverage
52-
tqdm==4.41.0 # via twine
58+
tqdm==4.41.1 # via twine
5359
twine==3.1.1
5460
typed-ast==1.4.0 # via black, mypy
55-
typing-extensions==3.7.4.1
61+
typing-extensions==3.7.4.1 # via mypy
5662
urllib3==1.25.7 # via requests
57-
wcwidth==0.1.7 # via pytest
63+
wcwidth==0.1.8 # via pytest
5864
webencodings==0.5.1 # via bleach
5965
wheel==0.33.6
6066
zipp==0.6.0 # via importlib-metadata
6167

6268
# The following packages are considered to be unsafe in a requirements file:
63-
setuptools==42.0.2 # via twine
69+
setuptools==44.0.0 # via twine

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
python_requires = "~=3.6"
1111
setup_requires = ["setuptools_scm"]
12-
install_requires = ["typing_extensions>=3.6"]
12+
install_requires = ["colored", "typing_extensions>=3.6"]
1313
dev_requires = [
1414
"black",
1515
"codecov",

src/syrupy/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
from .location import TestLocation
1111
from .serializers import DEFAULT_SERIALIZER
1212
from .session import SnapshotSession
13+
from .terminal import (
14+
green,
15+
red,
16+
reset,
17+
)
1318

1419

1520
def pytest_addoption(parser: Any) -> None:
@@ -40,10 +45,10 @@ def pytest_assertrepr_compare(op: str, left: Any, right: Any) -> Optional[List[s
4045
https://docs.pytest.org/en/latest/reference.html#_pytest.hookspec.pytest_assertrepr_compare
4146
"""
4247
if isinstance(left, SnapshotAssertion):
43-
assert_msg = f"{left.name} {op} {right}"
48+
assert_msg = reset(f"{green(left.name)} {op} {red('received')}")
4449
return [assert_msg] + left.get_assert_diff(right)
4550
elif isinstance(right, SnapshotAssertion):
46-
assert_msg = f"{left} {op} {right.name}"
51+
assert_msg = reset(f"{red('received')} {op} {green(right.name)}")
4752
return [assert_msg] + right.get_assert_diff(left)
4853
return None
4954

src/syrupy/assertion.py

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from gettext import gettext
2-
from itertools import zip_longest
32
from typing import (
43
TYPE_CHECKING,
54
Dict,
@@ -16,12 +15,6 @@
1615
SnapshotFiles,
1716
)
1817
from .exceptions import SnapshotDoesNotExist
19-
from .terminal import (
20-
error_style,
21-
green,
22-
red,
23-
success_style,
24-
)
2518
from .utils import walk_snapshot_dir
2619

2720

@@ -111,27 +104,9 @@ def get_assert_diff(self, data: "SerializableData") -> List[str]:
111104
if snapshot_data is None:
112105
return [gettext("Snapshot does not exist!")]
113106

114-
diff = []
107+
diff: List[str] = []
115108
if not assertion_result.success:
116-
received = serialized_data.splitlines()
117-
stored = snapshot_data.splitlines()
118-
119-
marker_stored = success_style("-")
120-
marker_received = error_style("+")
121-
122-
for received_line, stored_line in zip_longest(received, stored):
123-
if received_line is None:
124-
diff.append(f"{marker_stored} {green(stored_line)}")
125-
elif stored_line is None:
126-
diff.append(f"{marker_received} {red(received_line)}")
127-
elif received_line != stored_line:
128-
diff.extend(
129-
[
130-
f"{marker_stored} {green(stored_line)}",
131-
f"{marker_received} {red(received_line)}",
132-
]
133-
)
134-
109+
diff.extend(self.serializer.diff_lines(serialized_data, snapshot_data))
135110
return diff
136111

137112
def __repr__(self) -> str:

src/syrupy/report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def lines(self) -> Generator[str, None, None]:
175175
snapshots = (snapshot.name for snapshot in snapshot_file)
176176
path_to_file = os.path.relpath(filepath, self.base_dir)
177177
deleted_snapshots = ", ".join(map(bold, sorted(snapshots)))
178-
yield gettext("Deleted {} ({})").format(
178+
yield warning_style(gettext("Deleted {} ({})")).format(
179179
deleted_snapshots, path_to_file
180180
)
181181
else:

src/syrupy/serializers/base.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
ABC,
55
abstractmethod,
66
)
7+
from difflib import ndiff
8+
from itertools import zip_longest
79
from typing import (
810
TYPE_CHECKING,
11+
Callable,
12+
Generator,
913
Optional,
1014
Set,
15+
Union,
1116
)
1217

1318
from typing_extensions import final
@@ -18,6 +23,13 @@
1823
SnapshotFile,
1924
)
2025
from syrupy.exceptions import SnapshotDoesNotExist
26+
from syrupy.terminal import (
27+
emphasize,
28+
green,
29+
mute,
30+
red,
31+
reset,
32+
)
2133

2234

2335
if TYPE_CHECKING:
@@ -182,3 +194,35 @@ def _write_snapshot_to_file(self, snapshot_file: "SnapshotFile") -> None:
182194
Adds the snapshot data to the snapshots read from the file
183195
"""
184196
raise NotImplementedError
197+
198+
def diff_lines(
199+
self, serialized_data: "SerializedData", snapshot_data: "SerializedData"
200+
) -> Generator[str, None, None]:
201+
for line in self.__diff_lines(str(snapshot_data), str(serialized_data)):
202+
yield reset(line)
203+
204+
def __diff_lines(self, a: str, b: str) -> Generator[str, None, None]:
205+
line_styler = {"-": green, "+": red}
206+
staged_line, skip = "", False
207+
for line in ndiff(a.splitlines(), b.splitlines()):
208+
if staged_line and line[:1] != "?":
209+
yield line_styler[staged_line[:1]](staged_line)
210+
staged_line, skip = "", False
211+
if line[:1] in "-+":
212+
staged_line = line
213+
elif line[:1] == "?":
214+
yield self.__diff_line(line, staged_line, line_styler[staged_line[:1]])
215+
staged_line, skip = "", False
216+
elif not skip:
217+
yield mute(" ...")
218+
skip = True
219+
if staged_line:
220+
yield line_styler[staged_line[:1]](staged_line)
221+
222+
def __diff_line(
223+
self, marker_line: str, line: str, line_style: Callable[[Union[str, int]], str]
224+
) -> str:
225+
return "".join(
226+
emphasize(line_style(char)) if str(marker) in "-+^" else line_style(char)
227+
for marker, char in zip_longest(marker_line.strip(), line)
228+
)

src/syrupy/terminal.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,34 @@
11
from typing import Union
22

3+
import colored
4+
5+
6+
def reset(text: Union[str, int]) -> str:
7+
return colored.stylize(text, colored.attr("reset"))
8+
39

410
def red(text: Union[str, int]) -> str:
5-
return f"\033[31m{text}\033[0m"
11+
return colored.stylize(text, colored.fg("red"))
612

713

814
def yellow(text: Union[str, int]) -> str:
9-
return f"\033[33m{text}\033[0m"
15+
return colored.stylize(text, colored.fg("yellow"))
1016

1117

1218
def green(text: Union[str, int]) -> str:
13-
return f"\033[32m{text}\033[0m"
19+
return colored.stylize(text, colored.fg("green"))
1420

1521

1622
def bold(text: Union[str, int]) -> str:
17-
return f"\033[1m{text}\033[0m"
23+
return colored.stylize(text, colored.attr("bold"))
24+
25+
26+
def mute(text: Union[str, int]) -> str:
27+
return colored.stylize(text, colored.attr("dim"))
28+
29+
30+
def emphasize(text: Union[str, int]) -> str:
31+
return colored.stylize(bold(text), colored.attr("underlined"))
1832

1933

2034
def error_style(text: Union[str, int]) -> str:

stubs/colored.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from typing import Union
2+
3+
def attr(color: Union[str, int]) -> str: ...
4+
def bg(color: Union[str, int]) -> str: ...
5+
def fg(color: Union[str, int]) -> str: ...
6+
def stylize(text: Union[str, int], style: str, reset: bool = True) -> str: ...
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# name: test_failing_snapshots_diff
2+
'
3+
4+
snapshot = SnapshotAssertion(name='snapshot', num_executions=1)
5+
6+
def test_updated_1(snapshot):
7+
> assert snapshot == ['this', 'will', 'not', 'match']
8+
E AssertionError: assert snapshot == received
9+
E ...
10+
E - 'be',
11+
E - 'updated',
12+
E + 'not',
13+
E + 'match',
14+
E ...
15+
16+
test_file.py:17: AssertionError
17+
18+
snapshot = SnapshotAssertion(name='snapshot', num_executions=1)
19+
20+
def test_updated_2(snapshot):
21+
> assert ['this', 'will', 'fail'] == snapshot
22+
E AssertionError: assert received == snapshot
23+
E ...
24+
E + 'fail',
25+
E - 'be',
26+
E - 'updated',
27+
E ...
28+
29+
test_file.py:22: AssertionError
30+
31+
snapshot = SnapshotAssertion(name='snapshot', num_executions=1)
32+
33+
def test_updated_3(snapshot):
34+
> assert snapshot == ['this', 'will', 'be', 'too', 'much']
35+
E AssertionError: assert snapshot == received
36+
E ...
37+
E - 'updated',
38+
E + 'too',
39+
E + 'much',
40+
E ...
41+
42+
test_file.py:27: AssertionError
43+
44+
snapshot = SnapshotAssertion(name='snapshot', num_executions=1)
45+
46+
def test_updated_4(snapshot):
47+
> assert snapshot == "sing line changeling"
48+
E AssertionError: assert snapshot == received
49+
E - 'single line change'
50+
E + 'sing line changeling'
51+
52+
test_file.py:32: AssertionError
53+
54+
snapshot = SnapshotAssertion(name='snapshot', num_executions=1)
55+
56+
def test_updated_5(snapshot):
57+
> assert snapshot == '''
58+
multiple line changes
59+
with some lines not staying the same
60+
intermittent changes so unchanged lines have to be ignored by the differ
61+
cause when there are a lot of changes you only want to see what changed
62+
you do not want to see this line
63+
or this line
64+
this line should show up because it changes color
65+
and this line does not exist in the first one
66+
'''
67+
E assert snapshot == received
68+
E ...
69+
E - with some lines staying the same
70+
E + with some lines not staying the same
71+
E - intermittent changes that have to be ignore by the differ output
72+
E + intermittent changes so unchanged lines have to be ignored by the differ
73+
E - because when there are a lot of changes you only want to see changes
74+
E + cause when there are a lot of changes you only want to see what changed
75+
E ...
76+
E - this line should show up because it changes color
77+
E + this line should show up because it changes color
78+
E + and this line does not exist in the first one
79+
E ...
80+
81+
test_file.py:37: AssertionError
82+
'
83+
---

0 commit comments

Comments
 (0)