Skip to content

Commit

Permalink
Merge pull request #53 from jg-rp/fluent-iapi
Browse files Browse the repository at this point in the history
Add a fluent API for JSONPathMatch iterators.
  • Loading branch information
jg-rp authored Mar 4, 2024
2 parents c529b3f + 67c32bf commit 5b1514b
Show file tree
Hide file tree
Showing 12 changed files with 636 additions and 43 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Python JSONPath Change Log

## Version 1.1.0 (unreleased)

**Features**

- Added the "query API", a fluent, chainable API for manipulating `JSONPathMatch` iterators.

## Version 1.0.0

[RFC 9535](https://datatracker.ietf.org/doc/html/rfc9535) (JSONPath: Query Expressions for JSON) is now out, replacing the [draft IETF JSONPath base](https://datatracker.ietf.org/doc/html/draft-ietf-jsonpath-base-21).
Expand Down
3 changes: 3 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
::: jsonpath.CompoundJSONPath
handler: python

::: jsonpath.Query
handler: python

::: jsonpath.function_extensions.FilterFunction
handler: python

Expand Down
94 changes: 94 additions & 0 deletions docs/query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Query Iterators

**_New in version 1.1.0_**

In addition to [`findall()`](api.md#jsonpath.JSONPathEnvironment.findall) and [`finditer()`](api.md#jsonpath.JSONPathEnvironment.finditer), covered in the [quick start guide](./quickstart.md), Python JSONPath offers a fluent _query_ iterator interface.

[`Query`](api.md#jsonpath.Query) objects provide chainable methods for manipulating a [`JSONPathMatch`](api.md#jsonpath.JSONPathMatch) iterator, just like you'd get from `finditer()`. Obtain a `Query` object using the package-level `query()` function or [`JSONPathEnvironment.query()`](api.md#jsonpath.JSONPathEnvironment.query).

This example uses the query API to skip the first five matches, limit the total number of matches to ten, and get the value associated with each match.

```python
from jsonpath import query

# data = ...

values = (
query("$.some[[email protected]]", data)
.skip(5)
.limit(10)
.values()
)

for value in values:
# ...
```

`Query` objects are iterable and can only be iterated once. Pass the query to `list()` (or other sequence) to get a list of results that can be iterated multiple times or otherwise manipulated.

```python
from jsonpath import query

# data = ...

values = list(
query("$.some[[email protected]]", data)
.skip(5)
.limit(10)
.values()
)

print(values[1])
```

## Chainable methods

The following `Query` methods all return `self` (the same `Query` instance), so method calls can be chained to further manipulate the underlying iterator.

| Method | Aliases | Description |
| --------------- | --------------- | -------------------------------------------------- |
| `skip(n: int)` | `drop` | Drop up to _n_ matches from the iterator. |
| `limit(n: int)` | `head`, `first` | Yield at most _n_ matches from the iterator. |
| `tail(n: int)` | `last` | Drop matches from the iterator up to the last _n_. |

## Terminal methods

These are terminal methods of the `Query` class. They can not be chained.

| Method | Aliases | Description |
| ------------- | ------- | ------------------------------------------------------------------------------------------- |
| `values()` | | Return an iterable of objects, one for each match in the iterable. |
| `locations()` | | Return an iterable of normalized paths, one for each match in the iterable. |
| `items()` | | Return an iterable of (object, normalized path) tuples, one for each match in the iterable. |
| `pointers()` | | Return an iterable of `JSONPointer` instances, one for each match in the iterable. |
| `first_one()` | `one` | Return the first `JSONPathMatch`, or `None` if there were no matches. |
| `last_one()` | | Return the last `JSONPathMatch`, or `None` if there were no matches. |

## Take

[`Query.take(self, n: int)`](api.md#jsonpath.Query.take) returns a new `Query` instance, iterating over the next _n_ matches. It leaves the existing query in a safe state, ready to resume iteration of remaining matches.

```python
from jsonpath import query

it = query("$.some.*", {"some": [0, 1, 2, 3]})

for match in it.take(2):
print(match.value) # 0, 1

for value in it.values():
print(value) # 2, 3
```

## Tee

And finally there's `tee()`, which creates multiple independent queries from one query iterator. It is not safe to use the initial `Query` instance after calling `tee()`.

```python
from jsonpath import query

it1, it2 = query("$.some[[email protected]]", data).tee()

head = it1.head(10) # first 10 matches
tail = it2.tail(10) # last 10 matches
```
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ print(data) # {'some': {'other': 'thing', 'foo': {'bar': [1], 'else': 'thing'}}

## What's Next?

Read about user-defined filter functions at [Function Extensions](advanced.md#function-extensions), or see how to make extra data available to filters with [Extra Filter Context](advanced.md#extra-filter-context).
Read about the [Query Iterators](query.md) API or [user-defined filter functions](advanced.md#function-extensions). Also see how to make extra data available to filters with [Extra Filter Context](advanced.md#extra-filter-context).

`findall()`, `finditer()` and `compile()` are shortcuts that use the default[`JSONPathEnvironment`](api.md#jsonpath.JSONPathEnvironment). `jsonpath.findall(path, data)` is equivalent to:

Expand Down
6 changes: 6 additions & 0 deletions jsonpath/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .exceptions import RelativeJSONPointerIndexError
from .exceptions import RelativeJSONPointerSyntaxError
from .filter import UNDEFINED
from .fluent_api import Query
from .lex import Lexer
from .match import JSONPathMatch
from .parse import Parser
Expand All @@ -30,10 +31,12 @@
__all__ = (
"compile",
"CompoundJSONPath",
"find",
"findall_async",
"findall",
"finditer_async",
"finditer",
"first",
"JSONPatch",
"JSONPath",
"JSONPathEnvironment",
Expand All @@ -52,6 +55,8 @@
"Lexer",
"match",
"Parser",
"query",
"Query",
"RelativeJSONPointer",
"RelativeJSONPointerError",
"RelativeJSONPointerIndexError",
Expand All @@ -69,3 +74,4 @@
finditer = DEFAULT_ENV.finditer
finditer_async = DEFAULT_ENV.finditer_async
match = DEFAULT_ENV.match
query = DEFAULT_ENV.query
59 changes: 52 additions & 7 deletions jsonpath/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .filter import FunctionExtension
from .filter import InfixExpression
from .filter import Path
from .fluent_api import Query
from .function_extensions import ExpressionType
from .function_extensions import FilterFunction
from .function_extensions import validate
Expand Down Expand Up @@ -76,8 +77,6 @@ class attributes `root_token`, `self_token` and `filter_context_token`.
- Hook in to mapping and sequence item getting by overriding `getitem()`.
- Change filter comparison operator behavior by overriding `compare()`.
## Class attributes
Arguments:
filter_caching (bool): If `True`, filter expressions will be cached
where possible.
Expand All @@ -89,6 +88,8 @@ class attributes `root_token`, `self_token` and `filter_context_token`.
**New in version 0.10.0**
## Class attributes
Attributes:
fake_root_token (str): The pattern used to select a "fake" root node, one level
above the real root node.
Expand Down Expand Up @@ -229,9 +230,9 @@ def findall(
*,
filter_context: Optional[FilterContextVars] = None,
) -> List[object]:
"""Find all objects in `data` matching the given JSONPath `path`.
"""Find all objects in _data_ matching the JSONPath _path_.
If `data` is a string or a file-like objects, it will be loaded
If _data_ is a string or a file-like objects, it will be loaded
using `json.loads()` and the default `JSONDecoder`.
Arguments:
Expand Down Expand Up @@ -259,10 +260,10 @@ def finditer(
*,
filter_context: Optional[FilterContextVars] = None,
) -> Iterable[JSONPathMatch]:
"""Generate `JSONPathMatch` objects for each match.
"""Generate `JSONPathMatch` objects for each match of _path_ in _data_.
If `data` is a string or a file-like objects, it will be loaded
using `json.loads()` and the default `JSONDecoder`.
If _data_ is a string or a file-like objects, it will be loaded using
`json.loads()` and the default `JSONDecoder`.
Arguments:
path: The JSONPath as a string.
Expand Down Expand Up @@ -310,6 +311,50 @@ def match(
"""
return self.compile(path).match(data, filter_context=filter_context)

def query(
self,
path: str,
data: Union[str, IOBase, Sequence[Any], Mapping[str, Any]],
filter_context: Optional[FilterContextVars] = None,
) -> Query:
"""Return a `Query` object over matches found by applying _path_ to _data_.
`Query` objects are iterable.
```
for match in jsonpath.query("$.foo..bar", data):
...
```
You can skip and limit results with `Query.skip()` and `Query.limit()`.
```
matches = (
jsonpath.query("$.foo..bar", data)
.skip(5)
.limit(10)
)
for match in matches
...
```
`Query.tail()` will get the last _n_ results.
```
for match in jsonpath.query("$.foo..bar", data).tail(5):
...
```
Get values for each match using `Query.values()`.
```
for obj in jsonpath.query("$.foo..bar", data).limit(5).values():
...
```
"""
return Query(self.finditer(path, data, filter_context=filter_context))

async def findall_async(
self,
path: str,
Expand Down
Loading

0 comments on commit 5b1514b

Please sign in to comment.