Implement JSON Merge Patch from IETF RFC 7396
This commit is contained in:
parent
551e392e91
commit
9e9764e196
2 changed files with 117 additions and 0 deletions
|
|
@ -9,6 +9,8 @@ from typing import (
|
||||||
Concatenate,
|
Concatenate,
|
||||||
Self,
|
Self,
|
||||||
overload,
|
overload,
|
||||||
|
TypeAlias,
|
||||||
|
Literal,
|
||||||
)
|
)
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -25,6 +27,9 @@ import numpy as np
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
P = ParamSpec("P")
|
P = ParamSpec("P")
|
||||||
|
|
||||||
|
JSONScalar: TypeAlias = str | int | float | bool | None
|
||||||
|
JSONType: TypeAlias = dict[str, "JSONType"] | list["JSONType"] | JSONScalar
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
@ -359,3 +364,69 @@ def quadratic(
|
||||||
:return: The quadratic, evaluated at each point in ``x``
|
:return: The quadratic, evaluated at each point in ``x``
|
||||||
"""
|
"""
|
||||||
return a * x**2 + b * x + c
|
return a * x**2 + b * x + c
|
||||||
|
|
||||||
|
|
||||||
|
# Use overload to clarify to MyPy that if enforce_dict is true, inputs and outputs are
|
||||||
|
# dictionaries
|
||||||
|
@overload
|
||||||
|
def merge_patch(
|
||||||
|
target: dict[str, JSONType], patch: dict[str, JSONType], enforce_dict: Literal[True]
|
||||||
|
) -> dict[str, JSONType]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# If not they are any JSONType
|
||||||
|
@overload
|
||||||
|
def merge_patch(
|
||||||
|
target: JSONType, patch: JSONType, enforce_dict: Literal[False]
|
||||||
|
) -> JSONType: ...
|
||||||
|
@overload
|
||||||
|
def merge_patch(target: JSONType, patch: JSONType) -> JSONType: ...
|
||||||
|
|
||||||
|
|
||||||
|
def merge_patch(
|
||||||
|
target: JSONType, patch: JSONType, enforce_dict: bool = False
|
||||||
|
) -> JSONType:
|
||||||
|
"""Merge json data using the methods from IETF RFC 7396.
|
||||||
|
|
||||||
|
Primarily this is designed to merge dictionaries, but IETF RFC 7396 provides
|
||||||
|
defined methods for handling non-dictionary data loaded from JSON, so this
|
||||||
|
has been provided in full.
|
||||||
|
|
||||||
|
:param target: The target object
|
||||||
|
:param patch: The patch to be applied
|
||||||
|
:param enforce_dict: Boolean, set True enfoces that the target and patch are both
|
||||||
|
dictionaries.
|
||||||
|
"""
|
||||||
|
target_is_dict = isinstance(target, dict)
|
||||||
|
patch_is_dict = isinstance(patch, dict)
|
||||||
|
if enforce_dict and not (target_is_dict and patch_is_dict):
|
||||||
|
raise ValueError("Both target and patch should be dictionaries.")
|
||||||
|
|
||||||
|
# If patch is not a dict (object), it replaces target entirely:
|
||||||
|
if not patch_is_dict:
|
||||||
|
return patch
|
||||||
|
|
||||||
|
# If target is not an object, replace it with an empty object
|
||||||
|
if not target_is_dict:
|
||||||
|
target = {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
# First, keep all keys from target that are not in patch
|
||||||
|
for key, target_val in target.items():
|
||||||
|
if key not in patch:
|
||||||
|
result[key] = target_val
|
||||||
|
|
||||||
|
# Then apply patch keys
|
||||||
|
for key, patch_val in patch.items():
|
||||||
|
# If patch_val is None, do not add the key (deleting from target).
|
||||||
|
if patch_val is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if values are dictionaries.
|
||||||
|
if isinstance(patch_val, dict):
|
||||||
|
# If patch value is a dictionary then recurse.
|
||||||
|
result[key] = merge_patch(target.get(key), patch_val)
|
||||||
|
else:
|
||||||
|
# Else, use the patch value
|
||||||
|
result[key] = patch_val
|
||||||
|
return result
|
||||||
|
|
|
||||||
46
tests/test_utilities.py
Normal file
46
tests/test_utilities.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Test the utilities functions."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from openflexure_microscope_server import utilities
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("target", "patch", "outcome", "err_if_enforce"),
|
||||||
|
[
|
||||||
|
({"a": "b"}, {"a": "c"}, {"a": "c"}, False),
|
||||||
|
({"a": "b"}, {"b": "c"}, {"a": "b", "b": "c"}, False),
|
||||||
|
({"a": "b"}, {"a": None}, {}, False),
|
||||||
|
({"a": "b", "b": "c"}, {"a": None}, {"b": "c"}, False),
|
||||||
|
({"a": ["b"]}, {"a": "c"}, {"a": "c"}, False),
|
||||||
|
({"a": "c"}, {"a": ["b"]}, {"a": ["b"]}, False),
|
||||||
|
(
|
||||||
|
{"a": {"b": "c"}},
|
||||||
|
{"a": {"b": "d", "c": None}},
|
||||||
|
{"a": {"b": "d"}},
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
({"a": [{"b": "c"}]}, {"a": [1]}, {"a": [1]}, False),
|
||||||
|
(["a", "b"], ["c", "d"], ["c", "d"], True),
|
||||||
|
({"a": "b"}, ["c"], ["c"], True),
|
||||||
|
({"a": "foo"}, None, None, True),
|
||||||
|
({"a": "foo"}, "bar", "bar", True),
|
||||||
|
({"e": None}, {"a": 1}, {"e": None, "a": 1}, False),
|
||||||
|
([1, 2], {"a": "b", "c": None}, {"a": "b"}, True),
|
||||||
|
({}, {"a": {"bb": {"ccc": None}}}, {"a": {"bb": {}}}, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_merge_patch(target, patch, outcome, err_if_enforce):
|
||||||
|
"""Check that merge_patch returns the expected outcome for each target and patch.
|
||||||
|
|
||||||
|
These test cases are specified in Appedix A of IETF RFC 7396.
|
||||||
|
"""
|
||||||
|
assert utilities.merge_patch(target, patch) == outcome
|
||||||
|
|
||||||
|
# If expected to error when enforce_dict is True then check:
|
||||||
|
if err_if_enforce:
|
||||||
|
with pytest.raises(ValueError, match="Both target and patch"):
|
||||||
|
utilities.merge_patch(target, patch, enforce_dict=True)
|
||||||
|
else:
|
||||||
|
# Else should run without error with same expected value
|
||||||
|
assert utilities.merge_patch(target, patch, enforce_dict=True) == outcome
|
||||||
Loading…
Add table
Add a link
Reference in a new issue