Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compare serialized data in assertion compr & improve diff report #46

Merged
merged 9 commits into from
Dec 24, 2019
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pip-delete-this-directory.txt
# Unit test / coverage reports
.pytest_cache/
.mypy_cache/
.coverage

# Environments
.env
Expand All @@ -44,3 +45,4 @@ venv/
ENV/
env.bak/
venv.bak/
.vscode
1 change: 1 addition & 0 deletions dev-requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ py-githooks
pytest-cov
codecov
isort
coverage[toml]
43 changes: 22 additions & 21 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,51 @@
#
# pip-compile dev-requirements.in
#
--extra-index-url https://pip-readonly:eyJ2ZXIiOiIyIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYiLCJraWQiOiJYaFp6N3l3eV8zSGdEdDFFa3BNTTY4bnYzTGNRd0syb1p6SFI0TjY3cXdnIn0.eyJzdWIiOiJqZnJ0QDAxYnQzbnhtNTltNGVoMGd4NHBwZjcxZzBxXC91c2Vyc1wvcGlwLXJlYWRvbmx5Iiwic2NwIjoibWVtYmVyLW9mLWdyb3Vwczpwcml2YXRlLXJlYWRlcnMgYXBpOioiLCJhdWQiOiJqZnJ0QDAxYnQzbnhtNTltNGVoMGd4NHBwZjcxZzBxIiwiaXNzIjoiamZydEAwMWJ0M254bTU5bTRlaDBneDRwcGY3MWcwcSIsImlhdCI6MTU2NDUxNDE1NSwianRpIjoiNzBkNTViYTctODY0ZC00MTAzLWIyY2EtNDYyNzIwZWVlMzhjIn0.ZlzRR-AxX24HD_5ePp3omZ0woH7yldOmab7Wq6g4-zRwWHVqvhO6vcGzuqDi2L3D_16iE6G9qLQN8Y3ggL4ykXxBXViOYhRq7ifxv8m-x13EdW2h9K2bzuIRuVLkZlxY6WKP31zgbmPcMx2N5NkjRF4q-EeNxtOOk9nd1DbP3E6mCH5VtjmQDm5nGYebO-uu_gCz5ym533j1cdX6yil5u5f6k3AaNJ6GNGeZWkZw29muAk6cGwaVDsLq-_cjIPgiQZa6fxh01d-WG3JJO2ZAOM5DzOkyDB2kuWq7WclyIL58invG1TpL4Dny7DPXDnmqfh_QqzAZiEKaaZW2IHdA5Q@thm.jfrog.io/thm/api/pypi/th-pip-prod/simple

appdirs==1.4.3 # via black
atomicwrites==1.3.0 # via pytest
attrs==19.3.0 # via black, pytest
black==19.10b0
bleach==3.1.0 # via readme-renderer
certifi==2019.9.11 # via requests
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
configparser==4.0.2 # via py-githooks
coverage==4.5.4 # via codecov, pytest-cov
coverage[toml]==5.0.1
cryptography==2.8 # via secretstorage
docutils==0.15.2 # via readme-renderer
entrypoints==0.3 # via keyring
idna==2.8 # via requests
importlib-metadata==0.23 # via pluggy, pytest, twine
importlib-metadata==1.3.0 # via keyring, pluggy, pytest, twine
invoke==1.3.0
isort==4.3.21
keyring==19.2.0 # via twine
more-itertools==7.2.0 # via pytest, zipp
jeepney==0.4.1 # via secretstorage
keyring==20.0.0 # via twine
more-itertools==8.0.2 # via pytest, zipp
mypy-extensions==0.4.3 # via mypy
mypy==0.740
mypy==0.761
packaging==19.2 # via pytest
pathspec==0.6.0 # via black
pip-tools==4.2.0
pip-tools==4.3.0
pkginfo==1.5.0.1 # via twine
pluggy==0.13.0 # via pytest
pluggy==0.13.1 # via pytest
py-githooks==1.1.0
py==1.8.0 # via pytest
pygments==2.4.2 # via readme-renderer
pycparser==2.19 # via cffi
pygments==2.5.2 # via readme-renderer
pyparsing==2.4.5 # via packaging
pytest-cov==2.8.1
pytest==5.2.4
pyyaml==5.1.2
pytest==5.3.2
pyyaml==5.2
readme-renderer==24.0 # via twine
regex==2019.11.1 # via black
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
toml==0.10.0 # via black
tqdm==4.39.0 # via twine
twine==3.1.0
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
twine==3.1.1
typed-ast==1.4.0 # via black, mypy
typing-extensions==3.7.4.1 # via mypy
urllib3==1.25.7 # via requests
Expand All @@ -57,4 +58,4 @@ 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
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,10 @@ disable = '''
missing-function-docstring,
missing-module-docstring,
'''

[tool.coverage.report]
exclude_lines = [
iamogbz marked this conversation as resolved.
Show resolved Hide resolved
"pragma: no-cover",
"@abstractmethod"
]

4 changes: 1 addition & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,4 @@
#
# pip-compile requirements.in
#
--extra-index-url https://pip-readonly:eyJ2ZXIiOiIyIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYiLCJraWQiOiJYaFp6N3l3eV8zSGdEdDFFa3BNTTY4bnYzTGNRd0syb1p6SFI0TjY3cXdnIn0.eyJzdWIiOiJqZnJ0QDAxYnQzbnhtNTltNGVoMGd4NHBwZjcxZzBxXC91c2Vyc1wvcGlwLXJlYWRvbmx5Iiwic2NwIjoibWVtYmVyLW9mLWdyb3Vwczpwcml2YXRlLXJlYWRlcnMgYXBpOioiLCJhdWQiOiJqZnJ0QDAxYnQzbnhtNTltNGVoMGd4NHBwZjcxZzBxIiwiaXNzIjoiamZydEAwMWJ0M254bTU5bTRlaDBneDRwcGY3MWcwcSIsImlhdCI6MTU2NDUxNDE1NSwianRpIjoiNzBkNTViYTctODY0ZC00MTAzLWIyY2EtNDYyNzIwZWVlMzhjIn0.ZlzRR-AxX24HD_5ePp3omZ0woH7yldOmab7Wq6g4-zRwWHVqvhO6vcGzuqDi2L3D_16iE6G9qLQN8Y3ggL4ykXxBXViOYhRq7ifxv8m-x13EdW2h9K2bzuIRuVLkZlxY6WKP31zgbmPcMx2N5NkjRF4q-EeNxtOOk9nd1DbP3E6mCH5VtjmQDm5nGYebO-uu_gCz5ym533j1cdX6yil5u5f6k3AaNJ6GNGeZWkZw29muAk6cGwaVDsLq-_cjIPgiQZa6fxh01d-WG3JJO2ZAOM5DzOkyDB2kuWq7WclyIL58invG1TpL4Dny7DPXDnmqfh_QqzAZiEKaaZW2IHdA5Q@thm.jfrog.io/thm/api/pypi/th-pip-prod/simple

pyyaml==5.1.2
pyyaml==5.2
34 changes: 31 additions & 3 deletions src/syrupy/assertion.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import namedtuple
from itertools import zip_longest
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -11,6 +12,12 @@
)

from .exceptions import SnapshotDoesNotExist
from .terminal import (
error_style,
green,
red,
success_style,
)
from .types import SerializableData
from .utils import walk_snapshot_dir

Expand Down Expand Up @@ -85,11 +92,31 @@ def assert_match(self, data: "SerializableData") -> None:
def get_assert_diff(self, data: "SerializableData") -> List[str]:
assertion_result = self._execution_results[self.num_executions - 1]
snapshot_data = assertion_result.recalled
serialized_data = self.serializer.serialize(data)
if snapshot_data is None:
return ["Snapshot does not exist!"]

if not assertion_result.success:
return [f"- {data}", f"+ {snapshot_data}"]
received = serialized_data.splitlines()
stored = snapshot_data.splitlines()

marker_stored = success_style("-")
marker_received = error_style("+")

diff = []
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)}",
]
)
return diff

return []

Expand All @@ -105,9 +132,10 @@ def __eq__(self, other: "SerializableData") -> bool:
def _assert(self, data: "SerializableData") -> bool:
snapshot_file = self.serializer.get_filepath(self.num_executions)
snapshot_name = self.serializer.get_snapshot_name(self.num_executions)
serialized_data = self.serializer.serialize(data)
try:
snapshot_data = self._recall_data(index=self.num_executions)
matches = snapshot_data is not None and data == snapshot_data
matches = snapshot_data is not None and serialized_data == snapshot_data
assertion_success = matches
if not matches and self._update_snapshots:
self.serializer.create_or_update_snapshot(
Expand All @@ -122,7 +150,7 @@ def _assert(self, data: "SerializableData") -> bool:
file=snapshot_file,
name=snapshot_name,
recalled=snapshot_data,
asserted=data,
asserted=serialized_data,
success=assertion_success,
created=snapshot_created,
updated=snapshot_updated,
Expand Down
17 changes: 13 additions & 4 deletions src/syrupy/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Callable,
Optional,
Set,
Union,
)

from syrupy.constants import SNAPSHOT_DIRNAME
Expand All @@ -26,7 +27,7 @@ def __init__(self, test_location: "TestLocation"):
@property
@abstractmethod
def file_extension(self) -> str:
pass
raise NotImplementedError

@property
def test_location(self) -> "TestLocation":
Expand All @@ -47,7 +48,7 @@ def discover_snapshots(self, filepath: str) -> Set[str]:
within the file. Snapshot name is dependent on serializer
implementation.
"""
pass
raise NotImplementedError

def read_snapshot(self, index: int) -> "SerializableData":
"""
Expand Down Expand Up @@ -141,7 +142,7 @@ def read_snapshot_from_file(
"""
Read the snapshot file and get only the snapshot data for assertion
"""
pass
raise NotImplementedError

@abstractmethod
def write_snapshot_or_remove_file(
Expand All @@ -152,4 +153,12 @@ def write_snapshot_or_remove_file(
or removes the snapshot entry if data is `None`.
If the snapshot file will be empty remove the entire file.
"""
pass
raise NotImplementedError

@abstractmethod
def serialize(self, data: "SerializableData") -> Union[str, bytes]:
"""
Serializes a python object / data structure into a string
to be used for comparison with snapshot data from disk.
"""
raise NotImplementedError
5 changes: 5 additions & 0 deletions src/syrupy/serializers/raw_single.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ def read_snapshot_from_file(
) -> "SerializableData":
return self._read_file(snapshot_file)

def serialize(self, data: "SerializableData") -> bytes:
if isinstance(data, bytes):
return data
raise ValueError("Failure to serialize image data. Expected bytes.")

def _read_file(self, filepath: str) -> Any:
try:
with open(filepath, "rb") as f:
Expand Down
43 changes: 39 additions & 4 deletions src/syrupy/serializers/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from typing import (
TYPE_CHECKING,
Any,
Dict,
Optional,
Set,
)

Expand All @@ -21,15 +23,15 @@ def file_extension(self) -> str:

def discover_snapshots(self, filepath: str) -> Set[str]:
try:
return set(self._read_file(filepath).keys())
return set(self._read_raw_file(filepath).keys())
except:
return set()

def read_snapshot_from_file(
self, snapshot_file: str, snapshot_name: str
) -> "SerializableData":
snapshots = self._read_file(snapshot_file)
return snapshots.get(snapshot_name, {}).get("data", None)
raw_snapshots = self._read_raw_file(snapshot_file)
return raw_snapshots.get(snapshot_name, None)

def write_snapshot_or_remove_file(
self, snapshot_file: str, snapshot_name: str, data: "SerializableData"
Expand All @@ -44,13 +46,24 @@ def write_snapshot_or_remove_file(
del snapshots[snapshot_name]
else:
snapshots[snapshot_name] = snapshots.get(snapshot_name, {})
snapshots[snapshot_name]["data"] = data
snapshots[snapshot_name][self._data_key] = data

if snapshots:
self._write_file(snapshot_file, snapshots)
else:
os.remove(snapshot_file)

def serialize(self, data: "SerializableData") -> str:
"""
Returns the serialized form of 'data' to be compared
with the snapshot data written to disk.
"""
return str(yaml.dump({self._data_key: data}, allow_unicode=True))

@property
def _data_key(self) -> str:
return "data"

def _write_file(self, filepath: str, data: "SerializableData") -> None:
"""
Writes the snapshot data into the snapshot file that be read later.
Expand All @@ -68,3 +81,25 @@ def _read_file(self, filepath: str) -> Any:
except FileNotFoundError:
pass
return {}

def _read_raw_file(self, filepath: str) -> Dict[str, str]:
"""
Read the raw snapshot data (str) from the snapshot file into a dict
of snapshot name to raw data. This does not attempt any deserialization
of the snapshot data.
"""
snapshots = {}
try:
with open(filepath, "r") as f:
test_name = None
for line in f:
indent = len(line) - len(line.lstrip(" "))
if not indent:
test_name = line[:-2] # newline & colon
snapshots[test_name] = ""
elif test_name is not None:
snapshots[test_name] += line[2:]
except FileNotFoundError:
pass

return snapshots
4 changes: 4 additions & 0 deletions src/syrupy/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ def bold(text: Union[str, int]) -> str:

def error_style(text: Union[str, int]) -> str:
return bold(red(text))


def success_style(text: Union[str, int]) -> str:
return bold(green(text))
11 changes: 8 additions & 3 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ def requirements(ctx):
"""
Build requirements lock file
"""
ctx.run(f"python -m piptools compile dev-requirements.in", pty=True)
input_files = ["requirements.in", "dev-requirements.in"]
for input_file in input_files:
ctx.run(f"python -m piptools compile {input_file}", pty=True)


@task
Expand Down Expand Up @@ -59,14 +61,17 @@ def test(ctx, coverage=False, dev=False, update_snapshots=False, verbose=False):
"""
env = {"PYTHONPATH": "./src"} if dev else {}
flags = {
"-s": verbose,
"-s -vv": verbose,
noahnu marked this conversation as resolved.
Show resolved Hide resolved
"--cov=./src": coverage,
"--snapshot-update": update_snapshots,
}
test_flags = " ".join(flag for flag, enabled in flags.items() if enabled)
ctx.run(f"python -m pytest {test_flags} .", env=env, pty=True)
if coverage:
ctx.run("codecov", pty=True)
if not os.environ.get("CI"):
print("\nNote: Test coverage is only uploaded in CI.\n")
else:
ctx.run("codecov", pty=True)


@task(pre=[clean])
Expand Down
8 changes: 0 additions & 8 deletions tests/__snapshots__/test_snapshots.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,6 @@ test_set:
is: null
set: null
this: null
test_set.1:
data: !!set
a: null
? !!python/object/apply:builtins.frozenset
- - nested, set
: null
is: null
this: null
test_simple_string:
data: Loreeeeeem ipsum.
test_tuples:
Expand Down
5 changes: 5 additions & 0 deletions tests/test_image_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ def test_image(snapshot_png):
b"RK5CYII="
)
assert actual == snapshot_png


def test_raises_error_for_unserializable_data(snapshot_png):
with pytest.raises(ValueError):
assert "not a byte string" == snapshot_png
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if it's best to throw an error here, or consider this a failed snapshot.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could wrap the serialization with an error handler and compare the raw values if it fails

1 change: 0 additions & 1 deletion tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ def test_dict(snapshot, actual):

def test_set(snapshot):
assert snapshot == {"this", "is", "a", "set"}
assert snapshot == {"this", "is", "a", frozenset({"nested, set"})}
noahnu marked this conversation as resolved.
Show resolved Hide resolved


ExampleTuple = namedtuple("ExampleTuple", ["a", "b", "c", "d"])
Expand Down