Skip to content

Commit c28196a

Browse files
authored
Merge pull request #61 from jg-rp/projection
Add simple query projection
2 parents 91e2f12 + fd65acd commit c28196a

File tree

14 files changed

+619
-18
lines changed

14 files changed

+619
-18
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
# Python JSONPath Change Log
22

3-
## Version 1.1.2 (unreleased)
3+
## Version 1.2.0 (unreleased)
4+
5+
**Fixes**
46

57
- Fixed handling of JSONPath literals in filter expressions. We now raise a `JSONPathSyntaxError` if a filter expression literal is not part of a comparison or function expression. See [jsonpath-compliance-test-suite#81](https://github.com/jsonpath-standard/jsonpath-compliance-test-suite/pull/81).
68

9+
**Features**
10+
11+
- Added a `select` method to the JSONPath [query iterator interface](https://jg-rp.github.io/python-jsonpath/query/), generating a projection of each JSONPath match by selecting a subset of its values.
12+
- Added the `addne` and `addap` operations to [JSONPatch](https://jg-rp.github.io/python-jsonpath/api/#jsonpath.JSONPatch). `addne` (add if not exists) is like the standard `add` operation, but only adds object keys/values if the key does not exist. `addap` (add or append) is like the standard `add` operation, but assumes an index of `-` if the target index can not be resolved.
13+
714
## Version 1.1.1
815

916
**Fixes**

docs/advanced.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ user_names = jsonpath.findall(
3939

4040
## Function Extensions
4141

42-
Add, remove or replace [filter functions](functions.md) by updating the [`function_extensions`](api.md#jsonpath.env.JSONPathEnvironment.function_extensions) attribute of a [`JSONPathEnvironment`](api.md#jsonpath.env.JSONPathEnvironment). It is a regular Python dictionary mapping filter function names to any [callable](https://docs.python.org/3/library/typing.html#typing.Callable), like a function or class with a `__call__` method.
42+
Add, remove or replace [filter functions](functions.md) by updating the [`function_extensions`](api.md#jsonpath.JSONPathEnvironment.function_extensions) attribute of a [`JSONPathEnvironment`](api.md#jsonpath.JSONPathEnvironment). It is a regular Python dictionary mapping filter function names to any [callable](https://docs.python.org/3/library/typing.html#typing.Callable), like a function or class with a `__call__` method.
4343

4444
### Type System for Function Expressions
4545

4646
[Section 2.4.1](https://datatracker.ietf.org/doc/html/rfc9535#name-type-system-for-function-ex) of RFC 9535 defines a type system for function expressions and requires that we check that filter expressions are well-typed. With that in mind, you are encouraged to implement custom filter functions by extending [`jsonpath.function_extensions.FilterFunction`](api.md#jsonpath.function_extensions.FilterFunction), which forces you to be explicit about the [types](api.md#jsonpath.function_extensions.ExpressionType) of arguments the function extension accepts and the type of its return value.
4747

4848
!!! info
4949

50-
[`FilterFunction`](api.md#jsonpath.function_extensions.FilterFunction) was new in Python JSONPath version 0.10.0. Prior to that we did not enforce function expression well-typedness. To use any arbitrary [callable](https://docs.python.org/3/library/typing.html#typing.Callable) as a function extension - or if you don't want built-in filter functions to raise a `JSONPathTypeError` for function expressions that are not well-typed - set [`well_typed`](api.md#jsonpath.env.JSONPathEnvironment.well_typed) to `False` when constructing a [`JSONPathEnvironment`](api.md#jsonpath.env.JSONPathEnvironment).
50+
[`FilterFunction`](api.md#jsonpath.function_extensions.FilterFunction) was new in Python JSONPath version 0.10.0. Prior to that we did not enforce function expression well-typedness. To use any arbitrary [callable](https://docs.python.org/3/library/typing.html#typing.Callable) as a function extension - or if you don't want built-in filter functions to raise a `JSONPathTypeError` for function expressions that are not well-typed - set [`well_typed`](api.md#jsonpath.JSONPathEnvironment.well_typed) to `False` when constructing a [`JSONPathEnvironment`](api.md#jsonpath.JSONPathEnvironment).
5151

5252
### Example
5353

@@ -137,7 +137,7 @@ env = MyEnv()
137137

138138
### Compile Time Validation
139139

140-
Calls to [type-aware](#type-system-for-function-expressions) function extension are validated at JSONPath compile-time automatically. If [`well_typed`](api.md#jsonpath.env.JSONPathEnvironment.well_typed) is set to `False` or a custom function extension does not inherit from [`FilterFunction`](api.md#jsonpath.function_extensions.FilterFunction), its arguments can be validated by implementing the function as a class with a `__call__` method, and a `validate` method. `validate` will be called after parsing the function, giving you the opportunity to inspect its arguments and raise a `JSONPathTypeError` should any arguments be unacceptable. If defined, `validate` must take a reference to the current environment, an argument list and the token pointing to the start of the function call.
140+
Calls to [type-aware](#type-system-for-function-expressions) function extension are validated at JSONPath compile-time automatically. If [`well_typed`](api.md#jsonpath.JSONPathEnvironment.well_typed) is set to `False` or a custom function extension does not inherit from [`FilterFunction`](api.md#jsonpath.function_extensions.FilterFunction), its arguments can be validated by implementing the function as a class with a `__call__` method, and a `validate` method. `validate` will be called after parsing the function, giving you the opportunity to inspect its arguments and raise a `JSONPathTypeError` should any arguments be unacceptable. If defined, `validate` must take a reference to the current environment, an argument list and the token pointing to the start of the function call.
141141

142142
```python
143143
def validate(

docs/api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
::: jsonpath.Query
1515
handler: python
1616

17+
::: jsonpath.Projection
18+
handler: python
19+
1720
::: jsonpath.function_extensions.FilterFunction
1821
handler: python
1922

docs/async.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Largely motivated by its integration with [Python Liquid](https://jg-rp.github.io/liquid/jsonpath/introduction), Python JSONPath offers an asynchronous API that allows for items in a target data structure to be "fetched" lazily.
44

5-
[`findall_async()`](api.md#jsonpath.env.JSONPathEnvironment.findall_async) and [`finditer_async()`](api.md#jsonpath.env.JSONPathEnvironment.finditer) are [asyncio](https://docs.python.org/3/library/asyncio.html) equivalents to [`findall()`](api.md#jsonpath.env.JSONPathEnvironment.findall) and [`finditer()`](api.md#jsonpath.env.JSONPathEnvironment.finditer). By default, any class implementing the [mapping](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping) or [sequence](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence) interfaces, and a `__getitem_async__()` method, will have `__getitem_async__()` awaited instead of calling `__getitem__()` when resolving mapping keys or sequence indices.
5+
[`findall_async()`](api.md#jsonpath.JSONPathEnvironment.findall_async) and [`finditer_async()`](api.md#jsonpath.JSONPathEnvironment.finditer_async) are [asyncio](https://docs.python.org/3/library/asyncio.html) equivalents to [`findall()`](api.md#jsonpath.JSONPathEnvironment.findall) and [`finditer()`](api.md#jsonpath.JSONPathEnvironment.finditer). By default, any class implementing the [mapping](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping) or [sequence](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence) interfaces, and a `__getitem_async__()` method, will have `__getitem_async__()` awaited instead of calling `__getitem__()` when resolving mapping keys or sequence indices.
66

77
## Example
88

docs/exceptions.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,36 @@ Each of the following exceptions has a `token` property, referencing the [`Token
1616

1717
::: jsonpath.JSONPathNameError
1818
handler: python
19+
20+
::: jsonpath.JSONPointerError
21+
handler: python
22+
23+
::: jsonpath.JSONPointerEncodeError
24+
handler: python
25+
26+
::: jsonpath.JSONPointerResolutionError
27+
handler: python
28+
29+
::: jsonpath.JSONPointerIndexError
30+
handler: python
31+
32+
::: jsonpath.JSONPointerKeyError
33+
handler: python
34+
35+
::: jsonpath.JSONPointerTypeError
36+
handler: python
37+
38+
::: jsonpath.RelativeJSONPointerError
39+
handler: python
40+
41+
::: jsonpath.RelativeJSONPointerIndexError
42+
handler: python
43+
44+
::: jsonpath.RelativeJSONPointerSyntaxError
45+
handler: python
46+
47+
::: jsonpath.JSONPatchError
48+
handler: python
49+
50+
::: jsonpath.JSONPatchTestFailure
51+
handler: python

docs/query.md

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ for value in it.values():
8282

8383
## Tee
8484

85-
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()`.
85+
[`tee()`](api.md#jsonpath.Query.tee) creates multiple independent queries from one query iterator. It is not safe to use the initial `Query` instance after calling `tee()`.
8686

8787
```python
8888
from jsonpath import query
@@ -92,3 +92,130 @@ it1, it2 = query("$.some[[email protected]]", data).tee()
9292
head = it1.head(10) # first 10 matches
9393
tail = it2.tail(10) # last 10 matches
9494
```
95+
96+
## Select
97+
98+
[`select(*expressions, projection=Projection.RELATIVE)`](api.md/#jsonpath.Query.select) performs JSONPath match projection, selecting a subset of values according to one or more JSONPath query expressions relative to the match location. For example:
99+
100+
```python
101+
from jsonpath import query
102+
103+
data = {
104+
"categories": [
105+
{
106+
"name": "footwear",
107+
"products": [
108+
{
109+
"title": "Trainers",
110+
"description": "Fashionable trainers.",
111+
"price": 89.99,
112+
},
113+
{
114+
"title": "Barefoot Trainers",
115+
"description": "Running trainers.",
116+
"price": 130.00,
117+
"social": {"likes": 12, "shares": 7},
118+
},
119+
],
120+
},
121+
{
122+
"name": "headwear",
123+
"products": [
124+
{
125+
"title": "Cap",
126+
"description": "Baseball cap",
127+
"price": 15.00,
128+
},
129+
{
130+
"title": "Beanie",
131+
"description": "Winter running hat.",
132+
"price": 9.00,
133+
},
134+
],
135+
},
136+
],
137+
"price_cap": 10,
138+
}
139+
140+
for product in query("$..products.*", data).select("title", "price"):
141+
print(product)
142+
```
143+
144+
Which selects just the `title` and `price` fields for each product.
145+
146+
```text
147+
{'title': 'Trainers', 'price': 89.99}
148+
{'title': 'Barefoot Trainers', 'price': 130.0}
149+
{'title': 'Cap', 'price': 15.0}
150+
{'title': 'Beanie', 'price': 9.0}
151+
```
152+
153+
Without the call to `select()`, we'd get all fields in each product object.
154+
155+
```python
156+
# ...
157+
158+
for product in query("$..products.*", data).values():
159+
print(product)
160+
```
161+
162+
```text
163+
{'title': 'Trainers', 'description': 'Fashionable trainers.', 'price': 89.99}
164+
{'title': 'Barefoot Trainers', 'description': 'Running trainers.', 'price': 130.0, 'social': {'likes': 12, 'shares': 7}}
165+
{'title': 'Cap', 'description': 'Baseball cap', 'price': 15.0}
166+
{'title': 'Beanie', 'description': 'Winter running hat.', 'price': 9.0}
167+
```
168+
169+
We can select nested values too.
170+
171+
```python
172+
# ...
173+
174+
for product in query("$..products.*", data).select("title", "social.shares"):
175+
print(product)
176+
```
177+
178+
```text
179+
{'title': 'Trainers'}
180+
{'title': 'Barefoot Trainers', 'social': {'shares': 7}}
181+
{'title': 'Cap'}
182+
{'title': 'Beanie'}
183+
```
184+
185+
And flatten the selection into a sequence of values.
186+
187+
```python
188+
from jsonpath import Projection
189+
190+
# ...
191+
192+
for product in query("$..products.*", data).select(
193+
"title", "social.shares", projection=Projection.FLAT
194+
):
195+
print(product)
196+
```
197+
198+
```text
199+
['Trainers']
200+
['Barefoot Trainers', 7]
201+
['Cap']
202+
['Beanie']
203+
```
204+
205+
Or project the selection from the JSON value root.
206+
207+
```python
208+
# ..
209+
210+
for product in query("$..products[[email protected]]", data).select(
211+
"title",
212+
"social.shares",
213+
projection=Projection.ROOT,
214+
):
215+
print(product)
216+
217+
```
218+
219+
```text
220+
{'categories': [{'products': [{'title': 'Barefoot Trainers', 'social': {'shares': 7}}]}]}
221+
```

docs/quickstart.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This page gets you started using JSONPath, JSON Pointer and JSON Patch wih Pytho
44

55
## `findall(path, data)`
66

7-
Find all objects matching a JSONPath with [`jsonpath.findall()`](api.md#jsonpath.env.JSONPathEnvironment.findall). It takes, as arguments, a JSONPath string and some _data_ object. It always returns a list of objects selected from _data_, never a scalar value.
7+
Find all objects matching a JSONPath with [`jsonpath.findall()`](api.md#jsonpath.JSONPathEnvironment.findall). It takes, as arguments, a JSONPath string and some _data_ object. It always returns a list of objects selected from _data_, never a scalar value.
88

99
_data_ can be a file-like object or string containing JSON formatted data, or a Python [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping) or [`Sequence`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence), like a dictionary or list. In this example we select user names from a dictionary containing a list of user dictionaries.
1010

@@ -52,7 +52,7 @@ with open("users.json") as fd:
5252

5353
## `finditer(path, data)`
5454

55-
Use [`jsonpath.finditer()`](api.md#jsonpath.env.JSONPathEnvironment.finditer) to iterate over instances of [`jsonpath.JSONPathMatch`](api.md#jsonpath.JSONPathMatch) for every object in _data_ that matches _path_. It accepts the same arguments as [`findall()`](#findall), a path string and data from which to select matches.
55+
Use [`jsonpath.finditer()`](api.md#jsonpath.JSONPathEnvironment.finditer) to iterate over instances of [`jsonpath.JSONPathMatch`](api.md#jsonpath.JSONPathMatch) for every object in _data_ that matches _path_. It accepts the same arguments as [`findall()`](#findallpath-data), a path string and data from which to select matches.
5656

5757
```python
5858
import jsonpath
@@ -96,7 +96,7 @@ The selected object is available from a [`JSONPathMatch`](api.md#jsonpath.JSONPa
9696

9797
## `compile(path)`
9898

99-
When you have a JSONPath that needs to be matched against different data repeatedly, you can _compile_ the path ahead of time using [`jsonpath.compile()`](api.md#jsonpath.env.JSONPathEnvironment.compile). It takes a path as a string and returns a [`JSONPath`](api.md#jsonpath.JSONPath) instance. `JSONPath` has `findall()` and `finditer()` methods that behave similarly to package-level `findall()` and `finditer()`, just without the `path` argument.
99+
When you have a JSONPath that needs to be matched against different data repeatedly, you can _compile_ the path ahead of time using [`jsonpath.compile()`](api.md#jsonpath.JSONPathEnvironment.compile). It takes a path as a string and returns a [`JSONPath`](api.md#jsonpath.JSONPath) instance. `JSONPath` has `findall()` and `finditer()` methods that behave similarly to package-level `findall()` and `finditer()`, just without the `path` argument.
100100

101101
```python
102102
import jsonpath
@@ -137,7 +137,7 @@ other_users = path.findall(other_data)
137137

138138
**_New in version 0.8.0_**
139139

140-
Get a [`jsonpath.JSONPathMatch`](api.md#jsonpath.JSONPathMatch) instance for the first match found in _data_. If there are no matches, `None` is returned. `match()` accepts the same arguments as [`findall()`](#findall).
140+
Get a [`jsonpath.JSONPathMatch`](api.md#jsonpath.JSONPathMatch) instance for the first match found in _data_. If there are no matches, `None` is returned. `match()` accepts the same arguments as [`findall()`](#findallpath-data).
141141

142142
```python
143143
import jsonpath
@@ -228,7 +228,7 @@ sue_score = pointer.resolve("/users/99/score", data, default=0)
228228
print(sue_score) # 0
229229
```
230230

231-
See also [`JSONPathMatch.pointer()`](api.md#jsonpath.match.JSONPathMatch.pointer), which builds a [`JSONPointer`](api.md#jsonpath.JSONPointer) from a `JSONPathMatch`.
231+
See also [`JSONPathMatch.pointer()`](api.md#jsonpath.JSONPathMatch.pointer), which builds a [`JSONPointer`](api.md#jsonpath.JSONPointer) from a `JSONPathMatch`.
232232

233233
## `patch.apply(patch, data)`
234234

@@ -294,7 +294,7 @@ print(data) # {'some': {'other': 'thing', 'foo': {'bar': [1], 'else': 'thing'}}
294294

295295
## What's Next?
296296

297-
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).
297+
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#filter-variables).
298298

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

jsonpath/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# SPDX-FileCopyrightText: 2023-present James Prior <[email protected]>
22
#
33
# SPDX-License-Identifier: MIT
4-
__version__ = "1.1.2"
4+
__version__ = "1.2.0"

jsonpath/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
# SPDX-License-Identifier: MIT
44

55
from .env import JSONPathEnvironment
6+
from .exceptions import JSONPatchError
7+
from .exceptions import JSONPatchTestFailure
68
from .exceptions import JSONPathError
79
from .exceptions import JSONPathIndexError
810
from .exceptions import JSONPathNameError
911
from .exceptions import JSONPathSyntaxError
1012
from .exceptions import JSONPathTypeError
13+
from .exceptions import JSONPointerEncodeError
1114
from .exceptions import JSONPointerError
1215
from .exceptions import JSONPointerIndexError
1316
from .exceptions import JSONPointerKeyError
@@ -17,6 +20,7 @@
1720
from .exceptions import RelativeJSONPointerIndexError
1821
from .exceptions import RelativeJSONPointerSyntaxError
1922
from .filter import UNDEFINED
23+
from .fluent_api import Projection
2024
from .fluent_api import Query
2125
from .lex import Lexer
2226
from .match import JSONPathMatch
@@ -36,6 +40,8 @@
3640
"finditer_async",
3741
"finditer",
3842
"JSONPatch",
43+
"JSONPatchError",
44+
"JSONPatchTestFailure",
3945
"JSONPath",
4046
"JSONPathEnvironment",
4147
"JSONPathError",
@@ -45,6 +51,7 @@
4551
"JSONPathSyntaxError",
4652
"JSONPathTypeError",
4753
"JSONPointer",
54+
"JSONPointerEncodeError",
4855
"JSONPointerError",
4956
"JSONPointerIndexError",
5057
"JSONPointerKeyError",
@@ -53,6 +60,7 @@
5360
"Lexer",
5461
"match",
5562
"Parser",
63+
"Projection",
5664
"query",
5765
"Query",
5866
"RelativeJSONPointer",

jsonpath/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ def query(
353353
...
354354
```
355355
"""
356-
return Query(self.finditer(path, data, filter_context=filter_context))
356+
return Query(self.finditer(path, data, filter_context=filter_context), self)
357357

358358
async def findall_async(
359359
self,

0 commit comments

Comments
 (0)