Skip to content

Commit ceacd56

Browse files
committed
feat(jsonpatch): implement RFC7396 json patch function
1 parent 7daf298 commit ceacd56

2 files changed

Lines changed: 45 additions & 3 deletions

File tree

src/lsst/cmservice/common/jsonpatch.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
import operator
6-
from collections.abc import MutableMapping, MutableSequence
6+
from collections.abc import Mapping, MutableMapping, MutableSequence
77
from functools import reduce
88
from typing import TYPE_CHECKING, Any, Literal
99

@@ -51,7 +51,7 @@ def apply_json_patch[T: MutableMapping](op: JSONPatch, o: T) -> T:
5151
numeric, e.g., {"1": "first", "2": "second"}
5252
- Unsupported: JSON pointer values that refer to an entire object, e.g.,
5353
"" -- the JSON Patch must have a root element ("/") per the model.
54-
- Unsupported: JSON pointer values taht refer to a nameless object, e.g.,
54+
- Unsupported: JSON pointer values that refer to a nameless object, e.g.,
5555
"/" -- JSON allows object keys to be the empty string ("") but this is
5656
disallowed by the application.
5757
"""
@@ -222,3 +222,31 @@ def apply_json_patch[T: MutableMapping](op: JSONPatch, o: T) -> T:
222222
raise JSONPatchError(f"Unknown JSON Patch operation: {op.op}")
223223

224224
return o
225+
226+
227+
def apply_json_merge[T: MutableMapping](patch: Any, o: T) -> T:
228+
"""Applies a patch to a mapping object as per the RFC7396 JSON Merge Patch.
229+
230+
Notably, this operation may only target a ``MutableMapping`` as an analogue
231+
of a JSON object. This means that any keyed value in a Mapping may be
232+
replaced, added, or removed by a JSON Merge. This is not appropriate for
233+
patches that need to perform more tactical updates, such as modifying
234+
elements of a ``Sequence``.
235+
236+
This function does not allow setting a field value in the target to `None`;
237+
instead, any `None` value in a patch is an instruction to remove that
238+
field from the target completely.
239+
240+
This function differs from the RFC in the following ways: it will not
241+
replace the entire target object with a new mapping (i.e., the target must
242+
be a Mapping).
243+
"""
244+
if isinstance(patch, Mapping):
245+
for k, v in patch.items():
246+
if v is None:
247+
_ = o.pop(k, None)
248+
else:
249+
o[k] = apply_json_merge(v, o.get(k, {}))
250+
return o
251+
else:
252+
return patch

tests/common/test_jsonpatch.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from lsst.cmservice.common.jsonpatch import JSONPatch, JSONPatchError, apply_json_patch
5+
from lsst.cmservice.common.jsonpatch import JSONPatch, JSONPatchError, apply_json_merge, apply_json_patch
66

77

88
@pytest.fixture
@@ -148,3 +148,17 @@ def test_jsonpatch_test(target_object: dict[str, Any]) -> None:
148148
op = JSONPatch(op="test", path="/spec/a_list/-", value="bob_alice")
149149
with pytest.raises(JSONPatchError):
150150
_ = apply_json_patch(op, target_object)
151+
152+
153+
def test_json_merge_patch(target_object: dict[str, Any]) -> None:
154+
"""Tests the RFC7396 JSON Merge patch function."""
155+
156+
patch = {
157+
"metadata": {"owner": None, "pilot": "bob_loblaw"},
158+
"spec": {"new_key": {"new_key": "new_value"}},
159+
}
160+
new_object = apply_json_merge(patch, target_object)
161+
162+
assert new_object["metadata"]["pilot"] == "bob_loblaw"
163+
assert "owner" not in new_object["metadata"]
164+
assert new_object["spec"]["new_key"]["new_key"] == "new_value"

0 commit comments

Comments
 (0)