Skip to content

Commit eca4f8a

Browse files
author
Artyom Nikitin
committed
Merge branch 'master' into feature/custom-pointer
# Conflicts: # jsonpatch.py # tests.py
2 parents 50fb942 + 24b5e86 commit eca4f8a

File tree

5 files changed

+230
-11
lines changed

5 files changed

+230
-11
lines changed

.coveragerc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# .coveragerc to control coverage.py
22
[run]
33
branch = True
4+
source = jsonpatch
45

56
[report]
7+
show_missing = True
68
# Regexes for lines to exclude from consideration
79
exclude_lines =
810
# Have to re-enable the standard pragma

doc/tutorial.rst

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,55 @@ explicitly.
6767
# or from a list
6868
>>> patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}]
6969
>>> res = jsonpatch.apply_patch(obj, patch)
70+
71+
72+
Dealing with Custom Types
73+
-------------------------
74+
75+
Custom JSON dump and load functions can be used to support custom types such as
76+
`decimal.Decimal`. The following examples shows how the
77+
`simplejson <https://simplejson.readthedocs.io/>`_ package, which has native
78+
support for Python's ``Decimal`` type, can be used to create a custom
79+
``JsonPatch`` subclass with ``Decimal`` support:
80+
81+
.. code-block:: python
82+
83+
>>> import decimal
84+
>>> import simplejson
85+
86+
>>> class DecimalJsonPatch(jsonpatch.JsonPatch):
87+
@staticmethod
88+
def json_dumper(obj):
89+
return simplejson.dumps(obj)
90+
91+
@staticmethod
92+
def json_loader(obj):
93+
return simplejson.loads(obj, use_decimal=True,
94+
object_pairs_hook=jsonpatch.multidict)
95+
96+
>>> src = {}
97+
>>> dst = {'bar': decimal.Decimal('1.10')}
98+
>>> patch = DecimalJsonPatch.from_diff(src, dst)
99+
>>> doc = {'foo': 1}
100+
>>> result = patch.apply(doc)
101+
{'foo': 1, 'bar': Decimal('1.10')}
102+
103+
Instead of subclassing it is also possible to pass a dump function to
104+
``from_diff``:
105+
106+
>>> patch = jsonpatch.JsonPatch.from_diff(src, dst, dumps=simplejson.dumps)
107+
108+
a dumps function to ``to_string``:
109+
110+
>>> serialized_patch = patch.to_string(dumps=simplejson.dumps)
111+
'[{"op": "add", "path": "/bar", "value": 1.10}]'
112+
113+
and load function to ``from_string``:
114+
115+
>>> import functools
116+
>>> loads = functools.partial(simplejson.loads, use_decimal=True,
117+
object_pairs_hook=jsonpatch.multidict)
118+
>>> patch.from_string(serialized_patch, loads=loads)
119+
>>> doc = {'foo': 1}
120+
>>> result = patch.apply(doc)
121+
{'foo': 1, 'bar': Decimal('1.10')}

jsonpatch.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ def make_patch(src, dst, pointer_cls=JsonPointer):
171171

172172

173173
class JsonPatch(object):
174+
json_dumper = staticmethod(json.dumps)
175+
json_loader = staticmethod(_jsonloads)
176+
174177
"""A JSON Patch is a list of Patch Operations.
175178
176179
>>> patch = JsonPatch([
@@ -229,6 +232,13 @@ def __init__(self, patch, pointer_cls=JsonPointer):
229232
'copy': CopyOperation,
230233
}
231234

235+
# Verify that the structure of the patch document
236+
# is correct by retrieving each patch element.
237+
# Much of the validation is done in the initializer
238+
# though some is delayed until the patch is applied.
239+
for op in self.patch:
240+
self._get_operation(op)
241+
232242
def __str__(self):
233243
"""str(self) -> self.to_string()"""
234244
return self.to_string()
@@ -253,22 +263,30 @@ def __ne__(self, other):
253263
return not(self == other)
254264

255265
@classmethod
256-
def from_string(cls, patch_str, pointer_cls=JsonPointer):
266+
def from_string(cls, patch_str, loads=None, pointer_cls=JsonPointer):
257267
"""Creates JsonPatch instance from string source.
258268
259269
:param patch_str: JSON patch as raw string.
260-
:type pointer_cls: str
270+
:type patch_str: str
271+
272+
:param loads: A function of one argument that loads a serialized
273+
JSON string.
274+
:type loads: function
261275
262276
:param pointer_cls: JSON pointer class to use.
263277
:type pointer_cls: Type[JsonPointer]
264278
265279
:return: :class:`JsonPatch` instance.
266280
"""
267-
patch = _jsonloads(patch_str)
281+
json_loader = loads or cls.json_loader
282+
patch = json_loader(patch_str)
268283
return cls(patch, pointer_cls=pointer_cls)
269284

270285
@classmethod
271-
def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer):
286+
def from_diff(
287+
cls, src, dst, optimization=True, dumps=None,
288+
pointer_cls=JsonPointer,
289+
):
272290
"""Creates JsonPatch instance based on comparison of two document
273291
objects. Json patch would be created for `src` argument against `dst`
274292
one.
@@ -279,6 +297,10 @@ def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer):
279297
:param dst: Data source document object.
280298
:type dst: dict
281299
300+
:param dumps: A function of one argument that produces a serialized
301+
JSON string.
302+
:type dumps: function
303+
282304
:param pointer_cls: JSON pointer class to use.
283305
:type pointer_cls: Type[JsonPointer]
284306
@@ -291,15 +313,16 @@ def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer):
291313
>>> new == dst
292314
True
293315
"""
294-
295-
builder = DiffBuilder(pointer_cls=pointer_cls)
316+
json_dumper = dumps or cls.json_dumper
317+
builder = DiffBuilder(json_dumper, pointer_cls=pointer_cls)
296318
builder._compare_values('', None, src, dst)
297319
ops = list(builder.execute())
298320
return cls(ops, pointer_cls=pointer_cls)
299321

300-
def to_string(self):
322+
def to_string(self, dumps=None):
301323
"""Returns patch set as JSON string."""
302-
return json.dumps(self.patch)
324+
json_dumper = dumps or self.json_dumper
325+
return json_dumper(self.patch)
303326

304327
@property
305328
def _ops(self):
@@ -660,7 +683,8 @@ def apply(self, obj):
660683

661684
class DiffBuilder(object):
662685

663-
def __init__(self, pointer_cls=JsonPointer):
686+
def __init__(self, dumps=json.dumps, pointer_cls=JsonPointer):
687+
self.dumps = dumps
664688
self.pointer_cls = pointer_cls
665689
self.index_storage = [{}, {}]
666690
self.index_storage2 = [[], []]
@@ -856,7 +880,7 @@ def _compare_values(self, path, key, src, dst):
856880
# and ignore those that don't. The performance of this could be
857881
# improved by doing more direct type checks, but we'd need to be
858882
# careful to accept type changes that don't matter when JSONified.
859-
elif json.dumps(src) == json.dumps(dst):
883+
elif self.dumps(src) == self.dumps(dst):
860884
return
861885

862886
else:

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
coverage
12
wheel
23
pypandoc

tests.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import unicode_literals
55

66
import json
7+
import decimal
78
import doctest
89
import unittest
910
import jsonpatch
@@ -278,6 +279,34 @@ def test_str(self):
278279
self.assertEqual(json.dumps(patch_obj), patch.to_string())
279280

280281

282+
def custom_types_dumps(obj):
283+
def default(obj):
284+
if isinstance(obj, decimal.Decimal):
285+
return {'__decimal__': str(obj)}
286+
raise TypeError('Unknown type')
287+
288+
return json.dumps(obj, default=default)
289+
290+
291+
def custom_types_loads(obj):
292+
def as_decimal(dct):
293+
if '__decimal__' in dct:
294+
return decimal.Decimal(dct['__decimal__'])
295+
return dct
296+
297+
return json.loads(obj, object_hook=as_decimal)
298+
299+
300+
class CustomTypesJsonPatch(jsonpatch.JsonPatch):
301+
@staticmethod
302+
def json_dumper(obj):
303+
return custom_types_dumps(obj)
304+
305+
@staticmethod
306+
def json_loader(obj):
307+
return custom_types_loads(obj)
308+
309+
281310
class MakePatchTestCase(unittest.TestCase):
282311

283312
def test_apply_patch_to_copy(self):
@@ -456,6 +485,35 @@ def test_issue103(self):
456485
self.assertEqual(res, dst)
457486
self.assertIsInstance(res['A'], float)
458487

488+
def test_custom_types_diff(self):
489+
old = {'value': decimal.Decimal('1.0')}
490+
new = {'value': decimal.Decimal('1.00')}
491+
generated_patch = jsonpatch.JsonPatch.from_diff(
492+
old, new, dumps=custom_types_dumps)
493+
str_patch = generated_patch.to_string(dumps=custom_types_dumps)
494+
loaded_patch = jsonpatch.JsonPatch.from_string(
495+
str_patch, loads=custom_types_loads)
496+
self.assertEqual(generated_patch, loaded_patch)
497+
new_from_patch = jsonpatch.apply_patch(old, generated_patch)
498+
self.assertEqual(new, new_from_patch)
499+
500+
def test_custom_types_subclass(self):
501+
old = {'value': decimal.Decimal('1.0')}
502+
new = {'value': decimal.Decimal('1.00')}
503+
generated_patch = CustomTypesJsonPatch.from_diff(old, new)
504+
str_patch = generated_patch.to_string()
505+
loaded_patch = CustomTypesJsonPatch.from_string(str_patch)
506+
self.assertEqual(generated_patch, loaded_patch)
507+
new_from_patch = jsonpatch.apply_patch(old, loaded_patch)
508+
self.assertEqual(new, new_from_patch)
509+
510+
def test_custom_types_subclass_load(self):
511+
old = {'value': decimal.Decimal('1.0')}
512+
new = {'value': decimal.Decimal('1.00')}
513+
patch = CustomTypesJsonPatch.from_string(
514+
'[{"op": "replace", "path": "/value", "value": {"__decimal__": "1.00"}}]')
515+
new_from_patch = jsonpatch.apply_patch(old, patch)
516+
self.assertEqual(new, new_from_patch)
459517

460518

461519
class OptimizationTests(unittest.TestCase):
@@ -671,6 +729,86 @@ def test_create_with_pointer(self):
671729
self.assertEqual(result, expected)
672730

673731

732+
class JsonPatchCreationTest(unittest.TestCase):
733+
734+
def test_creation_fails_with_invalid_patch(self):
735+
invalid_patches = [
736+
{ 'path': '/foo', 'value': 'bar'},
737+
{'op': 0xADD, 'path': '/foo', 'value': 'bar'},
738+
{'op': 'boo', 'path': '/foo', 'value': 'bar'},
739+
{'op': 'add', 'value': 'bar'},
740+
]
741+
for patch in invalid_patches:
742+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
743+
jsonpatch.JsonPatch([patch])
744+
745+
with self.assertRaises(jsonpointer.JsonPointerException):
746+
jsonpatch.JsonPatch([{'op': 'add', 'path': 'foo', 'value': 'bar'}])
747+
748+
749+
class UtilityMethodTests(unittest.TestCase):
750+
751+
def test_boolean_coercion(self):
752+
empty_patch = jsonpatch.JsonPatch([])
753+
self.assertFalse(empty_patch)
754+
755+
def test_patch_equality(self):
756+
p = jsonpatch.JsonPatch([{'op': 'add', 'path': '/foo', 'value': 'bar'}])
757+
q = jsonpatch.JsonPatch([{'op': 'add', 'path': '/foo', 'value': 'bar'}])
758+
different_op = jsonpatch.JsonPatch([{'op': 'remove', 'path': '/foo'}])
759+
different_path = jsonpatch.JsonPatch([{'op': 'add', 'path': '/bar', 'value': 'bar'}])
760+
different_value = jsonpatch.JsonPatch([{'op': 'add', 'path': '/foo', 'value': 'foo'}])
761+
self.assertNotEqual(p, different_op)
762+
self.assertNotEqual(p, different_path)
763+
self.assertNotEqual(p, different_value)
764+
self.assertEqual(p, q)
765+
766+
def test_operation_equality(self):
767+
add = jsonpatch.AddOperation({'path': '/new-element', 'value': 'new-value'})
768+
add2 = jsonpatch.AddOperation({'path': '/new-element', 'value': 'new-value'})
769+
rm = jsonpatch.RemoveOperation({'path': '/target'})
770+
self.assertEqual(add, add2)
771+
self.assertNotEqual(add, rm)
772+
773+
def test_add_operation_structure(self):
774+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
775+
jsonpatch.AddOperation({'path': '/'}).apply({})
776+
777+
def test_replace_operation_structure(self):
778+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
779+
jsonpatch.ReplaceOperation({'path': '/'}).apply({})
780+
781+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
782+
jsonpatch.ReplaceOperation({'path': '/top/-', 'value': 'foo'}).apply({'top': {'inner': 'value'}})
783+
784+
with self.assertRaises(jsonpatch.JsonPatchConflict):
785+
jsonpatch.ReplaceOperation({'path': '/top/missing', 'value': 'foo'}).apply({'top': {'inner': 'value'}})
786+
787+
def test_move_operation_structure(self):
788+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
789+
jsonpatch.MoveOperation({'path': '/target'}).apply({})
790+
791+
with self.assertRaises(jsonpatch.JsonPatchConflict):
792+
jsonpatch.MoveOperation({'from': '/source', 'path': '/target'}).apply({})
793+
794+
def test_test_operation_structure(self):
795+
with self.assertRaises(jsonpatch.JsonPatchTestFailed):
796+
jsonpatch.TestOperation({'path': '/target'}).apply({})
797+
798+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
799+
jsonpatch.TestOperation({'path': '/target'}).apply({'target': 'value'})
800+
801+
def test_copy_operation_structure(self):
802+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
803+
jsonpatch.CopyOperation({'path': '/target'}).apply({})
804+
805+
with self.assertRaises(jsonpatch.JsonPatchConflict):
806+
jsonpatch.CopyOperation({'path': '/target', 'from': '/source'}).apply({})
807+
808+
with self.assertRaises(jsonpatch.JsonPatchConflict):
809+
jsonpatch.CopyOperation({'path': '/target', 'from': '/source'}).apply({})
810+
811+
674812
class CustomJsonPointer(jsonpointer.JsonPointer):
675813
pass
676814

@@ -690,7 +828,7 @@ def test_json_patch_from_string(self):
690828
self.assertEqual(res.pointer_cls, CustomJsonPointer)
691829

692830
def test_json_patch_from_object(self):
693-
patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}],
831+
patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}]
694832
res = jsonpatch.JsonPatch(
695833
patch, pointer_cls=CustomJsonPointer,
696834
)
@@ -815,6 +953,8 @@ def get_suite():
815953
suite.addTest(unittest.makeSuite(ConflictTests))
816954
suite.addTest(unittest.makeSuite(OptimizationTests))
817955
suite.addTest(unittest.makeSuite(JsonPointerTests))
956+
suite.addTest(unittest.makeSuite(JsonPatchCreationTest))
957+
suite.addTest(unittest.makeSuite(UtilityMethodTests))
818958
suite.addTest(unittest.makeSuite(CustomJsonPointerTests))
819959
return suite
820960

0 commit comments

Comments
 (0)