From 57e02e16c3a6806f1d36800bdc07eb2a4939f1ee Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi Date: Sun, 5 Jan 2020 03:24:06 -0500 Subject: [PATCH] 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 --- requirements.txt | 26 ++++--- setup.py | 2 +- src/syrupy/__init__.py | 9 ++- src/syrupy/assertion.py | 29 +------- src/syrupy/report.py | 2 +- src/syrupy/serializers/base.py | 44 ++++++++++++ src/syrupy/terminal.py | 22 ++++-- stubs/colored.pyi | 6 ++ tests/__snapshots__/test_integration.ambr | 83 +++++++++++++++++++++++ tests/test_integration.py | 82 +++++++++++++++------- 10 files changed, 234 insertions(+), 71 deletions(-) create mode 100644 stubs/colored.pyi create mode 100644 tests/__snapshots__/test_integration.ambr diff --git a/requirements.txt b/requirements.txt index 8d2d5647..522af3dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,29 +2,33 @@ # This file is autogenerated by pip-compile # To update, run: # -# pip-compile --allow-unsafe --no-emit-find-links --no-index requirements.in +# pip-compile --allow-unsafe --no-emit-find-links --no-index --output-file=- - # appdirs==1.4.3 # via black attrs==19.3.0 # via black, flake8-bugbear, pytest black==19.10b0 bleach==3.1.0 # via readme-renderer certifi==2019.11.28 # via requests +cffi==1.13.2 # via cryptography chardet==3.0.4 # via requests click==7.0 # via black, pip-tools codecov==2.0.15 +colored==1.4.2 configparser==4.0.2 # via py-githooks -coverage[toml]==5.0.1 +coverage[toml]==5.0.1 # via codecov +cryptography==2.8 # via secretstorage docutils==0.15.2 # via readme-renderer entrypoints==0.3 # via flake8 -flake8-bugbear==19.8.0 +flake8-bugbear==20.1.0 flake8-builtins==1.4.2 flake8-comprehensions==3.1.4 flake8-i18n==0.1.0 -flake8==3.7.9 +flake8==3.7.9 # via flake8-bugbear, flake8-builtins, flake8-comprehensions, flake8-i18n idna==2.8 # via requests importlib-metadata==1.3.0 # via flake8-comprehensions, keyring, pluggy, pytest, twine -invoke==1.3.0 +invoke==1.4.0 isort==4.3.21 +jeepney==0.4.2 # via secretstorage keyring==21.0.0 # via twine mccabe==0.6.1 # via flake8 more-itertools==8.0.2 # via pytest, zipp @@ -38,6 +42,7 @@ pluggy==0.13.1 # via pytest py-githooks==1.1.0 py==1.8.1 # via pytest pycodestyle==2.5.0 # via flake8 +pycparser==2.19 # via cffi pyflakes==2.1.1 # via flake8 pygments==2.5.2 # via readme-renderer pyparsing==2.4.6 # via packaging @@ -46,18 +51,19 @@ readme-renderer==24.0 # via twine regex==2019.12.20 # via black requests-toolbelt==0.9.1 # via twine requests==2.22.0 # via codecov, requests-toolbelt, twine +secretstorage==3.1.1 # via keyring semver==2.9.0 -six==1.13.0 # via bleach, packaging, pip-tools, readme-renderer +six==1.13.0 # via bleach, cryptography, packaging, pip-tools, readme-renderer toml==0.10.0 # via black, coverage -tqdm==4.41.0 # via twine +tqdm==4.41.1 # via twine twine==3.1.1 typed-ast==1.4.0 # via black, mypy -typing-extensions==3.7.4.1 +typing-extensions==3.7.4.1 # via mypy urllib3==1.25.7 # via requests -wcwidth==0.1.7 # via pytest +wcwidth==0.1.8 # via pytest webencodings==0.5.1 # via bleach wheel==0.33.6 zipp==0.6.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: -setuptools==42.0.2 # via twine +setuptools==44.0.0 # via twine diff --git a/setup.py b/setup.py index 51064af2..cb1607f5 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ python_requires = "~=3.6" setup_requires = ["setuptools_scm"] -install_requires = ["typing_extensions>=3.6"] +install_requires = ["colored", "typing_extensions>=3.6"] dev_requires = [ "black", "codecov", diff --git a/src/syrupy/__init__.py b/src/syrupy/__init__.py index 2d63dc29..3cf21f3f 100644 --- a/src/syrupy/__init__.py +++ b/src/syrupy/__init__.py @@ -10,6 +10,11 @@ from .location import TestLocation from .serializers import DEFAULT_SERIALIZER from .session import SnapshotSession +from .terminal import ( + green, + red, + reset, +) def pytest_addoption(parser: Any) -> None: @@ -40,10 +45,10 @@ def pytest_assertrepr_compare(op: str, left: Any, right: Any) -> Optional[List[s https://docs.pytest.org/en/latest/reference.html#_pytest.hookspec.pytest_assertrepr_compare """ if isinstance(left, SnapshotAssertion): - assert_msg = f"{left.name} {op} {right}" + assert_msg = reset(f"{green(left.name)} {op} {red('received')}") return [assert_msg] + left.get_assert_diff(right) elif isinstance(right, SnapshotAssertion): - assert_msg = f"{left} {op} {right.name}" + assert_msg = reset(f"{red('received')} {op} {green(right.name)}") return [assert_msg] + right.get_assert_diff(left) return None diff --git a/src/syrupy/assertion.py b/src/syrupy/assertion.py index 12c1f57b..f134310f 100644 --- a/src/syrupy/assertion.py +++ b/src/syrupy/assertion.py @@ -1,5 +1,4 @@ from gettext import gettext -from itertools import zip_longest from typing import ( TYPE_CHECKING, Dict, @@ -16,12 +15,6 @@ SnapshotFiles, ) from .exceptions import SnapshotDoesNotExist -from .terminal import ( - error_style, - green, - red, - success_style, -) from .utils import walk_snapshot_dir @@ -111,27 +104,9 @@ def get_assert_diff(self, data: "SerializableData") -> List[str]: if snapshot_data is None: return [gettext("Snapshot does not exist!")] - diff = [] + diff: List[str] = [] if not assertion_result.success: - received = serialized_data.splitlines() - stored = snapshot_data.splitlines() - - marker_stored = success_style("-") - marker_received = error_style("+") - - for received_line, stored_line in zip_longest(received, stored): - if received_line is None: - diff.append(f"{marker_stored} {green(stored_line)}") - elif stored_line is None: - diff.append(f"{marker_received} {red(received_line)}") - elif received_line != stored_line: - diff.extend( - [ - f"{marker_stored} {green(stored_line)}", - f"{marker_received} {red(received_line)}", - ] - ) - + diff.extend(self.serializer.diff_lines(serialized_data, snapshot_data)) return diff def __repr__(self) -> str: diff --git a/src/syrupy/report.py b/src/syrupy/report.py index f7caf8fc..5ee9ed8d 100644 --- a/src/syrupy/report.py +++ b/src/syrupy/report.py @@ -175,7 +175,7 @@ def lines(self) -> Generator[str, None, None]: snapshots = (snapshot.name for snapshot in snapshot_file) path_to_file = os.path.relpath(filepath, self.base_dir) deleted_snapshots = ", ".join(map(bold, sorted(snapshots))) - yield gettext("Deleted {} ({})").format( + yield warning_style(gettext("Deleted {} ({})")).format( deleted_snapshots, path_to_file ) else: diff --git a/src/syrupy/serializers/base.py b/src/syrupy/serializers/base.py index 1206e837..1f843318 100644 --- a/src/syrupy/serializers/base.py +++ b/src/syrupy/serializers/base.py @@ -4,10 +4,15 @@ ABC, abstractmethod, ) +from difflib import ndiff +from itertools import zip_longest from typing import ( TYPE_CHECKING, + Callable, + Generator, Optional, Set, + Union, ) from typing_extensions import final @@ -18,6 +23,13 @@ SnapshotFile, ) from syrupy.exceptions import SnapshotDoesNotExist +from syrupy.terminal import ( + emphasize, + green, + mute, + red, + reset, +) if TYPE_CHECKING: @@ -182,3 +194,35 @@ def _write_snapshot_to_file(self, snapshot_file: "SnapshotFile") -> None: Adds the snapshot data to the snapshots read from the file """ raise NotImplementedError + + def diff_lines( + self, serialized_data: "SerializedData", snapshot_data: "SerializedData" + ) -> Generator[str, None, None]: + for line in self.__diff_lines(str(snapshot_data), str(serialized_data)): + yield reset(line) + + def __diff_lines(self, a: str, b: str) -> Generator[str, None, None]: + line_styler = {"-": green, "+": red} + staged_line, skip = "", False + for line in ndiff(a.splitlines(), b.splitlines()): + if staged_line and line[:1] != "?": + yield line_styler[staged_line[:1]](staged_line) + staged_line, skip = "", False + if line[:1] in "-+": + staged_line = line + elif line[:1] == "?": + yield self.__diff_line(line, staged_line, line_styler[staged_line[:1]]) + staged_line, skip = "", False + elif not skip: + yield mute(" ...") + skip = True + if staged_line: + yield line_styler[staged_line[:1]](staged_line) + + def __diff_line( + self, marker_line: str, line: str, line_style: Callable[[Union[str, int]], str] + ) -> str: + return "".join( + emphasize(line_style(char)) if str(marker) in "-+^" else line_style(char) + for marker, char in zip_longest(marker_line.strip(), line) + ) diff --git a/src/syrupy/terminal.py b/src/syrupy/terminal.py index 4f74518c..90209c23 100644 --- a/src/syrupy/terminal.py +++ b/src/syrupy/terminal.py @@ -1,20 +1,34 @@ from typing import Union +import colored + + +def reset(text: Union[str, int]) -> str: + return colored.stylize(text, colored.attr("reset")) + def red(text: Union[str, int]) -> str: - return f"\033[31m{text}\033[0m" + return colored.stylize(text, colored.fg("red")) def yellow(text: Union[str, int]) -> str: - return f"\033[33m{text}\033[0m" + return colored.stylize(text, colored.fg("yellow")) def green(text: Union[str, int]) -> str: - return f"\033[32m{text}\033[0m" + return colored.stylize(text, colored.fg("green")) def bold(text: Union[str, int]) -> str: - return f"\033[1m{text}\033[0m" + return colored.stylize(text, colored.attr("bold")) + + +def mute(text: Union[str, int]) -> str: + return colored.stylize(text, colored.attr("dim")) + + +def emphasize(text: Union[str, int]) -> str: + return colored.stylize(bold(text), colored.attr("underlined")) def error_style(text: Union[str, int]) -> str: diff --git a/stubs/colored.pyi b/stubs/colored.pyi new file mode 100644 index 00000000..61e76b42 --- /dev/null +++ b/stubs/colored.pyi @@ -0,0 +1,6 @@ +from typing import Union + +def attr(color: Union[str, int]) -> str: ... +def bg(color: Union[str, int]) -> str: ... +def fg(color: Union[str, int]) -> str: ... +def stylize(text: Union[str, int], style: str, reset: bool = True) -> str: ... diff --git a/tests/__snapshots__/test_integration.ambr b/tests/__snapshots__/test_integration.ambr new file mode 100644 index 00000000..06358b57 --- /dev/null +++ b/tests/__snapshots__/test_integration.ambr @@ -0,0 +1,83 @@ +# name: test_failing_snapshots_diff + ' + + snapshot = SnapshotAssertion(name='snapshot', num_executions=1) + + def test_updated_1(snapshot): + > assert snapshot == ['this', 'will', 'not', 'match'] + E AssertionError: assert snapshot == received + E ... + E - 'be', + E - 'updated', + E + 'not', + E + 'match', + E ... + + test_file.py:17: AssertionError + + snapshot = SnapshotAssertion(name='snapshot', num_executions=1) + + def test_updated_2(snapshot): + > assert ['this', 'will', 'fail'] == snapshot + E AssertionError: assert received == snapshot + E ... + E + 'fail', + E - 'be', + E - 'updated', + E ... + + test_file.py:22: AssertionError + + snapshot = SnapshotAssertion(name='snapshot', num_executions=1) + + def test_updated_3(snapshot): + > assert snapshot == ['this', 'will', 'be', 'too', 'much'] + E AssertionError: assert snapshot == received + E ... + E - 'updated', + E + 'too', + E + 'much', + E ... + + test_file.py:27: AssertionError + + snapshot = SnapshotAssertion(name='snapshot', num_executions=1) + + def test_updated_4(snapshot): + > assert snapshot == "sing line changeling" + E AssertionError: assert snapshot == received + E - 'single line change' + E + 'sing line changeling' + + test_file.py:32: AssertionError + + snapshot = SnapshotAssertion(name='snapshot', num_executions=1) + + def test_updated_5(snapshot): + > assert snapshot == ''' + multiple line changes + with some lines not staying the same + intermittent changes so unchanged lines have to be ignored by the differ + cause when there are a lot of changes you only want to see what changed + you do not want to see this line + or this line + this line should show up because it changes color + and this line does not exist in the first one + ''' + E assert snapshot == received + E ... + E - with some lines staying the same + E + with some lines not staying the same + E - intermittent changes that have to be ignore by the differ output + E + intermittent changes so unchanged lines have to be ignored by the differ + E - because when there are a lot of changes you only want to see changes + E + cause when there are a lot of changes you only want to see what changed + E ... + E - this line should show up because it changes color + E + this line should show up because it changes color + E + and this line does not exist in the first one + E ... + + test_file.py:37: AssertionError + ' +--- diff --git a/tests/test_integration.py b/tests/test_integration.py index b54ef7b8..24710372 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -125,6 +125,26 @@ def test_updated_3(snapshot): assert snapshot == ['this', 'will', 'be', 'updated'] """ ), + "updated_4": ( + """ + def test_updated_4(snapshot): + assert snapshot == "single line change" + """ + ), + "updated_5": ( + """ + def test_updated_5(snapshot): + assert snapshot == ''' + multiple line changes + with some lines staying the same + intermittent changes that have to be ignore by the differ output + because when there are a lot of changes you only want to see changes + you do not want to see this line + or this line + \x1b[38;5;1mthis line should show up because it changes color\x1b[0m + ''' + """ + ), } @@ -149,6 +169,27 @@ def test_updated_3(snapshot): assert snapshot == ['this', 'will', 'be', 'too', 'much'] """ ), + "updated_4": ( + """ + def test_updated_4(snapshot): + assert snapshot == "sing line changeling" + """ + ), + "updated_5": ( + """ + def test_updated_5(snapshot): + assert snapshot == ''' + multiple line changes + with some lines not staying the same + intermittent changes so unchanged lines have to be ignored by the differ + cause when there are a lot of changes you only want to see what changed + you do not want to see this line + or this line + \x1b[38;5;3mthis line should show up because it changes color\x1b[0m + and this line does not exist in the first one + ''' + """ + ), } return {**testcases, **updated_testcases} @@ -177,35 +218,24 @@ def test_injected_fixture(stubs): def test_generated_snapshots(stubs): result = stubs[0] result_stdout = clean_output(result.stdout.str()) - assert "5 snapshots generated" in result_stdout + assert "7 snapshots generated" in result_stdout assert "snapshots unused" not in result_stdout assert result.ret == 0 -def test_failing_snapshots(stubs, testcases_updated): +def test_failing_snapshots_diff(stubs, testcases_updated, snapshot): testdir = stubs[1] testdir.makepyfile(test_file="\n\n".join(testcases_updated.values())) - result = testdir.runpytest("-v") + result = testdir.runpytest("-vv") result_stdout = clean_output(result.stdout.str()) - assert "2 snapshots passed" in result_stdout - assert "3 snapshots failed" in result_stdout - expected_strings = [ - # 1 - "- 'be',", - "+ 'not',", - "- 'updated',", - "+ 'match',", - # 2 - "- 'be',", - "+ 'fail',", - "- 'updated',", - # 3 - "- 'updated',", - "+ 'too',", - "+ 'much',", - ] - for string in expected_strings: - assert string in result_stdout + start_index = result_stdout.find("==== FAILURES") + end_index = result_stdout.find("==== 5 failed") + result_stdout = "\n".join( + line + for line in result_stdout[start_index:end_index].splitlines() + if not line.startswith("____") and not line.startswith("====") + ) + assert snapshot == result_stdout assert result.ret == 1 @@ -215,7 +245,7 @@ def test_updated_snapshots(stubs, testcases_updated): result = testdir.runpytest("-v", "--snapshot-update") result_stdout = clean_output(result.stdout.str()) assert "2 snapshots passed" in result_stdout - assert "3 snapshots updated" in result_stdout + assert "5 snapshots updated" in result_stdout assert result.ret == 0 @@ -225,7 +255,7 @@ def test_unused_snapshots(stubs): result = testdir.runpytest("-v") result_stdout = clean_output(result.stdout.str()) assert "snapshots generated" not in result_stdout - assert "4 snapshots passed" in result_stdout + assert "6 snapshots passed" in result_stdout assert "1 snapshot unused" in result_stdout assert result.ret == 1 @@ -236,7 +266,7 @@ def test_unused_snapshots_warning(stubs): result = testdir.runpytest("-v", "--snapshot-warn-unused") result_stdout = clean_output(result.stdout.str()) assert "snapshots generated" not in result_stdout - assert "4 snapshots passed" in result_stdout + assert "6 snapshots passed" in result_stdout assert "1 snapshot unused" in result_stdout assert result.ret == 0 @@ -260,7 +290,7 @@ def test_removed_snapshot_file(stubs): result = testdir.runpytest("-v", "--snapshot-update") result_stdout = clean_output(result.stdout.str()) assert "snapshots unused" not in result_stdout - assert "5 unused snapshots deleted" in result_stdout + assert "7 unused snapshots deleted" in result_stdout assert result.ret == 0 assert not os.path.isfile(filepath)