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
10 changes: 1 addition & 9 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,8 @@ 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", "3.14"]
python-version: ["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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Python JSONPath Change Log

## Version 2.1.0

Added `patch.atomic(patch, data)` and `JSONPatch.atomic(data)`. `atomic()` is similar to `apply()`, but preserves input data if a patch operation fails. See [#129](https://github.com/jg-rp/python-jsonpath/issues/129).

## Version 2.0.2

**Fixes**
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ print(jane_score) # 55

### JSON Patch

We also include an [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) compliant implementation of JSON Patch. See JSON Patch [quick start](https://jg-rp.github.io/python-jsonpath/quickstart/#patchapplypatch-data) and [API reference](https://jg-rp.github.io/python-jsonpath/api/#jsonpath.JSONPatch)
We also include an [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) compliant implementation of JSON Patch. See JSON Patch [quick start](https://jg-rp.github.io/python-jsonpath/quickstart/#patchapplypatch-data) and the [API reference](https://jg-rp.github.io/python-jsonpath/api/#jsonpath.JSONPatch).

> [!WARNING]
> Objects passed to `patch.apply()` and `JSONPatch.apply()` are modified in place, even if a patch operation fails. Use `patch.atomic()` or `JSONPatch.atomic()` if you need to preserve input data on patch failure.

```python
from jsonpath import patch
Expand All @@ -134,7 +137,30 @@ patch_operations = [
data = {"some": {"other": "thing"}}
patch.apply(patch_operations, data)
print(data) # {'some': {'other': 'thing', 'foo': {'bar': [1], 'else': 'thing'}}}
```

Use `patch.atomic()` or `JSONPatch.atomic()` if you need to preserve input data on patch failure.

```python
import contextlib

from jsonpath import JSONPatchError
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},
{"op": "test", "path": "/some/thing", "value": "baz"}, # Always fails
]

data = {"some": {"other": "thing"}}

with contextlib.suppress(JSONPatchError):
patch.atomic(patch_operations, data)

assert data == {"some": {"other": "thing"}}
```

## License
Expand Down
40 changes: 40 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ _patch_ can be a string or file-like object containing a valid JSON Patch docume

_data_ is the target JSON document to modify. If _data_ is a string or file-like object, it will be loaded with _json.loads_. Otherwise _data_ should be a JSON-like data structure and will be **modified in place**.

!!! warning

Data passed to `patch.apply()` and `JSONPatch.apply()` is modified in place, even if a patch operation fails. If you need to preserve input data on failure, you should make a copy of your data before calling `apply()`, or use `JSONPatch.atomic()`, which performs a deep copy, then mutates input data on success.

```python
from jsonpath import patch

Expand Down Expand Up @@ -324,6 +328,42 @@ patch.apply(data)
print(data) # {'some': {'other': 'thing', 'foo': {'bar': [1], 'else': 'thing'}}}
```

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

**_New in version 2.1.0_**

Data passed to `patch.apply()` and `JSONPatch.apply()` is modified in place, even if a patch operation fails. If you need to preserve input data on failure, you should make a copy of your data before calling `apply()`, or use `patch.atomic()`, which performs a deep copy, then mutates input data on success.

!!! note

`patch.atomic()` and `JSONPatch.atomic()` are limited to JSON-like like dictionaries and lists, whereas `patch.apply()` and `JSONPatch.apply(data)` accept file-like objects, JSON-formatted strings or JSON-like data.

```python
import copy
from typing import Any

from jsonpath import JSONPatch
from jsonpath import JSONPatchError

patch = JSONPatch(
[
{"op": "replace", "path": "/a/b/c", "value": 42},
{"op": "test", "path": "/a/b/c", "value": "C"}, # Always fails
]
)

data: dict[str, Any] = {"a": {"b": {"c": 1}}}
data_ = copy.deepcopy(data)

try:
patch.atomic(data)
except JSONPatchError:
# TODO: something
pass

assert data == 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.0.2"
__version__ = "2.1.0"
76 changes: 70 additions & 6 deletions jsonpath/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def apply(
if target == "-":
parent.append(self.value)
else:
index = self.path._index(target) # noqa: SLF001
index = self.path._index(target) # type: ignore # noqa: SLF001
if index == len(parent):
parent.append(self.value)
else:
Expand Down Expand Up @@ -303,12 +303,12 @@ def apply(
self, data: Union[MutableSequence[object], MutableMapping[str, object]]
) -> Union[MutableSequence[object], MutableMapping[str, object]]:
"""Apply this patch operation to _data_."""
source_parent, source_obj = self.source.resolve_parent(data)
_, source_obj = self.source.resolve_parent(data)

if source_obj is UNDEFINED:
raise JSONPatchError("source object does not exist")

dest_parent, dest_obj = self.dest.resolve_parent(data)
dest_parent, _ = self.dest.resolve_parent(data)

if dest_parent is None:
# Copy source to root
Expand Down Expand Up @@ -639,7 +639,7 @@ def apply(

If _data_ is a string or file-like object, it will be loaded with
_json.loads_. Otherwise _data_ should be a JSON-like data structure and
will be modified in place.
will be modified in place, even if a patch operation fails.

When modifying _data_ in place, we return modified data too. This is
to allow for replacing _data's_ root element, which is allowed by some
Expand Down Expand Up @@ -674,6 +674,37 @@ def apply(

return _data

def atomic(
self,
data: Union[List[Any], Dict[str, Any]],
) -> object:
"""Apply this patch to _data_ atomically.

Unlike `apply()`, if any patch operation fails, _data_ remains
unchanged.

Arguments:
data: A Python object representing JSON-like data.

Returns:
Patched _data_.

Raises:
JSONPatchError: When a patch operation fails.
JSONPatchTestFailure: When a _test_ operation does not pass.
`JSONPatchTestFailure` is a subclass of `JSONPatchError`.
"""
data_ = copy.deepcopy(data)
self.apply(data_) # This could raise a JSONPatchError.
data.clear()

if isinstance(data, dict):
data.update(data_)
else:
data.extend(data_)

return 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 All @@ -690,7 +721,7 @@ def apply(

If _data_ is a string or file-like object, it will be loaded with
_json.loads_. Otherwise _data_ should be a JSON-like data structure and
will be **modified in-place**.
will be **modified in-place**, even if a patch operation fails.

When modifying _data_ in-place, we return modified data too. This is
to allow for replacing _data's_ root element, which is allowed by some
Expand All @@ -711,10 +742,43 @@ def apply(
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,
).apply(data)


def atomic(
patch: Union[str, IOBase, 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 _data_ atomically.

Unlike `apply()`, if any patch operation fails, _data_ remains unchanged.

Arguments:
patch: A JSON Patch formatted document or equivalent Python objects.
data: A Python object representing 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:
Patched _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,
).atomic(data)
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
[tool.mypy]
files = ["jsonpath", "tests"]
exclude = ["tests/nts", "tests/cts"]
python_version = "3.11"
python_version = "3.10"
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_defs = true
Expand Down Expand Up @@ -152,8 +152,8 @@ exclude = [
line-length = 88


# Assume Python 3.10.
target-version = "py310"
# Assume Python 3.8.
target-version = "py38"

[tool.ruff.lint]
select = [
Expand Down
79 changes: 79 additions & 0 deletions tests/test_json_patch.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""JSON Patch test cases."""

import copy
import json
import re
from collections.abc import Mapping
from contextlib import suppress
from io import StringIO
from typing import Any
from typing import Dict
from typing import Iterator
from typing import List

import pytest

Expand Down Expand Up @@ -273,3 +277,78 @@ def test_non_standard_addap_op() -> None:
def test_add_to_mapping_with_int_key() -> None:
patch = JSONPatch().add(path="/1", value=99)
assert patch.apply({"foo": 1}) == {"foo": 1, "1": 99}


def test_apply_does_not_copy_data() -> None:
"""Test that _apply_ modifies data in place, even if the patch fails."""
patch_doc: List[Dict[str, Any]] = [
{"op": "replace", "path": "/a/b/c", "value": 42},
{"op": "test", "path": "/a/b/c", "value": "C"},
]

data: Dict[str, Any] = {"a": {"b": {"c": 1}}}
data_ = copy.deepcopy(data)

patch = JSONPatch(patch_doc)

with suppress(JSONPatchError):
patch.apply(data)

assert data != data_


def test_atomic_patch_success() -> None:
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)
patch.atomic(data)
assert data == {"a": {"b": {"c": 42, "d": 2}}}


def test_atomic_patch_fail() -> None:
patch_doc: List[Dict[str, Any]] = [
{"op": "replace", "path": "/a/b/c", "value": 42},
{"op": "test", "path": "/a/b/c", "value": "C"}, # Always fails
]

data: Dict[str, Any] = {"a": {"b": {"c": 1}}}
data_ = copy.deepcopy(data)

patch = JSONPatch(patch_doc)

with suppress(JSONPatchError):
patch.atomic(data)

assert data == data_


def test_atomic_patch_array_success() -> None:
patch_doc: List[Dict[str, Any]] = [
{"op": "add", "path": "/2", "value": "c"},
]

data = ["a", "b"]
patch = JSONPatch(patch_doc)
patch.atomic(data)
assert data == ["a", "b", "c"]


def test_atomic_patch_array_fail() -> None:
patch_doc: List[Dict[str, Any]] = [
{"op": "add", "path": "/2", "value": "c"},
{"op": "test", "path": "/2", "value": "x"}, # Always fails
]

data = ["a", "b"]
data_ = copy.deepcopy(data)

patch = JSONPatch(patch_doc)

with suppress(JSONPatchError):
patch.atomic(data)

assert data == data_
Loading