Skip to content

Commit b3a755d

Browse files
committed
Add a fluent API for JSONPathMatch iterators.
1 parent c529b3f commit b3a755d

6 files changed

Lines changed: 192 additions & 9 deletions

File tree

docs/api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
::: jsonpath.CompoundJSONPath
1212
handler: python
1313

14+
::: jsonpath.Query
15+
handler: python
16+
1417
::: jsonpath.function_extensions.FilterFunction
1518
handler: python
1619

jsonpath/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .exceptions import RelativeJSONPointerIndexError
1818
from .exceptions import RelativeJSONPointerSyntaxError
1919
from .filter import UNDEFINED
20+
from .fluent_api import Query
2021
from .lex import Lexer
2122
from .match import JSONPathMatch
2223
from .parse import Parser
@@ -58,6 +59,7 @@
5859
"RelativeJSONPointerSyntaxError",
5960
"resolve",
6061
"UNDEFINED",
62+
"Query",
6163
)
6264

6365

@@ -69,3 +71,6 @@
6971
finditer = DEFAULT_ENV.finditer
7072
finditer_async = DEFAULT_ENV.finditer_async
7173
match = DEFAULT_ENV.match
74+
first = DEFAULT_ENV.match
75+
query = DEFAULT_ENV.query
76+
find = DEFAULT_ENV.query

jsonpath/env.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .filter import FunctionExtension
2828
from .filter import InfixExpression
2929
from .filter import Path
30+
from .fluent_api import Query
3031
from .function_extensions import ExpressionType
3132
from .function_extensions import FilterFunction
3233
from .function_extensions import validate
@@ -76,8 +77,6 @@ class attributes `root_token`, `self_token` and `filter_context_token`.
7677
- Hook in to mapping and sequence item getting by overriding `getitem()`.
7778
- Change filter comparison operator behavior by overriding `compare()`.
7879
79-
## Class attributes
80-
8180
Arguments:
8281
filter_caching (bool): If `True`, filter expressions will be cached
8382
where possible.
@@ -89,6 +88,8 @@ class attributes `root_token`, `self_token` and `filter_context_token`.
8988
9089
**New in version 0.10.0**
9190
91+
## Class attributes
92+
9293
Attributes:
9394
fake_root_token (str): The pattern used to select a "fake" root node, one level
9495
above the real root node.
@@ -229,9 +230,9 @@ def findall(
229230
*,
230231
filter_context: Optional[FilterContextVars] = None,
231232
) -> List[object]:
232-
"""Find all objects in `data` matching the given JSONPath `path`.
233+
"""Find all objects in _data_ matching the JSONPath _path_.
233234
234-
If `data` is a string or a file-like objects, it will be loaded
235+
If _data_ is a string or a file-like objects, it will be loaded
235236
using `json.loads()` and the default `JSONDecoder`.
236237
237238
Arguments:
@@ -259,10 +260,10 @@ def finditer(
259260
*,
260261
filter_context: Optional[FilterContextVars] = None,
261262
) -> Iterable[JSONPathMatch]:
262-
"""Generate `JSONPathMatch` objects for each match.
263+
"""Generate `JSONPathMatch` objects for each match of _path_ in _data_.
263264
264-
If `data` is a string or a file-like objects, it will be loaded
265-
using `json.loads()` and the default `JSONDecoder`.
265+
If _data_ is a string or a file-like objects, it will be loaded using
266+
`json.loads()` and the default `JSONDecoder`.
266267
267268
Arguments:
268269
path: The JSONPath as a string.
@@ -310,6 +311,50 @@ def match(
310311
"""
311312
return self.compile(path).match(data, filter_context=filter_context)
312313

314+
def query(
315+
self,
316+
path: str,
317+
data: Union[str, IOBase, Sequence[Any], Mapping[str, Any]],
318+
filter_context: Optional[FilterContextVars] = None,
319+
) -> Query:
320+
"""Return a `Query` object over matches found by applying _path_ to _data_.
321+
322+
`Query` objects are iterable.
323+
324+
```
325+
for match in jsonpath.query("$.foo..bar", data):
326+
...
327+
```
328+
329+
You can skip and limit results with `Query.skip()` and `Query.limit()`.
330+
331+
```
332+
matches = (
333+
jsonpath.query("$.foo..bar", data)
334+
.skip(5)
335+
.limit(10)
336+
)
337+
338+
for match in matches
339+
...
340+
```
341+
342+
`Query.tail()` will get the last _n_ results.
343+
344+
```
345+
for match in jsonpath.query("$.foo..bar", data).tail(5):
346+
...
347+
```
348+
349+
Get values for each match using `Query.values()`.
350+
351+
```
352+
for obj in jsonpath.query("$.foo..bar", data).limit(5).values():
353+
...
354+
```
355+
"""
356+
return Query(self.finditer(path, data, filter_context=filter_context))
357+
313358
async def findall_async(
314359
self,
315360
path: str,

jsonpath/fluent_api.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""A fluent API for managing JSONPathMatch iterators."""
2+
from __future__ import annotations
3+
4+
import collections
5+
import itertools
6+
from typing import TYPE_CHECKING
7+
from typing import Iterable
8+
from typing import Iterator
9+
from typing import Optional
10+
from typing import Tuple
11+
12+
if TYPE_CHECKING:
13+
from jsonpath import JSONPathMatch
14+
15+
16+
class Query:
17+
"""A fluent API for managing `JSONPathMatch` iterators.
18+
19+
Usually you'll want to use `jsonpath.query()` or `JSONPathEnvironment.query()`
20+
to create instances of `Query` rather than instantiating `Query` directly.
21+
22+
Arguments:
23+
it: A `JSONPathMatch` iterable, as you'd get from `jsonpath.finditer()` or
24+
`JSONPathEnvironment.finditer()`.
25+
26+
**New in version 1.1.0**
27+
"""
28+
29+
def __init__(self, it: Iterable[JSONPathMatch]) -> None:
30+
self._it = iter(it)
31+
32+
def __iter__(self) -> Iterator[JSONPathMatch]:
33+
return self._it
34+
35+
def take(self, n: int) -> Query:
36+
"""Limit the result set to at most _n_ matches.
37+
38+
Raises:
39+
ValueError: If _n_ < 0.
40+
"""
41+
if n < 0:
42+
raise ValueError("can't take a negative number of matches")
43+
44+
self._it = itertools.islice(self._it, n)
45+
return self
46+
47+
def limit(self, n: int) -> Query:
48+
"""Limit the result set to at most _n_ matches.
49+
50+
`limit()` is an alias of `take()`.
51+
52+
Raises:
53+
ValueError: If _n_ < 0.
54+
"""
55+
return self.take(n)
56+
57+
def head(self, n: int) -> Query:
58+
"""Take the first _n_ matches.
59+
60+
`head()` is an alias for `take()`.
61+
62+
Raises:
63+
ValueError: If _n_ < 0.
64+
"""
65+
return self.take(n)
66+
67+
def drop(self, n: int) -> Query:
68+
"""Skip up to _n_ matches from the result set.
69+
70+
Raises:
71+
ValueError: If _n_ < 0.
72+
"""
73+
if n < 0:
74+
raise ValueError("can't drop a negative number of matches")
75+
76+
if n > 0:
77+
next(itertools.islice(self._it, n, n), None)
78+
79+
return self
80+
81+
def skip(self, n: int) -> Query:
82+
"""Skip up to _n_ matches from the result set.
83+
84+
Raises:
85+
ValueError: If _n_ < 0.
86+
"""
87+
return self.drop(n)
88+
89+
def tail(self, n: int) -> Query:
90+
"""Drop matches up to the last _n_ matches.
91+
92+
Raises:
93+
ValueError: If _n_ < 0.
94+
"""
95+
if n < 0:
96+
raise ValueError("can't select a negative number of matches")
97+
98+
self._it = iter(collections.deque(self._it, maxlen=n))
99+
return self
100+
101+
def values(self) -> Iterable[object]:
102+
"""Return an iterable of objects associated with each match."""
103+
return (m.obj for m in self._it)
104+
105+
def locations(self) -> Iterable[str]:
106+
"""Return an iterable of normalized paths for each match."""
107+
return (m.path for m in self._it)
108+
109+
def items(self) -> Iterable[Tuple[str, object]]:
110+
"""Return an iterable of (object, normalized path) tuples for each match."""
111+
return ((m.path, m.obj) for m in self._it)
112+
113+
def first(self) -> Optional[JSONPathMatch]:
114+
"""Return the first `JSONPathMatch` or `None` if there were no matches."""
115+
try:
116+
return next(self._it)
117+
except StopIteration:
118+
return None
119+
120+
def last(self) -> Optional[JSONPathMatch]:
121+
"""Return the last `JSONPathMatch` or `None` if there were no matches."""
122+
try:
123+
return next(iter(self.tail(1)))
124+
except StopIteration:
125+
return None

jsonpath/match.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ def pointer(self) -> JSONPointer:
7676
"""Return a `JSONPointer` pointing to this match's path."""
7777
return JSONPointer.from_match(self)
7878

79+
@property
80+
def value(self) -> object:
81+
"""Return the value associated with this match/node."""
82+
return self.obj
83+
7984

8085
def _truncate(val: str, num: int, end: str = "...") -> str:
8186
# Replaces consecutive whitespace with a single newline.

tests/consensus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ class Query:
4444
}
4545

4646
SKIP = {
47-
"bracket_notation_with_number_on_object": "Bad consensus",
48-
"dot_notation_with_number_-1": "Unexpected token",
47+
"bracket_notation_with_number_on_object": "We support unquoted property names",
48+
"dot_notation_with_number_-1": "conflict with compliance",
4949
"dot_notation_with_number_on_object": "conflict with compliance",
5050
}
5151

0 commit comments

Comments
 (0)