localstack-snapshot 0.3.0__tar.gz → 0.3.2__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.3.0 → localstack_snapshot-0.3.2}/PKG-INFO +1 -1
  2. localstack_snapshot-0.3.2/localstack_snapshot/__init__.py +1 -0
  3. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/snapshots/prototype.py +60 -11
  4. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/snapshots/report.py +8 -1
  5. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/snapshots/transformer.py +6 -0
  6. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot.egg-info/PKG-INFO +1 -1
  7. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/pyproject.toml +1 -1
  8. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/tests/test_snapshots.py +201 -0
  9. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/tests/test_transformer.py +79 -0
  10. localstack_snapshot-0.3.0/localstack_snapshot/__init__.py +0 -1
  11. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/LICENSE +0 -0
  12. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/README.md +0 -0
  13. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/pytest/__init__.py +0 -0
  14. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/pytest/snapshot.py +0 -0
  15. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/snapshots/__init__.py +0 -0
  16. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/snapshots/transformer_utility.py +0 -0
  17. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/util/__init__.py +0 -0
  18. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot/util/encoding.py +0 -0
  19. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot.egg-info/SOURCES.txt +0 -0
  20. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot.egg-info/dependency_links.txt +0 -0
  21. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot.egg-info/requires.txt +0 -0
  22. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/localstack_snapshot.egg-info/top_level.txt +0 -0
  23. {localstack_snapshot-0.3.0 → localstack_snapshot-0.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localstack-snapshot
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -0,0 +1 @@
1
+ __version__ = "0.3.2"
@@ -2,7 +2,9 @@ import io
2
2
  import json
3
3
  import logging
4
4
  import os
5
+ from collections.abc import Iterator
5
6
  from datetime import datetime, timezone
7
+ from enum import Enum
6
8
  from json import JSONDecodeError
7
9
  from pathlib import Path
8
10
  from re import Pattern
@@ -25,6 +27,8 @@ from .transformer_utility import TransformerUtility
25
27
  SNAPSHOT_LOGGER = logging.getLogger(__name__)
26
28
  SNAPSHOT_LOGGER.setLevel(logging.DEBUG if os.environ.get("DEBUG_SNAPSHOT") else logging.WARNING)
27
29
 
30
+ _SKIP_PLACEHOLDER_VALUE = "$__to_be_skipped__$"
31
+
28
32
 
29
33
  class SnapshotMatchResult:
30
34
  def __init__(self, a: dict, b: dict, key: str = ""):
@@ -167,16 +171,27 @@ class SnapshotSession:
167
171
  def match_object(self, key: str, obj: object) -> None:
168
172
  def _convert_object_to_dict(obj_):
169
173
  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)
174
+ # Serialize the values of the dictionary, while skipping any private keys (starting with '_')
175
+ return {
176
+ key_: _convert_object_to_dict(obj_[key_])
177
+ for key_ in obj_
178
+ if not key_.startswith("_")
179
+ }
180
+ elif isinstance(obj_, (list, Iterator)):
181
+ return [_convert_object_to_dict(val) for val in obj_]
182
+ elif isinstance(obj_, Enum):
183
+ return obj_.value
178
184
  elif hasattr(obj_, "__dict__"):
179
- return _convert_object_to_dict(obj_.__dict__)
185
+ # This is an object - let's try to convert it to a dictionary
186
+ # A naive approach would be to use the '__dict__' object directly, but that only lists the attributes
187
+ # In order to also serialize the properties, we use the __dir__() method
188
+ # Filtering by everything that is not a method gives us both attributes and properties
189
+ # We also (still) skip private attributes/properties, so everything that starts with an underscore
190
+ return {
191
+ k: _convert_object_to_dict(getattr(obj_, k))
192
+ for k in obj_.__dir__()
193
+ if not k.startswith("_") and type(getattr(obj_, k, "")).__name__ != "method"
194
+ }
180
195
  return obj_
181
196
 
182
197
  return self.match(key, _convert_object_to_dict(obj))
@@ -218,7 +233,7 @@ class SnapshotSession:
218
233
  self.skip_verification_paths = skip_verification_paths or []
219
234
  if skip_verification_paths:
220
235
  SNAPSHOT_LOGGER.warning(
221
- f"Snapshot verification disabled for paths: {skip_verification_paths}"
236
+ "Snapshot verification disabled for paths: %s", skip_verification_paths
222
237
  )
223
238
 
224
239
  if self.update:
@@ -306,7 +321,7 @@ class SnapshotSession:
306
321
  try:
307
322
  replaced_tmp[key] = json.loads(dumped_value)
308
323
  except JSONDecodeError:
309
- SNAPSHOT_LOGGER.error(f"could not decode json-string:\n{tmp}")
324
+ SNAPSHOT_LOGGER.error("could not decode json-string:\n%s", tmp)
310
325
  return {}
311
326
 
312
327
  return replaced_tmp
@@ -365,6 +380,23 @@ class SnapshotSession:
365
380
 
366
381
  return full_path_nodes[::-1][1:] # reverse the list and remove Root()/$
367
382
 
383
+ def _remove_placeholder(_tmp):
384
+ """Traverse the object and remove any values in a list that would be equal to the placeholder"""
385
+ if isinstance(_tmp, dict):
386
+ for k, v in _tmp.items():
387
+ if isinstance(v, dict):
388
+ _remove_placeholder(v)
389
+ elif isinstance(v, list):
390
+ _tmp[k] = _remove_placeholder(v)
391
+ elif isinstance(_tmp, list):
392
+ return [
393
+ _remove_placeholder(item) for item in _tmp if item != _SKIP_PLACEHOLDER_VALUE
394
+ ]
395
+
396
+ return _tmp
397
+
398
+ has_placeholder = False
399
+
368
400
  for path in self.skip_verification_paths:
369
401
  matches = parse(path).find(tmp) or []
370
402
  for m in matches:
@@ -378,7 +410,24 @@ class SnapshotSession:
378
410
  helper = helper.get(p, None)
379
411
  if not helper:
380
412
  continue
413
+
381
414
  if (
382
415
  isinstance(helper, dict) and full_path[-1] in helper.keys()
383
416
  ): # might have been deleted already
384
417
  del helper[full_path[-1]]
418
+ elif isinstance(helper, list):
419
+ try:
420
+ index = int(full_path[-1].lstrip("[").rstrip("]"))
421
+ # we need to set a placeholder value as the skips are based on index
422
+ # if we are to pop the values, the next skip index will have shifted and won't be correct
423
+ helper[index] = _SKIP_PLACEHOLDER_VALUE
424
+ has_placeholder = True
425
+ except ValueError:
426
+ SNAPSHOT_LOGGER.warning(
427
+ "Snapshot skip path '%s' was not applied as it was invalid for that snapshot",
428
+ path,
429
+ exc_info=SNAPSHOT_LOGGER.isEnabledFor(logging.DEBUG),
430
+ )
431
+
432
+ if has_placeholder:
433
+ _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
 
@@ -102,8 +102,14 @@ class ResponseMetaDataTransformer:
102
102
  if k == "ResponseMetadata":
103
103
  metadata = v
104
104
  http_headers = metadata.get("HTTPHeaders")
105
+ if not isinstance(http_headers, dict):
106
+ continue
107
+
105
108
  # TODO "x-amz-bucket-region"
106
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
107
113
  headers_to_collect = ["content_type"]
108
114
  simplified_headers = {}
109
115
  for h in headers_to_collect:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localstack-snapshot
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Extracted snapshot testing lib for LocalStack
5
5
  Author-email: LocalStack Contributors <info@localstack.cloud>
6
6
  License:
@@ -7,7 +7,7 @@ name = "localstack-snapshot"
7
7
  authors = [
8
8
  { name = "LocalStack Contributors", email = "info@localstack.cloud" }
9
9
  ]
10
- version = "0.3.0"
10
+ version = "0.3.2"
11
11
  description = "Extracted snapshot testing lib for LocalStack"
12
12
  dependencies = [
13
13
  "jsonpath-ng>1.6",
@@ -1,4 +1,5 @@
1
1
  import io
2
+ from enum import Enum
2
3
 
3
4
  import pytest
4
5
 
@@ -75,6 +76,67 @@ class TestSnapshotManager:
75
76
  sm.match_object("key_a", CustomObject(name="myname"))
76
77
  sm._assert_all()
77
78
 
79
+ def test_match_object_lists_and_iterators(self):
80
+ class CustomObject:
81
+ def __init__(self, name):
82
+ self.name = name
83
+ self.my_list = [9, 8, 7, 6, 5]
84
+ self.my_iterator = (x for x in range(5))
85
+
86
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
87
+ sm.recorded_state = {
88
+ "key_a": {"name": "myname", "my_iterator": [0, 1, 2, 3, 4], "my_list": [9, 8, 7, 6, 5]}
89
+ }
90
+ sm.match_object("key_a", CustomObject(name="myname"))
91
+ sm._assert_all()
92
+
93
+ def test_match_object_include_properties(self):
94
+ class CustomObject:
95
+ def __init__(self, name):
96
+ self.name = name
97
+ self._internal = "n/a"
98
+
99
+ def some_method(self):
100
+ # method should not be serialized
101
+ return False
102
+
103
+ @property
104
+ def some_prop(self):
105
+ # properties should be serialized
106
+ return True
107
+
108
+ @property
109
+ def some_iterator(self):
110
+ for i in range(3):
111
+ yield i
112
+
113
+ @property
114
+ def _private_prop(self):
115
+ # private properties should be ignored
116
+ return False
117
+
118
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
119
+ sm.recorded_state = {
120
+ "key_a": {"name": "myname", "some_prop": True, "some_iterator": [0, 1, 2]}
121
+ }
122
+ sm.match_object("key_a", CustomObject(name="myname"))
123
+ sm._assert_all()
124
+
125
+ def test_match_object_enums(self):
126
+ class TestEnum(Enum):
127
+ value1 = "Value 1"
128
+ value2 = "Value 2"
129
+
130
+ class CustomObject:
131
+ def __init__(self, name):
132
+ self.name = name
133
+ self.my_enum = TestEnum.value2
134
+
135
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
136
+ sm.recorded_state = {"key_a": {"name": "myname", "my_enum": "Value 2"}}
137
+ sm.match_object("key_a", CustomObject(name="myname"))
138
+ sm._assert_all()
139
+
78
140
  def test_match_object_change(self):
79
141
  class CustomObject:
80
142
  def __init__(self, name):
@@ -191,6 +253,137 @@ class TestSnapshotManager:
191
253
  sm.match("key1", [{"key2": "value1"}, "value2", 3])
192
254
  sm._assert_all()
193
255
 
256
+ def test_list_as_last_node_in_skip_verification_path(self):
257
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
258
+ sm.recorded_state = {"key_a": {"aaa": ["item1", "item2", "item3"]}}
259
+ sm.match(
260
+ "key_a",
261
+ {"aaa": ["item1", "different-value"]},
262
+ )
263
+
264
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
265
+ sm._assert_all()
266
+ ctx.match("Parity snapshot failed")
267
+
268
+ skip_path = ["$..aaa[1]", "$..aaa[2]"]
269
+ sm._assert_all(skip_verification_paths=skip_path)
270
+
271
+ skip_path = ["$..aaa.1", "$..aaa.2"]
272
+ sm._assert_all(skip_verification_paths=skip_path)
273
+
274
+ def test_list_as_last_node_in_skip_verification_path_complex(self):
275
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
276
+ sm.recorded_state = {
277
+ "key_a": {
278
+ "aaa": [
279
+ {"aab": ["aac", "aad"]},
280
+ {"aab": ["aac", "aad"]},
281
+ {"aab": ["aac", "aad"]},
282
+ ]
283
+ }
284
+ }
285
+ sm.match(
286
+ "key_a",
287
+ {
288
+ "aaa": [
289
+ {"aab": ["aac", "bad-value"], "bbb": "value"},
290
+ {"aab": ["aac", "aad", "bad-value"]},
291
+ {"aab": ["bad-value", "aad"]},
292
+ ]
293
+ },
294
+ )
295
+
296
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
297
+ sm._assert_all()
298
+ ctx.match("Parity snapshot failed")
299
+
300
+ skip_path = [
301
+ "$..aaa[0].aab[1]",
302
+ "$..aaa[0].bbb",
303
+ "$..aaa[1].aab[2]",
304
+ "$..aaa[2].aab[0]",
305
+ ]
306
+ sm._assert_all(skip_verification_paths=skip_path)
307
+
308
+ skip_path = [
309
+ "$..aaa.0..aab.1",
310
+ "$..aaa.0..bbb",
311
+ "$..aaa.1..aab.2",
312
+ "$..aaa.2..aab.0",
313
+ ]
314
+ sm._assert_all(skip_verification_paths=skip_path)
315
+
316
+ def test_list_as_mid_node_in_skip_verification_path(self):
317
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
318
+ sm.recorded_state = {"key_a": {"aaa": [{"aab": "value1"}, {"aab": "value2"}]}}
319
+ sm.match(
320
+ "key_a",
321
+ {"aaa": [{"aab": "value1"}, {"aab": "bad-value"}]},
322
+ )
323
+
324
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
325
+ sm._assert_all()
326
+ ctx.match("Parity snapshot failed")
327
+
328
+ skip_path = ["$..aaa[1].aab"]
329
+ sm._assert_all(skip_verification_paths=skip_path)
330
+
331
+ skip_path = ["$..aaa.1.aab"]
332
+ sm._assert_all(skip_verification_paths=skip_path)
333
+
334
+ def test_list_as_last_node_in_skip_verification_path_nested(self):
335
+ sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
336
+ sm.recorded_state = {
337
+ "key_a": {
338
+ "aaa": [
339
+ "bbb",
340
+ "ccc",
341
+ [
342
+ "ddd",
343
+ "eee",
344
+ [
345
+ "fff",
346
+ "ggg",
347
+ ],
348
+ ],
349
+ ]
350
+ }
351
+ }
352
+ sm.match(
353
+ "key_a",
354
+ {
355
+ "aaa": [
356
+ "bbb",
357
+ "ccc",
358
+ [
359
+ "bad-value",
360
+ "eee",
361
+ [
362
+ "fff",
363
+ "ggg",
364
+ ],
365
+ ],
366
+ ]
367
+ },
368
+ )
369
+
370
+ with pytest.raises(Exception) as ctx: # asserts it fail without skipping
371
+ sm._assert_all()
372
+ ctx.match("Parity snapshot failed")
373
+
374
+ skip_path = ["$..aaa[2][0]"]
375
+ sm._assert_all(skip_verification_paths=skip_path)
376
+
377
+ skip_path = ["$..aaa.2[0]"]
378
+ sm._assert_all(skip_verification_paths=skip_path)
379
+
380
+ # these 2 will actually skip almost everything, as they will match every first element of any list inside `aaa`
381
+ skip_path = ["$..aaa..[0]"]
382
+ sm._assert_all(skip_verification_paths=skip_path)
383
+
384
+ skip_path = ["$..aaa..0"]
385
+ sm._assert_all(skip_verification_paths=skip_path)
386
+
194
387
 
195
388
  def test_json_diff_format():
196
389
  path = ["Records", 1]
@@ -209,6 +402,14 @@ def test_json_diff_format():
209
402
  assert _format_json_path(path) == '"$.."'
210
403
  path = [1, 1, 0, "SomeKey"]
211
404
  assert _format_json_path(path) == '"$..SomeKey"'
405
+ path = ["Some:Key"]
406
+ assert _format_json_path(path) == "\"$..'Some:Key'\""
407
+ path = ["Some.Key"]
408
+ assert _format_json_path(path) == "\"$..'Some.Key'\""
409
+ path = ["Some-Key"]
410
+ assert _format_json_path(path) == '"$..Some-Key"'
411
+ path = ["Some0Key"]
412
+ assert _format_json_path(path) == '"$..Some0Key"'
212
413
 
213
414
 
214
415
  def test_sorting_transformer():
@@ -5,6 +5,7 @@ import pytest
5
5
 
6
6
  from localstack_snapshot.snapshots.transformer import (
7
7
  JsonStringTransformer,
8
+ ResponseMetaDataTransformer,
8
9
  SortingTransformer,
9
10
  TimestampTransformer,
10
11
  TransformContext,
@@ -405,3 +406,81 @@ class TestTimestampTransformer:
405
406
  ctx = TransformContext()
406
407
  output = transformer.transform(input, ctx=ctx)
407
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 +0,0 @@
1
- __version__ = "0.3.0"