Skip to content
Merged
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
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# Python JSONPath Change Log

## Unreleased
## Version 2.2.0 (unreleased)

**Fixes**

- Fixed lexing of quoted name selectors ending in an escaped backslash, like `$['a\\']['b']`. The lexer's look-behind for the closing quote treated a quote following an escaped backslash (`\\`) as an escaped quote (`\'`), so these selectors failed to tokenize. This also broke the RFC 9535 normalized path round-trip: normalized paths produced for object keys containing backslashes could not be parsed back.
- Fixed lexing of quoted name selectors ending in an escaped backslash, like `$['a\\']['b']`. The lexer's look-behind for the closing quote treated a quote following an escaped backslash (`\\`) as an escaped quote (`\'`), so these selectors failed to tokenize. This also broke the RFC 9535 normalized path round-trip: normalized paths produced for object keys containing backslashes could not be parsed back. See [#132](https://github.com/jg-rp/python-jsonpath/pull/132).

**Features**

- Added `patch.patched(ops, data)` and `JSONPatch.patched(data)`. `patched()` is a non-mutating form of JSONPatch application. They always perform a deep copy of `data` and return the patched copy. See [#131](https://github.com/jg-rp/python-jsonpath/issues/131).

## Version 2.1.0

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,24 @@ with contextlib.suppress(JSONPatchError):
assert data == {"some": {"other": "thing"}}
```

`patch.patched(ops, data)` and `JSONPatch.patched(data)` apply patch operations to a deep copy of `data`.

```python
from jsonpath import patch

patch_operations = [
{"op": "add", "path": "/some/foo", "value": {"foo": {}}},
{"op": "add", "path": "/some/foo", "value": {"bar": []}},
{"op": "copy", "from": "/some/other", "path": "/some/foo/else"},
{"op": "add", "path": "/some/foo/bar/-", "value": 1},
]

data = {"some": {"other": "thing"}}
patched_data = patch.patched(patch_operations, data)

assert data != patched_data
```

## License

`python-jsonpath` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
22 changes: 22 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,28 @@ except JSONPatchError:
assert data == data_
```

## `patch.patched(patch, data)`

**_New in version 2.2.0_**

`patch.patched(ops, data)` and `JSONPatch.patched(data)` never mutate `data`. They apply patch operations to a deep copy of `data` and return the patched copy.

```python
from jsonpath import patch

patch_operations = [
{"op": "add", "path": "/some/foo", "value": {"foo": {}}},
{"op": "add", "path": "/some/foo", "value": {"bar": []}},
{"op": "copy", "from": "/some/other", "path": "/some/foo/else"},
{"op": "add", "path": "/some/foo/bar/-", "value": 1},
]

data = {"some": {"other": "thing"}}
patched_data = patch.patched(patch_operations, data)

assert data != patched_data
```

## What's Next?

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).
Expand Down
2 changes: 1 addition & 1 deletion jsonpath/__about__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2023-present James Prior <jamesgr.prior@gmail.com>
#
# SPDX-License-Identifier: MIT
__version__ = "2.1.0"
__version__ = "2.2.0"
53 changes: 52 additions & 1 deletion jsonpath/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,25 @@ def atomic(

return data

def patched(
self,
data: Union[List[Any], Dict[str, Any]],
) -> object:
"""Apply this patch to a deep copy of _data_.

Arguments:
data: JSON-like data.

Returns:
A patched copy of _data_.

Raises:
JSONPatchError: When a patch operation fails.
JSONPatchTestFailure: When a _test_ operation does not pass.
`JSONPatchTestFailure` is a subclass of `JSONPatchError`.
"""
return self.apply(copy.deepcopy(data))

def asdicts(self) -> List[Dict[str, object]]:
"""Return a list of this patch's operations as dictionaries."""
return [op.asdict() for op in self.ops]
Expand Down Expand Up @@ -751,7 +770,7 @@ def apply(


def atomic(
patch: Union[str, IOBase, Iterable[Mapping[str, object]], None],
patch: Union[str, Iterable[Mapping[str, object]], None],
data: Union[List[Any], Dict[str, Any]],
*,
unicode_escape: bool = True,
Expand Down Expand Up @@ -782,3 +801,35 @@ def atomic(
unicode_escape=unicode_escape,
uri_decode=uri_decode,
).atomic(data)


def patched(
patch: Union[str, Iterable[Mapping[str, object]], None],
data: Union[List[Any], Dict[str, Any]],
*,
unicode_escape: bool = True,
uri_decode: bool = False,
) -> object:
"""Apply patch operations from _patch_ to a deep copy of _data_.

Arguments:
patch: A JSON Patch formatted document or equivalent Python objects.
data: JSON-like data.
unicode_escape: If `True`, UTF-16 escape sequences will be decoded
before parsing JSON pointers.
uri_decode: If `True`, JSON pointers will be unescaped using _urllib_
before being parsed.

Returns:
A patched copy of _data_.

Raises:
JSONPatchError: When a patch operation fails.
JSONPatchTestFailure: When a _test_ operation does not pass.
`JSONPatchTestFailure` is a subclass of `JSONPatchError`.
"""
return JSONPatch(
patch,
unicode_escape=unicode_escape,
uri_decode=uri_decode,
).patched(data)
15 changes: 15 additions & 0 deletions tests/test_json_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,18 @@ def test_atomic_patch_array_fail() -> None:
patch.atomic(data)

assert data == data_


def test_patched_does_not_mutate_data() -> None:
"""Test that _patched_ modifies a deep copy of data."""
patch_doc: List[Dict[str, Any]] = [
{"op": "replace", "path": "/a/b/c", "value": 42},
{"op": "add", "path": "/a/b/d", "value": 2},
]

data: Dict[str, Any] = {"a": {"b": {"c": 1}}}
patch = JSONPatch(patch_doc)
patched_data = patch.patched(data)

assert data == {"a": {"b": {"c": 1}}}
assert patched_data == {"a": {"b": {"c": 42, "d": 2}}}
Loading