Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,24 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
exclude:
- os: macos-latest
python-version: "3.8"
- os: windows-latest
python-version: "3.8"
- os: macos-latest
python-version: "3.9"
- os: windows-latest
python-version: "3.9"
- os: macos-latest
python-version: "3.10"
- os: windows-latest
python-version: "3.10"
- os: macos-latest
python-version: "3.11"
- os: windows-latest
python-version: "3.11"
steps:
- uses: actions/checkout@v4
with:
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Python JSONPath RFC 9535 Change Log

## Version 0.2.0 (unreleased)

**Features**

- Added `JSONPathNode.parent`, a reference the the node's parent node.
- Changed `JSONPathNode.value` to be a `@property` and `setter`. When assigning to `JSONPathNode.value`, source data is updated too.

## Version 0.1.6

- Added py.typed.
Expand Down
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,16 @@ Apply JSONPath expression _query_ to _value_. _value_ should arbitrary, possible

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.

Each `JSONPathNode` has:
Each `JSONPathNode` has properties:

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

```python
import jsonpath_rfc9535 as jsonpath

value = {
data = {
"users": [
{"name": "Sue", "score": 100},
{"name": "John", "score": 86, "admin": True},
Expand All @@ -135,18 +135,36 @@ value = {
"moderator": "John",
}

for node in jsonpath.find("$.users[?@.score > 85]", value):
nodes = jsonpath.find("$.users[?@.score > 85]", data)

for node in nodes:
print(f"{node.value} at '{node.path()}'")

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

`JSONPathNode.path()` returns the normalized path to the node in the target JSON document.

`JSONPathNodeList` is a subclass of `list` with some helper methods.

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

**_New in version 0.2.0_**

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.

```python
# ... continued from above

node = jsonpath.find_one("$.users[@.name == 'John'].score")
if node:
node.value = 999

print(data["users"][1]) # {'name': 'John', 'score': 999, 'admin': True}
```

### find_one

`find_one(query: str, value: JSONValue) -> Optional[JSONPathNode]`
Expand Down
2 changes: 1 addition & 1 deletion jsonpath_rfc9535/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.6"
__version__ = "0.2.0"
35 changes: 32 additions & 3 deletions jsonpath_rfc9535/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from typing import TYPE_CHECKING
from typing import List
from typing import Optional
from typing import Tuple
from typing import Union

Expand All @@ -16,14 +17,20 @@
class JSONPathNode:
"""A JSON-like value and its location in a JSON document.

Assigning to `JSONPathNode.value` will automatically mutate source data.
Updating data after evaluating a query can invalidate existing child
nodes. Use at your own risk.

Attributes:
value: The JSON-like value at this node.
location: The names indices that make up the normalized path to _value_.
parent: The parent node, or None if this is the root node.
"""

__slots__ = (
"value",
"_value",
"location",
"parent",
"root",
)

Expand All @@ -32,24 +39,46 @@ def __init__(
*,
value: object,
location: Tuple[Union[int, str], ...],
parent: Optional[JSONPathNode],
root: JSONValue,
) -> None:
self.value: object = value
self._value: object = value
self.location: Tuple[Union[int, str], ...] = location
self.parent = parent
self.root = root

@property
def value(self) -> object:
"""The JSON-like value at this node."""
return self._value

@value.setter
def value(self, val: object) -> None:
parent = self.parent
if parent is not None and self.location:
# If data has changed since this node was created, this could fail.
# Letting the exception raise is probably the most useful thing we can do.
parent._value[self.location[-1]] = val # type: ignore # noqa: SLF001
self._value = val

def path(self) -> str:
"""Return the normalized path to this node."""
return "$" + "".join(
f"[{canonical_string(p)}]" if isinstance(p, str) else f"[{p}]"
for p in self.location
)

def new_child(self, value: object, key: Union[int, str]) -> JSONPathNode:
def new_child(
self,
value: object,
key: Union[int, str],
parent: Optional[JSONPathNode],
) -> JSONPathNode:
"""Return a new node using this node's location."""
return JSONPathNode(
value=value,
location=self.location + (key,),
parent=parent,
root=self.root,
)

Expand Down
1 change: 1 addition & 0 deletions jsonpath_rfc9535/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def finditer(
JSONPathNode(
value=value,
location=(),
parent=None,
root=value,
)
]
Expand Down
8 changes: 4 additions & 4 deletions jsonpath_rfc9535/segments.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ def _visit(self, node: JSONPathNode, depth: int = 1) -> Iterable[JSONPathNode]:
if isinstance(node.value, dict):
for name, val in node.value.items():
if isinstance(val, (dict, list)):
_node = node.new_child(val, name)
_node = node.new_child(val, name, node)
yield from self._visit(_node, depth + 1)
elif isinstance(node.value, list):
for i, element in enumerate(node.value):
if isinstance(element, (dict, list)):
_node = node.new_child(element, i)
_node = node.new_child(element, i, node)
yield from self._visit(_node, depth + 1)

def _nondeterministic_visit(
Expand Down Expand Up @@ -167,7 +167,7 @@ def _nondeterministic_children(node: JSONPathNode) -> Iterable[JSONPathNode]:
items = list(node.value.items())
random.shuffle(items)
for name, val in items:
yield node.new_child(val, name)
yield node.new_child(val, name, node)
elif isinstance(node.value, list):
for i, element in enumerate(node.value):
yield node.new_child(element, i)
yield node.new_child(element, i, node)
14 changes: 7 additions & 7 deletions jsonpath_rfc9535/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]:
"""Select a value from a dict/object by its property/key."""
if isinstance(node.value, dict):
with suppress(KeyError):
yield node.new_child(node.value[self.name], self.name)
yield node.new_child(node.value[self.name], self.name, node)


class IndexSelector(JSONPathSelector):
Expand Down Expand Up @@ -122,7 +122,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]:
if isinstance(node.value, list):
norm_index = self._normalized_index(node.value)
with suppress(IndexError):
yield node.new_child(node.value[self.index], norm_index)
yield node.new_child(node.value[self.index], norm_index, node)


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


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

for name, val in members:
yield node.new_child(val, name)
yield node.new_child(val, name, node)

elif isinstance(node.value, list):
for i, element in enumerate(node.value):
yield node.new_child(element, i)
yield node.new_child(element, i, node)


class FilterSelector(JSONPathSelector):
Expand Down Expand Up @@ -254,7 +254,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]: # noqa: PLR091
)
try:
if self.expression.evaluate(context):
yield node.new_child(val, name)
yield node.new_child(val, name, node)
except JSONPathTypeError as err:
if not err.token:
err.token = self.token
Expand All @@ -269,7 +269,7 @@ def resolve(self, node: JSONPathNode) -> Iterable[JSONPathNode]: # noqa: PLR091
)
try:
if self.expression.evaluate(context):
yield node.new_child(element, i)
yield node.new_child(element, i, node)
except JSONPathTypeError as err:
if not err.token:
err.token = self.token
Expand Down
2 changes: 1 addition & 1 deletion tests/cts
Submodule cts updated 2 files
+422 −0 cts.json
+422 −0 tests/filter.json
56 changes: 56 additions & 0 deletions tests/test_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from jsonpath_rfc9535 import find_one


def test_parent() -> None:
data = {"a": {"b": {"c": 1}}}
query = "$.a.b.c"
node = find_one(query, data)
assert node is not None
assert node.value == 1
assert node.parent is not None
assert node.parent.value == data["a"]["b"]


def test_parent_of_root() -> None:
data = {"a": {"b": {"c": 1}}}
query = "$"
node = find_one(query, data)
assert node is not None
assert node.value == data
assert node.parent is None


def test_set_dict_value() -> None:
data = {"a": {"b": {"c": 1}}}
query = "$.a.b.c"
node = find_one(query, data)
assert node is not None

new_value = 99
node.value = new_value
assert node.value == new_value
assert data["a"]["b"]["c"] == new_value


def test_set_list_value() -> None:
data = {"a": {"b": [1, 2, 3]}}
query = "$.a.b[1]"
node = find_one(query, data)
assert node is not None

new_value = 99
node.value = new_value
assert node.value == new_value
assert data["a"]["b"][1] == new_value


def test_set_root_value() -> None:
data = {"a": {"b": {"c": 1}}}
query = "$"
node = find_one(query, data)
assert node is not None

new_value = 99
node.value = new_value
assert node.value == new_value
assert data == {"a": {"b": {"c": 1}}}