localstack-snapshot 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/PKG-INFO +3 -4
  2. localstack_snapshot-0.2.0/localstack_snapshot/__init__.py +1 -0
  3. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/snapshots/prototype.py +22 -6
  4. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/snapshots/transformer.py +33 -0
  5. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/snapshots/transformer_utility.py +14 -0
  6. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot.egg-info/PKG-INFO +3 -4
  7. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot.egg-info/requires.txt +1 -2
  8. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/pyproject.toml +2 -3
  9. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/tests/test_snapshots.py +50 -0
  10. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/tests/test_transformer.py +25 -0
  11. localstack-snapshot-0.1.0/localstack_snapshot/__init__.py +0 -1
  12. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/LICENSE +0 -0
  13. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/README.md +0 -0
  14. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/pytest/__init__.py +0 -0
  15. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/pytest/snapshot.py +0 -0
  16. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/snapshots/__init__.py +0 -0
  17. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/snapshots/report.py +0 -0
  18. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/util/__init__.py +0 -0
  19. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot/util/encoding.py +0 -0
  20. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot.egg-info/SOURCES.txt +0 -0
  21. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot.egg-info/dependency_links.txt +0 -0
  22. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/localstack_snapshot.egg-info/top_level.txt +0 -0
  23. {localstack-snapshot-0.1.0 → localstack_snapshot-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: localstack-snapshot
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -215,9 +215,8 @@ Classifier: Programming Language :: Python :: 3.12
215
215
  Requires-Python: >=3.10
216
216
  Description-Content-Type: text/markdown
217
217
  License-File: LICENSE
218
- Requires-Dist: jsonpath-ng==1.5.3
218
+ Requires-Dist: jsonpath-ng>1.6
219
219
  Requires-Dist: deepdiff
220
- Requires-Dist: botocore
221
220
  Provides-Extra: dev
222
221
  Requires-Dist: black==23.10.0; extra == "dev"
223
222
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -1,3 +1,4 @@
1
+ import io
1
2
  import json
2
3
  import logging
3
4
  import os
@@ -7,7 +8,6 @@ from pathlib import Path
7
8
  from re import Pattern
8
9
  from typing import Dict, List, Optional
9
10
 
10
- from botocore.response import StreamingBody
11
11
  from deepdiff import DeepDiff
12
12
  from jsonpath_ng import DatumInContext
13
13
  from jsonpath_ng.ext import parse
@@ -164,6 +164,23 @@ class SnapshotSession:
164
164
  def _update(self, key: str, obj_state: dict) -> None:
165
165
  self.observed_state[key] = obj_state
166
166
 
167
+ def match_object(self, key: str, obj: object) -> None:
168
+ def _convert_object_to_dict(obj_):
169
+ if isinstance(obj_, dict):
170
+ for key in list(obj_.keys()):
171
+ if key.startswith("_"):
172
+ del obj_[key]
173
+ else:
174
+ obj_[key] = _convert_object_to_dict(obj_[key])
175
+ elif isinstance(obj_, list):
176
+ for idx, val in enumerate(obj_):
177
+ obj_[idx] = _convert_object_to_dict(val)
178
+ elif hasattr(obj_, "__dict__"):
179
+ return _convert_object_to_dict(obj_.__dict__)
180
+ return obj_
181
+
182
+ return self.match(key, _convert_object_to_dict(obj))
183
+
167
184
  def match(self, key: str, obj: dict) -> None:
168
185
  if key in self.called_keys:
169
186
  raise Exception(
@@ -243,11 +260,10 @@ class SnapshotSession:
243
260
  def _transform_dict_to_parseable_values(self, original):
244
261
  """recursively goes through dict and tries to resolve values to strings (& parse them as json if possible)"""
245
262
  for k, v in original.items():
246
- if isinstance(v, StreamingBody):
263
+ if isinstance(v, io.IOBase):
247
264
  # update v for json parsing below
248
- original[k] = v = v.read().decode(
249
- "utf-8"
250
- ) # TODO: patch boto client so this doesn't break any further read() calls
265
+ # TODO: patch boto client so this doesn't break any further read() calls
266
+ original[k] = v = v.read().decode("utf-8")
251
267
  if isinstance(v, list) and v:
252
268
  for item in v:
253
269
  if isinstance(item, dict):
@@ -339,7 +355,7 @@ class SnapshotSession:
339
355
 
340
356
  def build_full_path_nodes(field_match: DatumInContext):
341
357
  """Traverse the matched Datum to build the path field by field"""
342
- full_path_nodes = [str(field_match.path)]
358
+ full_path_nodes = [str(field_match.path).replace("'", "")]
343
359
  next_node = field_match
344
360
  while next_node.context is not None:
345
361
  full_path_nodes.append(str(next_node.context.path))
@@ -16,6 +16,10 @@ SNAPSHOT_LOGGER.setLevel(logging.DEBUG if os.environ.get("DEBUG_SNAPSHOT") else
16
16
  GlobalReplacementFn = Callable[[str], str]
17
17
 
18
18
 
19
+ class TransformerException(Exception):
20
+ pass
21
+
22
+
19
23
  class TransformContext:
20
24
  _cache: dict
21
25
  replacements: list[GlobalReplacementFn]
@@ -43,6 +47,19 @@ class TransformContext:
43
47
  def _register_serialized_reference_replacement(
44
48
  transform_context: TransformContext, *, reference_value: str, replacement: str
45
49
  ):
50
+ # Provide a better error message for the TypeError if the reference value is not iterable (e.g., float)
51
+ # Example: `TypeError: argument of type 'float' is not iterable`
52
+ # Using a list would throw an AttributeError because `.replace` is not applicable.
53
+ # The snapshot library currently only supports strings for reference replacements
54
+ if not isinstance(reference_value, str):
55
+ message = (
56
+ f"The reference value {reference_value} of type {type(reference_value)} is not a string."
57
+ f" Consider using `reference_replacement=False` in your transformer"
58
+ f" for the replacement {replacement} because reference replacements are only supported for strings."
59
+ )
60
+ SNAPSHOT_LOGGER.error(message)
61
+ raise TransformerException(message)
62
+
46
63
  if '"' in reference_value:
47
64
  reference_value = reference_value.replace('"', '\\"')
48
65
 
@@ -327,3 +344,19 @@ class TimestampTransformer:
327
344
 
328
345
  def _transform_list(self, input_data: list, ctx: TransformContext = None) -> list:
329
346
  return [self._transform(e, ctx=ctx) for e in input_data]
347
+
348
+
349
+ class TextTransformer:
350
+ def __init__(self, text: str, replacement: str):
351
+ self.text = text
352
+ self.replacement = replacement
353
+
354
+ def transform(self, input_data: dict, *, ctx: TransformContext) -> dict:
355
+ def replace_val(s):
356
+ return s.replace(self.text, self.replacement)
357
+
358
+ ctx.register_serialized_replacement(replace_val)
359
+ SNAPSHOT_LOGGER.debug(
360
+ f"Registering text pattern '{self.text}' in snapshot with '{self.replacement}'"
361
+ )
362
+ return input_data
@@ -5,6 +5,7 @@ from localstack_snapshot.snapshots.transformer import (
5
5
  JsonpathTransformer,
6
6
  KeyValueBasedTransformer,
7
7
  RegexTransformer,
8
+ TextTransformer,
8
9
  )
9
10
 
10
11
 
@@ -67,3 +68,16 @@ class TransformerUtility:
67
68
  :return: RegexTransformer
68
69
  """
69
70
  return RegexTransformer(regex, replacement)
71
+
72
+ @staticmethod
73
+ def text(text: str, replacement: str):
74
+ """Creates a new TextTransformer. All occurrences in the string-converted dict will be replaced.
75
+
76
+ Useful if the text contains special characters that would confuse the RegexTransformer, like '+' or '('.
77
+
78
+ :param text: the text that should be replaced
79
+ :param replacement: the value which will replace the original value.
80
+
81
+ :return: TextTransformer
82
+ """
83
+ return TextTransformer(text, replacement)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: localstack-snapshot
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -215,9 +215,8 @@ Classifier: Programming Language :: Python :: 3.12
215
215
  Requires-Python: >=3.10
216
216
  Description-Content-Type: text/markdown
217
217
  License-File: LICENSE
218
- Requires-Dist: jsonpath-ng==1.5.3
218
+ Requires-Dist: jsonpath-ng>1.6
219
219
  Requires-Dist: deepdiff
220
- Requires-Dist: botocore
221
220
  Provides-Extra: dev
222
221
  Requires-Dist: black==23.10.0; extra == "dev"
223
222
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -1,6 +1,5 @@
1
- jsonpath-ng==1.5.3
1
+ jsonpath-ng>1.6
2
2
  deepdiff
3
- botocore
4
3
 
5
4
  [dev]
6
5
  black==23.10.0
@@ -7,12 +7,11 @@ name = "localstack-snapshot"
7
7
  authors = [
8
8
  { name = "LocalStack Contributors", email = "info@localstack.cloud" }
9
9
  ]
10
- version = "0.1.0"
10
+ version = "0.2.0"
11
11
  description = "Extracted snapshot testing lib for LocalStack"
12
12
  dependencies = [
13
- "jsonpath-ng==1.5.3", # TODO: resolve this pin
13
+ "jsonpath-ng>1.6",
14
14
  "deepdiff",
15
- "botocore",
16
15
  ]
17
16
  requires-python = ">=3.10"
18
17
  license = {file = "LICENSE"}
@@ -1,3 +1,5 @@
1
+ import io
2
+
1
3
  import pytest
2
4
 
3
5
  from localstack_snapshot.snapshots import SnapshotSession
@@ -20,6 +22,12 @@ class TestSnapshotManager:
20
22
  sm._assert_all()
21
23
  ctx.match("Parity snapshot failed")
22
24
 
25
+ def test_diff_with_io_stream(self):
26
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
27
+ sm.recorded_state = {"key_a": {"a": "data"}}
28
+ sm.match("key_a", {"a": io.BytesIO(b"data")})
29
+ sm._assert_all()
30
+
23
31
  def test_multiple_assertmatch_with_same_key_fail(self):
24
32
  sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
25
33
  sm.recorded_state = {"key_a": {"a": 3}}
@@ -37,6 +45,48 @@ class TestSnapshotManager:
37
45
  sm.match("key_a", {"aaa": "something", "bbb": "something hello"})
38
46
  sm._assert_all()
39
47
 
48
+ def test_match_object_nochange(self):
49
+ class CustomObject:
50
+ def __init__(self, name, nested=False):
51
+ self.name = name
52
+ if nested:
53
+ self.nested = CustomObject(f"nested{name}")
54
+ self.listed = [CustomObject(f"listed{name}"), "otherobj"]
55
+
56
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
57
+ sm.recorded_state = {
58
+ "key_a": {
59
+ "name": "myname",
60
+ "nested": {"name": "nestedmyname"},
61
+ "listed": [{"name": "listedmyname"}, "otherobj"],
62
+ }
63
+ }
64
+ sm.match_object("key_a", CustomObject(name="myname", nested=True))
65
+ sm._assert_all()
66
+
67
+ def test_match_object_ignore_private_values(self):
68
+ class CustomObject:
69
+ def __init__(self, name):
70
+ self.name = name
71
+ self._internal = "n/a"
72
+
73
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
74
+ sm.recorded_state = {"key_a": {"name": "myname"}}
75
+ sm.match_object("key_a", CustomObject(name="myname"))
76
+ sm._assert_all()
77
+
78
+ def test_match_object_change(self):
79
+ class CustomObject:
80
+ def __init__(self, name):
81
+ self.name = name
82
+
83
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
84
+ sm.recorded_state = {"key_a": {"name": "myname"}}
85
+ sm.match_object("key_a", CustomObject(name="diffname"))
86
+ with pytest.raises(Exception) as ctx:
87
+ sm._assert_all()
88
+ ctx.match("Parity snapshot failed")
89
+
40
90
  # def test_context_replacement_no_change(self):
41
91
  # sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
42
92
  # sm.add_transformer(TransformerUtility.key_value("name"))
@@ -211,6 +211,31 @@ class TestTransformer:
211
211
  output = transformer.transform(input, ctx=ctx)
212
212
  assert output == expected
213
213
 
214
+ @pytest.mark.parametrize(
215
+ "value",
216
+ [
217
+ "a+b",
218
+ "question?",
219
+ "amount: $4.00",
220
+ "emoji: ^^",
221
+ "sentence.",
222
+ "others (like so)",
223
+ "special {char}",
224
+ ],
225
+ )
226
+ def test_text(self, value):
227
+ input = {"key": f"some {value} with more text"}
228
+
229
+ expected = {"key": "some <value> with more text"}
230
+
231
+ transformer = TransformerUtility.text(value, "<value>")
232
+
233
+ ctx = TransformContext()
234
+ output = transformer.transform(json.dumps(input), ctx=ctx)
235
+ for sr in ctx.serialized_replacements:
236
+ output = sr(output)
237
+ assert json.loads(output) == expected
238
+
214
239
 
215
240
  class TestTimestampTransformer:
216
241
  def test_generic_timestamp_transformer(self):
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"