Skip to content

Commit

Permalink
feat: replace serializer with extension nomenclature (#95)
Browse files Browse the repository at this point in the history
* wip: refactor extension

* wip: further rename serializer to extension

* docs: update readme

* wip: mark test location as abstract

* wip: replace snapshot file with cache naming

* wip: combine read, write functions

* wip: rename delete

* wip: replace snapshot file with cache naming

* wip: write snapshot cache name

* wip: reorder and encapsulate methods

* wip: move snapshot discovery into snapshot cacher

* wip: apply suggestions from code review

* wip: lint

* refactor: use extension and force kwarg

* wip: rename raw single to single file

* wip: fossilizer

* wip: rename cache to fossil

* wip: add doc string for snapshot fossil
  • Loading branch information
Noah authored Jan 11, 2020
1 parent cb17855 commit 13dd118
Show file tree
Hide file tree
Showing 19 changed files with 428 additions and 416 deletions.
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,32 +55,30 @@ These are the cli options exposed to `pytest` by the plugin.
| `--snapshot-update` | When supplied updates existing snapshots of any run tests, as well as deleting unused and generating new snapshots. |
| `--snapshot-warn-unused` | Syrupy default behaviour is to fail the test session when there any unused snapshots. This instructs the plugin not to fail. |

### Serializers
### Syrupy Extensions

Syrupy comes with a few built-in serializers for you to choose from. You should also feel free to extend the AbstractSnapshotSerializer if your project has a need not captured by one our built-ins.
Syrupy comes with a few built-in preset configurations for you to choose from. You should also feel free to extend the `AbstractSyrupyExtension` if your project has a need not captured by one our built-ins.

- **`AmberSnapshotSerializer`**: This is the default serializer which generates `.ambr` files. Serialization of most data types are supported, however non-sortable types such as frozenset are experimental.
- **`RawSingleSnapshotSerializer`**: Unlike the `AmberSnapshotSerializer`, which groups all tests within a single test file into a singular snapshot file, the Raw Single serializer creates one `.raw` file per test case.
- **`PNGSnapshotSerializer`**: An extension of the Raw Single serializer, this should be used to produce `.png` files.
- **`SVGSnapshotSerializer`**: Another extension of Raw Single. This produces `.svg` files from an svg string.
- **`AmberSnapshotExtension`**: This is the default extension which generates `.ambr` files. Serialization of most data types are supported, however non-sortable types such as frozenset are experimental.
- **`SingleFileSnapshotExtension`**: Unlike the `AmberSnapshotExtension`, which groups all tests within a single test file into a singular snapshot file, and creates one `.raw` file per test case.
- **`PNGSnapshotExtension`**: An extension of single file, this should be used to produce `.png` files.
- **`SVGSnapshotExtension`**: Another extension of single file. This produces `.svg` files from an svg string.

### Advanced Usage, Plugin Support
### Advanced Usage, Extending Syrupy

```python
import pytest

@pytest.fixture
def snapshot_custom(snapshot):
return snapshot.with_class(
serializer_class=CustomSerializerClass,
)
return snapshot.use_extension(CustomExtensionClass)

def test_image(snapshot_custom):
actual = "..."
assert actual == snapshot_custom
```

`CustomSerializerClass` should extend `syrupy.serializers.base.AbstractSnapshotSerializer`.
`CustomExtensionClass` should extend `syrupy.extensions.base.AbstractSyrupyExtension`.

## Uninstalling

Expand Down
4 changes: 2 additions & 2 deletions src/syrupy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import pytest

from .assertion import SnapshotAssertion
from .extensions import DEFAULT_EXTENSION
from .location import TestLocation
from .serializers import DEFAULT_SERIALIZER
from .session import SnapshotSession
from .terminal import (
green,
Expand Down Expand Up @@ -99,7 +99,7 @@ def pytest_sessionfinish(session: Any, exitstatus: int) -> None:
def snapshot(request: Any) -> "SnapshotAssertion":
return SnapshotAssertion(
update_snapshots=request.config.option.update_snapshots,
serializer_class=DEFAULT_SERIALIZER,
extension_class=DEFAULT_EXTENSION,
test_location=TestLocation(request.node),
session=request.session._syrupy,
)
38 changes: 19 additions & 19 deletions src/syrupy/assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@

if TYPE_CHECKING:
from .location import TestLocation
from .serializers.base import AbstractSnapshotSerializer
from .extensions.base import AbstractSyrupyExtension
from .session import SnapshotSession
from .types import SerializableData, SerializedData # noqa: F401


@attr.s
class AssertionResult(object):
snapshot_filepath: str = attr.ib()
snapshot_location: str = attr.ib()
snapshot_name: str = attr.ib()
asserted_data: Optional["SerializedData"] = attr.ib()
recalled_data: Optional["SerializedData"] = attr.ib()
Expand All @@ -40,7 +40,7 @@ def final_data(self) -> Optional["SerializedData"]:
class SnapshotAssertion:
name: str = attr.ib(default="snapshot")
_session: "SnapshotSession" = attr.ib(kw_only=True)
_serializer_class: Type["AbstractSnapshotSerializer"] = attr.ib(kw_only=True)
_extension_class: Type["AbstractSyrupyExtension"] = attr.ib(kw_only=True)
_test_location: "TestLocation" = attr.ib(kw_only=True)
_update_snapshots: bool = attr.ib(kw_only=True)
_executions: int = attr.ib(init=False, default=0, kw_only=True)
Expand All @@ -52,12 +52,12 @@ def __attrs_post_init__(self) -> None:
self._session.register_request(self)

@property
def serializer(self) -> "AbstractSnapshotSerializer":
if not getattr(self, "_serializer", None):
self._serializer: "AbstractSnapshotSerializer" = self._serializer_class(
def extension(self) -> "AbstractSyrupyExtension":
if not getattr(self, "_extension", None):
self._extension: "AbstractSyrupyExtension" = self._extension_class(
test_location=self._test_location
)
return self._serializer
return self._extension

@property
def num_executions(self) -> int:
Expand All @@ -67,13 +67,13 @@ def num_executions(self) -> int:
def executions(self) -> Dict[int, AssertionResult]:
return self._execution_results

def with_class(
self, serializer_class: Optional[Type["AbstractSnapshotSerializer"]] = None,
def use_extension(
self, extension_class: Optional[Type["AbstractSyrupyExtension"]] = None,
) -> "SnapshotAssertion":
return self.__class__(
update_snapshots=self._update_snapshots,
test_location=self._test_location,
serializer_class=serializer_class or self._serializer_class,
extension_class=extension_class or self._extension_class,
session=self._session,
)

Expand All @@ -83,13 +83,13 @@ 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_data
serialized_data = self.serializer.serialize(data)
serialized_data = self.extension.serialize(data)
if snapshot_data is None:
return [gettext("Snapshot does not exist!")]

diff: List[str] = []
if not assertion_result.success:
diff.extend(self.serializer.diff_lines(serialized_data, snapshot_data))
diff.extend(self.extension.diff_lines(serialized_data, snapshot_data))
return diff

def __repr__(self) -> str:
Expand All @@ -106,23 +106,23 @@ def _assert(self, data: "SerializableData") -> bool:
snapshot_data: Optional["SerializedData"] = None
serialized_data: Optional["SerializedData"] = None
try:
snapshot_filepath = self.serializer.get_filepath(self.num_executions)
snapshot_name = self.serializer.get_snapshot_name(self.num_executions)
snapshot_location = self.extension.get_location(index=self.num_executions)
snapshot_name = self.extension.get_snapshot_name(index=self.num_executions)
snapshot_data = self._recall_data(index=self.num_executions)
serialized_data = self.serializer.serialize(data)
serialized_data = self.extension.serialize(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(
data=data, index=self.num_executions
self.extension.write_snapshot(
data=serialized_data, index=self.num_executions
)
assertion_success = True
return assertion_success
finally:
snapshot_created = snapshot_data is None and assertion_success
snapshot_updated = matches is False and assertion_success
self._execution_results[self._executions] = AssertionResult(
snapshot_filepath=snapshot_filepath,
snapshot_location=snapshot_location,
snapshot_name=snapshot_name,
recalled_data=snapshot_data,
asserted_data=serialized_data,
Expand All @@ -134,6 +134,6 @@ def _assert(self, data: "SerializableData") -> bool:

def _recall_data(self, index: int) -> Optional["SerializableData"]:
try:
return self.serializer.read_snapshot(index=index)
return self.extension.read_snapshot(index=index)
except SnapshotDoesNotExist:
return None
4 changes: 2 additions & 2 deletions src/syrupy/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SNAPSHOT_DIRNAME = "__snapshots__"
SNAPSHOT_EMPTY_FILE_KEY = "empty snapshot file"
SNAPSHOT_UNKNOWN_FILE_KEY = "unknown snapshot file"
SNAPSHOT_EMPTY_FOSSIL_KEY = "empty snapshot fossil"
SNAPSHOT_UNKNOWN_FOSSIL_KEY = "unknown snapshot fossil"

EXIT_STATUS_FAIL_UNUSED = 1
64 changes: 36 additions & 28 deletions src/syrupy/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
import attr

from .constants import (
SNAPSHOT_EMPTY_FILE_KEY,
SNAPSHOT_UNKNOWN_FILE_KEY,
SNAPSHOT_EMPTY_FOSSIL_KEY,
SNAPSHOT_UNKNOWN_FOSSIL_KEY,
)


Expand All @@ -26,17 +26,19 @@ class Snapshot(object):

@attr.s(frozen=True)
class SnapshotEmpty(Snapshot):
name: str = attr.ib(default=SNAPSHOT_EMPTY_FILE_KEY, init=False)
name: str = attr.ib(default=SNAPSHOT_EMPTY_FOSSIL_KEY, init=False)


@attr.s(frozen=True)
class SnapshotUnknown(Snapshot):
name: str = attr.ib(default=SNAPSHOT_UNKNOWN_FILE_KEY, init=False)
name: str = attr.ib(default=SNAPSHOT_UNKNOWN_FOSSIL_KEY, init=False)


@attr.s
class SnapshotFile(object):
filepath: str = attr.ib()
class SnapshotFossil(object):
"""A collection of snapshots at a save location"""

location: str = attr.ib()
_snapshots: Dict[str, "Snapshot"] = attr.ib(factory=dict)

@property
Expand All @@ -49,8 +51,8 @@ def get(self, snapshot_name: str) -> Optional["Snapshot"]:
def add(self, snapshot: "Snapshot") -> None:
self._snapshots[snapshot.name] = snapshot

def merge(self, snapshot_file: "SnapshotFile") -> None:
for snapshot in snapshot_file:
def merge(self, snapshot_fossil: "SnapshotFossil") -> None:
for snapshot in snapshot_fossil:
self.add(snapshot)

def remove(self, snapshot_name: str) -> None:
Expand All @@ -68,7 +70,9 @@ def __iter__(self) -> Iterator["Snapshot"]:


@attr.s(frozen=True)
class SnapshotEmptyFile(SnapshotFile):
class SnapshotEmptyFossil(SnapshotFossil):
"""This is a saved fossil that is known to be empty and thus can be removed"""

_snapshots: Dict[str, "Snapshot"] = attr.ib(default=SNAPSHOTS_EMPTY, init=False)

@property
Expand All @@ -77,33 +81,37 @@ def has_snapshots(self) -> bool:


@attr.s(frozen=True)
class SnapshotUnknownFile(SnapshotFile):
class SnapshotUnknownFossil(SnapshotFossil):
"""This is a saved fossil that is unclaimed by any extension currenly in use"""

_snapshots: Dict[str, "Snapshot"] = attr.ib(default=SNAPSHOTS_UNKNOWN, init=False)


@attr.s
class SnapshotFiles(object):
_snapshot_files: Dict[str, "SnapshotFile"] = attr.ib(factory=dict)
class SnapshotFossils(object):
_snapshot_fossils: Dict[str, "SnapshotFossil"] = attr.ib(factory=dict)

def get(self, filepath: str) -> Optional["SnapshotFile"]:
return self._snapshot_files.get(filepath)
def get(self, location: str) -> Optional["SnapshotFossil"]:
return self._snapshot_fossils.get(location)

def add(self, snapshot_file: "SnapshotFile") -> None:
self._snapshot_files[snapshot_file.filepath] = snapshot_file
def add(self, snapshot_fossil: "SnapshotFossil") -> None:
self._snapshot_fossils[snapshot_fossil.location] = snapshot_fossil

def update(self, snapshot_file: "SnapshotFile") -> None:
snapshot_file_to_update = self.get(snapshot_file.filepath)
if snapshot_file_to_update is None:
snapshot_file_to_update = SnapshotFile(filepath=snapshot_file.filepath)
self.add(snapshot_file_to_update)
snapshot_file_to_update.merge(snapshot_file)
def update(self, snapshot_fossil: "SnapshotFossil") -> None:
snapshot_fossil_to_update = self.get(snapshot_fossil.location)
if snapshot_fossil_to_update is None:
snapshot_fossil_to_update = SnapshotFossil(
location=snapshot_fossil.location
)
self.add(snapshot_fossil_to_update)
snapshot_fossil_to_update.merge(snapshot_fossil)

def merge(self, snapshot_files: "SnapshotFiles") -> None:
for snapshot_file in snapshot_files:
self.update(snapshot_file)
def merge(self, snapshot_fossils: "SnapshotFossils") -> None:
for snapshot_fossil in snapshot_fossils:
self.update(snapshot_fossil)

def __iter__(self) -> Iterator["SnapshotFile"]:
return iter(self._snapshot_files.values())
def __iter__(self) -> Iterator["SnapshotFossil"]:
return iter(self._snapshot_fossils.values())

def __contains__(self, key: str) -> bool:
return key in self._snapshot_files
return key in self._snapshot_fossils
4 changes: 4 additions & 0 deletions src/syrupy/extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .amber import AmberSnapshotExtension


DEFAULT_EXTENSION = AmberSnapshotExtension
Loading

0 comments on commit 13dd118

Please sign in to comment.