summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArtyom Nikitin <a.nikitin@edadeal.ru>2020-11-23 12:51:18 +0300
committerArtyom Nikitin <a.nikitin@edadeal.ru>2020-11-23 12:51:18 +0300
commiteca4f8ab337b8ca8e9db5d0d0d81f536d77fbd7d (patch)
tree21d584d7d59b6cd4a917417b1f5a022e2d0cfe1a
parent50fb942e3500d84950ec9309f886f1952bd2fa25 (diff)
parent24b5e86dd66824dce232ea07e95d39a67b1dd735 (diff)
downloadpython-json-patch-eca4f8ab337b8ca8e9db5d0d0d81f536d77fbd7d.tar.gz
Merge branch 'master' into feature/custom-pointer
# Conflicts: # jsonpatch.py # tests.py
-rw-r--r--.coveragerc2
-rw-r--r--doc/tutorial.rst52
-rw-r--r--jsonpatch.py44
-rw-r--r--requirements-dev.txt1
-rwxr-xr-xtests.py142
5 files changed, 230 insertions, 11 deletions
diff --git a/.coveragerc b/.coveragerc
index 2a98e09..f0d91db 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,8 +1,10 @@
# .coveragerc to control coverage.py
[run]
branch = True
+source = jsonpatch
[report]
+show_missing = True
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
diff --git a/doc/tutorial.rst b/doc/tutorial.rst
index 538cd0e..0bb1a9c 100644
--- a/doc/tutorial.rst
+++ b/doc/tutorial.rst
@@ -67,3 +67,55 @@ explicitly.
# or from a list
>>> patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}]
>>> res = jsonpatch.apply_patch(obj, patch)
+
+
+Dealing with Custom Types
+-------------------------
+
+Custom JSON dump and load functions can be used to support custom types such as
+`decimal.Decimal`. The following examples shows how the
+`simplejson <https://simplejson.readthedocs.io/>`_ package, which has native
+support for Python's ``Decimal`` type, can be used to create a custom
+``JsonPatch`` subclass with ``Decimal`` support:
+
+.. code-block:: python
+
+ >>> import decimal
+ >>> import simplejson
+
+ >>> class DecimalJsonPatch(jsonpatch.JsonPatch):
+ @staticmethod
+ def json_dumper(obj):
+ return simplejson.dumps(obj)
+
+ @staticmethod
+ def json_loader(obj):
+ return simplejson.loads(obj, use_decimal=True,
+ object_pairs_hook=jsonpatch.multidict)
+
+ >>> src = {}
+ >>> dst = {'bar': decimal.Decimal('1.10')}
+ >>> patch = DecimalJsonPatch.from_diff(src, dst)
+ >>> doc = {'foo': 1}
+ >>> result = patch.apply(doc)
+ {'foo': 1, 'bar': Decimal('1.10')}
+
+Instead of subclassing it is also possible to pass a dump function to
+``from_diff``:
+
+ >>> patch = jsonpatch.JsonPatch.from_diff(src, dst, dumps=simplejson.dumps)
+
+a dumps function to ``to_string``:
+
+ >>> serialized_patch = patch.to_string(dumps=simplejson.dumps)
+ '[{"op": "add", "path": "/bar", "value": 1.10}]'
+
+and load function to ``from_string``:
+
+ >>> import functools
+ >>> loads = functools.partial(simplejson.loads, use_decimal=True,
+ object_pairs_hook=jsonpatch.multidict)
+ >>> patch.from_string(serialized_patch, loads=loads)
+ >>> doc = {'foo': 1}
+ >>> result = patch.apply(doc)
+ {'foo': 1, 'bar': Decimal('1.10')}
diff --git a/jsonpatch.py b/jsonpatch.py
index 92857ef..5522d50 100644
--- a/jsonpatch.py
+++ b/jsonpatch.py
@@ -171,6 +171,9 @@ def make_patch(src, dst, pointer_cls=JsonPointer):
class JsonPatch(object):
+ json_dumper = staticmethod(json.dumps)
+ json_loader = staticmethod(_jsonloads)
+
"""A JSON Patch is a list of Patch Operations.
>>> patch = JsonPatch([
@@ -229,6 +232,13 @@ class JsonPatch(object):
'copy': CopyOperation,
}
+ # Verify that the structure of the patch document
+ # is correct by retrieving each patch element.
+ # Much of the validation is done in the initializer
+ # though some is delayed until the patch is applied.
+ for op in self.patch:
+ self._get_operation(op)
+
def __str__(self):
"""str(self) -> self.to_string()"""
return self.to_string()
@@ -253,22 +263,30 @@ class JsonPatch(object):
return not(self == other)
@classmethod
- def from_string(cls, patch_str, pointer_cls=JsonPointer):
+ def from_string(cls, patch_str, loads=None, pointer_cls=JsonPointer):
"""Creates JsonPatch instance from string source.
:param patch_str: JSON patch as raw string.
- :type pointer_cls: str
+ :type patch_str: str
+
+ :param loads: A function of one argument that loads a serialized
+ JSON string.
+ :type loads: function
:param pointer_cls: JSON pointer class to use.
:type pointer_cls: Type[JsonPointer]
:return: :class:`JsonPatch` instance.
"""
- patch = _jsonloads(patch_str)
+ json_loader = loads or cls.json_loader
+ patch = json_loader(patch_str)
return cls(patch, pointer_cls=pointer_cls)
@classmethod
- def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer):
+ def from_diff(
+ cls, src, dst, optimization=True, dumps=None,
+ pointer_cls=JsonPointer,
+ ):
"""Creates JsonPatch instance based on comparison of two document
objects. Json patch would be created for `src` argument against `dst`
one.
@@ -279,6 +297,10 @@ class JsonPatch(object):
:param dst: Data source document object.
:type dst: dict
+ :param dumps: A function of one argument that produces a serialized
+ JSON string.
+ :type dumps: function
+
:param pointer_cls: JSON pointer class to use.
:type pointer_cls: Type[JsonPointer]
@@ -291,15 +313,16 @@ class JsonPatch(object):
>>> new == dst
True
"""
-
- builder = DiffBuilder(pointer_cls=pointer_cls)
+ json_dumper = dumps or cls.json_dumper
+ builder = DiffBuilder(json_dumper, pointer_cls=pointer_cls)
builder._compare_values('', None, src, dst)
ops = list(builder.execute())
return cls(ops, pointer_cls=pointer_cls)
- def to_string(self):
+ def to_string(self, dumps=None):
"""Returns patch set as JSON string."""
- return json.dumps(self.patch)
+ json_dumper = dumps or self.json_dumper
+ return json_dumper(self.patch)
@property
def _ops(self):
@@ -660,7 +683,8 @@ class CopyOperation(PatchOperation):
class DiffBuilder(object):
- def __init__(self, pointer_cls=JsonPointer):
+ def __init__(self, dumps=json.dumps, pointer_cls=JsonPointer):
+ self.dumps = dumps
self.pointer_cls = pointer_cls
self.index_storage = [{}, {}]
self.index_storage2 = [[], []]
@@ -856,7 +880,7 @@ class DiffBuilder(object):
# and ignore those that don't. The performance of this could be
# improved by doing more direct type checks, but we'd need to be
# careful to accept type changes that don't matter when JSONified.
- elif json.dumps(src) == json.dumps(dst):
+ elif self.dumps(src) == self.dumps(dst):
return
else:
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 21daf9a..c729ece 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,2 +1,3 @@
+coverage
wheel
pypandoc
diff --git a/tests.py b/tests.py
index 7df2c2b..b5b7b9a 100755
--- a/tests.py
+++ b/tests.py
@@ -4,6 +4,7 @@
from __future__ import unicode_literals
import json
+import decimal
import doctest
import unittest
import jsonpatch
@@ -278,6 +279,34 @@ class EqualityTestCase(unittest.TestCase):
self.assertEqual(json.dumps(patch_obj), patch.to_string())
+def custom_types_dumps(obj):
+ def default(obj):
+ if isinstance(obj, decimal.Decimal):
+ return {'__decimal__': str(obj)}
+ raise TypeError('Unknown type')
+
+ return json.dumps(obj, default=default)
+
+
+def custom_types_loads(obj):
+ def as_decimal(dct):
+ if '__decimal__' in dct:
+ return decimal.Decimal(dct['__decimal__'])
+ return dct
+
+ return json.loads(obj, object_hook=as_decimal)
+
+
+class CustomTypesJsonPatch(jsonpatch.JsonPatch):
+ @staticmethod
+ def json_dumper(obj):
+ return custom_types_dumps(obj)
+
+ @staticmethod
+ def json_loader(obj):
+ return custom_types_loads(obj)
+
+
class MakePatchTestCase(unittest.TestCase):
def test_apply_patch_to_copy(self):
@@ -456,6 +485,35 @@ class MakePatchTestCase(unittest.TestCase):
self.assertEqual(res, dst)
self.assertIsInstance(res['A'], float)
+ def test_custom_types_diff(self):
+ old = {'value': decimal.Decimal('1.0')}
+ new = {'value': decimal.Decimal('1.00')}
+ generated_patch = jsonpatch.JsonPatch.from_diff(
+ old, new, dumps=custom_types_dumps)
+ str_patch = generated_patch.to_string(dumps=custom_types_dumps)
+ loaded_patch = jsonpatch.JsonPatch.from_string(
+ str_patch, loads=custom_types_loads)
+ self.assertEqual(generated_patch, loaded_patch)
+ new_from_patch = jsonpatch.apply_patch(old, generated_patch)
+ self.assertEqual(new, new_from_patch)
+
+ def test_custom_types_subclass(self):
+ old = {'value': decimal.Decimal('1.0')}
+ new = {'value': decimal.Decimal('1.00')}
+ generated_patch = CustomTypesJsonPatch.from_diff(old, new)
+ str_patch = generated_patch.to_string()
+ loaded_patch = CustomTypesJsonPatch.from_string(str_patch)
+ self.assertEqual(generated_patch, loaded_patch)
+ new_from_patch = jsonpatch.apply_patch(old, loaded_patch)
+ self.assertEqual(new, new_from_patch)
+
+ def test_custom_types_subclass_load(self):
+ old = {'value': decimal.Decimal('1.0')}
+ new = {'value': decimal.Decimal('1.00')}
+ patch = CustomTypesJsonPatch.from_string(
+ '[{"op": "replace", "path": "/value", "value": {"__decimal__": "1.00"}}]')
+ new_from_patch = jsonpatch.apply_patch(old, patch)
+ self.assertEqual(new, new_from_patch)
class OptimizationTests(unittest.TestCase):
@@ -671,6 +729,86 @@ class JsonPointerTests(unittest.TestCase):
self.assertEqual(result, expected)
+class JsonPatchCreationTest(unittest.TestCase):
+
+ def test_creation_fails_with_invalid_patch(self):
+ invalid_patches = [
+ { 'path': '/foo', 'value': 'bar'},
+ {'op': 0xADD, 'path': '/foo', 'value': 'bar'},
+ {'op': 'boo', 'path': '/foo', 'value': 'bar'},
+ {'op': 'add', 'value': 'bar'},
+ ]
+ for patch in invalid_patches:
+ with self.assertRaises(jsonpatch.InvalidJsonPatch):
+ jsonpatch.JsonPatch([patch])
+
+ with self.assertRaises(jsonpointer.JsonPointerException):
+ jsonpatch.JsonPatch([{'op': 'add', 'path': 'foo', 'value': 'bar'}])
+
+
+class UtilityMethodTests(unittest.TestCase):
+
+ def test_boolean_coercion(self):
+ empty_patch = jsonpatch.JsonPatch([])
+ self.assertFalse(empty_patch)
+
+ def test_patch_equality(self):
+ p = jsonpatch.JsonPatch([{'op': 'add', 'path': '/foo', 'value': 'bar'}])
+ q = jsonpatch.JsonPatch([{'op': 'add', 'path': '/foo', 'value': 'bar'}])
+ different_op = jsonpatch.JsonPatch([{'op': 'remove', 'path': '/foo'}])
+ different_path = jsonpatch.JsonPatch([{'op': 'add', 'path': '/bar', 'value': 'bar'}])
+ different_value = jsonpatch.JsonPatch([{'op': 'add', 'path': '/foo', 'value': 'foo'}])
+ self.assertNotEqual(p, different_op)
+ self.assertNotEqual(p, different_path)
+ self.assertNotEqual(p, different_value)
+ self.assertEqual(p, q)
+
+ def test_operation_equality(self):
+ add = jsonpatch.AddOperation({'path': '/new-element', 'value': 'new-value'})
+ add2 = jsonpatch.AddOperation({'path': '/new-element', 'value': 'new-value'})
+ rm = jsonpatch.RemoveOperation({'path': '/target'})
+ self.assertEqual(add, add2)
+ self.assertNotEqual(add, rm)
+
+ def test_add_operation_structure(self):
+ with self.assertRaises(jsonpatch.InvalidJsonPatch):
+ jsonpatch.AddOperation({'path': '/'}).apply({})
+
+ def test_replace_operation_structure(self):
+ with self.assertRaises(jsonpatch.InvalidJsonPatch):
+ jsonpatch.ReplaceOperation({'path': '/'}).apply({})
+
+ with self.assertRaises(jsonpatch.InvalidJsonPatch):
+ jsonpatch.ReplaceOperation({'path': '/top/-', 'value': 'foo'}).apply({'top': {'inner': 'value'}})
+
+ with self.assertRaises(jsonpatch.JsonPatchConflict):
+ jsonpatch.ReplaceOperation({'path': '/top/missing', 'value': 'foo'}).apply({'top': {'inner': 'value'}})
+
+ def test_move_operation_structure(self):
+ with self.assertRaises(jsonpatch.InvalidJsonPatch):
+ jsonpatch.MoveOperation({'path': '/target'}).apply({})
+
+ with self.assertRaises(jsonpatch.JsonPatchConflict):
+ jsonpatch.MoveOperation({'from': '/source', 'path': '/target'}).apply({})
+
+ def test_test_operation_structure(self):
+ with self.assertRaises(jsonpatch.JsonPatchTestFailed):
+ jsonpatch.TestOperation({'path': '/target'}).apply({})
+
+ with self.assertRaises(jsonpatch.InvalidJsonPatch):
+ jsonpatch.TestOperation({'path': '/target'}).apply({'target': 'value'})
+
+ def test_copy_operation_structure(self):
+ with self.assertRaises(jsonpatch.InvalidJsonPatch):
+ jsonpatch.CopyOperation({'path': '/target'}).apply({})
+
+ with self.assertRaises(jsonpatch.JsonPatchConflict):
+ jsonpatch.CopyOperation({'path': '/target', 'from': '/source'}).apply({})
+
+ with self.assertRaises(jsonpatch.JsonPatchConflict):
+ jsonpatch.CopyOperation({'path': '/target', 'from': '/source'}).apply({})
+
+
class CustomJsonPointer(jsonpointer.JsonPointer):
pass
@@ -690,7 +828,7 @@ class CustomJsonPointerTests(unittest.TestCase):
self.assertEqual(res.pointer_cls, CustomJsonPointer)
def test_json_patch_from_object(self):
- patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}],
+ patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}]
res = jsonpatch.JsonPatch(
patch, pointer_cls=CustomJsonPointer,
)
@@ -815,6 +953,8 @@ if __name__ == '__main__':
suite.addTest(unittest.makeSuite(ConflictTests))
suite.addTest(unittest.makeSuite(OptimizationTests))
suite.addTest(unittest.makeSuite(JsonPointerTests))
+ suite.addTest(unittest.makeSuite(JsonPatchCreationTest))
+ suite.addTest(unittest.makeSuite(UtilityMethodTests))
suite.addTest(unittest.makeSuite(CustomJsonPointerTests))
return suite