diff options
author | Artyom Nikitin <a.nikitin@edadeal.ru> | 2020-11-23 12:51:18 +0300 |
---|---|---|
committer | Artyom Nikitin <a.nikitin@edadeal.ru> | 2020-11-23 12:51:18 +0300 |
commit | eca4f8ab337b8ca8e9db5d0d0d81f536d77fbd7d (patch) | |
tree | 21d584d7d59b6cd4a917417b1f5a022e2d0cfe1a | |
parent | 50fb942e3500d84950ec9309f886f1952bd2fa25 (diff) | |
parent | 24b5e86dd66824dce232ea07e95d39a67b1dd735 (diff) | |
download | python-json-patch-eca4f8ab337b8ca8e9db5d0d0d81f536d77fbd7d.tar.gz |
Merge branch 'master' into feature/custom-pointer
# Conflicts:
# jsonpatch.py
# tests.py
-rw-r--r-- | .coveragerc | 2 | ||||
-rw-r--r-- | doc/tutorial.rst | 52 | ||||
-rw-r--r-- | jsonpatch.py | 44 | ||||
-rw-r--r-- | requirements-dev.txt | 1 | ||||
-rwxr-xr-x | tests.py | 142 |
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 @@ -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 |