diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8764e..6f87598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 35e8c01..184e40e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/quickstart.md b/docs/quickstart.md index 6f4298d..c8786e6 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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). diff --git a/jsonpath/__about__.py b/jsonpath/__about__.py index d570b96..f1a6891 100644 --- a/jsonpath/__about__.py +++ b/jsonpath/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2023-present James Prior # # SPDX-License-Identifier: MIT -__version__ = "2.1.0" +__version__ = "2.2.0" diff --git a/jsonpath/patch.py b/jsonpath/patch.py index f7c5d7b..c4a800b 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -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] @@ -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, @@ -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) diff --git a/tests/test_json_patch.py b/tests/test_json_patch.py index 7e3660d..bba7489 100644 --- a/tests/test_json_patch.py +++ b/tests/test_json_patch.py @@ -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}}}