From 2db9b28bc4af4549302a92f840ddd762a3758d35 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi-Ugbe Date: Sat, 28 Dec 2019 20:04:02 -0500 Subject: [PATCH 1/7] test: add cycle failure cases --- tests/test_amber_serializer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_amber_serializer.py b/tests/test_amber_serializer.py index 9d09740f..b51f60e6 100644 --- a/tests/test_amber_serializer.py +++ b/tests/test_amber_serializer.py @@ -72,6 +72,18 @@ def test_list(snapshot): assert snapshot == [1, 2, "string", {"key": "value"}] +list_cycle = [1, 2, 3] +list_cycle.append(list_cycle) + +dict_cycle = {"a": 1, "b": 2, "c": 3} +dict_cycle.update(d=dict_cycle) + + +@pytest.mark.parametrize("cyclic", [list_cycle, dict_cycle]) +def test_cycle(cyclic, snapshot): + assert cyclic == snapshot + + class TestClass: def test_name(self, snapshot): assert snapshot == "this is in a test class" From 8438b9feb002278cfd6dd7e661d9500a1f115561 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi-Ugbe Date: Sat, 28 Dec 2019 20:29:06 -0500 Subject: [PATCH 2/7] feat: catch serialization cycle --- src/syrupy/serializers/amber.py | 77 ++++++++++++++----- .../__snapshots__/test_amber_serializer.ambr | 16 ++++ 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/syrupy/serializers/amber.py b/src/syrupy/serializers/amber.py index f99f7a10..9c15e2df 100644 --- a/src/syrupy/serializers/amber.py +++ b/src/syrupy/serializers/amber.py @@ -6,6 +6,7 @@ Any, Dict, Iterable, + List, Set, ) @@ -17,6 +18,8 @@ class DataSerializer: + _max_depth: int = 99 + _max_depth_value: str = "..." indent: str = " " name_marker: str = "# name:" divider: str = "---" @@ -89,7 +92,9 @@ def object_type(cls, data: "SerializableData") -> str: return f"" @classmethod - def serialize_string(cls, data: "SerializableData", indent: int = 0) -> str: + def serialize_string( + cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + ) -> str: if "\n" in data: return ( cls.with_indent("'\n", indent) @@ -99,27 +104,40 @@ def serialize_string(cls, data: "SerializableData", indent: int = 0) -> str: return cls.with_indent(repr(data), indent) @classmethod - def serialize_number(cls, data: "SerializableData", indent: int = 0) -> str: + def serialize_number( + cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + ) -> str: return cls.with_indent(repr(data), indent) @classmethod - def serialize_set(cls, data: "SerializableData", indent: int = 0) -> str: + def serialize_set( + cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + ) -> str: return ( cls.with_indent(f"{cls.object_type(data)} {{\n", indent) - + "".join([f"{cls.serialize(d, indent + 1)},\n" for d in cls.sort(data)]) + + "".join( + [ + f"{cls.serialize(d, indent=indent + 1, visited=visited)},\n" + for d in cls.sort(data) + ] + ) + cls.with_indent("}", indent) ) @classmethod - def serialize_dict(cls, data: "SerializableData", indent: int = 0) -> str: + def serialize_dict( + cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + ) -> str: return ( cls.with_indent(f"{cls.object_type(data)} {{\n", indent) + "".join( [ ( - cls.serialize(key, indent + 1) + cls.serialize(key, indent=indent + 1) + ": " - + cls.serialize(data[key], indent + 1).lstrip(cls.indent) + + cls.serialize( + data[key], indent=indent + 1, visited=visited + ).lstrip(cls.indent) + ",\n" ) for key in cls.sort(data.keys()) @@ -129,7 +147,9 @@ def serialize_dict(cls, data: "SerializableData", indent: int = 0) -> str: ) @classmethod - def serialize_iterable(cls, data: "SerializableData", indent: int = 0) -> str: + def serialize_iterable( + cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + ) -> str: open_paren, close_paren = next( paren[1] for paren in {list: "[]", tuple: "()", GeneratorType: "()"}.items() @@ -137,23 +157,42 @@ def serialize_iterable(cls, data: "SerializableData", indent: int = 0) -> str: ) return ( cls.with_indent(f"{cls.object_type(data)} {open_paren}\n", indent) - + "".join([f"{cls.serialize(d, indent + 1)},\n" for d in data]) + + "".join( + [ + f"{cls.serialize(d, indent=indent + 1, visited=visited)},\n" + for d in data + ] + ) + cls.with_indent(close_paren, indent) ) @classmethod - def serialize(cls, data: "SerializableData", indent: int = 0) -> str: + def serialize_unknown( + cls, data: Any, *, indent: int = 0, visited: List[Any] = [] + ) -> str: + return cls.with_indent(repr(data), indent) + + @classmethod + def serialize( + cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + ) -> str: + if indent > cls._max_depth or data in visited: + data = cls._max_depth_value + + serialize_kwargs = dict(data=data, indent=indent, visited=[*visited, data]) if isinstance(data, str): - return cls.serialize_string(data, indent) + serialize_method = cls.serialize_string elif isinstance(data, (int, float)): - return cls.serialize_number(data, indent) + serialize_method = cls.serialize_number elif isinstance(data, (set, frozenset)): - return cls.serialize_set(data, indent) + serialize_method = cls.serialize_set elif isinstance(data, dict): - return cls.serialize_dict(data, indent) + serialize_method = cls.serialize_dict elif isinstance(data, (list, tuple, GeneratorType)): - return cls.serialize_iterable(data, indent) - return cls.with_indent(repr(data), indent) + serialize_method = cls.serialize_iterable + else: + serialize_method = cls.serialize_unknown + return serialize_method(**serialize_kwargs) class AmberSnapshotSerializer(AbstractSnapshotSerializer): @@ -162,12 +201,10 @@ class AmberSnapshotSerializer(AbstractSnapshotSerializer): ``` # name: test_name_1 - - data + data --- # name: test_name_2 - - data + data ``` """ diff --git a/tests/__snapshots__/test_amber_serializer.ambr b/tests/__snapshots__/test_amber_serializer.ambr index 8718fe23..df6b72ba 100644 --- a/tests/__snapshots__/test_amber_serializer.ambr +++ b/tests/__snapshots__/test_amber_serializer.ambr @@ -1,6 +1,22 @@ # name: TestClass.test_name 'this is in a test class' --- +# name: test_cycle[cyclic0] + [ + 1, + 2, + 3, + '...', + ] +--- +# name: test_cycle[cyclic1] + { + 'a': 1, + 'b': 2, + 'c': 3, + 'd': '...', + } +--- # name: test_dict[actual0] { 'a': { From aa95d4b1924ad9113e5f18c5161fa4ce522dbd3d Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi-Ugbe Date: Sat, 28 Dec 2019 23:27:52 -0500 Subject: [PATCH 3/7] refactor: rename amber data serializer internal vars --- src/syrupy/serializers/amber.py | 80 ++++++++++++++++----------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src/syrupy/serializers/amber.py b/src/syrupy/serializers/amber.py index 9c15e2df..9999f51c 100644 --- a/src/syrupy/serializers/amber.py +++ b/src/syrupy/serializers/amber.py @@ -18,11 +18,11 @@ class DataSerializer: + _indent: str = " " _max_depth: int = 99 - _max_depth_value: str = "..." - indent: str = " " - name_marker: str = "# name:" - divider: str = "---" + _marker_depth_max: str = "..." + _marker_divider: str = "---" + _marker_name: str = "# name:" @classmethod def write_file(cls, filepath: str, snapshots: Dict[str, Dict[str, Any]]) -> None: @@ -34,10 +34,10 @@ def write_file(cls, filepath: str, snapshots: Dict[str, Dict[str, Any]]) -> None snapshot = snapshots[key] snapshot_data = snapshot.get("data") if snapshot_data is not None: - f.write(f"{cls.name_marker} {key}\n") + f.write(f"{cls._marker_name} {key}\n") for data_line in snapshot_data.split("\n"): - f.write(f"{cls.indent}{data_line}\n") - f.write(f"{cls.divider}\n") + f.write(f"{cls._indent}{data_line}\n") + f.write(f"{cls._marker_divider}\n") @classmethod def read_file(cls, filepath: str) -> Dict[str, Dict[str, Any]]: @@ -46,22 +46,22 @@ def read_file(cls, filepath: str) -> Dict[str, Dict[str, Any]]: of snapshot name to raw data. This does not attempt any deserialization of the snapshot data. """ - name_marker_len = len(cls.name_marker) - indent_len = len(cls.indent) + name_marker_len = len(cls._marker_name) + indent_len = len(cls._indent) snapshots = {} test_name = None snapshot_data = "" try: with open(filepath, "r") as f: for line in f: - if line.startswith(cls.name_marker): + if line.startswith(cls._marker_name): test_name = line[name_marker_len:-1].strip(" \n") snapshot_data = "" continue elif test_name is not None: - if line.startswith(cls.indent): + if line.startswith(cls._indent): snapshot_data += line[indent_len:] - elif line.startswith(cls.divider) and snapshot_data: + elif line.startswith(cls._marker_divider) and snapshot_data: snapshots[test_name] = {"data": snapshot_data[:-1]} except FileNotFoundError: pass @@ -84,8 +84,8 @@ def _sort_key(value: Any) -> Any: return sorted(iterable, key=_sort_key) @classmethod - def with_indent(cls, string: str, indent: int) -> str: - return f"{cls.indent * indent}{string}" + def with_indent(cls, string: str, depth: int) -> str: + return f"{cls._indent * depth}{string}" @classmethod def object_type(cls, data: "SerializableData") -> str: @@ -93,62 +93,62 @@ def object_type(cls, data: "SerializableData") -> str: @classmethod def serialize_string( - cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] ) -> str: if "\n" in data: return ( - cls.with_indent("'\n", indent) + cls.with_indent("'\n", depth) + str(data) - + cls.with_indent("\n'", indent) + + cls.with_indent("\n'", depth) ) - return cls.with_indent(repr(data), indent) + return cls.with_indent(repr(data), depth) @classmethod def serialize_number( - cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] ) -> str: - return cls.with_indent(repr(data), indent) + return cls.with_indent(repr(data), depth) @classmethod def serialize_set( - cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] ) -> str: return ( - cls.with_indent(f"{cls.object_type(data)} {{\n", indent) + cls.with_indent(f"{cls.object_type(data)} {{\n", depth) + "".join( [ - f"{cls.serialize(d, indent=indent + 1, visited=visited)},\n" + f"{cls.serialize(d, depth=depth + 1, visited=visited)},\n" for d in cls.sort(data) ] ) - + cls.with_indent("}", indent) + + cls.with_indent("}", depth) ) @classmethod def serialize_dict( - cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] ) -> str: return ( - cls.with_indent(f"{cls.object_type(data)} {{\n", indent) + cls.with_indent(f"{cls.object_type(data)} {{\n", depth) + "".join( [ ( - cls.serialize(key, indent=indent + 1) + cls.serialize(key, depth=depth + 1) + ": " + cls.serialize( - data[key], indent=indent + 1, visited=visited - ).lstrip(cls.indent) + data[key], depth=depth + 1, visited=visited + ).lstrip(cls._indent) + ",\n" ) for key in cls.sort(data.keys()) ] ) - + cls.with_indent("}", indent) + + cls.with_indent("}", depth) ) @classmethod def serialize_iterable( - cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] ) -> str: open_paren, close_paren = next( paren[1] @@ -156,30 +156,30 @@ def serialize_iterable( if isinstance(data, paren[0]) ) return ( - cls.with_indent(f"{cls.object_type(data)} {open_paren}\n", indent) + cls.with_indent(f"{cls.object_type(data)} {open_paren}\n", depth) + "".join( [ - f"{cls.serialize(d, indent=indent + 1, visited=visited)},\n" + f"{cls.serialize(d, depth=depth + 1, visited=visited)},\n" for d in data ] ) - + cls.with_indent(close_paren, indent) + + cls.with_indent(close_paren, depth) ) @classmethod def serialize_unknown( - cls, data: Any, *, indent: int = 0, visited: List[Any] = [] + cls, data: Any, *, depth: int = 0, visited: List[Any] = [] ) -> str: - return cls.with_indent(repr(data), indent) + return cls.with_indent(repr(data), depth) @classmethod def serialize( - cls, data: "SerializableData", *, indent: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] ) -> str: - if indent > cls._max_depth or data in visited: - data = cls._max_depth_value + if depth > cls._max_depth or data in visited: + data = cls._marker_depth_max - serialize_kwargs = dict(data=data, indent=indent, visited=[*visited, data]) + serialize_kwargs = dict(data=data, depth=depth, visited=[*visited, data]) if isinstance(data, str): serialize_method = cls.serialize_string elif isinstance(data, (int, float)): From db614e6691220785623ce982278c8ac3994e0ec9 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi-Ugbe Date: Sat, 28 Dec 2019 23:33:18 -0500 Subject: [PATCH 4/7] refactor: use object id to identify visited --- src/syrupy/serializers/amber.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/syrupy/serializers/amber.py b/src/syrupy/serializers/amber.py index 9999f51c..ef39e4c3 100644 --- a/src/syrupy/serializers/amber.py +++ b/src/syrupy/serializers/amber.py @@ -6,7 +6,6 @@ Any, Dict, Iterable, - List, Set, ) @@ -93,7 +92,7 @@ def object_type(cls, data: "SerializableData") -> str: @classmethod def serialize_string( - cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: Set[Any] = set() ) -> str: if "\n" in data: return ( @@ -105,13 +104,13 @@ def serialize_string( @classmethod def serialize_number( - cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: Set[Any] = set() ) -> str: return cls.with_indent(repr(data), depth) @classmethod def serialize_set( - cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: Set[Any] = set() ) -> str: return ( cls.with_indent(f"{cls.object_type(data)} {{\n", depth) @@ -126,7 +125,7 @@ def serialize_set( @classmethod def serialize_dict( - cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: Set[Any] = set() ) -> str: return ( cls.with_indent(f"{cls.object_type(data)} {{\n", depth) @@ -148,7 +147,7 @@ def serialize_dict( @classmethod def serialize_iterable( - cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: Set[Any] = set() ) -> str: open_paren, close_paren = next( paren[1] @@ -168,18 +167,19 @@ def serialize_iterable( @classmethod def serialize_unknown( - cls, data: Any, *, depth: int = 0, visited: List[Any] = [] + cls, data: Any, *, depth: int = 0, visited: Set[Any] = set() ) -> str: return cls.with_indent(repr(data), depth) @classmethod def serialize( - cls, data: "SerializableData", *, depth: int = 0, visited: List[Any] = [] + cls, data: "SerializableData", *, depth: int = 0, visited: Set[Any] = set() ) -> str: - if depth > cls._max_depth or data in visited: + data_id = id(data) + if depth > cls._max_depth or data_id in visited: data = cls._marker_depth_max - serialize_kwargs = dict(data=data, depth=depth, visited=[*visited, data]) + serialize_kwargs = dict(data=data, depth=depth, visited={*visited, data_id}) if isinstance(data, str): serialize_method = cls.serialize_string elif isinstance(data, (int, float)): @@ -225,9 +225,7 @@ def _write_snapshot_to_file( self, snapshot_file: str, snapshot_name: str, data: "SerializableData" ) -> None: snapshots = DataSerializer.read_file(snapshot_file) - snapshots[snapshot_name] = { - "data": self.serialize(data), - } + snapshots[snapshot_name] = {"data": self.serialize(data)} DataSerializer.write_file(snapshot_file, snapshots) def delete_snapshots_from_file( From ec553a8fc7484bf39a5b29fe8317cbd035e05020 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi-Ugbe Date: Sun, 29 Dec 2019 00:03:33 -0500 Subject: [PATCH 5/7] refactor: use custom class for cleaner max depth repr --- src/syrupy/serializers/amber.py | 7 +++++-- tests/__snapshots__/test_amber_serializer.ambr | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/syrupy/serializers/amber.py b/src/syrupy/serializers/amber.py index ef39e4c3..904be23d 100644 --- a/src/syrupy/serializers/amber.py +++ b/src/syrupy/serializers/amber.py @@ -19,10 +19,13 @@ class DataSerializer: _indent: str = " " _max_depth: int = 99 - _marker_depth_max: str = "..." _marker_divider: str = "---" _marker_name: str = "# name:" + class MarkerDepthMax: + def __repr__(self) -> str: + return "..." + @classmethod def write_file(cls, filepath: str, snapshots: Dict[str, Dict[str, Any]]) -> None: """ @@ -177,7 +180,7 @@ def serialize( ) -> str: data_id = id(data) if depth > cls._max_depth or data_id in visited: - data = cls._marker_depth_max + data = cls.MarkerDepthMax() serialize_kwargs = dict(data=data, depth=depth, visited={*visited, data_id}) if isinstance(data, str): diff --git a/tests/__snapshots__/test_amber_serializer.ambr b/tests/__snapshots__/test_amber_serializer.ambr index df6b72ba..b194fd48 100644 --- a/tests/__snapshots__/test_amber_serializer.ambr +++ b/tests/__snapshots__/test_amber_serializer.ambr @@ -6,7 +6,7 @@ 1, 2, 3, - '...', + ..., ] --- # name: test_cycle[cyclic1] @@ -14,7 +14,7 @@ 'a': 1, 'b': 2, 'c': 3, - 'd': '...', + 'd': ..., } --- # name: test_dict[actual0] From a90679b46b6bba3d7b27d81e018e2f5737099dc1 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi-Ugbe Date: Sun, 29 Dec 2019 00:23:59 -0500 Subject: [PATCH 6/7] refactor: remove unneeded list --- src/syrupy/serializers/amber.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/syrupy/serializers/amber.py b/src/syrupy/serializers/amber.py index 904be23d..139f7328 100644 --- a/src/syrupy/serializers/amber.py +++ b/src/syrupy/serializers/amber.py @@ -118,10 +118,8 @@ def serialize_set( return ( cls.with_indent(f"{cls.object_type(data)} {{\n", depth) + "".join( - [ - f"{cls.serialize(d, depth=depth + 1, visited=visited)},\n" - for d in cls.sort(data) - ] + f"{cls.serialize(d, depth=depth + 1, visited=visited)},\n" + for d in cls.sort(data) ) + cls.with_indent("}", depth) ) @@ -130,20 +128,18 @@ def serialize_set( def serialize_dict( cls, data: "SerializableData", *, depth: int = 0, visited: Set[Any] = set() ) -> str: + kwargs = dict(depth=depth + 1, visited=visited) return ( cls.with_indent(f"{cls.object_type(data)} {{\n", depth) + "".join( - [ + f"{serialized_key}: {serialized_value.lstrip(cls._indent)},\n" + for serialized_key, serialized_value in ( ( - cls.serialize(key, depth=depth + 1) - + ": " - + cls.serialize( - data[key], depth=depth + 1, visited=visited - ).lstrip(cls._indent) - + ",\n" + cls.serialize(**dict(data=key, **kwargs)), + cls.serialize(**dict(data=data[key], **kwargs)), ) for key in cls.sort(data.keys()) - ] + ) ) + cls.with_indent("}", depth) ) @@ -160,10 +156,7 @@ def serialize_iterable( return ( cls.with_indent(f"{cls.object_type(data)} {open_paren}\n", depth) + "".join( - [ - f"{cls.serialize(d, depth=depth + 1, visited=visited)},\n" - for d in data - ] + f"{cls.serialize(d, depth=depth + 1, visited=visited)},\n" for d in data ) + cls.with_indent(close_paren, depth) ) From a3e28c6b6d98172a6b4cf92de7e92bb6cd8cd792 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi-Ugbe Date: Sun, 29 Dec 2019 00:28:38 -0500 Subject: [PATCH 7/7] refactor: undo unneeded change --- src/syrupy/serializers/amber.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/syrupy/serializers/amber.py b/src/syrupy/serializers/amber.py index 139f7328..d0cfe27d 100644 --- a/src/syrupy/serializers/amber.py +++ b/src/syrupy/serializers/amber.py @@ -176,6 +176,7 @@ def serialize( data = cls.MarkerDepthMax() serialize_kwargs = dict(data=data, depth=depth, visited={*visited, data_id}) + serialize_method = cls.serialize_unknown if isinstance(data, str): serialize_method = cls.serialize_string elif isinstance(data, (int, float)): @@ -186,8 +187,6 @@ def serialize( serialize_method = cls.serialize_dict elif isinstance(data, (list, tuple, GeneratorType)): serialize_method = cls.serialize_iterable - else: - serialize_method = cls.serialize_unknown return serialize_method(**serialize_kwargs) @@ -221,7 +220,9 @@ def _write_snapshot_to_file( self, snapshot_file: str, snapshot_name: str, data: "SerializableData" ) -> None: snapshots = DataSerializer.read_file(snapshot_file) - snapshots[snapshot_name] = {"data": self.serialize(data)} + snapshots[snapshot_name] = { + "data": self.serialize(data), + } DataSerializer.write_file(snapshot_file, snapshots) def delete_snapshots_from_file(