localstack-snapshot 0.1.1__tar.gz → 0.3.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 (25) hide show
  1. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/PKG-INFO +13 -4
  2. localstack_snapshot-0.3.0/README.md +35 -0
  3. localstack_snapshot-0.3.0/localstack_snapshot/__init__.py +1 -0
  4. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/snapshots/prototype.py +23 -5
  5. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/snapshots/transformer.py +133 -8
  6. localstack_snapshot-0.3.0/localstack_snapshot/snapshots/transformer_utility.py +137 -0
  7. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot.egg-info/PKG-INFO +13 -4
  8. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot.egg-info/requires.txt +0 -1
  9. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/pyproject.toml +1 -2
  10. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/tests/test_snapshots.py +50 -0
  11. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/tests/test_transformer.py +152 -0
  12. localstack-snapshot-0.1.1/README.md +0 -26
  13. localstack-snapshot-0.1.1/localstack_snapshot/__init__.py +0 -1
  14. localstack-snapshot-0.1.1/localstack_snapshot/snapshots/transformer_utility.py +0 -69
  15. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/LICENSE +0 -0
  16. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/pytest/__init__.py +0 -0
  17. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/pytest/snapshot.py +0 -0
  18. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/snapshots/__init__.py +0 -0
  19. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/snapshots/report.py +0 -0
  20. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/util/__init__.py +0 -0
  21. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot/util/encoding.py +0 -0
  22. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot.egg-info/SOURCES.txt +0 -0
  23. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot.egg-info/dependency_links.txt +0 -0
  24. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/localstack_snapshot.egg-info/top_level.txt +0 -0
  25. {localstack-snapshot-0.1.1 → localstack_snapshot-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: localstack-snapshot
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -217,12 +217,12 @@ Description-Content-Type: text/markdown
217
217
  License-File: LICENSE
218
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"
224
223
  Requires-Dist: coverage[toml]>=5.0.0; extra == "dev"
225
224
  Requires-Dist: ruff==0.1.0; extra == "dev"
225
+ Dynamic: license-file
226
226
 
227
227
  Snapshot testing for pytest
228
228
  ===============================
@@ -234,10 +234,19 @@ This project is in a very early stage and will be both restructured and renamed.
234
234
 
235
235
  ## Quickstart
236
236
 
237
- 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:
238
238
 
239
239
  make install
240
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
+
241
250
  ## Format code
242
251
 
243
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.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):
@@ -256,6 +272,8 @@ class SnapshotSession:
256
272
  self._transform_dict_to_parseable_values(v)
257
273
 
258
274
  if isinstance(v, str) and v.startswith("{"):
275
+ # Doesn't handle JSON arrays and nested JSON strings. See JsonStringTransformer.
276
+ # TODO for the major release consider having JSON parsing in one place only: either here or in JsonStringTransformer
259
277
  try:
260
278
  json_value = json.loads(v)
261
279
  original[k] = json_value
@@ -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
 
@@ -16,6 +18,10 @@ SNAPSHOT_LOGGER.setLevel(logging.DEBUG if os.environ.get("DEBUG_SNAPSHOT") else
16
18
  GlobalReplacementFn = Callable[[str], str]
17
19
 
18
20
 
21
+ class TransformerException(Exception):
22
+ pass
23
+
24
+
19
25
  class TransformContext:
20
26
  _cache: dict
21
27
  replacements: list[GlobalReplacementFn]
@@ -43,6 +49,19 @@ class TransformContext:
43
49
  def _register_serialized_reference_replacement(
44
50
  transform_context: TransformContext, *, reference_value: str, replacement: str
45
51
  ):
52
+ # Provide a better error message for the TypeError if the reference value is not iterable (e.g., float)
53
+ # Example: `TypeError: argument of type 'float' is not iterable`
54
+ # Using a list would throw an AttributeError because `.replace` is not applicable.
55
+ # The snapshot library currently only supports strings for reference replacements
56
+ if not isinstance(reference_value, str):
57
+ message = (
58
+ f"The reference value {reference_value} of type {type(reference_value)} is not a string."
59
+ f" Consider using `reference_replacement=False` in your transformer"
60
+ f" for the replacement {replacement} because reference replacements are only supported for strings."
61
+ )
62
+ SNAPSHOT_LOGGER.error(message)
63
+ raise TransformerException(message)
64
+
46
65
  if '"' in reference_value:
47
66
  reference_value = reference_value.replace('"', '\\"')
48
67
 
@@ -167,35 +186,36 @@ class RegexTransformer:
167
186
  return input_data
168
187
 
169
188
 
170
- class KeyValueBasedTransformer:
189
+ class KeyValueBasedTransformerFunctionReplacement:
171
190
  def __init__(
172
191
  self,
173
192
  match_fn: Callable[[str, Any], Optional[str]],
174
- replacement: str,
193
+ replacement_function: [Callable[[str, Any], str]],
175
194
  replace_reference: bool = True,
176
195
  ):
177
196
  self.match_fn = match_fn
178
- self.replacement = replacement
197
+ self.replacement_function = replacement_function
179
198
  self.replace_reference = replace_reference
180
199
 
181
200
  def transform(self, input_data: dict, *, ctx: TransformContext) -> dict:
182
201
  for k, v in input_data.items():
183
202
  if (match_result := self.match_fn(k, v)) is not None:
203
+ replacement = self.replacement_function(k, v)
184
204
  if self.replace_reference:
185
205
  _register_serialized_reference_replacement(
186
- ctx, reference_value=match_result, replacement=self.replacement
206
+ ctx, reference_value=match_result, replacement=replacement
187
207
  )
188
208
  else:
189
209
  if isinstance(v, str):
190
210
  SNAPSHOT_LOGGER.debug(
191
- f"Replacing value for key '{k}': Match result '{match_result:.200s}' with '{self.replacement}'. (Original value: {str(v)})"
211
+ f"Replacing value for key '{k}': Match result '{match_result:.200s}' with '{replacement}'. (Original value: {str(v)})"
192
212
  )
193
- input_data[k] = v.replace(match_result, self.replacement)
213
+ input_data[k] = v.replace(match_result, replacement)
194
214
  else:
195
215
  SNAPSHOT_LOGGER.debug(
196
- f"Replacing value for key '{k}' with '{self.replacement}'. (Original value: {str(v)})"
216
+ f"Replacing value for key '{k}' with '{replacement}'. (Original value: {str(v)})"
197
217
  )
198
- input_data[k] = self.replacement
218
+ input_data[k] = replacement
199
219
  elif isinstance(v, list) and len(v) > 0:
200
220
  for i in range(0, len(v)):
201
221
  if isinstance(v[i], dict):
@@ -206,6 +226,20 @@ class KeyValueBasedTransformer:
206
226
  return input_data
207
227
 
208
228
 
229
+ class KeyValueBasedTransformer(KeyValueBasedTransformerFunctionReplacement):
230
+ def __init__(
231
+ self,
232
+ match_fn: Callable[[str, Any], Optional[str]],
233
+ replacement: str,
234
+ replace_reference: bool = True,
235
+ ):
236
+ super().__init__(
237
+ match_fn=match_fn,
238
+ replacement_function=lambda k, v: replacement,
239
+ replace_reference=replace_reference,
240
+ )
241
+
242
+
209
243
  class GenericTransformer:
210
244
  def __init__(self, fn: Callable[[dict, TransformContext], dict]):
211
245
  self.fn = fn
@@ -327,3 +361,94 @@ class TimestampTransformer:
327
361
 
328
362
  def _transform_list(self, input_data: list, ctx: TransformContext = None) -> list:
329
363
  return [self._transform(e, ctx=ctx) for e in input_data]
364
+
365
+
366
+ class TextTransformer:
367
+ def __init__(self, text: str, replacement: str):
368
+ self.text = text
369
+ self.replacement = replacement
370
+
371
+ def transform(self, input_data: dict, *, ctx: TransformContext) -> dict:
372
+ def replace_val(s):
373
+ return s.replace(self.text, self.replacement)
374
+
375
+ ctx.register_serialized_replacement(replace_val)
376
+ SNAPSHOT_LOGGER.debug(
377
+ f"Registering text pattern '{self.text}' in snapshot with '{self.replacement}'"
378
+ )
379
+ return input_data
380
+
381
+
382
+ class JsonStringTransformer:
383
+ """
384
+ Parses JSON string at the specified key.
385
+ Additionally, attempts to parse any JSON strings inside the parsed JSON
386
+
387
+ This transformer complements the default parsing of JSON strings in
388
+ localstack_snapshot.snapshots.prototype.SnapshotSession._transform_dict_to_parseable_values
389
+
390
+ Shortcomings of the default parser that this transformer addresses:
391
+ - parsing of nested JSON strings '{"a": "{\\"b\\":42}"}'
392
+ - parsing of JSON arrays at the specified key, e.g. '["a", "b"]'
393
+
394
+ Such parsing allows applying transformations further to the elements of the parsed JSON - timestamps, ARNs, etc.
395
+
396
+ Such parsing is not done by default because it's not a common use case.
397
+ Whether to parse a JSON string or not should be decided by the user on a case by case basis.
398
+ Limited general parsing that we already have is preserved for backwards compatibility.
399
+ """
400
+
401
+ key: str
402
+
403
+ def __init__(self, key: str):
404
+ self.key = key
405
+
406
+ def transform(self, input_data: dict, *, ctx: TransformContext = None) -> dict:
407
+ return self._transform_dict(input_data, ctx=ctx)
408
+
409
+ def _transform(self, input_data: Any, ctx: TransformContext = None) -> Any:
410
+ if isinstance(input_data, dict):
411
+ return self._transform_dict(input_data, ctx=ctx)
412
+ elif isinstance(input_data, list):
413
+ return self._transform_list(input_data, ctx=ctx)
414
+ return input_data
415
+
416
+ def _transform_dict(self, input_data: dict, ctx: TransformContext = None) -> dict:
417
+ for k, v in input_data.items():
418
+ if k == self.key and isinstance(v, str) and v.strip().startswith(("{", "[")):
419
+ try:
420
+ SNAPSHOT_LOGGER.debug(f"Replacing string value of {k} with parsed JSON")
421
+ json_value = json.loads(v)
422
+ input_data[k] = self._transform_nested(json_value)
423
+ except JSONDecodeError:
424
+ SNAPSHOT_LOGGER.exception(
425
+ f'Value mapped to "{k}" key is not a valid JSON string and won\'t be transformed. Value: {v}'
426
+ )
427
+ else:
428
+ input_data[k] = self._transform(v, ctx=ctx)
429
+ return input_data
430
+
431
+ def _transform_list(self, input_data: list, ctx: TransformContext = None) -> list:
432
+ return [self._transform(item, ctx=ctx) for item in input_data]
433
+
434
+ def _transform_nested(self, input_data: Any) -> Any:
435
+ """
436
+ Separate method from the main `_transform_dict` one because
437
+ it checks every string while the main one attempts to load at specified key only.
438
+ This one is implicit, best-effort attempt,
439
+ while the main one is explicit about at which key transform should happen
440
+ """
441
+ if isinstance(input_data, list):
442
+ input_data = [self._transform_nested(item) for item in input_data]
443
+ if isinstance(input_data, dict):
444
+ for k, v in input_data.items():
445
+ input_data[k] = self._transform_nested(v)
446
+ if isinstance(input_data, str) and input_data.strip().startswith(("{", "[")):
447
+ try:
448
+ json_value = json.loads(input_data)
449
+ input_data = self._transform_nested(json_value)
450
+ except JSONDecodeError:
451
+ SNAPSHOT_LOGGER.debug(
452
+ f"The value is not a valid JSON string and won't be transformed. The value: {input_data}"
453
+ )
454
+ return input_data
@@ -0,0 +1,137 @@
1
+ from re import Pattern
2
+ from typing import Any, Callable, Optional
3
+
4
+ from localstack_snapshot.snapshots.transformer import (
5
+ JsonpathTransformer,
6
+ JsonStringTransformer,
7
+ KeyValueBasedTransformer,
8
+ KeyValueBasedTransformerFunctionReplacement,
9
+ RegexTransformer,
10
+ SortingTransformer,
11
+ TextTransformer,
12
+ )
13
+
14
+
15
+ def _replace_camel_string_with_hyphen(input_string: str):
16
+ return "".join(["-" + char.lower() if char.isupper() else char for char in input_string]).strip(
17
+ "-"
18
+ )
19
+
20
+
21
+ class TransformerUtility:
22
+ @staticmethod
23
+ def key_value(
24
+ key: str, value_replacement: Optional[str] = None, reference_replacement: bool = True
25
+ ):
26
+ """Creates a new KeyValueBasedTransformer. If the key matches, the value will be replaced.
27
+
28
+ :param key: the name of the key which should be replaced
29
+ :param value_replacement: the value which will replace the original value.
30
+ By default it is the key-name in lowercase, separated with hyphen
31
+ :param reference_replacement: if False, only the original value for this key will be replaced.
32
+ If True all references of this value will be replaced (using a regex pattern), for the entire test case.
33
+ In this case, the replaced value will be nummerated as well.
34
+ Default: True
35
+
36
+ :return: KeyValueBasedTransformer
37
+ """
38
+ return KeyValueBasedTransformer(
39
+ lambda k, v: v if k == key and (v is not None and v != "") else None,
40
+ replacement=value_replacement or _replace_camel_string_with_hyphen(key),
41
+ replace_reference=reference_replacement,
42
+ )
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
+
71
+ @staticmethod
72
+ def jsonpath(jsonpath: str, value_replacement: str, reference_replacement: bool = True):
73
+ """Creates a new JsonpathTransformer. If the jsonpath matches, the value will be replaced.
74
+
75
+ :param jsonpath: the jsonpath that should be matched
76
+ :param value_replacement: the value which will replace the original value.
77
+ By default it is the key-name in lowercase, separated with hyphen
78
+ :param reference_replacement: if False, only the original value for this key will be replaced.
79
+ If True all references of this value will be replaced (using a regex pattern), for the entire test case.
80
+ In this case, the replaced value will be nummerated as well.
81
+ Default: True
82
+
83
+ :return: JsonpathTransformer
84
+ """
85
+ return JsonpathTransformer(
86
+ jsonpath=jsonpath,
87
+ replacement=value_replacement,
88
+ replace_reference=reference_replacement,
89
+ )
90
+
91
+ @staticmethod
92
+ def regex(regex: str | Pattern[str], replacement: str):
93
+ """Creates a new RegexTransformer. All matches in the string-converted dict will be replaced.
94
+
95
+ :param regex: the regex that should be matched
96
+ :param replacement: the value which will replace the original value.
97
+
98
+ :return: RegexTransformer
99
+ """
100
+ return RegexTransformer(regex, replacement)
101
+
102
+ @staticmethod
103
+ def text(text: str, replacement: str):
104
+ """Creates a new TextTransformer. All occurrences in the string-converted dict will be replaced.
105
+
106
+ Useful if the text contains special characters that would confuse the RegexTransformer, like '+' or '('.
107
+
108
+ :param text: the text that should be replaced
109
+ :param replacement: the value which will replace the original value.
110
+
111
+ :return: TextTransformer
112
+ """
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.1
1
+ Metadata-Version: 2.4
2
2
  Name: localstack-snapshot
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -217,12 +217,12 @@ Description-Content-Type: text/markdown
217
217
  License-File: LICENSE
218
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"
224
223
  Requires-Dist: coverage[toml]>=5.0.0; extra == "dev"
225
224
  Requires-Dist: ruff==0.1.0; extra == "dev"
225
+ Dynamic: license-file
226
226
 
227
227
  Snapshot testing for pytest
228
228
  ===============================
@@ -234,10 +234,19 @@ This project is in a very early stage and will be both restructured and renamed.
234
234
 
235
235
  ## Quickstart
236
236
 
237
- 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:
238
238
 
239
239
  make install
240
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
+
241
250
  ## Format code
242
251
 
243
252
  We use black and isort as code style tools.
@@ -1,6 +1,5 @@
1
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.1"
10
+ version = "0.3.0"
11
11
  description = "Extracted snapshot testing lib for LocalStack"
12
12
  dependencies = [
13
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"))
@@ -4,6 +4,7 @@ import json
4
4
  import pytest
5
5
 
6
6
  from localstack_snapshot.snapshots.transformer import (
7
+ JsonStringTransformer,
7
8
  SortingTransformer,
8
9
  TimestampTransformer,
9
10
  TransformContext,
@@ -50,6 +51,81 @@ class TestTransformer:
50
51
 
51
52
  assert json.loads(tmp) == expected_key_value_reference
52
53
 
54
+ def test_key_value_replacement_custom_function(self):
55
+ input = {
56
+ "hello": "12characters",
57
+ "hello2": "again",
58
+ "path": {
59
+ "to": {
60
+ "anotherkey": "hi",
61
+ "twelvesymbol": {"hello": "twelvesymbol"},
62
+ "fifteen_symbols": {"hello": "fifteen_symbols"},
63
+ }
64
+ },
65
+ }
66
+
67
+ key_value = TransformerUtility.key_value_replacement_function(
68
+ "hello",
69
+ replacement_function=lambda k, v: f"placeholder({len(v)})",
70
+ reference_replacement=False,
71
+ )
72
+
73
+ expected_key_value = {
74
+ "hello": "placeholder(12)",
75
+ "hello2": "again",
76
+ "path": {
77
+ "to": {
78
+ "anotherkey": "hi",
79
+ "twelvesymbol": {"hello": "placeholder(12)"},
80
+ "fifteen_symbols": {"hello": "placeholder(15)"},
81
+ }
82
+ },
83
+ }
84
+
85
+ ctx = TransformContext()
86
+ assert key_value.transform(input, ctx=ctx) == expected_key_value
87
+ assert ctx.serialized_replacements == []
88
+
89
+ def test_key_value_replacement_custom_function_reference_replacement(self):
90
+ input = {
91
+ "hello": "12characters",
92
+ "hello2": "again",
93
+ "path": {
94
+ "to": {
95
+ "anotherkey": "hi",
96
+ "twelvesymbol": {"hello": "twelvesymbol"},
97
+ "fifteen_symbols": {"hello": "fifteen_symbols"},
98
+ }
99
+ },
100
+ }
101
+
102
+ key_value = TransformerUtility.key_value_replacement_function(
103
+ "hello",
104
+ replacement_function=lambda k, v: f"placeholder({len(v)})",
105
+ reference_replacement=True,
106
+ )
107
+ # replacement counters are per replacement key, so it will start from 1 again.
108
+ expected_key_value_reference = {
109
+ "hello": "<placeholder(12):1>",
110
+ "hello2": "again",
111
+ "path": {
112
+ "to": {
113
+ "anotherkey": "hi",
114
+ "<placeholder(12):2>": {"hello": "<placeholder(12):2>"},
115
+ "<placeholder(15):1>": {"hello": "<placeholder(15):1>"},
116
+ }
117
+ },
118
+ }
119
+ ctx = TransformContext()
120
+ assert key_value.transform(input, ctx=ctx) == input
121
+ assert len(ctx.serialized_replacements) == 3
122
+
123
+ tmp = json.dumps(input, default=str)
124
+ for sr in ctx.serialized_replacements:
125
+ tmp = sr(tmp)
126
+
127
+ assert json.loads(tmp) == expected_key_value_reference
128
+
53
129
  def test_key_value_replacement_with_falsy_value(self):
54
130
  input = {
55
131
  "hello": "world",
@@ -211,6 +287,82 @@ class TestTransformer:
211
287
  output = transformer.transform(input, ctx=ctx)
212
288
  assert output == expected
213
289
 
290
+ @pytest.mark.parametrize(
291
+ "value",
292
+ [
293
+ "a+b",
294
+ "question?",
295
+ "amount: $4.00",
296
+ "emoji: ^^",
297
+ "sentence.",
298
+ "others (like so)",
299
+ "special {char}",
300
+ ],
301
+ )
302
+ def test_text(self, value):
303
+ input = {"key": f"some {value} with more text"}
304
+
305
+ expected = {"key": "some <value> with more text"}
306
+
307
+ transformer = TransformerUtility.text(value, "<value>")
308
+
309
+ ctx = TransformContext()
310
+ output = transformer.transform(json.dumps(input), ctx=ctx)
311
+ for sr in ctx.serialized_replacements:
312
+ output = sr(output)
313
+ assert json.loads(output) == expected
314
+
315
+ @pytest.mark.parametrize(
316
+ "input_value,transformed_value",
317
+ [
318
+ pytest.param('{"a": "b"}', {"a": "b"}, id="simple_json_object"),
319
+ pytest.param('{\n "a": "b"\n}', {"a": "b"}, id="formatted_json_object"),
320
+ pytest.param('\n {"a": "b"}', {"a": "b"}, id="json_with_whitespaces"),
321
+ pytest.param('{"a": 42}malformed', '{"a": 42}malformed', id="malformed_json"),
322
+ pytest.param('["a", "b"]', ["a", "b"], id="simple_json_list"),
323
+ pytest.param('{"a": "{\\"b\\":42}"}', {"a": {"b": 42}}, id="nested_json_object"),
324
+ pytest.param(
325
+ '{"a": "\\n {\\n \\"b\\":42}"}',
326
+ {"a": {"b": 42}},
327
+ id="nested_formatted_json_object_with_whitespaces",
328
+ ),
329
+ pytest.param(
330
+ '{"a": "[{\\"b\\":\\"c\\"}]"}', {"a": [{"b": "c"}]}, id="nested_json_list"
331
+ ),
332
+ pytest.param(
333
+ '{"a": "{\\"b\\":42malformed}"}',
334
+ {"a": '{"b":42malformed}'},
335
+ id="malformed_nested_json",
336
+ ),
337
+ pytest.param("[]", [], id="empty_list"),
338
+ pytest.param("{}", {}, id="empty_object"),
339
+ pytest.param("", "", id="empty_string"),
340
+ ],
341
+ )
342
+ def test_json_string(self, input_value, transformed_value):
343
+ key = "key"
344
+ input_data = {key: input_value}
345
+ expected = {key: transformed_value}
346
+
347
+ transformer = JsonStringTransformer(key)
348
+
349
+ ctx = TransformContext()
350
+ output = transformer.transform(input_data, ctx=ctx)
351
+
352
+ assert output == expected
353
+
354
+ def test_json_string_in_a_nested_key(self):
355
+ key = "nested-key-in-an-object-hidden-inside-a-list"
356
+ input_data = {"top-level-key": [{key: '{"a": "b"}'}]}
357
+ expected = {"top-level-key": [{key: {"a": "b"}}]}
358
+
359
+ transformer = JsonStringTransformer(key)
360
+
361
+ ctx = TransformContext()
362
+ output = transformer.transform(input_data, ctx=ctx)
363
+
364
+ assert output == expected
365
+
214
366
 
215
367
  class TestTimestampTransformer:
216
368
  def test_generic_timestamp_transformer(self):
@@ -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.1.1"
@@ -1,69 +0,0 @@
1
- from re import Pattern
2
- from typing import Optional
3
-
4
- from localstack_snapshot.snapshots.transformer import (
5
- JsonpathTransformer,
6
- KeyValueBasedTransformer,
7
- RegexTransformer,
8
- )
9
-
10
-
11
- def _replace_camel_string_with_hyphen(input_string: str):
12
- return "".join(["-" + char.lower() if char.isupper() else char for char in input_string]).strip(
13
- "-"
14
- )
15
-
16
-
17
- class TransformerUtility:
18
- @staticmethod
19
- def key_value(
20
- key: str, value_replacement: Optional[str] = None, reference_replacement: bool = True
21
- ):
22
- """Creates a new KeyValueBasedTransformer. If the key matches, the value will be replaced.
23
-
24
- :param key: the name of the key which should be replaced
25
- :param value_replacement: the value which will replace the original value.
26
- By default it is the key-name in lowercase, separated with hyphen
27
- :param reference_replacement: if False, only the original value for this key will be replaced.
28
- If True all references of this value will be replaced (using a regex pattern), for the entire test case.
29
- In this case, the replaced value will be nummerated as well.
30
- Default: True
31
-
32
- :return: KeyValueBasedTransformer
33
- """
34
- return KeyValueBasedTransformer(
35
- lambda k, v: v if k == key and (v is not None and v != "") else None,
36
- replacement=value_replacement or _replace_camel_string_with_hyphen(key),
37
- replace_reference=reference_replacement,
38
- )
39
-
40
- @staticmethod
41
- def jsonpath(jsonpath: str, value_replacement: str, reference_replacement: bool = True):
42
- """Creates a new JsonpathTransformer. If the jsonpath matches, the value will be replaced.
43
-
44
- :param jsonpath: the jsonpath that should be matched
45
- :param value_replacement: the value which will replace the original value.
46
- By default it is the key-name in lowercase, separated with hyphen
47
- :param reference_replacement: if False, only the original value for this key will be replaced.
48
- If True all references of this value will be replaced (using a regex pattern), for the entire test case.
49
- In this case, the replaced value will be nummerated as well.
50
- Default: True
51
-
52
- :return: JsonpathTransformer
53
- """
54
- return JsonpathTransformer(
55
- jsonpath=jsonpath,
56
- replacement=value_replacement,
57
- replace_reference=reference_replacement,
58
- )
59
-
60
- @staticmethod
61
- def regex(regex: str | Pattern[str], replacement: str):
62
- """Creates a new RegexTransformer. All matches in the string-converted dict will be replaced.
63
-
64
- :param regex: the regex that should be matched
65
- :param replacement: the value which will replace the original value.
66
-
67
- :return: RegexTransformer
68
- """
69
- return RegexTransformer(regex, replacement)