From 8dddebf6c217abe64b81137ad78561e0f7e8ab61 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi Date: Sat, 5 Jun 2021 17:03:23 -0400 Subject: [PATCH] feat(types): explicit property matcher and filter types kwargs (#515) * feat(types): make property matcher and filter types explicit about keyword arguments * test: snapshot markers and assertions * refactor(amber): explicit about serializer method accepted types --- conftest.py | 2 +- src/syrupy/assertion.py | 6 +-- src/syrupy/extensions/amber/__init__.py | 8 ---- src/syrupy/extensions/amber/serializer.py | 46 +++++++++++++------ src/syrupy/filters.py | 4 +- src/syrupy/matchers.py | 2 +- src/syrupy/types.py | 24 ++++++++-- .../__snapshots__/test_amber_serializer.ambr | 18 +++++++- .../extensions/amber/test_amber_serializer.py | 16 +++++++ 9 files changed, 91 insertions(+), 35 deletions(-) diff --git a/conftest.py b/conftest.py index af3510bd..d55ac41e 100644 --- a/conftest.py +++ b/conftest.py @@ -4,7 +4,7 @@ from syrupy.utils import env_context -typing.TYPE_CHECKING = True +typing.TYPE_CHECKING = False pytest_plugins = "pytester" diff --git a/src/syrupy/assertion.py b/src/syrupy/assertion.py index 9da99968..49740850 100644 --- a/src/syrupy/assertion.py +++ b/src/syrupy/assertion.py @@ -161,10 +161,8 @@ def __call__( self.__with_prop("_matcher", matcher) return self - def __repr__(self) -> str: - attrs_to_repr = ["name", "num_executions"] - attrs_repr = ", ".join(f"{a}={repr(getattr(self, a))}" for a in attrs_to_repr) - return f"SnapshotAssertion({attrs_repr})" + def __dir__(self) -> List[str]: + return ["name", "num_executions"] def __eq__(self, other: "SerializableData") -> bool: return self._assert(other) diff --git a/src/syrupy/extensions/amber/__init__.py b/src/syrupy/extensions/amber/__init__.py index 72c6eb75..ffbe42d2 100644 --- a/src/syrupy/extensions/amber/__init__.py +++ b/src/syrupy/extensions/amber/__init__.py @@ -18,14 +18,6 @@ class AmberSnapshotExtension(AbstractSyrupyExtension): """ An amber snapshot file stores data in the following format: - - ``` - # name: test_name_1 - data - --- - # name: test_name_2 - data - ``` """ def serialize(self, data: "SerializableData", **kwargs: Any) -> str: diff --git a/src/syrupy/extensions/amber/serializer.py b/src/syrupy/extensions/amber/serializer.py index 9b0203d0..1a1e5ac9 100644 --- a/src/syrupy/extensions/amber/serializer.py +++ b/src/syrupy/extensions/amber/serializer.py @@ -4,10 +4,13 @@ TYPE_CHECKING, Any, Callable, + Dict, Iterable, + NamedTuple, Optional, Set, Tuple, + Union, ) from syrupy.constants import SYMBOL_ELLIPSIS @@ -25,8 +28,10 @@ SerializableData, ) - PropertyValueFilter = Callable[[SerializableData], bool] - PropertyValueGetter = Callable[..., SerializableData] + PropertyValueFilter = Callable[["PropertyName"], bool] + PropertyValueGetter = Callable[ + ["SerializableData", "PropertyName"], "SerializableData" + ] IterableEntries = Tuple[ Iterable["PropertyName"], "PropertyValueGetter", @@ -42,11 +47,20 @@ def __repr__(self) -> str: return self._repr +def attr_getter(o: "SerializableData", p: "PropertyName") -> "SerializableData": + return getattr(o, str(p)) + + +def item_getter(o: "SerializableData", p: "PropertyName") -> "SerializableData": + return o[p] + + class DataSerializer: _indent: str = " " _max_depth: int = 99 + _marker_comment: str = "# " _marker_divider: str = "---" - _marker_name: str = "# name:" + _marker_name: str = f"{_marker_comment}name:" _marker_crn: str = "\r\n" @classmethod @@ -157,14 +171,12 @@ def _serialize( @classmethod def serialize_number( - cls, data: "SerializableData", *, depth: int = 0, **kwargs: Any + cls, data: Union[int, float], *, depth: int = 0, **kwargs: Any ) -> str: return cls.__serialize_plain(data=data, depth=depth) @classmethod - def serialize_string( - cls, data: "SerializableData", *, depth: int = 0, **kwargs: Any - ) -> str: + def serialize_string(cls, data: str, *, depth: int = 0, **kwargs: Any) -> str: if all(c not in data for c in cls._marker_crn): return cls.__serialize_plain(data=data, depth=depth) @@ -182,7 +194,9 @@ def serialize_string( ) @classmethod - def serialize_iterable(cls, data: "SerializableData", **kwargs: Any) -> str: + def serialize_iterable( + cls, data: Iterable["SerializableData"], **kwargs: Any + ) -> str: open_paren, close_paren = next( parens for iter_type, parens in { @@ -195,14 +209,14 @@ def serialize_iterable(cls, data: "SerializableData", **kwargs: Any) -> str: values = list(data) return cls.__serialize_iterable( data=data, - resolve_entries=(range(len(values)), lambda _, p: values[p], None), + resolve_entries=(range(len(values)), item_getter, None), open_tag=open_paren, close_tag=close_paren, **kwargs, ) @classmethod - def serialize_set(cls, data: "SerializableData", **kwargs: Any) -> str: + def serialize_set(cls, data: Set["SerializableData"], **kwargs: Any) -> str: return cls.__serialize_iterable( data=data, resolve_entries=(cls.sort(data), lambda _, p: p, None), @@ -212,10 +226,10 @@ def serialize_set(cls, data: "SerializableData", **kwargs: Any) -> str: ) @classmethod - def serialize_namedtuple(cls, data: "SerializableData", **kwargs: Any) -> str: + def serialize_namedtuple(cls, data: NamedTuple, **kwargs: Any) -> str: return cls.__serialize_iterable( data=data, - resolve_entries=(cls.sort(data._fields), getattr, None), + resolve_entries=(cls.sort(data._fields), attr_getter, None), open_tag="(", close_tag=")", separator="=", @@ -223,10 +237,12 @@ def serialize_namedtuple(cls, data: "SerializableData", **kwargs: Any) -> str: ) @classmethod - def serialize_dict(cls, data: "SerializableData", **kwargs: Any) -> str: + def serialize_dict( + cls, data: Dict["PropertyName", "SerializableData"], **kwargs: Any + ) -> str: return cls.__serialize_iterable( data=data, - resolve_entries=(cls.sort(data.keys()), lambda d, p: d[p], None), + resolve_entries=(cls.sort(data.keys()), item_getter, None), open_tag="{", close_tag="}", separator=": ", @@ -243,7 +259,7 @@ def serialize_unknown(cls, data: Any, *, depth: int = 0, **kwargs: Any) -> str: data=data, resolve_entries=( (name for name in cls.sort(dir(data)) if not name.startswith("_")), - getattr, + attr_getter, lambda v: not callable(v), ), depth=depth, diff --git a/src/syrupy/filters.py b/src/syrupy/filters.py index 5c68401f..86470b1c 100644 --- a/src/syrupy/filters.py +++ b/src/syrupy/filters.py @@ -13,7 +13,7 @@ def paths(path_string: str, *path_strings: str) -> "PropertyFilter": Factory to create a filter using list of paths """ - def path_filter(prop: "PropertyName", path: "PropertyPath") -> bool: + def path_filter(*, prop: "PropertyName", path: "PropertyPath") -> bool: path_str = ".".join(str(p) for p, _ in (*path, (prop, None))) return any(path_str == p for p in (path_string, *path_strings)) @@ -25,7 +25,7 @@ def props(prop_name: str, *prop_names: str) -> "PropertyFilter": Factory to create filter using list of props """ - def prop_filter(prop: "PropertyName", path: "PropertyPath") -> bool: + def prop_filter(*, prop: "PropertyName", path: "PropertyPath") -> bool: return any(str(prop) == p for p in (prop_name, *prop_names)) return prop_filter diff --git a/src/syrupy/matchers.py b/src/syrupy/matchers.py index 007597d6..c4aa95fe 100644 --- a/src/syrupy/matchers.py +++ b/src/syrupy/matchers.py @@ -37,7 +37,7 @@ def path_type( raise PathTypeError(gettext("Both mapping and types argument cannot be empty")) def path_type_matcher( - data: "SerializableData", path: "PropertyPath" + *, data: "SerializableData", path: "PropertyPath" ) -> Optional["SerializableData"]: path_str = ".".join(str(p) for p, _ in path) if mapping: diff --git a/src/syrupy/types.py b/src/syrupy/types.py index d795260b..6ff733dc 100644 --- a/src/syrupy/types.py +++ b/src/syrupy/types.py @@ -1,6 +1,5 @@ from typing import ( Any, - Callable, Hashable, Optional, Tuple, @@ -14,5 +13,24 @@ PropertyValueType = Type[SerializableData] PropertyPathEntry = Tuple[PropertyName, PropertyValueType] PropertyPath = Tuple[PropertyPathEntry, ...] -PropertyMatcher = Callable[..., Optional[SerializableData]] -PropertyFilter = Callable[..., bool] +try: + # Python minimum version 3.8 + # https://docs.python.org/3/library/typing.html#typing.Protocol + from typing import Protocol + + class PropertyMatcher(Protocol): + def __call__( + self, + *, + data: "SerializableData", + path: "PropertyPath", + ) -> Optional["SerializableData"]: + ... + + class PropertyFilter(Protocol): + def __call__(self, *, prop: "PropertyName", path: "PropertyPath") -> bool: + ... + + +except ImportError: + pass diff --git a/tests/syrupy/extensions/amber/__snapshots__/test_amber_serializer.ambr b/tests/syrupy/extensions/amber/__snapshots__/test_amber_serializer.ambr index 9dd204f2..392d7e53 100644 --- a/tests/syrupy/extensions/amber/__snapshots__/test_amber_serializer.ambr +++ b/tests/syrupy/extensions/amber/__snapshots__/test_amber_serializer.ambr @@ -136,6 +136,11 @@ --- # name: test_dict[actual2] { + ' + multi + line + key + ': 'Some morre text.', 'a': 'Some ttext.', 1: True, ( @@ -309,7 +314,10 @@ 'value.with.dot' --- # name: test_reflection - SnapshotAssertion(name='snapshot', num_executions=0) + { + name='snapshot', + num_executions=0, + } --- # name: test_set[actual0] { @@ -355,6 +363,14 @@ { } --- +# name: test_snapshot_markers + ' + # + # + --- + # name: + ' +--- # name: test_string[0] '' --- diff --git a/tests/syrupy/extensions/amber/test_amber_serializer.py b/tests/syrupy/extensions/amber/test_amber_serializer.py index df464c74..7066e86b 100644 --- a/tests/syrupy/extensions/amber/test_amber_serializer.py +++ b/tests/syrupy/extensions/amber/test_amber_serializer.py @@ -2,6 +2,8 @@ import pytest +from syrupy.extensions.amber.serializer import DataSerializer + def test_non_snapshots(snapshot): with pytest.raises(AssertionError): @@ -17,6 +19,19 @@ def test_empty_snapshot(snapshot): assert snapshot == "" +def test_snapshot_markers(snapshot): + """ + Test snapshot markers do not break serialization when in snapshot data + """ + marker_strings = ( + DataSerializer._marker_comment, + f"{DataSerializer._indent}{DataSerializer._marker_comment}", + DataSerializer._marker_divider, + DataSerializer._marker_name, + ) + assert snapshot == "\n".join(marker_strings) + + def test_newline_control_characters(snapshot): assert snapshot == "line 1\nline 2" assert snapshot == "line 1\r\nline 2" @@ -100,6 +115,7 @@ def test_set(snapshot, actual): { 1: True, "a": "Some ttext.", + "multi\nline\nkey": "Some morre text.", frozenset({"1", "2"}): ["1", 2], ExampleTuple(a=1, b=2, c=3, d=4): {"e": False}, },