Skip to content

Commit a5605a1

Browse files
committed
Introduce strict mode and use regex if available
1 parent 616f438 commit a5605a1

File tree

9 files changed

+115
-51
lines changed

9 files changed

+115
-51
lines changed

jsonpath/env.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ class attributes `root_token`, `self_token` and `filter_context_token`.
8888
well-typedness as compile time.
8989
9090
**New in version 0.10.0**
91-
91+
strict: When `True`, follow RFC 9535 strictly.
92+
**New in version 2.0.0**
9293
## Class attributes
9394
9495
Attributes:
@@ -143,6 +144,7 @@ def __init__(
143144
filter_caching: bool = True,
144145
unicode_escape: bool = True,
145146
well_typed: bool = True,
147+
strict: bool = False,
146148
) -> None:
147149
self.filter_caching: bool = filter_caching
148150
"""Enable or disable filter expression caching."""
@@ -154,6 +156,14 @@ def __init__(
154156
self.well_typed: bool = well_typed
155157
"""Control well-typedness checks on filter function expressions."""
156158

159+
self.strict: bool = strict
160+
"""When `True`, follow RFC 9535 strictly.
161+
162+
This includes things like enforcing a leading root identifier and
163+
ensuring there's not leading or trailing whitespace when parsing a
164+
JSONPath query.
165+
"""
166+
157167
self.lexer: Lexer = self.lexer_class(env=self)
158168
"""The lexer bound to this environment."""
159169

@@ -188,8 +198,10 @@ def compile(self, path: str) -> Union[JSONPath, CompoundJSONPath]: # noqa: A003
188198
env=self, segments=self.parser.parse(stream), pseudo_root=pseudo_root
189199
)
190200

191-
# TODO: Optionally raise for trailing whitespace
192-
stream.skip_whitespace()
201+
if stream.skip_whitespace() and self.strict:
202+
raise JSONPathSyntaxError(
203+
"unexpected whitespace", token=stream.tokens[stream.pos - 1]
204+
)
193205

194206
# TODO: better!
195207
if stream.current().kind != TOKEN_EOF:

jsonpath/function_extensions/match.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""The standard `match` function extension."""
22

3-
import re
3+
try:
4+
import regex as re
5+
except ImportError:
6+
import re # type: ignore
47

58
from jsonpath.function_extensions import ExpressionType
69
from jsonpath.function_extensions import FilterFunction

jsonpath/function_extensions/search.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""The standard `search` function extension."""
22

3-
import re
3+
try:
4+
import regex as re
5+
except ImportError:
6+
import re # type: ignore
47

58
from jsonpath.function_extensions import ExpressionType
69
from jsonpath.function_extensions import FilterFunction

jsonpath/parse.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
from .token import TOKEN_TRUE
102102
from .token import TOKEN_UNDEFINED
103103
from .token import TOKEN_UNION
104+
from .token import TOKEN_WHITESPACE
104105
from .token import TOKEN_WILD
105106
from .token import Token
106107

@@ -295,10 +296,29 @@ def __init__(self, *, env: JSONPathEnvironment) -> None:
295296
}
296297

297298
def parse(self, stream: TokenStream) -> Iterator[JSONPathSegment]:
298-
"""Parse a JSONPath from a stream of tokens."""
299-
# TODO: Optionally require TOKEN_ROOT
300-
if stream.current().kind in {TOKEN_ROOT, TOKEN_PSEUDO_ROOT}:
299+
"""Parse a JSONPath query from a stream of tokens."""
300+
if stream.skip_whitespace() and self.env.strict:
301+
raise JSONPathSyntaxError(
302+
"unexpected leading whitespace", token=stream.current()
303+
)
304+
305+
if (
306+
self.env.strict
307+
and len(stream.tokens)
308+
and stream.tokens[-1].kind == TOKEN_WHITESPACE
309+
):
310+
raise JSONPathSyntaxError(
311+
"unexpected trailing whitespace", token=stream.tokens[-1]
312+
)
313+
314+
token = stream.current()
315+
316+
if token.kind == TOKEN_ROOT or (
317+
token.kind == TOKEN_PSEUDO_ROOT and not self.env.strict
318+
):
301319
stream.next()
320+
elif self.env.strict:
321+
stream.expect(TOKEN_ROOT)
302322

303323
yield from self.parse_query(stream)
304324

jsonpath/selectors.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,15 @@ def _normalized_index(self, obj: Sequence[object]) -> int:
130130
return self.index
131131

132132
def resolve(self, node: JSONPathMatch) -> Iterable[JSONPathMatch]:
133-
# TODO: Optionally try string representation of int
134-
# if isinstance(node.obj, Mapping):
135-
# # Try the string representation of the index as a key.
136-
# with suppress(KeyError):
137-
# match = node.new_child(
138-
# self.env.getitem(node.obj, self._as_key), self.index
139-
# )
140-
# node.add_child(match)
141-
# yield match
133+
# Optionally try string representation of int
134+
if not self.env.strict and isinstance(node.obj, Mapping):
135+
# Try the string representation of the index as a key.
136+
with suppress(KeyError):
137+
match = node.new_child(
138+
self.env.getitem(node.obj, self._as_key), self.index
139+
)
140+
node.add_child(match)
141+
yield match
142142
if isinstance(node.obj, Sequence) and not isinstance(node.obj, str):
143143
norm_index = self._normalized_index(node.obj)
144144
with suppress(IndexError):
@@ -149,15 +149,14 @@ def resolve(self, node: JSONPathMatch) -> Iterable[JSONPathMatch]:
149149
yield match
150150

151151
async def resolve_async(self, node: JSONPathMatch) -> AsyncIterable[JSONPathMatch]:
152-
# XXX
153-
# if isinstance(node.obj, Mapping):
154-
# # Try the string representation of the index as a key.
155-
# with suppress(KeyError):
156-
# match = node.new_child(
157-
# await self.env.getitem_async(node.obj, self._as_key), self.index
158-
# )
159-
# node.add_child(match)
160-
# yield match
152+
if not self.env.strict and isinstance(node.obj, Mapping):
153+
# Try the string representation of the index as a key.
154+
with suppress(KeyError):
155+
match = node.new_child(
156+
await self.env.getitem_async(node.obj, self._as_key), self.index
157+
)
158+
node.add_child(match)
159+
yield match
161160
if isinstance(node.obj, Sequence) and not isinstance(node.obj, str):
162161
norm_index = self._normalized_index(node.obj)
163162
with suppress(IndexError):

jsonpath/stream.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ def expect_peek_not(self, typ: str, message: str) -> None:
8989
if self.peek().kind == typ:
9090
raise JSONPathSyntaxError(message, token=self.peek())
9191

92-
def skip_whitespace(self) -> None:
92+
def skip_whitespace(self) -> bool:
9393
"""Skip whitespace."""
9494
if self.current().kind == TOKEN_WHITESPACE:
9595
self.pos += 1
96+
return True
97+
return False

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ include = ["/jsonpath"]
4848
dependencies = [
4949
"pytest",
5050
"pytest-cov",
51-
"black",
5251
"mypy",
53-
"ipython",
52+
"regex",
53+
"iregexp-check",
5454
"pyyaml",
5555
"types-pyyaml",
56+
"types-regex",
5657
"twine",
5758
"ruff",
5859
]
@@ -78,6 +79,9 @@ dependencies = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material"]
7879
build = "mkdocs build --clean --strict"
7980
serve = "mkdocs serve --dev-addr localhost:8000"
8081

82+
[tool.hatch.envs.no-regex]
83+
dependencies = ["pytest"]
84+
8185
[tool.coverage.run]
8286
branch = true
8387
parallel = true

tests/test_compliance.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
import pytest
2020

21-
import jsonpath
21+
from jsonpath import JSONPathEnvironment
22+
from jsonpath import JSONPathError
23+
from jsonpath import NodeList
2224

2325

2426
@dataclass
@@ -35,10 +37,6 @@ class Case:
3537

3638

3739
SKIP = {
38-
# "basic, no leading whitespace": "flexible whitespace policy",
39-
"basic, no trailing whitespace": "flexible whitespace policy",
40-
# "basic, bald descendant segment": "almost has a consensus",
41-
# "filter, index segment on object, selects nothing": "flexible selector policy",
4240
"functions, match, dot matcher on \\u2028": "standard library re policy",
4341
"functions, match, dot matcher on \\u2029": "standard library re policy",
4442
"functions, search, dot matcher on \\u2028": "standard library re policy",
@@ -76,14 +74,6 @@ class Case:
7674
"name selector, double quotes, non-surrogate surrogate": "expected behavior policy",
7775
"name selector, double quotes, surrogate supplementary": "expected behavior policy",
7876
"name selector, double quotes, supplementary surrogate": "expected behavior policy",
79-
# "whitespace, selectors, space between dot and name": "flexible whitespace policy", # noqa: E501
80-
# "whitespace, selectors, newline between dot and name": "flexible whitespace policy", # noqa: E501
81-
# "whitespace, selectors, tab between dot and name": "flexible whitespace policy", # noqa: E501
82-
# "whitespace, selectors, return between dot and name": "flexible whitespace policy", # noqa: E501
83-
# "whitespace, selectors, space between recursive descent and name": "flexible whitespace policy", # noqa: E501
84-
# "whitespace, selectors, newline between recursive descent and name": "flexible whitespace policy", # noqa: E501
85-
# "whitespace, selectors, tab between recursive descent and name": "flexible whitespace policy", # noqa: E501
86-
# "whitespace, selectors, return between recursive descent and name": "flexible whitespace policy", # noqa: E501
8777
}
8878

8979

@@ -101,13 +91,18 @@ def invalid_cases() -> List[Case]:
10191
return [case for case in cases() if case.invalid_selector]
10292

10393

94+
@pytest.fixture()
95+
def env() -> JSONPathEnvironment:
96+
return JSONPathEnvironment(strict=True)
97+
98+
10499
@pytest.mark.parametrize("case", valid_cases(), ids=operator.attrgetter("name"))
105-
def test_compliance(case: Case) -> None:
100+
def test_compliance(env: JSONPathEnvironment, case: Case) -> None:
106101
if case.name in SKIP:
107102
pytest.skip(reason=SKIP[case.name])
108103

109104
assert case.document is not None
110-
nodes = jsonpath.NodeList(jsonpath.finditer(case.selector, case.document))
105+
nodes = NodeList(env.finditer(case.selector, case.document))
111106

112107
if case.results is not None:
113108
assert case.results_paths is not None
@@ -120,14 +115,14 @@ def test_compliance(case: Case) -> None:
120115

121116

122117
@pytest.mark.parametrize("case", valid_cases(), ids=operator.attrgetter("name"))
123-
def test_compliance_async(case: Case) -> None:
118+
def test_compliance_async(env: JSONPathEnvironment, case: Case) -> None:
124119
if case.name in SKIP:
125120
pytest.skip(reason=SKIP[case.name])
126121

127-
async def coro() -> jsonpath.NodeList:
122+
async def coro() -> NodeList:
128123
assert case.document is not None
129-
it = await jsonpath.finditer_async(case.selector, case.document)
130-
return jsonpath.NodeList([node async for node in it])
124+
it = await env.finditer_async(case.selector, case.document)
125+
return NodeList([node async for node in it])
131126

132127
nodes = asyncio.run(coro())
133128

@@ -142,9 +137,9 @@ async def coro() -> jsonpath.NodeList:
142137

143138

144139
@pytest.mark.parametrize("case", invalid_cases(), ids=operator.attrgetter("name"))
145-
def test_invalid_selectors(case: Case) -> None:
140+
def test_invalid_selectors(env: JSONPathEnvironment, case: Case) -> None:
146141
if case.name in SKIP:
147142
pytest.skip(reason=SKIP[case.name])
148143

149-
with pytest.raises(jsonpath.JSONPathError):
150-
jsonpath.compile(case.selector)
144+
with pytest.raises(JSONPathError):
145+
env.compile(case.selector)

tests/test_strictness.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import pytest
2+
3+
from jsonpath import JSONPathEnvironment
4+
5+
6+
@pytest.fixture()
7+
def env() -> JSONPathEnvironment:
8+
return JSONPathEnvironment(strict=False)
9+
10+
11+
def test_leading_whitespace(env: JSONPathEnvironment) -> None:
12+
query = " $.a"
13+
data = {"a": 1}
14+
assert env.findall(query, data) == [1]
15+
16+
17+
def test_trailing_whitespace(env: JSONPathEnvironment) -> None:
18+
query = "$.a "
19+
data = {"a": 1}
20+
assert env.findall(query, data) == [1]
21+
22+
23+
def test_index_as_object_name(env: JSONPathEnvironment) -> None:
24+
query = "$.a[0]"
25+
data = {"a": {"0": 1}}
26+
assert env.findall(query, data) == [1]

0 commit comments

Comments
 (0)