Skip to content

Commit 511cbc2

Browse files
authored
Merge pull request #108 from paperlessreceipts/custom-types
Make it possible for from_diff to support custom types (issue #107)
2 parents 4fe5c2c + 29c989e commit 511cbc2

File tree

3 files changed

+132
-9
lines changed

3 files changed

+132
-9
lines changed

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: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ def make_patch(src, dst):
165165

166166

167167
class JsonPatch(object):
168+
json_dumper = staticmethod(json.dumps)
169+
json_loader = staticmethod(_jsonloads)
170+
168171
"""A JSON Patch is a list of Patch Operations.
169172
170173
>>> patch = JsonPatch([
@@ -246,19 +249,23 @@ def __ne__(self, other):
246249
return not(self == other)
247250

248251
@classmethod
249-
def from_string(cls, patch_str):
252+
def from_string(cls, patch_str, loads=None):
250253
"""Creates JsonPatch instance from string source.
251254
252255
:param patch_str: JSON patch as raw string.
253256
:type patch_str: str
257+
:param loads: A function of one argument that loads a serialized
258+
JSON string.
259+
:type loads: function
254260
255261
:return: :class:`JsonPatch` instance.
256262
"""
257-
patch = _jsonloads(patch_str)
263+
json_loader = loads or cls.json_loader
264+
patch = json_loader(patch_str)
258265
return cls(patch)
259266

260267
@classmethod
261-
def from_diff(cls, src, dst, optimization=True):
268+
def from_diff(cls, src, dst, optimization=True, dumps=None):
262269
"""Creates JsonPatch instance based on comparison of two document
263270
objects. Json patch would be created for `src` argument against `dst`
264271
one.
@@ -269,6 +276,10 @@ def from_diff(cls, src, dst, optimization=True):
269276
:param dst: Data source document object.
270277
:type dst: dict
271278
279+
:param dumps: A function of one argument that produces a serialized
280+
JSON string.
281+
:type dumps: function
282+
272283
:return: :class:`JsonPatch` instance.
273284
274285
>>> src = {'foo': 'bar', 'numbers': [1, 3, 4, 8]}
@@ -278,15 +289,16 @@ def from_diff(cls, src, dst, optimization=True):
278289
>>> new == dst
279290
True
280291
"""
281-
282-
builder = DiffBuilder()
292+
json_dumper = dumps or cls.json_dumper
293+
builder = DiffBuilder(json_dumper)
283294
builder._compare_values('', None, src, dst)
284295
ops = list(builder.execute())
285296
return cls(ops)
286297

287-
def to_string(self):
298+
def to_string(self, dumps=None):
288299
"""Returns patch set as JSON string."""
289-
return json.dumps(self.patch)
300+
json_dumper = dumps or self.json_dumper
301+
return json_dumper(self.patch)
290302

291303
@property
292304
def _ops(self):
@@ -646,7 +658,8 @@ def apply(self, obj):
646658

647659
class DiffBuilder(object):
648660

649-
def __init__(self):
661+
def __init__(self, dumps=json.dumps):
662+
self.dumps = dumps
650663
self.index_storage = [{}, {}]
651664
self.index_storage2 = [[], []]
652665
self.__root = root = []
@@ -841,7 +854,7 @@ def _compare_values(self, path, key, src, dst):
841854
# and ignore those that don't. The performance of this could be
842855
# improved by doing more direct type checks, but we'd need to be
843856
# careful to accept type changes that don't matter when JSONified.
844-
elif json.dumps(src) == json.dumps(dst):
857+
elif self.dumps(src) == self.dumps(dst):
845858
return
846859

847860
else:

tests.py

Lines changed: 58 additions & 0 deletions
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):

0 commit comments

Comments
 (0)