localstack-snapshot 0.2.0__tar.gz → 0.3.1__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 (24) hide show
  1. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/PKG-INFO +13 -3
  2. localstack_snapshot-0.3.1/README.md +35 -0
  3. localstack_snapshot-0.3.1/localstack_snapshot/__init__.py +1 -0
  4. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/snapshots/prototype.py +40 -2
  5. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/snapshots/report.py +8 -1
  6. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/snapshots/transformer.py +106 -8
  7. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/snapshots/transformer_utility.py +55 -1
  8. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot.egg-info/PKG-INFO +13 -3
  9. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/pyproject.toml +1 -1
  10. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/tests/test_snapshots.py +139 -0
  11. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/tests/test_transformer.py +206 -0
  12. localstack_snapshot-0.2.0/README.md +0 -26
  13. localstack_snapshot-0.2.0/localstack_snapshot/__init__.py +0 -1
  14. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/LICENSE +0 -0
  15. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/pytest/__init__.py +0 -0
  16. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/pytest/snapshot.py +0 -0
  17. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/snapshots/__init__.py +0 -0
  18. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/util/__init__.py +0 -0
  19. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot/util/encoding.py +0 -0
  20. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot.egg-info/SOURCES.txt +0 -0
  21. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot.egg-info/dependency_links.txt +0 -0
  22. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot.egg-info/requires.txt +0 -0
  23. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/localstack_snapshot.egg-info/top_level.txt +0 -0
  24. {localstack_snapshot-0.2.0 → localstack_snapshot-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: localstack-snapshot
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -222,6 +222,7 @@ Requires-Dist: black==23.10.0; extra == "dev"
222
222
  Requires-Dist: pytest>=7.0; extra == "dev"
223
223
  Requires-Dist: coverage[toml]>=5.0.0; extra == "dev"
224
224
  Requires-Dist: ruff==0.1.0; extra == "dev"
225
+ Dynamic: license-file
225
226
 
226
227
  Snapshot testing for pytest
227
228
  ===============================
@@ -233,10 +234,19 @@ This project is in a very early stage and will be both restructured and renamed.
233
234
 
234
235
  ## Quickstart
235
236
 
236
- to install the python and other developer requirements into a venv run:
237
+ To install the python and other developer requirements into a venv run:
237
238
 
238
239
  make install
239
240
 
241
+ ### Configuration options
242
+
243
+ There's a few env vars that can be used with this project:
244
+
245
+ * `TEST_TARGET`: Set to `AWS_CLOUD` to use an externally-deployed instance when running tests.
246
+ * `SNAPSHOT_LEGACY_REPORT`: By default set to `0`. Can be set to `1`. This enables the legacy reporting output style.
247
+ * `SNAPSHOT_UDPATE`: By default set to `0`. Can be set to `1`. This enables updating the snapshot file rather than comparing with its contents.
248
+ * `SNAPSHOT_RAW`: By default set to `0`. Can be set to `1`. This outputs an additional snapshot file which contains the untransformed values.
249
+
240
250
  ## Format code
241
251
 
242
252
  We use black and isort as code style tools.
@@ -0,0 +1,35 @@
1
+ Snapshot testing for pytest
2
+ ===============================
3
+
4
+ Extracted snapshot testing lib for LocalStack.
5
+
6
+ This project is in a very early stage and will be both restructured and renamed.
7
+
8
+
9
+ ## Quickstart
10
+
11
+ To install the python and other developer requirements into a venv run:
12
+
13
+ make install
14
+
15
+ ### Configuration options
16
+
17
+ There's a few env vars that can be used with this project:
18
+
19
+ * `TEST_TARGET`: Set to `AWS_CLOUD` to use an externally-deployed instance when running tests.
20
+ * `SNAPSHOT_LEGACY_REPORT`: By default set to `0`. Can be set to `1`. This enables the legacy reporting output style.
21
+ * `SNAPSHOT_UDPATE`: By default set to `0`. Can be set to `1`. This enables updating the snapshot file rather than comparing with its contents.
22
+ * `SNAPSHOT_RAW`: By default set to `0`. Can be set to `1`. This outputs an additional snapshot file which contains the untransformed values.
23
+
24
+ ## Format code
25
+
26
+ We use black and isort as code style tools.
27
+ To execute them, run:
28
+
29
+ make format
30
+
31
+ ## Build distribution
32
+
33
+ To build a wheel and source distribution, simply run
34
+
35
+ make dist
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -25,6 +25,8 @@ from .transformer_utility import TransformerUtility
25
25
  SNAPSHOT_LOGGER = logging.getLogger(__name__)
26
26
  SNAPSHOT_LOGGER.setLevel(logging.DEBUG if os.environ.get("DEBUG_SNAPSHOT") else logging.WARNING)
27
27
 
28
+ _SKIP_PLACEHOLDER_VALUE = "$__to_be_skipped__$"
29
+
28
30
 
29
31
  class SnapshotMatchResult:
30
32
  def __init__(self, a: dict, b: dict, key: str = ""):
@@ -218,7 +220,7 @@ class SnapshotSession:
218
220
  self.skip_verification_paths = skip_verification_paths or []
219
221
  if skip_verification_paths:
220
222
  SNAPSHOT_LOGGER.warning(
221
- f"Snapshot verification disabled for paths: {skip_verification_paths}"
223
+ "Snapshot verification disabled for paths: %s", skip_verification_paths
222
224
  )
223
225
 
224
226
  if self.update:
@@ -272,6 +274,8 @@ class SnapshotSession:
272
274
  self._transform_dict_to_parseable_values(v)
273
275
 
274
276
  if isinstance(v, str) and v.startswith("{"):
277
+ # Doesn't handle JSON arrays and nested JSON strings. See JsonStringTransformer.
278
+ # TODO for the major release consider having JSON parsing in one place only: either here or in JsonStringTransformer
275
279
  try:
276
280
  json_value = json.loads(v)
277
281
  original[k] = json_value
@@ -304,7 +308,7 @@ class SnapshotSession:
304
308
  try:
305
309
  replaced_tmp[key] = json.loads(dumped_value)
306
310
  except JSONDecodeError:
307
- SNAPSHOT_LOGGER.error(f"could not decode json-string:\n{tmp}")
311
+ SNAPSHOT_LOGGER.error("could not decode json-string:\n%s", tmp)
308
312
  return {}
309
313
 
310
314
  return replaced_tmp
@@ -363,6 +367,23 @@ class SnapshotSession:
363
367
 
364
368
  return full_path_nodes[::-1][1:] # reverse the list and remove Root()/$
365
369
 
370
+ def _remove_placeholder(_tmp):
371
+ """Traverse the object and remove any values in a list that would be equal to the placeholder"""
372
+ if isinstance(_tmp, dict):
373
+ for k, v in _tmp.items():
374
+ if isinstance(v, dict):
375
+ _remove_placeholder(v)
376
+ elif isinstance(v, list):
377
+ _tmp[k] = _remove_placeholder(v)
378
+ elif isinstance(_tmp, list):
379
+ return [
380
+ _remove_placeholder(item) for item in _tmp if item != _SKIP_PLACEHOLDER_VALUE
381
+ ]
382
+
383
+ return _tmp
384
+
385
+ has_placeholder = False
386
+
366
387
  for path in self.skip_verification_paths:
367
388
  matches = parse(path).find(tmp) or []
368
389
  for m in matches:
@@ -376,7 +397,24 @@ class SnapshotSession:
376
397
  helper = helper.get(p, None)
377
398
  if not helper:
378
399
  continue
400
+
379
401
  if (
380
402
  isinstance(helper, dict) and full_path[-1] in helper.keys()
381
403
  ): # might have been deleted already
382
404
  del helper[full_path[-1]]
405
+ elif isinstance(helper, list):
406
+ try:
407
+ index = int(full_path[-1].lstrip("[").rstrip("]"))
408
+ # we need to set a placeholder value as the skips are based on index
409
+ # if we are to pop the values, the next skip index will have shifted and won't be correct
410
+ helper[index] = _SKIP_PLACEHOLDER_VALUE
411
+ has_placeholder = True
412
+ except ValueError:
413
+ SNAPSHOT_LOGGER.warning(
414
+ "Snapshot skip path '%s' was not applied as it was invalid for that snapshot",
415
+ path,
416
+ exc_info=SNAPSHOT_LOGGER.isEnabledFor(logging.DEBUG),
417
+ )
418
+
419
+ if has_placeholder:
420
+ _remove_placeholder(tmp)
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import re
2
3
 
3
4
  from localstack_snapshot.snapshots import SnapshotMatchResult
4
5
 
@@ -29,6 +30,8 @@ _esctable = {
29
30
  "underlined": 4,
30
31
  }
31
32
 
33
+ _regular_json_path_chars_regex = re.compile("[a-zA-Z0-9_-]+")
34
+
32
35
 
33
36
  class PatchPath(str):
34
37
  """
@@ -52,7 +55,11 @@ def _format_json_path(path: list):
52
55
  json_str = "$.."
53
56
  for idx, elem in enumerate(path):
54
57
  if not isinstance(elem, int):
55
- json_str += str(elem)
58
+ _elem = str(elem)
59
+ # we want to wrap in single quotes parts with special characters so that users can copy-paste them directly
60
+ if not _regular_json_path_chars_regex.fullmatch(_elem):
61
+ _elem = f"'{_elem}'"
62
+ json_str += _elem
56
63
  if idx < len(path) - 1 and not json_str.endswith(".."):
57
64
  json_str += "."
58
65
 
@@ -1,8 +1,10 @@
1
1
  import copy
2
+ import json
2
3
  import logging
3
4
  import os
4
5
  import re
5
6
  from datetime import datetime
7
+ from json import JSONDecodeError
6
8
  from re import Pattern
7
9
  from typing import Any, Callable, Optional, Protocol
8
10
 
@@ -100,8 +102,14 @@ class ResponseMetaDataTransformer:
100
102
  if k == "ResponseMetadata":
101
103
  metadata = v
102
104
  http_headers = metadata.get("HTTPHeaders")
105
+ if not isinstance(http_headers, dict):
106
+ continue
107
+
103
108
  # TODO "x-amz-bucket-region"
104
109
  # TestS3.test_region_header_exists -> verifies bucket-region
110
+
111
+ # FIXME: proper value is `content-type` with no underscore in lowercase, but this will necessitate a
112
+ # refresh of all snapshots
105
113
  headers_to_collect = ["content_type"]
106
114
  simplified_headers = {}
107
115
  for h in headers_to_collect:
@@ -184,35 +192,36 @@ class RegexTransformer:
184
192
  return input_data
185
193
 
186
194
 
187
- class KeyValueBasedTransformer:
195
+ class KeyValueBasedTransformerFunctionReplacement:
188
196
  def __init__(
189
197
  self,
190
198
  match_fn: Callable[[str, Any], Optional[str]],
191
- replacement: str,
199
+ replacement_function: [Callable[[str, Any], str]],
192
200
  replace_reference: bool = True,
193
201
  ):
194
202
  self.match_fn = match_fn
195
- self.replacement = replacement
203
+ self.replacement_function = replacement_function
196
204
  self.replace_reference = replace_reference
197
205
 
198
206
  def transform(self, input_data: dict, *, ctx: TransformContext) -> dict:
199
207
  for k, v in input_data.items():
200
208
  if (match_result := self.match_fn(k, v)) is not None:
209
+ replacement = self.replacement_function(k, v)
201
210
  if self.replace_reference:
202
211
  _register_serialized_reference_replacement(
203
- ctx, reference_value=match_result, replacement=self.replacement
212
+ ctx, reference_value=match_result, replacement=replacement
204
213
  )
205
214
  else:
206
215
  if isinstance(v, str):
207
216
  SNAPSHOT_LOGGER.debug(
208
- f"Replacing value for key '{k}': Match result '{match_result:.200s}' with '{self.replacement}'. (Original value: {str(v)})"
217
+ f"Replacing value for key '{k}': Match result '{match_result:.200s}' with '{replacement}'. (Original value: {str(v)})"
209
218
  )
210
- input_data[k] = v.replace(match_result, self.replacement)
219
+ input_data[k] = v.replace(match_result, replacement)
211
220
  else:
212
221
  SNAPSHOT_LOGGER.debug(
213
- f"Replacing value for key '{k}' with '{self.replacement}'. (Original value: {str(v)})"
222
+ f"Replacing value for key '{k}' with '{replacement}'. (Original value: {str(v)})"
214
223
  )
215
- input_data[k] = self.replacement
224
+ input_data[k] = replacement
216
225
  elif isinstance(v, list) and len(v) > 0:
217
226
  for i in range(0, len(v)):
218
227
  if isinstance(v[i], dict):
@@ -223,6 +232,20 @@ class KeyValueBasedTransformer:
223
232
  return input_data
224
233
 
225
234
 
235
+ class KeyValueBasedTransformer(KeyValueBasedTransformerFunctionReplacement):
236
+ def __init__(
237
+ self,
238
+ match_fn: Callable[[str, Any], Optional[str]],
239
+ replacement: str,
240
+ replace_reference: bool = True,
241
+ ):
242
+ super().__init__(
243
+ match_fn=match_fn,
244
+ replacement_function=lambda k, v: replacement,
245
+ replace_reference=replace_reference,
246
+ )
247
+
248
+
226
249
  class GenericTransformer:
227
250
  def __init__(self, fn: Callable[[dict, TransformContext], dict]):
228
251
  self.fn = fn
@@ -360,3 +383,78 @@ class TextTransformer:
360
383
  f"Registering text pattern '{self.text}' in snapshot with '{self.replacement}'"
361
384
  )
362
385
  return input_data
386
+
387
+
388
+ class JsonStringTransformer:
389
+ """
390
+ Parses JSON string at the specified key.
391
+ Additionally, attempts to parse any JSON strings inside the parsed JSON
392
+
393
+ This transformer complements the default parsing of JSON strings in
394
+ localstack_snapshot.snapshots.prototype.SnapshotSession._transform_dict_to_parseable_values
395
+
396
+ Shortcomings of the default parser that this transformer addresses:
397
+ - parsing of nested JSON strings '{"a": "{\\"b\\":42}"}'
398
+ - parsing of JSON arrays at the specified key, e.g. '["a", "b"]'
399
+
400
+ Such parsing allows applying transformations further to the elements of the parsed JSON - timestamps, ARNs, etc.
401
+
402
+ Such parsing is not done by default because it's not a common use case.
403
+ Whether to parse a JSON string or not should be decided by the user on a case by case basis.
404
+ Limited general parsing that we already have is preserved for backwards compatibility.
405
+ """
406
+
407
+ key: str
408
+
409
+ def __init__(self, key: str):
410
+ self.key = key
411
+
412
+ def transform(self, input_data: dict, *, ctx: TransformContext = None) -> dict:
413
+ return self._transform_dict(input_data, ctx=ctx)
414
+
415
+ def _transform(self, input_data: Any, ctx: TransformContext = None) -> Any:
416
+ if isinstance(input_data, dict):
417
+ return self._transform_dict(input_data, ctx=ctx)
418
+ elif isinstance(input_data, list):
419
+ return self._transform_list(input_data, ctx=ctx)
420
+ return input_data
421
+
422
+ def _transform_dict(self, input_data: dict, ctx: TransformContext = None) -> dict:
423
+ for k, v in input_data.items():
424
+ if k == self.key and isinstance(v, str) and v.strip().startswith(("{", "[")):
425
+ try:
426
+ SNAPSHOT_LOGGER.debug(f"Replacing string value of {k} with parsed JSON")
427
+ json_value = json.loads(v)
428
+ input_data[k] = self._transform_nested(json_value)
429
+ except JSONDecodeError:
430
+ SNAPSHOT_LOGGER.exception(
431
+ f'Value mapped to "{k}" key is not a valid JSON string and won\'t be transformed. Value: {v}'
432
+ )
433
+ else:
434
+ input_data[k] = self._transform(v, ctx=ctx)
435
+ return input_data
436
+
437
+ def _transform_list(self, input_data: list, ctx: TransformContext = None) -> list:
438
+ return [self._transform(item, ctx=ctx) for item in input_data]
439
+
440
+ def _transform_nested(self, input_data: Any) -> Any:
441
+ """
442
+ Separate method from the main `_transform_dict` one because
443
+ it checks every string while the main one attempts to load at specified key only.
444
+ This one is implicit, best-effort attempt,
445
+ while the main one is explicit about at which key transform should happen
446
+ """
447
+ if isinstance(input_data, list):
448
+ input_data = [self._transform_nested(item) for item in input_data]
449
+ if isinstance(input_data, dict):
450
+ for k, v in input_data.items():
451
+ input_data[k] = self._transform_nested(v)
452
+ if isinstance(input_data, str) and input_data.strip().startswith(("{", "[")):
453
+ try:
454
+ json_value = json.loads(input_data)
455
+ input_data = self._transform_nested(json_value)
456
+ except JSONDecodeError:
457
+ SNAPSHOT_LOGGER.debug(
458
+ f"The value is not a valid JSON string and won't be transformed. The value: {input_data}"
459
+ )
460
+ return input_data
@@ -1,10 +1,13 @@
1
1
  from re import Pattern
2
- from typing import Optional
2
+ from typing import Any, Callable, Optional
3
3
 
4
4
  from localstack_snapshot.snapshots.transformer import (
5
5
  JsonpathTransformer,
6
+ JsonStringTransformer,
6
7
  KeyValueBasedTransformer,
8
+ KeyValueBasedTransformerFunctionReplacement,
7
9
  RegexTransformer,
10
+ SortingTransformer,
8
11
  TextTransformer,
9
12
  )
10
13
 
@@ -38,6 +41,33 @@ class TransformerUtility:
38
41
  replace_reference=reference_replacement,
39
42
  )
40
43
 
44
+ @staticmethod
45
+ def key_value_replacement_function(
46
+ key: str,
47
+ replacement_function: Callable[[str, Any], str] = None,
48
+ reference_replacement: bool = True,
49
+ ):
50
+ """Creates a new KeyValueBasedTransformer. If the key matches, the value will be replaced.
51
+
52
+ :param key: the name of the key which should be replaced
53
+ :param replacement_function: The function calculating the replacement. Will be passed the key and value of the replaced pair.
54
+ By default it is the key-name in lowercase, separated with hyphen
55
+ :param reference_replacement: if False, only the original value for this key will be replaced.
56
+ If True all references of this value will be replaced (using a regex pattern), for the entire test case.
57
+ In this case, the replaced value will be nummerated as well.
58
+ Default: True
59
+
60
+ :return: KeyValueBasedTransformer
61
+ """
62
+ replacement_function = replacement_function or (
63
+ lambda x, y: _replace_camel_string_with_hyphen(key)
64
+ )
65
+ return KeyValueBasedTransformerFunctionReplacement(
66
+ lambda k, v: v if k == key and (v is not None and v != "") else None,
67
+ replacement_function=replacement_function,
68
+ replace_reference=reference_replacement,
69
+ )
70
+
41
71
  @staticmethod
42
72
  def jsonpath(jsonpath: str, value_replacement: str, reference_replacement: bool = True):
43
73
  """Creates a new JsonpathTransformer. If the jsonpath matches, the value will be replaced.
@@ -81,3 +111,27 @@ class TransformerUtility:
81
111
  :return: TextTransformer
82
112
  """
83
113
  return TextTransformer(text, replacement)
114
+
115
+ @staticmethod
116
+ def json_string(key: str) -> JsonStringTransformer:
117
+ """Creates a new JsonStringTransformer. If there is a valid JSON text string at specified key
118
+ it will be loaded as a regular object or array.
119
+
120
+ :param key: key at which JSON string is expected
121
+
122
+ :return: JsonStringTransformer
123
+ """
124
+ return JsonStringTransformer(key)
125
+
126
+ @staticmethod
127
+ def sorting(key: str, sorting_fn: Optional[Callable[[...], Any]]) -> SortingTransformer:
128
+ """Creates a new SortingTransformer.
129
+
130
+ Sorts a list at `key` with the given `sorting_fn` (argument for `sorted(list, key=sorting_fn)`)
131
+
132
+ :param key: key at which the list to sort is expected
133
+ :param sorting_fn: sorting function
134
+
135
+ :return: SortingTransformer
136
+ """
137
+ return SortingTransformer(key, sorting_fn)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: localstack-snapshot
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -222,6 +222,7 @@ Requires-Dist: black==23.10.0; extra == "dev"
222
222
  Requires-Dist: pytest>=7.0; extra == "dev"
223
223
  Requires-Dist: coverage[toml]>=5.0.0; extra == "dev"
224
224
  Requires-Dist: ruff==0.1.0; extra == "dev"
225
+ Dynamic: license-file
225
226
 
226
227
  Snapshot testing for pytest
227
228
  ===============================
@@ -233,10 +234,19 @@ This project is in a very early stage and will be both restructured and renamed.
233
234
 
234
235
  ## Quickstart
235
236
 
236
- to install the python and other developer requirements into a venv run:
237
+ To install the python and other developer requirements into a venv run:
237
238
 
238
239
  make install
239
240
 
241
+ ### Configuration options
242
+
243
+ There's a few env vars that can be used with this project:
244
+
245
+ * `TEST_TARGET`: Set to `AWS_CLOUD` to use an externally-deployed instance when running tests.
246
+ * `SNAPSHOT_LEGACY_REPORT`: By default set to `0`. Can be set to `1`. This enables the legacy reporting output style.
247
+ * `SNAPSHOT_UDPATE`: By default set to `0`. Can be set to `1`. This enables updating the snapshot file rather than comparing with its contents.
248
+ * `SNAPSHOT_RAW`: By default set to `0`. Can be set to `1`. This outputs an additional snapshot file which contains the untransformed values.
249
+
240
250
  ## Format code
241
251
 
242
252
  We use black and isort as code style tools.
@@ -7,7 +7,7 @@ name = "localstack-snapshot"
7
7
  authors = [
8
8
  { name = "LocalStack Contributors", email = "info@localstack.cloud" }
9
9
  ]
10
- version = "0.2.0"
10
+ version = "0.3.1"
11
11
  description = "Extracted snapshot testing lib for LocalStack"
12
12
  dependencies = [
13
13
  "jsonpath-ng>1.6",
@@ -191,6 +191,137 @@ class TestSnapshotManager:
191
191
  sm.match("key1", [{"key2": "value1"}, "value2", 3])
192
192
  sm._assert_all()
193
193
 
194
+ def test_list_as_last_node_in_skip_verification_path(self):
195
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
196
+ sm.recorded_state = {"key_a": {"aaa": ["item1", "item2", "item3"]}}
197
+ sm.match(
198
+ "key_a",
199
+ {"aaa": ["item1", "different-value"]},
200
+ )
201
+
202
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
203
+ sm._assert_all()
204
+ ctx.match("Parity snapshot failed")
205
+
206
+ skip_path = ["$..aaa[1]", "$..aaa[2]"]
207
+ sm._assert_all(skip_verification_paths=skip_path)
208
+
209
+ skip_path = ["$..aaa.1", "$..aaa.2"]
210
+ sm._assert_all(skip_verification_paths=skip_path)
211
+
212
+ def test_list_as_last_node_in_skip_verification_path_complex(self):
213
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
214
+ sm.recorded_state = {
215
+ "key_a": {
216
+ "aaa": [
217
+ {"aab": ["aac", "aad"]},
218
+ {"aab": ["aac", "aad"]},
219
+ {"aab": ["aac", "aad"]},
220
+ ]
221
+ }
222
+ }
223
+ sm.match(
224
+ "key_a",
225
+ {
226
+ "aaa": [
227
+ {"aab": ["aac", "bad-value"], "bbb": "value"},
228
+ {"aab": ["aac", "aad", "bad-value"]},
229
+ {"aab": ["bad-value", "aad"]},
230
+ ]
231
+ },
232
+ )
233
+
234
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
235
+ sm._assert_all()
236
+ ctx.match("Parity snapshot failed")
237
+
238
+ skip_path = [
239
+ "$..aaa[0].aab[1]",
240
+ "$..aaa[0].bbb",
241
+ "$..aaa[1].aab[2]",
242
+ "$..aaa[2].aab[0]",
243
+ ]
244
+ sm._assert_all(skip_verification_paths=skip_path)
245
+
246
+ skip_path = [
247
+ "$..aaa.0..aab.1",
248
+ "$..aaa.0..bbb",
249
+ "$..aaa.1..aab.2",
250
+ "$..aaa.2..aab.0",
251
+ ]
252
+ sm._assert_all(skip_verification_paths=skip_path)
253
+
254
+ def test_list_as_mid_node_in_skip_verification_path(self):
255
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
256
+ sm.recorded_state = {"key_a": {"aaa": [{"aab": "value1"}, {"aab": "value2"}]}}
257
+ sm.match(
258
+ "key_a",
259
+ {"aaa": [{"aab": "value1"}, {"aab": "bad-value"}]},
260
+ )
261
+
262
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
263
+ sm._assert_all()
264
+ ctx.match("Parity snapshot failed")
265
+
266
+ skip_path = ["$..aaa[1].aab"]
267
+ sm._assert_all(skip_verification_paths=skip_path)
268
+
269
+ skip_path = ["$..aaa.1.aab"]
270
+ sm._assert_all(skip_verification_paths=skip_path)
271
+
272
+ def test_list_as_last_node_in_skip_verification_path_nested(self):
273
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
274
+ sm.recorded_state = {
275
+ "key_a": {
276
+ "aaa": [
277
+ "bbb",
278
+ "ccc",
279
+ [
280
+ "ddd",
281
+ "eee",
282
+ [
283
+ "fff",
284
+ "ggg",
285
+ ],
286
+ ],
287
+ ]
288
+ }
289
+ }
290
+ sm.match(
291
+ "key_a",
292
+ {
293
+ "aaa": [
294
+ "bbb",
295
+ "ccc",
296
+ [
297
+ "bad-value",
298
+ "eee",
299
+ [
300
+ "fff",
301
+ "ggg",
302
+ ],
303
+ ],
304
+ ]
305
+ },
306
+ )
307
+
308
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
309
+ sm._assert_all()
310
+ ctx.match("Parity snapshot failed")
311
+
312
+ skip_path = ["$..aaa[2][0]"]
313
+ sm._assert_all(skip_verification_paths=skip_path)
314
+
315
+ skip_path = ["$..aaa.2[0]"]
316
+ sm._assert_all(skip_verification_paths=skip_path)
317
+
318
+ # these 2 will actually skip almost everything, as they will match every first element of any list inside `aaa`
319
+ skip_path = ["$..aaa..[0]"]
320
+ sm._assert_all(skip_verification_paths=skip_path)
321
+
322
+ skip_path = ["$..aaa..0"]
323
+ sm._assert_all(skip_verification_paths=skip_path)
324
+
194
325
 
195
326
  def test_json_diff_format():
196
327
  path = ["Records", 1]
@@ -209,6 +340,14 @@ def test_json_diff_format():
209
340
  assert _format_json_path(path) == '"$.."'
210
341
  path = [1, 1, 0, "SomeKey"]
211
342
  assert _format_json_path(path) == '"$..SomeKey"'
343
+ path = ["Some:Key"]
344
+ assert _format_json_path(path) == "\"$..'Some:Key'\""
345
+ path = ["Some.Key"]
346
+ assert _format_json_path(path) == "\"$..'Some.Key'\""
347
+ path = ["Some-Key"]
348
+ assert _format_json_path(path) == '"$..Some-Key"'
349
+ path = ["Some0Key"]
350
+ assert _format_json_path(path) == '"$..Some0Key"'
212
351
 
213
352
 
214
353
  def test_sorting_transformer():
@@ -4,6 +4,8 @@ import json
4
4
  import pytest
5
5
 
6
6
  from localstack_snapshot.snapshots.transformer import (
7
+ JsonStringTransformer,
8
+ ResponseMetaDataTransformer,
7
9
  SortingTransformer,
8
10
  TimestampTransformer,
9
11
  TransformContext,
@@ -50,6 +52,81 @@ class TestTransformer:
50
52
 
51
53
  assert json.loads(tmp) == expected_key_value_reference
52
54
 
55
+ def test_key_value_replacement_custom_function(self):
56
+ input = {
57
+ "hello": "12characters",
58
+ "hello2": "again",
59
+ "path": {
60
+ "to": {
61
+ "anotherkey": "hi",
62
+ "twelvesymbol": {"hello": "twelvesymbol"},
63
+ "fifteen_symbols": {"hello": "fifteen_symbols"},
64
+ }
65
+ },
66
+ }
67
+
68
+ key_value = TransformerUtility.key_value_replacement_function(
69
+ "hello",
70
+ replacement_function=lambda k, v: f"placeholder({len(v)})",
71
+ reference_replacement=False,
72
+ )
73
+
74
+ expected_key_value = {
75
+ "hello": "placeholder(12)",
76
+ "hello2": "again",
77
+ "path": {
78
+ "to": {
79
+ "anotherkey": "hi",
80
+ "twelvesymbol": {"hello": "placeholder(12)"},
81
+ "fifteen_symbols": {"hello": "placeholder(15)"},
82
+ }
83
+ },
84
+ }
85
+
86
+ ctx = TransformContext()
87
+ assert key_value.transform(input, ctx=ctx) == expected_key_value
88
+ assert ctx.serialized_replacements == []
89
+
90
+ def test_key_value_replacement_custom_function_reference_replacement(self):
91
+ input = {
92
+ "hello": "12characters",
93
+ "hello2": "again",
94
+ "path": {
95
+ "to": {
96
+ "anotherkey": "hi",
97
+ "twelvesymbol": {"hello": "twelvesymbol"},
98
+ "fifteen_symbols": {"hello": "fifteen_symbols"},
99
+ }
100
+ },
101
+ }
102
+
103
+ key_value = TransformerUtility.key_value_replacement_function(
104
+ "hello",
105
+ replacement_function=lambda k, v: f"placeholder({len(v)})",
106
+ reference_replacement=True,
107
+ )
108
+ # replacement counters are per replacement key, so it will start from 1 again.
109
+ expected_key_value_reference = {
110
+ "hello": "<placeholder(12):1>",
111
+ "hello2": "again",
112
+ "path": {
113
+ "to": {
114
+ "anotherkey": "hi",
115
+ "<placeholder(12):2>": {"hello": "<placeholder(12):2>"},
116
+ "<placeholder(15):1>": {"hello": "<placeholder(15):1>"},
117
+ }
118
+ },
119
+ }
120
+ ctx = TransformContext()
121
+ assert key_value.transform(input, ctx=ctx) == input
122
+ assert len(ctx.serialized_replacements) == 3
123
+
124
+ tmp = json.dumps(input, default=str)
125
+ for sr in ctx.serialized_replacements:
126
+ tmp = sr(tmp)
127
+
128
+ assert json.loads(tmp) == expected_key_value_reference
129
+
53
130
  def test_key_value_replacement_with_falsy_value(self):
54
131
  input = {
55
132
  "hello": "world",
@@ -236,6 +313,57 @@ class TestTransformer:
236
313
  output = sr(output)
237
314
  assert json.loads(output) == expected
238
315
 
316
+ @pytest.mark.parametrize(
317
+ "input_value,transformed_value",
318
+ [
319
+ pytest.param('{"a": "b"}', {"a": "b"}, id="simple_json_object"),
320
+ pytest.param('{\n "a": "b"\n}', {"a": "b"}, id="formatted_json_object"),
321
+ pytest.param('\n {"a": "b"}', {"a": "b"}, id="json_with_whitespaces"),
322
+ pytest.param('{"a": 42}malformed', '{"a": 42}malformed', id="malformed_json"),
323
+ pytest.param('["a", "b"]', ["a", "b"], id="simple_json_list"),
324
+ pytest.param('{"a": "{\\"b\\":42}"}', {"a": {"b": 42}}, id="nested_json_object"),
325
+ pytest.param(
326
+ '{"a": "\\n {\\n \\"b\\":42}"}',
327
+ {"a": {"b": 42}},
328
+ id="nested_formatted_json_object_with_whitespaces",
329
+ ),
330
+ pytest.param(
331
+ '{"a": "[{\\"b\\":\\"c\\"}]"}', {"a": [{"b": "c"}]}, id="nested_json_list"
332
+ ),
333
+ pytest.param(
334
+ '{"a": "{\\"b\\":42malformed}"}',
335
+ {"a": '{"b":42malformed}'},
336
+ id="malformed_nested_json",
337
+ ),
338
+ pytest.param("[]", [], id="empty_list"),
339
+ pytest.param("{}", {}, id="empty_object"),
340
+ pytest.param("", "", id="empty_string"),
341
+ ],
342
+ )
343
+ def test_json_string(self, input_value, transformed_value):
344
+ key = "key"
345
+ input_data = {key: input_value}
346
+ expected = {key: transformed_value}
347
+
348
+ transformer = JsonStringTransformer(key)
349
+
350
+ ctx = TransformContext()
351
+ output = transformer.transform(input_data, ctx=ctx)
352
+
353
+ assert output == expected
354
+
355
+ def test_json_string_in_a_nested_key(self):
356
+ key = "nested-key-in-an-object-hidden-inside-a-list"
357
+ input_data = {"top-level-key": [{key: '{"a": "b"}'}]}
358
+ expected = {"top-level-key": [{key: {"a": "b"}}]}
359
+
360
+ transformer = JsonStringTransformer(key)
361
+
362
+ ctx = TransformContext()
363
+ output = transformer.transform(input_data, ctx=ctx)
364
+
365
+ assert output == expected
366
+
239
367
 
240
368
  class TestTimestampTransformer:
241
369
  def test_generic_timestamp_transformer(self):
@@ -278,3 +406,81 @@ class TestTimestampTransformer:
278
406
  ctx = TransformContext()
279
407
  output = transformer.transform(input, ctx=ctx)
280
408
  assert output == expected
409
+
410
+
411
+ class TestResponseMetaDataTransformer:
412
+ def test_with_headers(self):
413
+ input_data = {"ResponseMetadata": {"HTTPHeaders": {"header1": "value1"}}}
414
+
415
+ metadata_transformer = ResponseMetaDataTransformer()
416
+
417
+ expected_key_value = {"ResponseMetadata": {"HTTPHeaders": {}}}
418
+
419
+ copied = copy.deepcopy(input_data)
420
+ ctx = TransformContext()
421
+ assert metadata_transformer.transform(copied, ctx=ctx) == expected_key_value
422
+ assert ctx.serialized_replacements == []
423
+
424
+ def test_with_headers_and_status_code(self):
425
+ input_data = {
426
+ "ResponseMetadata": {"HTTPHeaders": {"header1": "value1"}, "HTTPStatusCode": 500}
427
+ }
428
+
429
+ metadata_transformer = ResponseMetaDataTransformer()
430
+
431
+ expected_key_value = {"ResponseMetadata": {"HTTPHeaders": {}, "HTTPStatusCode": 500}}
432
+
433
+ copied = copy.deepcopy(input_data)
434
+ ctx = TransformContext()
435
+ assert metadata_transformer.transform(copied, ctx=ctx) == expected_key_value
436
+ assert ctx.serialized_replacements == []
437
+
438
+ def test_with_status_code_only(self):
439
+ input_data = {"ResponseMetadata": {"HTTPStatusCode": 500, "RandomData": "random"}}
440
+
441
+ metadata_transformer = ResponseMetaDataTransformer()
442
+
443
+ expected_key_value = {"ResponseMetadata": {"HTTPStatusCode": 500, "RandomData": "random"}}
444
+
445
+ copied = copy.deepcopy(input_data)
446
+ ctx = TransformContext()
447
+ assert metadata_transformer.transform(copied, ctx=ctx) == expected_key_value
448
+ assert ctx.serialized_replacements == []
449
+
450
+ def test_with_empty_response_metadata(self):
451
+ input_data = {"ResponseMetadata": {"NotHeaders": "data"}}
452
+
453
+ metadata_transformer = ResponseMetaDataTransformer()
454
+
455
+ expected_key_value = {"ResponseMetadata": {"NotHeaders": "data"}}
456
+
457
+ copied = copy.deepcopy(input_data)
458
+ ctx = TransformContext()
459
+ assert metadata_transformer.transform(copied, ctx=ctx) == expected_key_value
460
+ assert ctx.serialized_replacements == []
461
+
462
+ def test_with_headers_wrong_type(self):
463
+ input_data = {"ResponseMetadata": {"HTTPHeaders": "data"}}
464
+
465
+ metadata_transformer = ResponseMetaDataTransformer()
466
+
467
+ expected_key_value = {"ResponseMetadata": {"HTTPHeaders": "data"}}
468
+
469
+ copied = copy.deepcopy(input_data)
470
+ ctx = TransformContext()
471
+ assert metadata_transformer.transform(copied, ctx=ctx) == expected_key_value
472
+ assert ctx.serialized_replacements == []
473
+
474
+ def test_headers_filtering(self):
475
+ input_data = {
476
+ "ResponseMetadata": {"HTTPHeaders": {"content_type": "value1", "header1": "value1"}}
477
+ }
478
+
479
+ metadata_transformer = ResponseMetaDataTransformer()
480
+
481
+ expected_key_value = {"ResponseMetadata": {"HTTPHeaders": {"content_type": "value1"}}}
482
+
483
+ copied = copy.deepcopy(input_data)
484
+ ctx = TransformContext()
485
+ assert metadata_transformer.transform(copied, ctx=ctx) == expected_key_value
486
+ assert ctx.serialized_replacements == []
@@ -1,26 +0,0 @@
1
- Snapshot testing for pytest
2
- ===============================
3
-
4
- Extracted snapshot testing lib for LocalStack.
5
-
6
- This project is in a very early stage and will be both restructured and renamed.
7
-
8
-
9
- ## Quickstart
10
-
11
- to install the python and other developer requirements into a venv run:
12
-
13
- make install
14
-
15
- ## Format code
16
-
17
- We use black and isort as code style tools.
18
- To execute them, run:
19
-
20
- make format
21
-
22
- ## Build distribution
23
-
24
- To build a wheel and source distribution, simply run
25
-
26
- make dist
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"