Skip to content

Commit

Permalink
Merge branch 'master' into feature/custom-pointer
Browse files Browse the repository at this point in the history
# Conflicts:
#	jsonpatch.py
#	tests.py
  • Loading branch information
Artyom Nikitin committed Nov 23, 2020
2 parents 50fb942 + 24b5e86 commit eca4f8a
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -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
Expand Down
52 changes: 52 additions & 0 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
44 changes: 34 additions & 10 deletions jsonpatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -229,6 +232,13 @@ def __init__(self, patch, pointer_cls=JsonPointer):
'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()
Expand All @@ -253,22 +263,30 @@ def __ne__(self, other):
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.
Expand All @@ -279,6 +297,10 @@ def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer):
: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]
Expand All @@ -291,15 +313,16 @@ def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer):
>>> 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):
Expand Down Expand Up @@ -660,7 +683,8 @@ def apply(self, obj):

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 = [[], []]
Expand Down Expand Up @@ -856,7 +880,7 @@ def _compare_values(self, path, key, src, dst):
# 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:
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
coverage
wheel
pypandoc
142 changes: 141 additions & 1 deletion tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import unicode_literals

import json
import decimal
import doctest
import unittest
import jsonpatch
Expand Down Expand Up @@ -278,6 +279,34 @@ def test_str(self):
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):
Expand Down Expand Up @@ -456,6 +485,35 @@ def test_issue103(self):
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):
Expand Down Expand Up @@ -671,6 +729,86 @@ def test_create_with_pointer(self):
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

Expand All @@ -690,7 +828,7 @@ def test_json_patch_from_string(self):
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,
)
Expand Down Expand Up @@ -815,6 +953,8 @@ def get_suite():
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

Expand Down

0 comments on commit eca4f8a

Please sign in to comment.