Skip to content

Commit 17ac4d5

Browse files
authored
Merge pull request #11 from jg-rp/fix-canonical-paths
Fix normalized and canonical paths
2 parents 9fa460d + 2aab739 commit 17ac4d5

File tree

10 files changed

+119
-13
lines changed

10 files changed

+119
-13
lines changed

.gitmodules

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "tests/cts"]
22
path = tests/cts
33
url = [email protected]:jsonpath-standard/jsonpath-compliance-test-suite.git
4+
[submodule "tests/nts"]
5+
path = tests/nts
6+
url = [email protected]:jg-rp/jsonpath-compliance-normalized-paths.git

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Python JSONPath RFC 9535 Change Log
22

3+
## Version 0.1.4 (unreleased)
4+
5+
**Fixes**
6+
7+
- Fixed normalized paths produced by `JSONPathNode.path()`. Previously we were not handling some escape sequences correctly in name selectors.
8+
- Fixed serialization of `JSONPathQuery` instances. `JSONPathQuery.__str__()` now serialized name selectors and string literals to the canonical format, similar to normalized paths. We're also now minimizing the use of parentheses when serializing logical expressions.
9+
310
## Version 0.1.3
411

512
**Fixes**

jsonpath_rfc9535/__about__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.3"
1+
__version__ = "0.1.4"

jsonpath_rfc9535/filter_expressions.py

+34-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import json
65
from abc import ABC
76
from abc import abstractmethod
87
from typing import TYPE_CHECKING
@@ -16,6 +15,7 @@
1615

1716
from .exceptions import JSONPathTypeError
1817
from .node import JSONPathNodeList
18+
from .serialize import canonical_string
1919

2020
if TYPE_CHECKING:
2121
from .environment import JSONPathEnvironment
@@ -45,6 +45,12 @@ def evaluate(self, context: FilterContext) -> object:
4545
"""
4646

4747

48+
PRECEDENCE_LOWEST = 1
49+
PRECEDENCE_LOGICAL_OR = 3
50+
PRECEDENCE_LOGICAL_AND = 4
51+
PRECEDENCE_PREFIX = 7
52+
53+
4854
class FilterExpression(Expression):
4955
"""An expression that evaluates to `true` or `false`."""
5056

@@ -55,7 +61,7 @@ def __init__(self, token: Token, expression: Expression) -> None:
5561
self.expression = expression
5662

5763
def __str__(self) -> str:
58-
return str(self.expression)
64+
return self._canonical_string(self.expression, PRECEDENCE_LOWEST)
5965

6066
def __eq__(self, other: object) -> bool:
6167
return (
@@ -66,6 +72,31 @@ def evaluate(self, context: FilterContext) -> bool:
6672
"""Evaluate the filter expression in the given _context_."""
6773
return _is_truthy(self.expression.evaluate(context))
6874

75+
def _canonical_string(self, expression: Expression, parent_precedence: int) -> str:
76+
if isinstance(expression, LogicalExpression):
77+
if expression.operator == "&&":
78+
left = self._canonical_string(expression.left, PRECEDENCE_LOGICAL_AND)
79+
right = self._canonical_string(expression.right, PRECEDENCE_LOGICAL_AND)
80+
expr = f"{left} && {right}"
81+
return (
82+
f"({expr})" if parent_precedence >= PRECEDENCE_LOGICAL_AND else expr
83+
)
84+
85+
if expression.operator == "||":
86+
left = self._canonical_string(expression.left, PRECEDENCE_LOGICAL_OR)
87+
right = self._canonical_string(expression.right, PRECEDENCE_LOGICAL_OR)
88+
expr = f"{left} || {right}"
89+
return (
90+
f"({expr})" if parent_precedence >= PRECEDENCE_LOGICAL_OR else expr
91+
)
92+
93+
if isinstance(expression, PrefixExpression):
94+
operand = self._canonical_string(expression.right, PRECEDENCE_PREFIX)
95+
expr = f"!{operand}"
96+
return f"({expr})" if parent_precedence > PRECEDENCE_PREFIX else expr
97+
98+
return str(expression)
99+
69100

70101
LITERAL_T = TypeVar("LITERAL_T")
71102

@@ -105,7 +136,7 @@ class StringLiteral(FilterExpressionLiteral[str]):
105136
__slots__ = ()
106137

107138
def __str__(self) -> str:
108-
return json.dumps(self.value)
139+
return canonical_string(self.value)
109140

110141

111142
class IntegerLiteral(FilterExpressionLiteral[int]):

jsonpath_rfc9535/node.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from typing import Tuple
88
from typing import Union
99

10+
from .serialize import canonical_string
11+
1012
if TYPE_CHECKING:
1113
from .environment import JSONValue
1214

@@ -39,7 +41,8 @@ def __init__(
3941
def path(self) -> str:
4042
"""Return the normalized path to this node."""
4143
return "$" + "".join(
42-
f"['{p}']" if isinstance(p, str) else f"[{p}]" for p in self.location
44+
f"[{canonical_string(p)}]" if isinstance(p, str) else f"[{p}]"
45+
for p in self.location
4346
)
4447

4548
def new_child(self, value: object, key: Union[int, str]) -> JSONPathNode:

jsonpath_rfc9535/selectors.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .exceptions import JSONPathIndexError
1616
from .exceptions import JSONPathTypeError
1717
from .filter_expressions import FilterContext
18+
from .serialize import canonical_string
1819

1920
if TYPE_CHECKING:
2021
from .environment import JSONPathEnvironment
@@ -60,7 +61,7 @@ def __init__(
6061
self.name = name
6162

6263
def __str__(self) -> str:
63-
return repr(self.name)
64+
return canonical_string(self.name)
6465

6566
def __eq__(self, __value: object) -> bool:
6667
return (

jsonpath_rfc9535/serialize.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Helper functions for serializing compiled JSONPath queries."""
2+
3+
import json
4+
5+
6+
def canonical_string(value: str) -> str:
7+
"""Return _value_ as a canonically formatted string literal."""
8+
single_quoted = json.dumps(value)[1:-1].replace('\\"', '"').replace("'", "\\'")
9+
return f"'{single_quoted}'"

tests/nts

Submodule nts added at c9288b3

tests/test_nts.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Test Python JSONPath against the Normalized Path Test Suite."""
2+
3+
import json
4+
import operator
5+
from dataclasses import dataclass
6+
from typing import List
7+
8+
import pytest
9+
10+
import jsonpath_rfc9535 as jsonpath
11+
from jsonpath_rfc9535.environment import JSONValue
12+
13+
14+
@dataclass
15+
class NormalizedCase:
16+
name: str
17+
query: str
18+
document: JSONValue
19+
paths: List[str]
20+
21+
22+
def normalized_cases() -> List[NormalizedCase]:
23+
with open("tests/nts/normalized_paths.json", encoding="utf8") as fd:
24+
data = json.load(fd)
25+
return [NormalizedCase(**case) for case in data["tests"]]
26+
27+
28+
@pytest.mark.parametrize("case", normalized_cases(), ids=operator.attrgetter("name"))
29+
def test_nts_normalized_paths(case: NormalizedCase) -> None:
30+
nodes = jsonpath.find(case.query, case.document)
31+
paths = [node.path() for node in nodes]
32+
assert paths == case.paths
33+
34+
35+
@dataclass
36+
class CanonicalCase:
37+
name: str
38+
query: str
39+
canonical: str
40+
41+
42+
def canonical_cases() -> List[CanonicalCase]:
43+
with open("tests/nts/canonical_paths.json", encoding="utf8") as fd:
44+
data = json.load(fd)
45+
return [CanonicalCase(**case) for case in data["tests"]]
46+
47+
48+
@pytest.mark.parametrize("case", canonical_cases(), ids=operator.attrgetter("name"))
49+
def test_nts_canonical_paths(case: CanonicalCase) -> None:
50+
query = jsonpath.compile(case.query)
51+
assert str(query) == case.canonical

tests/test_parse.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ class Case:
8989
Case(
9090
description="filter with string literal",
9191
query="$.some[?(@.thing == 'foo')]",
92-
want="$['some'][?@['thing'] == \"foo\"]",
92+
want="$['some'][?@['thing'] == 'foo']",
9393
),
9494
Case(
9595
description="filter with integer literal",
@@ -104,32 +104,32 @@ class Case:
104104
Case(
105105
description="filter with logical not",
106106
query="$.some[?(@.thing > 1 && !$.other)]",
107-
want="$['some'][?(@['thing'] > 1 && !$['other'])]",
107+
want="$['some'][?@['thing'] > 1 && !$['other']]",
108108
),
109109
Case(
110110
description="filter with grouped expression",
111111
query="$.some[?(@.thing > 1 && ($.foo || $.bar))]",
112-
want="$['some'][?(@['thing'] > 1 && ($['foo'] || $['bar']))]",
112+
want="$['some'][?@['thing'] > 1 && ($['foo'] || $['bar'])]",
113113
),
114114
Case(
115115
description="comparison to single quoted string literal with escape",
116116
query="$[[email protected] == 'ba\\'r']",
117-
want="$[?@['foo'] == \"ba'r\"]",
117+
want="$[?@['foo'] == 'ba\\'r']",
118118
),
119119
Case(
120120
description="comparison to double quoted string literal with escape",
121121
query='$[[email protected] == "ba\\"r"]',
122-
want='$[?@[\'foo\'] == "ba\\"r"]',
122+
want="$[?@['foo'] == 'ba\"r']",
123123
),
124124
Case(
125125
description="not binds more tightly than or",
126126
127-
want="$[?(!@['a'] || !@['b'])]",
127+
want="$[?!@['a'] || !@['b']]",
128128
),
129129
Case(
130130
description="not binds more tightly than and",
131131
132-
want="$[?(!@['a'] && !@['b'])]",
132+
want="$[?!@['a'] && !@['b']]",
133133
),
134134
Case(
135135
description="control precedence with parens",

0 commit comments

Comments
 (0)