From a4dfcf51df4df6d5da1afd863126ddca3f2c9b43 Mon Sep 17 00:00:00 2001 From: Nir Schulman Date: Sat, 15 Jun 2024 12:32:33 +0300 Subject: [PATCH 1/4] feat: Added a new CLI flag: --snapshot-patch-pycharm-diff (Only relevant for Pycharm users) If the flag is passed, Syrupy will import and extend Pycharm's default diff script to provide more meaning full diffs when viewing snapshots mismatches in Pycharm's diff viewer --- README.md | 18 +++++++++- src/syrupy/__init__.py | 78 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5177b1fb..b7c6be91 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,13 @@ Both options will generate equivalent snapshots but the latter is only viable wh These are the cli options exposed to `pytest` by the plugin. | Option | Description | Default | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| ------------------------------ |--------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------| | `--snapshot-update` | Snapshots will be updated to match assertions and unused snapshots will be deleted. | `False` | | `--snapshot-details` | Includes details of unused snapshots (test name and snapshot location) in the final report. | `False` | | `--snapshot-warn-unused` | Prints a warning on unused snapshots rather than fail the test suite. | `False` | | `--snapshot-default-extension` | Use to change the default snapshot extension class. | [AmberSnapshotExtension](https://github.com/syrupy-project/syrupy/blob/main/src/syrupy/extensions/amber/__init__.py) | | `--snapshot-no-colors` | Disable test results output highlighting. Equivalent to setting the environment variables `ANSI_COLORS_DISABLED` or `NO_COLOR` | Disabled by default if not in terminal. | +| `--snapshot-patch-pycharm-diff`| Override Pycharm's default diffs viewer when looking at snapshot diffs. More information in [Using Syrupy from Pycharm] | `False` | ### Assertion Options @@ -470,6 +471,21 @@ The generated snapshot: - [JPEG image extension](https://github.com/syrupy-project/syrupy/tree/main/tests/examples/test_custom_image_extension.py) - [Built-in image extensions](https://github.com/syrupy-project/syrupy/blob/main/tests/syrupy/extensions/image/test_image_svg.py) +### Viewing Snapshot Diffs in Pycharm IDEs +Pycharm IDEs come with a built-in tool that helps you to more easily identify differences between the expected result and the actual result in a test. +However, this tool does not play nicely with syrupy snapshots by default. + +Fortunately, Syrupy comes with a runtime flag that will extend Pycharm's default behavior to work nicely with snapshots. +Pass the `--snapshot-patch-pycharm-diff` flag in your pytest run configuration or create a `pytest.ini` in your project with the following content: +```ini +[pytest] +addopts = --snapshot-patch-pycharm-diff + +``` + +Now you will be able to see snapshot diffs more easily. + + ## Uninstalling ```python diff --git a/src/syrupy/__init__.py b/src/syrupy/__init__.py index 3c42f7fe..1899fb2d 100644 --- a/src/syrupy/__init__.py +++ b/src/syrupy/__init__.py @@ -1,10 +1,16 @@ import argparse import sys -from functools import lru_cache +import warnings +from functools import ( + lru_cache, + wraps, +) from gettext import gettext +from inspect import signature from typing import ( Any, ContextManager, + Iterable, List, Optional, ) @@ -85,6 +91,13 @@ def pytest_addoption(parser: Any) -> None: dest="no_colors", help="Disable test results output highlighting", ) + group.addoption( + "--snapshot-patch-pycharm-diff", + action="store_true", + default=False, + dest="patch_pycharm_diff", + help="Patch Pycharm diff", + ) def __terminal_color(config: Any) -> "ContextManager[None]": @@ -192,3 +205,66 @@ def snapshot(request: Any) -> "SnapshotAssertion": test_location=PyTestLocation(request.node), session=request.session.config._syrupy, ) + + +@pytest.fixture(scope="session", autouse=True) +def _patch_pycharm_diff_viewer_for_snapshots(request: Any) -> Iterable[None]: + if not request.config.option.patch_pycharm_diff: + yield + return + + try: + from teamcity.diff_tools import EqualsAssertionError # type: ignore + except ImportError: + warnings.warn( + "Pycharm's diff tools have failed to be imported. " + "Snapshot diffs will not be patched.", + stacklevel=2, + ) + yield + return + + old_init = EqualsAssertionError.__init__ + old_init_signature = signature(old_init) + + @wraps(old_init) + def new_init(self: EqualsAssertionError, *args: Any, **kwargs: Any) -> None: + + # Extract the __init__ arguments as originally passed in order to + # process them later + parameters = old_init_signature.bind(self, *args, **kwargs) + parameters.apply_defaults() + expected = parameters.arguments["expected"] + actual = parameters.arguments["actual"] + real_exception = parameters.arguments["real_exception"] + + if isinstance(expected, SnapshotAssertion): + snapshot = expected + elif isinstance(actual, SnapshotAssertion): + snapshot = actual + else: + snapshot = None + + old_init(self, *args, **kwargs) + + # No snapshot was involved in the assertion. Let the old logic do its + # thing. + if snapshot is None: + return + + # Although a snapshot was involved in the assertion, it seems the error + # was a result of a non-assertion exception (Ex. `assert 1/0`). + # Therefore, We will not do anything here either. + if real_exception is not None: + return + + assertion_result = snapshot.executions[snapshot.num_executions - 1] + if assertion_result.exception is not None: + return + + self.expected = str(assertion_result.recalled_data) + self.actual = str(assertion_result.asserted_data) + + EqualsAssertionError.__init__ = new_init + yield + EqualsAssertionError.__init__ = old_init From 7d4c7505f7d33a948e0eb3e7bdadd5ca2450b443 Mon Sep 17 00:00:00 2001 From: noahnu Date: Thu, 22 Aug 2024 23:49:28 -0400 Subject: [PATCH 2/4] chore: add test coverage --- README.md | 18 ++-- src/syrupy/__init__.py | 80 +++------------ src/syrupy/patches/__init__.py | 0 src/syrupy/patches/pycharm_diff.py | 76 ++++++++++++++ tests/integration/test_pycharm_patch.py | 126 ++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 78 deletions(-) create mode 100644 src/syrupy/patches/__init__.py create mode 100644 src/syrupy/patches/pycharm_diff.py create mode 100644 tests/integration/test_pycharm_patch.py diff --git a/README.md b/README.md index b7c6be91..47cf3aa7 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ These are the cli options exposed to `pytest` by the plugin. | `--snapshot-warn-unused` | Prints a warning on unused snapshots rather than fail the test suite. | `False` | | `--snapshot-default-extension` | Use to change the default snapshot extension class. | [AmberSnapshotExtension](https://github.com/syrupy-project/syrupy/blob/main/src/syrupy/extensions/amber/__init__.py) | | `--snapshot-no-colors` | Disable test results output highlighting. Equivalent to setting the environment variables `ANSI_COLORS_DISABLED` or `NO_COLOR` | Disabled by default if not in terminal. | -| `--snapshot-patch-pycharm-diff`| Override Pycharm's default diffs viewer when looking at snapshot diffs. More information in [Using Syrupy from Pycharm] | `False` | +| `--snapshot-patch-pycharm-diff`| Override PyCharm's default diffs viewer when looking at snapshot diffs. See [IDE Integrations](#ide-integrations) | `False` | ### Assertion Options @@ -471,20 +471,20 @@ The generated snapshot: - [JPEG image extension](https://github.com/syrupy-project/syrupy/tree/main/tests/examples/test_custom_image_extension.py) - [Built-in image extensions](https://github.com/syrupy-project/syrupy/blob/main/tests/syrupy/extensions/image/test_image_svg.py) -### Viewing Snapshot Diffs in Pycharm IDEs -Pycharm IDEs come with a built-in tool that helps you to more easily identify differences between the expected result and the actual result in a test. -However, this tool does not play nicely with syrupy snapshots by default. +## IDE Integrations + +### PyCharm + +The [PyCharm](https://www.jetbrains.com/pycharm/) IDE comes with a built-in tool for visualizing differences between expected and actual results in a test. To properly render Syrupy snapshots in the PyCharm diff viewer, we need to apply a patch to the diff viewer library. To do this, use the `--snapshot-patch-pycharm-diff` flag, e.g.: + +In your `pytest.ini`: -Fortunately, Syrupy comes with a runtime flag that will extend Pycharm's default behavior to work nicely with snapshots. -Pass the `--snapshot-patch-pycharm-diff` flag in your pytest run configuration or create a `pytest.ini` in your project with the following content: ```ini [pytest] addopts = --snapshot-patch-pycharm-diff - ``` -Now you will be able to see snapshot diffs more easily. - +See [#675](https://github.com/syrupy-project/syrupy/issues/675) for the original issue. ## Uninstalling diff --git a/src/syrupy/__init__.py b/src/syrupy/__init__.py index 1899fb2d..8f621697 100644 --- a/src/syrupy/__init__.py +++ b/src/syrupy/__init__.py @@ -1,16 +1,11 @@ import argparse import sys -import warnings -from functools import ( - lru_cache, - wraps, -) +from functools import lru_cache from gettext import gettext -from inspect import signature from typing import ( Any, ContextManager, - Iterable, + Iterator, List, Optional, ) @@ -22,6 +17,7 @@ from .exceptions import FailedToLoadModuleMember from .extensions import DEFAULT_EXTENSION from .location import PyTestLocation +from .patches.pycharm_diff import patch_pycharm_diff from .session import SnapshotSession from .terminal import ( received_style, @@ -96,7 +92,7 @@ def pytest_addoption(parser: Any) -> None: action="store_true", default=False, dest="patch_pycharm_diff", - help="Patch Pycharm diff", + help="Patch PyCharm diff", ) @@ -198,73 +194,19 @@ def pytest_terminal_summary( @pytest.fixture -def snapshot(request: Any) -> "SnapshotAssertion": +def snapshot(request: "pytest.FixtureRequest") -> "SnapshotAssertion": return SnapshotAssertion( update_snapshots=request.config.option.update_snapshots, extension_class=__import_extension(request.config.option.default_extension), test_location=PyTestLocation(request.node), - session=request.session.config._syrupy, + session=request.session.config._syrupy, # type: ignore ) @pytest.fixture(scope="session", autouse=True) -def _patch_pycharm_diff_viewer_for_snapshots(request: Any) -> Iterable[None]: - if not request.config.option.patch_pycharm_diff: - yield - return - - try: - from teamcity.diff_tools import EqualsAssertionError # type: ignore - except ImportError: - warnings.warn( - "Pycharm's diff tools have failed to be imported. " - "Snapshot diffs will not be patched.", - stacklevel=2, - ) +def _syrupy_apply_ide_patches(request: "pytest.FixtureRequest") -> Iterator[None]: + if request.config.option.patch_pycharm_diff: + with patch_pycharm_diff(): + yield + else: yield - return - - old_init = EqualsAssertionError.__init__ - old_init_signature = signature(old_init) - - @wraps(old_init) - def new_init(self: EqualsAssertionError, *args: Any, **kwargs: Any) -> None: - - # Extract the __init__ arguments as originally passed in order to - # process them later - parameters = old_init_signature.bind(self, *args, **kwargs) - parameters.apply_defaults() - expected = parameters.arguments["expected"] - actual = parameters.arguments["actual"] - real_exception = parameters.arguments["real_exception"] - - if isinstance(expected, SnapshotAssertion): - snapshot = expected - elif isinstance(actual, SnapshotAssertion): - snapshot = actual - else: - snapshot = None - - old_init(self, *args, **kwargs) - - # No snapshot was involved in the assertion. Let the old logic do its - # thing. - if snapshot is None: - return - - # Although a snapshot was involved in the assertion, it seems the error - # was a result of a non-assertion exception (Ex. `assert 1/0`). - # Therefore, We will not do anything here either. - if real_exception is not None: - return - - assertion_result = snapshot.executions[snapshot.num_executions - 1] - if assertion_result.exception is not None: - return - - self.expected = str(assertion_result.recalled_data) - self.actual = str(assertion_result.asserted_data) - - EqualsAssertionError.__init__ = new_init - yield - EqualsAssertionError.__init__ = old_init diff --git a/src/syrupy/patches/__init__.py b/src/syrupy/patches/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/syrupy/patches/pycharm_diff.py b/src/syrupy/patches/pycharm_diff.py new file mode 100644 index 00000000..635dac7a --- /dev/null +++ b/src/syrupy/patches/pycharm_diff.py @@ -0,0 +1,76 @@ +import warnings +from contextlib import contextmanager +from functools import wraps +from inspect import signature +from typing import ( + Any, + Iterator, +) + +from syrupy.assertion import SnapshotAssertion + + +@contextmanager +def patch_pycharm_diff() -> Iterator[None]: + """ + Applies PyCharm diff patch to add Syrupy snapshot support. + See: https://github.com/syrupy-project/syrupy/issues/675 + """ + + try: + from teamcity.diff_tools import EqualsAssertionError # type: ignore + except ImportError: + warnings.warn( + "Failed to patch PyCharm's diff tools. Skipping patch.", + stacklevel=2, + ) + yield + return + + old_init = EqualsAssertionError.__init__ + old_init_signature = signature(old_init) + + @wraps(old_init) + def new_init(self: "EqualsAssertionError", *args: Any, **kwargs: Any) -> None: + + # Extract the __init__ arguments as originally passed in order to + # process them later + parameters = old_init_signature.bind(self, *args, **kwargs) + parameters.apply_defaults() + + expected = parameters.arguments["expected"] + actual = parameters.arguments["actual"] + real_exception = parameters.arguments["real_exception"] + + if isinstance(expected, SnapshotAssertion): + snapshot = expected + elif isinstance(actual, SnapshotAssertion): + snapshot = actual + else: + snapshot = None + + old_init(self, *args, **kwargs) + + # No snapshot was involved in the assertion. Let the old logic do its + # thing. + if snapshot is None: + return + + # Although a snapshot was involved in the assertion, it seems the error + # was a result of a non-assertion exception (Ex. `assert 1/0`). + # Therefore, We will not do anything here either. + if real_exception is not None: + return + + assertion_result = snapshot.executions[snapshot.num_executions - 1] + if assertion_result.exception is not None: + return + + self.expected = str(assertion_result.recalled_data) + self.actual = str(assertion_result.asserted_data) + + try: + EqualsAssertionError.__init__ = new_init + yield + finally: + EqualsAssertionError.__init__ = old_init diff --git a/tests/integration/test_pycharm_patch.py b/tests/integration/test_pycharm_patch.py new file mode 100644 index 00000000..1f72032f --- /dev/null +++ b/tests/integration/test_pycharm_patch.py @@ -0,0 +1,126 @@ +from pathlib import Path + +import pytest + + +# EqualsAssertionError comes from: +# https://github.com/JetBrains/intellij-community/blob/cd9bfbd98a7dca730fbc469156ce1ed30364afba/python/helpers/pycharm/teamcity/diff_tools.py#L53 +@pytest.fixture +def mock_teamcity_diff_tools(testdir: "pytest.Testdir"): + teamcity_pkg = testdir.mkpydir("teamcity") + diff_tools_file = teamcity_pkg / Path("diff_tools.py") + diff_tools_file.write_text( + """ +class EqualsAssertionError: + def __init__(self, expected, actual, msg=None, preformated=False, real_exception=None): # noqa: E501 + self.real_exception = real_exception + self.expected = expected + self.actual = actual + self.msg = str(msg) + """, + "utf-8", + ) + + +@pytest.mark.filterwarnings("default") +def test_logs_a_warning_if_unable_to_apply_patch(testdir): + testdir.makepyfile( + test_file=""" + def test_case(snapshot): + assert snapshot == [1, 2] + """ + ) + testdir.runpytest("-v", "--snapshot-update") + testdir.makepyfile( + test_file=""" + def test_case(snapshot): + assert snapshot == [1, 2, 3] + """ + ) + + result = testdir.runpytest("-v", "--snapshot-patch-pycharm-diff") + result.assert_outcomes(failed=1, passed=0, warnings=1) + + +@pytest.mark.filterwarnings("default") +def test_patches_pycharm_diff_tools_when_flag_set(testdir, mock_teamcity_diff_tools): + # Generate initial snapshot + testdir.makepyfile( + test_file=""" + def test_case(snapshot): + assert snapshot == [1, 2] + """ + ) + testdir.runpytest("-v", "--snapshot-update") + + # Generate diff and mimic EqualsAssertionError being thrown + testdir.makepyfile( + test_file=""" + + def test_case(snapshot): + try: + assert snapshot == [1, 2, 3] + except: + from teamcity.diff_tools import EqualsAssertionError + + err = EqualsAssertionError(expected=snapshot, actual=[1,2,3]) + print("Expected:", repr(err.expected)) + print("Actual:", repr(err.actual)) + raise + """ + ) + + result = testdir.runpytest("-v", "--snapshot-patch-pycharm-diff") + # No warnings because patch should have been successful + result.assert_outcomes(failed=1, passed=0, warnings=0) + + result.stdout.re_match_lines( + [ + r"Expected: 'list([\n 1,\n 2,\n])'", + # Actual is the amber-style list representation + r"Actual: 'list([\n 1,\n 2,\n 3,\n])'", + ] + ) + + +@pytest.mark.filterwarnings("default") +def test_it_does_not_patch_pycharm_diff_tools_by_default( + testdir, mock_teamcity_diff_tools +): + # Generate initial snapshot + testdir.makepyfile( + test_file=""" + def test_case(snapshot): + assert snapshot == [1, 2] + """ + ) + testdir.runpytest("-v", "--snapshot-update") + + # Generate diff and mimic EqualsAssertionError being thrown + testdir.makepyfile( + test_file=""" + + def test_case(snapshot): + try: + assert snapshot == [1, 2, 3] + except: + from teamcity.diff_tools import EqualsAssertionError + + err = EqualsAssertionError(expected=snapshot, actual=[1,2,3]) + print("Expected:", repr(str(err.expected))) + print("Actual:", repr(str(err.actual))) + raise + """ + ) + + result = testdir.runpytest("-v") + # No warnings because patch should have been successful + result.assert_outcomes(failed=1, passed=0, warnings=0) + + result.stdout.re_match_lines( + [ + r"Expected: 'list([\n 1,\n 2,\n])'", + # Actual is the original list's repr. No newlines or amber-style list prefix + r"Actual: '[1, 2, 3]'", + ] + ) From 7c1b24f3f91b137a36fe2e7d56c876e21252b664 Mon Sep 17 00:00:00 2001 From: noahnu Date: Thu, 22 Aug 2024 23:59:54 -0400 Subject: [PATCH 3/4] chore: additional test coverage --- tests/integration/test_pycharm_patch.py | 111 ++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/integration/test_pycharm_patch.py b/tests/integration/test_pycharm_patch.py index 1f72032f..1c2503ba 100644 --- a/tests/integration/test_pycharm_patch.py +++ b/tests/integration/test_pycharm_patch.py @@ -83,6 +83,49 @@ def test_case(snapshot): ) +@pytest.mark.filterwarnings("default") +def test_patches_pycharm_diff_tools_when_flag_set_and_snapshot_on_right( + testdir, mock_teamcity_diff_tools +): + # Generate initial snapshot + testdir.makepyfile( + test_file=""" + def test_case(snapshot): + assert [1, 2] == snapshot + """ + ) + testdir.runpytest("-v", "--snapshot-update") + + # Generate diff and mimic EqualsAssertionError being thrown + testdir.makepyfile( + test_file=""" + + def test_case(snapshot): + try: + assert [1, 2, 3] == snapshot + except: + from teamcity.diff_tools import EqualsAssertionError + + err = EqualsAssertionError(expected=snapshot, actual=[1,2,3]) + print("Expected:", repr(err.expected)) + print("Actual:", repr(err.actual)) + raise + """ + ) + + result = testdir.runpytest("-v", "--snapshot-patch-pycharm-diff") + # No warnings because patch should have been successful + result.assert_outcomes(failed=1, passed=0, warnings=0) + + result.stdout.re_match_lines( + [ + r"Expected: 'list([\n 1,\n 2,\n])'", + # Actual is the amber-style list representation + r"Actual: 'list([\n 1,\n 2,\n 3,\n])'", + ] + ) + + @pytest.mark.filterwarnings("default") def test_it_does_not_patch_pycharm_diff_tools_by_default( testdir, mock_teamcity_diff_tools @@ -124,3 +167,71 @@ def test_case(snapshot): r"Actual: '[1, 2, 3]'", ] ) + + +@pytest.mark.filterwarnings("default") +def test_it_has_no_impact_on_non_syrupy_assertions(testdir, mock_teamcity_diff_tools): + # Generate diff and mimic EqualsAssertionError being thrown + testdir.makepyfile( + test_file=""" + + def test_case(): + try: + assert [1, 3] == [1, 2, 3] + except: + from teamcity.diff_tools import EqualsAssertionError + + err = EqualsAssertionError(expected=[1,3], actual=[1,2,3]) + print("Expected:", repr(str(err.expected))) + print("Actual:", repr(str(err.actual))) + raise + """ + ) + + result = testdir.runpytest("-v") + # No warnings because patch should have been successful + result.assert_outcomes(failed=1, passed=0, warnings=0) + + result.stdout.re_match_lines( + [ + r"Expected: '[1, 3]'", + r"Actual: '[1, 2, 3]'", + ] + ) + + +@pytest.mark.filterwarnings("default") +def test_has_no_impact_on_real_exceptions_that_are_not_assertion_errors( + testdir, mock_teamcity_diff_tools +): + # Generate diff and mimic EqualsAssertionError being thrown + testdir.makepyfile( + test_file=""" + + def test_case(): + try: + assert [1, 3] == (1/0) + except Exception as err: + from teamcity.diff_tools import EqualsAssertionError + + err = EqualsAssertionError( + expected=[1,3], + actual=[1,2,3], + real_exception=err + ) + print("Expected:", repr(str(err.expected))) + print("Actual:", repr(str(err.actual))) + raise + """ + ) + + result = testdir.runpytest("-v") + # No warnings because patch should have been successful + result.assert_outcomes(failed=1, passed=0, warnings=0) + + result.stdout.re_match_lines( + [ + r"Expected: '[1, 3]'", + r"Actual: '[1, 2, 3]'", + ] + ) From b867d382cee4604e3fc91a6e2e58ae61cfb30812 Mon Sep 17 00:00:00 2001 From: noahnu Date: Fri, 23 Aug 2024 00:05:49 -0400 Subject: [PATCH 4/4] chore: fix test for snapshot order --- tests/integration/test_pycharm_patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_pycharm_patch.py b/tests/integration/test_pycharm_patch.py index 1c2503ba..e1e6a9fb 100644 --- a/tests/integration/test_pycharm_patch.py +++ b/tests/integration/test_pycharm_patch.py @@ -106,7 +106,7 @@ def test_case(snapshot): except: from teamcity.diff_tools import EqualsAssertionError - err = EqualsAssertionError(expected=snapshot, actual=[1,2,3]) + err = EqualsAssertionError(expected=[1,2,3], actual=snapshot) print("Expected:", repr(err.expected)) print("Actual:", repr(err.actual)) raise