Skip to content

Commit aba66e9

Browse files
authored
Merge pull request #22 from jg-rp/value-setter
Add `parent` and `value` setter to `JSONPathNode`
2 parents 9dd6138 + d92bf4f commit aba66e9

File tree

11 files changed

+171
-23
lines changed

11 files changed

+171
-23
lines changed

.github/workflows/tests.yaml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,24 @@ jobs:
99
fail-fast: false
1010
matrix:
1111
os: [ubuntu-latest, windows-latest, macos-latest]
12-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
12+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
13+
exclude:
14+
- os: macos-latest
15+
python-version: "3.8"
16+
- os: windows-latest
17+
python-version: "3.8"
18+
- os: macos-latest
19+
python-version: "3.9"
20+
- os: windows-latest
21+
python-version: "3.9"
22+
- os: macos-latest
23+
python-version: "3.10"
24+
- os: windows-latest
25+
python-version: "3.10"
26+
- os: macos-latest
27+
python-version: "3.11"
28+
- os: windows-latest
29+
python-version: "3.11"
1330
steps:
1431
- uses: actions/checkout@v4
1532
with:

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Python JSONPath RFC 9535 Change Log
22

3+
## Version 0.2.0 (unreleased)
4+
5+
**Features**
6+
7+
- Added `JSONPathNode.parent`, a reference the the node's parent node. See [#21](https://github.com/jg-rp/python-jsonpath-rfc9535/issues/21).
8+
- Changed `JSONPathNode.value` to be a `@property` and `setter`. When assigning to `JSONPathNode.value`, source data is updated too. See [#21](https://github.com/jg-rp/python-jsonpath-rfc9535/issues/21).
9+
310
## Version 0.1.6
411

512
- Added py.typed.

README.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,16 +116,16 @@ Apply JSONPath expression _query_ to _value_. _value_ should arbitrary, possible
116116

117117
A list of `JSONPathNode` instances is returned, one node for each value matched by _query_. The returned list will be empty if there were no matches.
118118

119-
Each `JSONPathNode` has:
119+
Each `JSONPathNode` has properties:
120120

121-
- a `value` property, which is the JSON-like value associated with the node.
122-
- a `location` property, which is a tuple of property names and array/list indexes that were required to reach the node's value in the target JSON document.
123-
- a `path()` method, which returns the normalized path to the node in the target JSON document.
121+
- `value` - The JSON-like value associated with the node.
122+
- `location` - A tuple of property names and array/list indexes that were required to reach the node's value in the target JSON document.
123+
- `parent` (_New in version 0.2.0_) - The node's parent node, or `None` if the current node is the root.
124124

125125
```python
126126
import jsonpath_rfc9535 as jsonpath
127127

128-
value = {
128+
data = {
129129
"users": [
130130
{"name": "Sue", "score": 100},
131131
{"name": "John", "score": 86, "admin": True},
@@ -135,18 +135,36 @@ value = {
135135
"moderator": "John",
136136
}
137137

138-
for node in jsonpath.find("$.users[?@.score > 85]", value):
138+
nodes = jsonpath.find("$.users[?@.score > 85]", data)
139+
140+
for node in nodes:
139141
print(f"{node.value} at '{node.path()}'")
140142

141143
# {'name': 'Sue', 'score': 100} at '$['users'][0]'
142144
# {'name': 'John', 'score': 86, 'admin': True} at '$['users'][1]'
143145
```
144146

147+
`JSONPathNode.path()` returns the normalized path to the node in the target JSON document.
148+
145149
`JSONPathNodeList` is a subclass of `list` with some helper methods.
146150

147151
- `values()` returns a list of values, one for each node.
148152
- `items()` returns a list of `(normalized path, value)` tuples.
149153

154+
**_New in version 0.2.0_**
155+
156+
Assigning to `JSONPathNode.value` will update the node's value **and mutate source data**. Beware,updating data after evaluating a query can invalidate existing child node paths.
157+
158+
```python
159+
# ... continued from above
160+
161+
node = jsonpath.find_one("$.users[@.name == 'John'].score")
162+
if node:
163+
node.value = 999
164+
165+
print(data["users"][1]) # {'name': 'John', 'score': 999, 'admin': True}
166+
```
167+
150168
### find_one
151169

152170
`find_one(query: str, value: JSONValue) -> Optional[JSONPathNode]`

jsonpath_rfc9535/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.6"
1+
__version__ = "0.2.0"

jsonpath_rfc9535/node.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from typing import TYPE_CHECKING
66
from typing import List
7+
from typing import Optional
78
from typing import Tuple
89
from typing import Union
910

@@ -16,14 +17,20 @@
1617
class JSONPathNode:
1718
"""A JSON-like value and its location in a JSON document.
1819
20+
Assigning to `JSONPathNode.value` will update and mutate source data too.
21+
Updating data after evaluating a query can invalidate existing child
22+
nodes. Use at your own risk.
23+
1924
Attributes:
2025
value: The JSON-like value at this node.
2126
location: The names indices that make up the normalized path to _value_.
27+
parent: The parent node, or None if this is the root node.
2228
"""
2329

2430
__slots__ = (
25-
"value",
31+
"_value",
2632
"location",
33+
"parent",
2734
"root",
2835
)
2936

@@ -32,24 +39,46 @@ def __init__(
3239
*,
3340
value: object,
3441
location: Tuple[Union[int, str], ...],
42+
parent: Optional[JSONPathNode],
3543
root: JSONValue,
3644
) -> None:
37-
self.value: object = value
45+
self._value: object = value
3846
self.location: Tuple[Union[int, str], ...] = location
47+
self.parent = parent
3948
self.root = root
4049

50+
@property
51+
def value(self) -> object:
52+
"""The JSON-like value at this node."""
53+
return self._value
54+
55+
@value.setter
56+
def value(self, val: object) -> None:
57+
parent = self.parent
58+
if parent is not None and self.location:
59+
# If data has changed since this node was created, this could fail.
60+
# Letting the exception raise is probably the most useful thing we can do.
61+
parent._value[self.location[-1]] = val # type: ignore # noqa: SLF001
62+
self._value = val
63+
4164
def path(self) -> str:
4265
"""Return the normalized path to this node."""
4366
return "$" + "".join(
4467
f"[{canonical_string(p)}]" if isinstance(p, str) else f"[{p}]"
4568
for p in self.location
4669
)
4770

48-
def new_child(self, value: object, key: Union[int, str]) -> JSONPathNode:
71+
def new_child(
72+
self,
73+
value: object,
74+
key: Union[int, str],
75+
parent: Optional[JSONPathNode],
76+
) -> JSONPathNode:
4977
"""Return a new node using this node's location."""
5078
return JSONPathNode(
5179
value=value,
5280
location=self.location + (key,),
81+
parent=parent,
5382
root=self.root,
5483
)
5584

jsonpath_rfc9535/query.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def finditer(
7070
JSONPathNode(
7171
value=value,
7272
location=(),
73+
parent=None,
7374
root=value,
7475
)
7576
]

jsonpath_rfc9535/segments.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,12 @@ def _visit(self, node: JSONPathNode, depth: int = 1) -> Iterable[JSONPathNode]:
8888
if isinstance(node.value, dict):
8989
for name, val in node.value.items():
9090
if isinstance(val, (dict, list)):
91-
_node = node.new_child(val, name)
91+
_node = node.new_child(val, name, node)
9292
yield from self._visit(_node, depth + 1)
9393
elif isinstance(node.value, list):
9494
for i, element in enumerate(node.value):
9595
if isinstance(element, (dict, list)):
96-
_node = node.new_child(element, i)
96+
_node = node.new_child(element, i, node)
9797
yield from self._visit(_node, depth + 1)
9898

9999
def _nondeterministic_visit(
@@ -167,7 +167,7 @@ def _nondeterministic_children(node: JSONPathNode) -> Iterable[JSONPathNode]:
167167
items = list(node.value.items())
168168
random.shuffle(items)
169169
for name, val in items:
170-
yield node.new_child(val, name)
170+
yield node.new_child(val, name, node)
171171
elif isinstance(node.value, list):
172172
for i, element in enumerate(node.value):
173-
yield node.new_child(element, i)
173+
yield node.new_child(element, i, node)

jsonpath_rfc9535/selectors.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]:
7777
"""Select a value from a dict/object by its property/key."""
7878
if isinstance(node.value, dict):
7979
with suppress(KeyError):
80-
yield node.new_child(node.value[self.name], self.name)
80+
yield node.new_child(node.value[self.name], self.name, node)
8181

8282

8383
class IndexSelector(JSONPathSelector):
@@ -122,7 +122,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]:
122122
if isinstance(node.value, list):
123123
norm_index = self._normalized_index(node.value)
124124
with suppress(IndexError):
125-
yield node.new_child(node.value[self.index], norm_index)
125+
yield node.new_child(node.value[self.index], norm_index, node)
126126

127127

128128
class SliceSelector(JSONPathSelector):
@@ -172,7 +172,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]:
172172
for idx, element in zip( # noqa: B905
173173
range(*self.slice.indices(len(node.value))), node.value[self.slice]
174174
):
175-
yield node.new_child(element, idx)
175+
yield node.new_child(element, idx, node)
176176

177177

178178
class WildcardSelector(JSONPathSelector):
@@ -201,11 +201,11 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]:
201201
members = node.value.items()
202202

203203
for name, val in members:
204-
yield node.new_child(val, name)
204+
yield node.new_child(val, name, node)
205205

206206
elif isinstance(node.value, list):
207207
for i, element in enumerate(node.value):
208-
yield node.new_child(element, i)
208+
yield node.new_child(element, i, node)
209209

210210

211211
class FilterSelector(JSONPathSelector):
@@ -254,7 +254,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]: # noqa: PLR091
254254
)
255255
try:
256256
if self.expression.evaluate(context):
257-
yield node.new_child(val, name)
257+
yield node.new_child(val, name, node)
258258
except JSONPathTypeError as err:
259259
if not err.token:
260260
err.token = self.token
@@ -269,7 +269,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]: # noqa: PLR091
269269
)
270270
try:
271271
if self.expression.evaluate(context):
272-
yield node.new_child(element, i)
272+
yield node.new_child(element, i, node)
273273
except JSONPathTypeError as err:
274274
if not err.token:
275275
err.token = self.token

tests/cts

tests/test_issues.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,23 @@
44
def test_issue_13() -> None:
55
# This was failing with "unbalanced parentheses".
66
_q = jsonpath.compile("$[? count(@.likes[? @.location]) > 3]")
7+
8+
9+
def test_issue_21() -> None:
10+
data = {"foo": {"bar": {"baz": 42}}}
11+
node = jsonpath.find_one("$.foo.bar.baz", data)
12+
13+
expected = 42
14+
assert node is not None
15+
assert node.value == expected
16+
assert data["foo"]["bar"]["baz"] == expected
17+
18+
new_value = 99
19+
node.value = new_value
20+
assert node.value == new_value
21+
assert data["foo"]["bar"]["baz"] == new_value
22+
23+
parent = node.parent
24+
assert parent is not None
25+
assert parent.value == {"baz": new_value}
26+
assert parent.value["baz"] == new_value # type: ignore

0 commit comments

Comments
 (0)