diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 08b2e51..3e37ffa 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 539a594..fb428df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. See [#21](https://github.com/jg-rp/python-jsonpath-rfc9535/issues/21). +- 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). + ## Version 0.1.6 - Added py.typed. diff --git a/README.md b/README.md index 073bf58..8860c90 100644 --- a/README.md +++ b/README.md @@ -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}, @@ -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]` diff --git a/jsonpath_rfc9535/__about__.py b/jsonpath_rfc9535/__about__.py index 0a8da88..d3ec452 100644 --- a/jsonpath_rfc9535/__about__.py +++ b/jsonpath_rfc9535/__about__.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.2.0" diff --git a/jsonpath_rfc9535/node.py b/jsonpath_rfc9535/node.py index 3a8144f..4725bc7 100644 --- a/jsonpath_rfc9535/node.py +++ b/jsonpath_rfc9535/node.py @@ -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 @@ -16,14 +17,20 @@ class JSONPathNode: """A JSON-like value and its location in a JSON document. + Assigning to `JSONPathNode.value` will update and mutate source data too. + 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", ) @@ -32,12 +39,28 @@ 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( @@ -45,11 +68,17 @@ def path(self) -> str: 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, ) diff --git a/jsonpath_rfc9535/query.py b/jsonpath_rfc9535/query.py index 38ac4ce..c4a784f 100644 --- a/jsonpath_rfc9535/query.py +++ b/jsonpath_rfc9535/query.py @@ -70,6 +70,7 @@ def finditer( JSONPathNode( value=value, location=(), + parent=None, root=value, ) ] diff --git a/jsonpath_rfc9535/segments.py b/jsonpath_rfc9535/segments.py index d5813bf..6f04397 100644 --- a/jsonpath_rfc9535/segments.py +++ b/jsonpath_rfc9535/segments.py @@ -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( @@ -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) diff --git a/jsonpath_rfc9535/selectors.py b/jsonpath_rfc9535/selectors.py index 69ef9ec..6cf223c 100644 --- a/jsonpath_rfc9535/selectors.py +++ b/jsonpath_rfc9535/selectors.py @@ -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): @@ -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): @@ -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): @@ -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): @@ -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 @@ -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 diff --git a/tests/cts b/tests/cts index 13b60f1..b9d7153 160000 --- a/tests/cts +++ b/tests/cts @@ -1 +1 @@ -Subproject commit 13b60f1749e49b591dbbcf62fed8cd67f9aee13d +Subproject commit b9d7153e58711ad38bb8e35ece69c13f4b2f7d63 diff --git a/tests/test_issues.py b/tests/test_issues.py index ed379ee..2bb15fa 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -4,3 +4,23 @@ def test_issue_13() -> None: # This was failing with "unbalanced parentheses". _q = jsonpath.compile("$[? count(@.likes[? @.location]) > 3]") + + +def test_issue_21() -> None: + data = {"foo": {"bar": {"baz": 42}}} + node = jsonpath.find_one("$.foo.bar.baz", data) + + expected = 42 + assert node is not None + assert node.value == expected + assert data["foo"]["bar"]["baz"] == expected + + new_value = 99 + node.value = new_value + assert node.value == new_value + assert data["foo"]["bar"]["baz"] == new_value + + parent = node.parent + assert parent is not None + assert parent.value == {"baz": new_value} + assert parent.value["baz"] == new_value # type: ignore diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..25d7c37 --- /dev/null +++ b/tests/test_node.py @@ -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}}}