Skip to content

Commit ea4df10

Browse files
bentskuclaude
andauthored
Support jsonpath-ng 1.9 and fix skip paths with special characters (#27)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e0c320b commit ea4df10

4 files changed

Lines changed: 99 additions & 136 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
strategy:
1717
fail-fast: false
1818
matrix:
19-
python-version: [ "3.10", "3.11", "3.12" ]
19+
python-version: [ "3.11", "3.12" ]
2020
steps:
2121
- name: Checkout
2222
uses: actions/checkout@v3

‎localstack_snapshot/snapshots/prototype.py‎

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Dict, List, Optional
1212

1313
from deepdiff import DeepDiff
14-
from jsonpath_ng import DatumInContext
14+
from jsonpath_ng import Fields, Index
1515
from jsonpath_ng.ext import parse
1616

1717
from localstack_snapshot.snapshots.transformer import (
@@ -377,16 +377,6 @@ def replace_value(self, pattern: Pattern[str], value: str):
377377
def _remove_skip_verification_paths(self, tmp: Dict):
378378
"""Removes all keys from the dict, that match the given json-paths in self.skip_verification_path"""
379379

380-
def build_full_path_nodes(field_match: DatumInContext):
381-
"""Traverse the matched Datum to build the path field by field"""
382-
full_path_nodes = [str(field_match.path).replace("'", "")]
383-
next_node = field_match
384-
while next_node.context is not None:
385-
full_path_nodes.append(str(next_node.context.path))
386-
next_node = next_node.context
387-
388-
return full_path_nodes[::-1][1:] # reverse the list and remove Root()/$
389-
390380
def _remove_placeholder(_tmp):
391381
"""Traverse the object and remove any values in a list that would be equal to the placeholder"""
392382
if isinstance(_tmp, dict):
@@ -407,34 +397,22 @@ def _remove_placeholder(_tmp):
407397
for path in self.skip_verification_paths:
408398
matches = parse(path).find(tmp) or []
409399
for m in matches:
410-
full_path = build_full_path_nodes(m)
411-
helper = tmp
412-
if len(full_path) > 1:
413-
for p in full_path[:-1]:
414-
if isinstance(helper, list) and p.lstrip("[").rstrip("]").isnumeric():
415-
helper = helper[int(p.lstrip("[").rstrip("]"))]
416-
elif isinstance(helper, dict):
417-
helper = helper.get(p, None)
418-
if not helper:
419-
continue
420-
421-
if (
422-
isinstance(helper, dict) and full_path[-1] in helper.keys()
423-
): # might have been deleted already
424-
del helper[full_path[-1]]
425-
elif isinstance(helper, list):
426-
try:
427-
index = int(full_path[-1].lstrip("[").rstrip("]"))
400+
# the context holds a reference to the container of the matched value, we can mutate it directly
401+
parent = m.context.value if m.context is not None else None
402+
if isinstance(parent, dict) and isinstance(m.path, Fields):
403+
for key in m.path.fields:
404+
parent.pop(key, None) # might have been deleted already
405+
elif isinstance(parent, list) and isinstance(m.path, Index):
406+
for index in m.path.indices:
428407
# we need to set a placeholder value as the skips are based on index
429408
# if we are to pop the values, the next skip index will have shifted and won't be correct
430-
helper[index] = _SKIP_PLACEHOLDER_VALUE
409+
parent[index] = _SKIP_PLACEHOLDER_VALUE
431410
has_placeholder = True
432-
except ValueError:
433-
SNAPSHOT_LOGGER.warning(
434-
"Snapshot skip path '%s' was not applied as it was invalid for that snapshot",
435-
path,
436-
exc_info=SNAPSHOT_LOGGER.isEnabledFor(logging.DEBUG),
437-
)
411+
elif isinstance(parent, (dict, list)):
412+
SNAPSHOT_LOGGER.warning(
413+
"Snapshot skip path '%s' was not applied as it was invalid for that snapshot",
414+
path,
415+
)
438416

439417
if has_placeholder:
440418
_remove_placeholder(tmp)

‎pyproject.toml‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,15 @@ authors = [
1010
version = "0.3.5"
1111
description = "Extracted snapshot testing lib for LocalStack"
1212
dependencies = [
13-
"jsonpath-ng>1.6",
13+
"jsonpath-ng>=1.9",
1414
"deepdiff",
1515
]
16-
requires-python = ">=3.10"
16+
requires-python = ">=3.11"
1717
license = {file = "LICENSE"}
1818
classifiers = [
1919
"License :: OSI Approved :: Apache Software License",
2020
"Operating System :: OS Independent",
2121
"Programming Language :: Python :: 3",
22-
"Programming Language :: Python :: 3.10",
2322
"Programming Language :: Python :: 3.11",
2423
"Programming Language :: Python :: 3.12",
2524
]
@@ -53,8 +52,8 @@ include = '((localstack_snapshot)/.*\.py$|tests/.*\.py$)'
5352
#extend_exclude = '()'
5453

5554
[tool.ruff]
56-
# Always generate Python 3.10-compatible code.
57-
target-version = "py310"
55+
# Always generate Python 3.11-compatible code.
56+
target-version = "py311"
5857
line-length = 110
5958
exclude = [
6059
".venv*",

‎tests/test_snapshots.py‎

Lines changed: 80 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import io
23
from enum import Enum
34

@@ -268,48 +269,44 @@ def test_non_homogeneous_list(self):
268269
sm.match("key1", [{"key2": "value1"}, "value2", 3])
269270
sm._assert_all()
270271

271-
def test_list_as_last_node_in_skip_verification_path(self):
272+
# skipping mutates the recorded and observed state, so every assertion needs a new session
273+
274+
@staticmethod
275+
def _new_session(recorded: dict, observed: dict) -> SnapshotSession:
272276
sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
273-
sm.recorded_state = {"key_a": {"aaa": ["item1", "item2", "item3"]}}
274-
sm.match(
275-
"key_a",
276-
{"aaa": ["item1", "different-value"]},
277-
)
277+
sm.recorded_state = {"key_a": copy.deepcopy(recorded)}
278+
sm.match("key_a", copy.deepcopy(observed))
279+
return sm
280+
281+
def test_list_as_last_node_in_skip_verification_path(self):
282+
recorded = {"aaa": ["item1", "item2", "item3"]}
283+
observed = {"aaa": ["item1", "different-value"]}
278284

279285
with pytest.raises(Exception) as ctx: # asserts it fail without skipping
280-
sm._assert_all()
286+
self._new_session(recorded, observed)._assert_all()
281287
ctx.match("Parity snapshot failed")
282288

283289
skip_path = ["$..aaa[1]", "$..aaa[2]"]
284-
sm._assert_all(skip_verification_paths=skip_path)
285-
286-
skip_path = ["$..aaa.1", "$..aaa.2"]
287-
sm._assert_all(skip_verification_paths=skip_path)
290+
self._new_session(recorded, observed)._assert_all(skip_verification_paths=skip_path)
288291

289292
def test_list_as_last_node_in_skip_verification_path_complex(self):
290-
sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
291-
sm.recorded_state = {
292-
"key_a": {
293-
"aaa": [
294-
{"aab": ["aac", "aad"]},
295-
{"aab": ["aac", "aad"]},
296-
{"aab": ["aac", "aad"]},
297-
]
298-
}
293+
recorded = {
294+
"aaa": [
295+
{"aab": ["aac", "aad"]},
296+
{"aab": ["aac", "aad"]},
297+
{"aab": ["aac", "aad"]},
298+
]
299+
}
300+
observed = {
301+
"aaa": [
302+
{"aab": ["aac", "bad-value"], "bbb": "value"},
303+
{"aab": ["aac", "aad", "bad-value"]},
304+
{"aab": ["bad-value", "aad"]},
305+
]
299306
}
300-
sm.match(
301-
"key_a",
302-
{
303-
"aaa": [
304-
{"aab": ["aac", "bad-value"], "bbb": "value"},
305-
{"aab": ["aac", "aad", "bad-value"]},
306-
{"aab": ["bad-value", "aad"]},
307-
]
308-
},
309-
)
310307

311308
with pytest.raises(Exception) as ctx: # asserts it fail without skipping
312-
sm._assert_all()
309+
self._new_session(recorded, observed)._assert_all()
313310
ctx.match("Parity snapshot failed")
314311

315312
skip_path = [
@@ -318,86 +315,75 @@ def test_list_as_last_node_in_skip_verification_path_complex(self):
318315
"$..aaa[1].aab[2]",
319316
"$..aaa[2].aab[0]",
320317
]
321-
sm._assert_all(skip_verification_paths=skip_path)
322-
323-
skip_path = [
324-
"$..aaa.0..aab.1",
325-
"$..aaa.0..bbb",
326-
"$..aaa.1..aab.2",
327-
"$..aaa.2..aab.0",
328-
]
329-
sm._assert_all(skip_verification_paths=skip_path)
318+
self._new_session(recorded, observed)._assert_all(skip_verification_paths=skip_path)
330319

331320
def test_list_as_mid_node_in_skip_verification_path(self):
332-
sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
333-
sm.recorded_state = {"key_a": {"aaa": [{"aab": "value1"}, {"aab": "value2"}]}}
334-
sm.match(
335-
"key_a",
336-
{"aaa": [{"aab": "value1"}, {"aab": "bad-value"}]},
337-
)
321+
recorded = {"aaa": [{"aab": "value1"}, {"aab": "value2"}]}
322+
observed = {"aaa": [{"aab": "value1"}, {"aab": "bad-value"}]}
338323

339324
with pytest.raises(Exception) as ctx: # asserts it fail without skipping
340-
sm._assert_all()
325+
self._new_session(recorded, observed)._assert_all()
341326
ctx.match("Parity snapshot failed")
342327

343328
skip_path = ["$..aaa[1].aab"]
344-
sm._assert_all(skip_verification_paths=skip_path)
329+
self._new_session(recorded, observed)._assert_all(skip_verification_paths=skip_path)
330+
331+
@pytest.mark.parametrize(
332+
"skip_path",
333+
[
334+
["$..aaa[2][0]"],
335+
# this will actually skip almost everything, as it will match every first element of any list inside `aaa`
336+
["$..aaa..[0]"],
337+
],
338+
)
339+
def test_list_as_last_node_in_skip_verification_path_nested(self, skip_path):
340+
recorded = {"aaa": ["bbb", "ccc", ["ddd", "eee", ["fff", "ggg"]]]}
341+
observed = {"aaa": ["bbb", "ccc", ["bad-value", "eee", ["fff", "ggg"]]]}
345342

346-
skip_path = ["$..aaa.1.aab"]
347-
sm._assert_all(skip_verification_paths=skip_path)
343+
with pytest.raises(Exception) as ctx: # asserts it fail without skipping
344+
self._new_session(recorded, observed)._assert_all()
345+
ctx.match("Parity snapshot failed")
348346

349-
def test_list_as_last_node_in_skip_verification_path_nested(self):
350-
sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
351-
sm.recorded_state = {
352-
"key_a": {
353-
"aaa": [
354-
"bbb",
355-
"ccc",
356-
[
357-
"ddd",
358-
"eee",
359-
[
360-
"fff",
361-
"ggg",
362-
],
363-
],
364-
]
347+
self._new_session(recorded, observed)._assert_all(skip_verification_paths=skip_path)
348+
349+
@pytest.mark.parametrize(
350+
"key",
351+
["with space", "Key%", "00", "10", "a.b", "aws:SourceArn", "Ünïcode", "x-amz-id"],
352+
)
353+
def test_special_characters_in_skip_verification_path(self, key):
354+
def _new_session():
355+
# use a new session for every assertion, as skipping mutates the recorded and observed state
356+
sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
357+
sm.recorded_state = {
358+
"key_a": {key: {"Inner": [{key: "value", "other": "same"}]}, "aaa": "same"}
365359
}
366-
}
367-
sm.match(
368-
"key_a",
369-
{
370-
"aaa": [
371-
"bbb",
372-
"ccc",
373-
[
374-
"bad-value",
375-
"eee",
376-
[
377-
"fff",
378-
"ggg",
379-
],
380-
],
381-
]
382-
},
383-
)
360+
sm.match(
361+
"key_a", {key: {"Inner": [{key: "bad-value", "other": "same"}]}, "aaa": "same"}
362+
)
363+
return sm
384364

385365
with pytest.raises(Exception) as ctx: # asserts it fail without skipping
386-
sm._assert_all()
366+
_new_session()._assert_all()
387367
ctx.match("Parity snapshot failed")
388368

389-
skip_path = ["$..aaa[2][0]"]
390-
sm._assert_all(skip_verification_paths=skip_path)
369+
# the key is used as an intermediate node as well as the last node
370+
_new_session()._assert_all(skip_verification_paths=[f"$..'{key}'.Inner[0].'{key}'"])
371+
_new_session()._assert_all(skip_verification_paths=[f"$..Inner[*].'{key}'"])
372+
_new_session()._assert_all(skip_verification_paths=[f"$..'{key}'"])
391373

392-
skip_path = ["$..aaa.2[0]"]
393-
sm._assert_all(skip_verification_paths=skip_path)
374+
def test_skip_verification_path_with_filter(self):
375+
def _new_session():
376+
sm = SnapshotSession(scope_key="A", verify=True, base_file_path="", update=False)
377+
sm.recorded_state = {"key_a": {"aaa": [{"id": "1", "v": "a"}, {"id": "2", "v": "b"}]}}
378+
sm.match("key_a", {"aaa": [{"id": "1", "v": "a"}, {"id": "2", "v": "bad-value"}]})
379+
return sm
394380

395-
# these 2 will actually skip almost everything, as they will match every first element of any list inside `aaa`
396-
skip_path = ["$..aaa..[0]"]
397-
sm._assert_all(skip_verification_paths=skip_path)
381+
with pytest.raises(Exception) as ctx: # asserts it fail without skipping
382+
_new_session()._assert_all()
383+
ctx.match("Parity snapshot failed")
398384

399-
skip_path = ["$..aaa..0"]
400-
sm._assert_all(skip_verification_paths=skip_path)
385+
_new_session()._assert_all(skip_verification_paths=["$..aaa[?(@.id == '2')].v"])
386+
_new_session()._assert_all(skip_verification_paths=["$..aaa[?(@.id == '2')]"])
401387

402388

403389
def test_json_diff_format():

0 commit comments

Comments
 (0)