summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Kögl <stefan@skoegl.net>2012-06-22 21:16:32 +0200
committerStefan Kögl <stefan@skoegl.net>2012-06-22 21:16:32 +0200
commit229519f6525a9bd0ee671912e7ac58a08925e8d8 (patch)
tree018620b4b79aa8ea6ac9593328508d828a4d8af9
parent0a53ac21550c32524e4e2f411ba87d6b8d632ed8 (diff)
parentadd518a3e0fbc680c22a3581420efb1c80235fb5 (diff)
downloadpython-json-patch-229519f6525a9bd0ee671912e7ac58a08925e8d8.tar.gz
Merge branch 'master' of https://github.com/kxepal/python-json-patch
-rw-r--r--README2
-rw-r--r--jsonpatch.py345
-rw-r--r--setup.py24
-rwxr-xr-xtests.py151
4 files changed, 407 insertions, 115 deletions
diff --git a/README b/README
index 24750ed..b0f910d 100644
--- a/README
+++ b/README
@@ -3,7 +3,7 @@ python-json-patch: Applying JSON Patches
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Library to apply JSON Patches according to
-http://tools.ietf.org/html/draft-pbryan-json-patch-01
+http://tools.ietf.org/html/draft-pbryan-json-patch-04
See Sourcecode for Examples
diff --git a/jsonpatch.py b/jsonpatch.py
index c6460b7..67bdfdb 100644
--- a/jsonpatch.py
+++ b/jsonpatch.py
@@ -30,7 +30,8 @@
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
-"""Apply JSON-Patches according to http://tools.ietf.org/html/draft-pbryan-json-patch-01"""
+"""Apply JSON-Patches according to
+http://tools.ietf.org/html/draft-pbryan-json-patch-04"""
# Will be parsed by setup.py to determine package metadata
__author__ = 'Stefan Kögl <stefan@skoegl.net>'
@@ -38,109 +39,299 @@ __version__ = '0.1'
__website__ = 'https://github.com/stefankoegl/python-json-patch'
__license__ = 'Modified BSD License'
-
import copy
-import json
+import sys
+
+if sys.version_info < (2, 6):
+ import simplejson as json
+else:
+ import json
+
+if sys.version_info >= (3, 0):
+ basestring = (bytes, str)
class JsonPatchException(Exception):
- pass
+ """Base Json Patch exception"""
class JsonPatchConflict(JsonPatchException):
- pass
+ """Raises if patch could be applied due to conflict situations such as:
+ - attempt to add object key then it already exists;
+ - attempt to operate with nonexistence object key;
+ - attempt to insert value to array at position beyond of it size;
+ - etc.
+ """
-def apply_patch(doc, patch):
- """
- >>> obj = { 'baz': 'qux', 'foo': 'bar' }
- >>> patch = [ { 'remove': '/baz' } ]
- >>> apply_patch(obj, patch)
- {'foo': 'bar'}
+def apply_patch(doc, patch, in_place=False):
+ """Apply list of patches to specified json document.
+
+ :param doc: Document object.
+ :type doc: dict
+
+ :param patch: JSON patch as list of dicts or raw JSON-encoded string.
+ :type patch: list or str
+
+ :param in_place: While :const:`True` patch will modify target document.
+ By default patch will be applied to document copy.
+ :type in_place: bool
+
+ :return: Patched document object.
+ :rtype: dict
+
+ >>> doc = {'foo': 'bar'}
+ >>> other = apply_patch(doc, [{'add': '/baz', 'value': 'qux'}])
+ >>> doc is not other
+ True
+ >>> other
+ {'foo': 'bar', 'baz': 'qux'}
+ >>> apply_patch(doc, [{'add': '/baz', 'value': 'qux'}], in_place=True)
+ {'foo': 'bar', 'baz': 'qux'}
+ >>> doc == other
+ True
"""
- p = JsonPatch(patch)
- return p.apply(doc)
+ if isinstance(patch, basestring):
+ patch = JsonPatch.from_string(patch)
+ else:
+ patch = JsonPatch(patch)
+ return patch.apply(doc, in_place)
+def make_patch(src, dst):
+ """Generates patch by comparing of two document objects. Actually is
+ a proxy to :meth:`JsonPatch.from_diff` method.
-class JsonPatch(object):
- """ A JSON Patch is a list of Patch Operations """
+ :param src: Data source document object.
+ :type src: dict
+
+ :param dst: Data source document object.
+ :type dst: dict
+ >>> src = {'foo': 'bar', 'numbers': [1, 3, 4, 8]}
+ >>> dst = {'baz': 'qux', 'numbers': [1, 4, 7]}
+ >>> patch = make_patch(src, dst)
+ >>> new = patch.apply(src)
+ >>> new == dst
+ True
+ """
+ return JsonPatch.from_diff(src, dst)
+
+
+class JsonPatch(object):
+ """A JSON Patch is a list of Patch Operations.
+
+ >>> patch = JsonPatch([
+ ... {'add': '/foo', 'value': 'bar'},
+ ... {'add': '/baz', 'value': [1, 2, 3]},
+ ... {'remove': '/baz/1'},
+ ... {'test': '/baz', 'value': [1, 3]},
+ ... {'replace': '/baz/0', 'value': 42},
+ ... {'remove': '/baz/1'},
+ ... ])
+ >>> doc = {}
+ >>> patch.apply(doc)
+ {'foo': 'bar', 'baz': [42]}
+
+ JsonPatch object is iterable, so you could easily access to each patch
+ statement in loop:
+
+ >>> lpatch = list(patch)
+ >>> lpatch[0]
+ {'add': '/foo', 'value': 'bar'}
+ >>> lpatch == patch.patch
+ True
+
+ Also JsonPatch could be converted directly to :class:`bool` if it contains
+ any operation statements:
+
+ >>> bool(patch)
+ True
+ >>> bool(JsonPatch([]))
+ False
+
+ This behavior is very handy with :func:`make_patch` to write more readable
+ code:
+
+ >>> old = {'foo': 'bar', 'numbers': [1, 3, 4, 8]}
+ >>> new = {'baz': 'qux', 'numbers': [1, 4, 7]}
+ >>> patch = make_patch(old, new)
+ >>> if patch:
+ ... # document have changed, do something useful
+ ... patch.apply(old) #doctest: +ELLIPSIS
+ {...}
+ """
def __init__(self, patch):
self.patch = patch
- self.OPERATIONS = {
+ self.operations = {
'remove': RemoveOperation,
'add': AddOperation,
'replace': ReplaceOperation,
+ 'move': MoveOperation,
+ 'test': TestOperation
}
+ def __str__(self):
+ """str(self) -> self.to_string()"""
+ return self.to_string()
+
+ def __bool__(self):
+ return bool(self.patch)
+
+ __nonzero__ = __bool__
+
+ def __iter__(self):
+ return iter(self.patch)
@classmethod
def from_string(cls, patch_str):
- patch = json.loads(patch_str)
- return cls(patch)
+ """Creates JsonPatch instance from string source.
+ :param patch_str: JSON patch as raw string.
+ :type patch_str: str
- def apply(self, obj):
- """ Applies the patch to a copy of the given object """
+ :return: :class:`JsonPatch` instance.
+ """
+ patch = json.loads(patch_str)
+ return cls(patch)
- obj = copy.deepcopy(obj)
+ @classmethod
+ def from_diff(cls, src, dst):
+ """Creates JsonPatch instance based on comparing of two document
+ objects. Json patch would be created for `src` argument against `dst`
+ one.
+
+ :param src: Data source document object.
+ :type src: dict
+
+ :param dst: Data source document object.
+ :type dst: dict
+
+ :return: :class:`JsonPatch` instance.
+
+ >>> src = {'foo': 'bar', 'numbers': [1, 3, 4, 8]}
+ >>> dst = {'baz': 'qux', 'numbers': [1, 4, 7]}
+ >>> patch = JsonPatch.from_diff(src, dst)
+ >>> new = patch.apply(src)
+ >>> new == dst
+ True
+ """
+ def compare_values(path, value, other):
+ if isinstance(value, dict) and isinstance(other, dict):
+ for operation in compare_dict(path, value, other):
+ yield operation
+ elif isinstance(value, list) and isinstance(other, list):
+ for operation in compare_list(path, value, other):
+ yield operation
+ else:
+ yield {'replace': '/'.join(path), 'value': other}
+
+ def compare_dict(path, src, dst):
+ for key in src:
+ if key not in dst:
+ yield {'remove': '/'.join(path + [key])}
+ elif src[key] != dst[key]:
+ current = path + [key]
+ for operation in compare_values(current, src[key], dst[key]):
+ yield operation
+ for key in dst:
+ if key not in src:
+ yield {'add': '/'.join(path + [key]), 'value': dst[key]}
+
+ def compare_list(path, src, dst):
+ lsrc, ldst = len(src), len(dst)
+ for idx in reversed(range(max(lsrc, ldst))):
+ if idx < lsrc and idx < ldst:
+ current = path + [str(idx)]
+ for operation in compare_values(current, src[idx], dst[idx]):
+ yield operation
+ elif idx < ldst:
+ yield {'add': '/'.join(path + [str(idx)]),
+ 'value': dst[idx]}
+ elif idx < lsrc:
+ yield {'remove': '/'.join(path + [str(idx)])}
+
+ return cls(list(compare_dict([''], src, dst)))
+
+ def to_string(self):
+ """Returns patch set as JSON string."""
+ return json.dumps(self.patch)
+
+ def apply(self, obj, in_place=False):
+ """Applies the patch to given object.
+
+ :param obj: Document object.
+ :type obj: dict
+
+ :param in_place: Tweaks way how patch would be applied - directly to
+ specified `obj` or to his copy.
+ :type in_place: bool
+
+ :return: Modified `obj`.
+ """
+
+ if not in_place:
+ obj = copy.deepcopy(obj)
for operation in self.patch:
- op = self._get_operation(operation)
- op.apply(obj)
+ operation = self._get_operation(operation)
+ operation.apply(obj)
return obj
-
def _get_operation(self, operation):
- for action, op_cls in self.OPERATIONS.items():
+ for action, op_cls in self.operations.items():
if action in operation:
location = operation[action]
- op = op_cls(location, operation)
- return op
+ return op_cls(location, operation)
raise JsonPatchException("invalid operation '%s'" % operation)
-
class PatchOperation(object):
- """ A single operation inside a JSON Patch """
+ """A single operation inside a JSON Patch."""
def __init__(self, location, operation):
self.location = location
self.operation = operation
+ def apply(self, obj):
+ """Abstract method that applies patch operation to specified object."""
+ raise NotImplementedError('should implement patch operation.')
def locate(self, obj, location, last_must_exist=True):
- """ Walks through the object according to location
+ """Walks through the object according to location.
- Returns the last step as (sub-object, last location-step) """
+ Returns the last step as (sub-object, last location-step)."""
parts = location.split('/')
if parts.pop(0) != '':
raise JsonPatchException('location must starts with /')
for part in parts[:-1]:
- obj, loc_part = self._step(obj, part)
+ obj, _ = self._step(obj, part)
_, last_loc = self._step(obj, parts[-1], must_exist=last_must_exist)
return obj, last_loc
-
def _step(self, obj, loc_part, must_exist=True):
- """ Goes one step in a locate() call """
+ """Goes one step in a locate() call."""
- # Its not clear if a location "1" should be considered as 1 or "1"
- # We prefer the integer-variant if possible
- part_variants = self._try_parse(loc_part) + [loc_part]
-
- for variant in part_variants:
- try:
+ if isinstance(obj, dict):
+ part_variants = [loc_part]
+ for variant in part_variants:
+ if variant not in obj:
+ continue
+ return obj[variant], variant
+ elif isinstance(obj, list):
+ part_variants = [int(loc_part)]
+ for variant in part_variants:
+ if variant >= len(obj):
+ continue
return obj[variant], variant
- except:
- continue
+ else:
+ raise ValueError('list or dict expected, got %r' % type(obj))
if must_exist:
raise JsonPatchConflict('key %s not found' % loc_part)
@@ -148,27 +339,8 @@ class PatchOperation(object):
return obj, part_variants[0]
- @staticmethod
- def _try_parse(val, cls=int):
- try:
- return [cls(val)]
- except:
- return []
-
-
class RemoveOperation(PatchOperation):
- """ Removes an object property or an array element
-
- >>> obj = { 'baz': 'qux', 'foo': 'bar' }
- >>> patch = JsonPatch( [ { 'remove': '/baz' } ] )
- >>> patch.apply(obj)
- {'foo': 'bar'}
-
- >>> obj = { 'foo': [ 'bar', 'qux', 'baz' ] }
- >>> patch = JsonPatch( [ { "remove": "/foo/1" } ] )
- >>> patch.apply(obj)
- {'foo': ['bar', 'baz']}
- """
+ """Removes an object property or an array element."""
def apply(self, obj):
subobj, part = self.locate(obj, self.location)
@@ -176,18 +348,7 @@ class RemoveOperation(PatchOperation):
class AddOperation(PatchOperation):
- """ Adds an object property or an array element
-
- >>> obj = { "foo": "bar" }
- >>> patch = JsonPatch([ { "add": "/baz", "value": "qux" } ])
- >>> patch.apply(obj)
- {'foo': 'bar', 'baz': 'qux'}
-
- >>> obj = { "foo": [ "bar", "baz" ] }
- >>> patch = JsonPatch([ { "add": "/foo/1", "value": "qux" } ])
- >>> patch.apply(obj)
- {'foo': ['bar', 'qux', 'baz']}
- """
+ """Adds an object property or an array element."""
def apply(self, obj):
value = self.operation["value"]
@@ -206,17 +367,12 @@ class AddOperation(PatchOperation):
subobj[part] = value
else:
- raise JsonPatchConflict("can't add to type '%s'" % subobj.__class__.__name__)
+ raise JsonPatchConflict("can't add to type '%s'"
+ "" % subobj.__class__.__name__)
class ReplaceOperation(PatchOperation):
- """ Replaces a value
-
- >>> obj = { "baz": "qux", "foo": "bar" }
- >>> patch = JsonPatch([ { "replace": "/baz", "value": "boo" } ])
- >>> patch.apply(obj)
- {'foo': 'bar', 'baz': 'boo'}
- """
+ """Replaces an object property or an array element by new value."""
def apply(self, obj):
value = self.operation["value"]
@@ -228,9 +384,30 @@ class ReplaceOperation(PatchOperation):
elif isinstance(subobj, dict):
if not part in subobj:
- raise JsonPatchConflict("can't replace non-existant object '%s'" % part)
+ raise JsonPatchConflict("can't replace non-existant object '%s'"
+ "" % part)
else:
- raise JsonPatchConflict("can't replace in type '%s'" % subobj.__class__.__name__)
+ raise JsonPatchConflict("can't replace in type '%s'"
+ "" % subobj.__class__.__name__)
subobj[part] = value
+
+
+class MoveOperation(PatchOperation):
+ """Moves an object property or an array element to new location."""
+
+ def apply(self, obj):
+ subobj, part = self.locate(obj, self.location)
+ value = subobj[part]
+ RemoveOperation(self.location, self.operation).apply(obj)
+ AddOperation(self.operation['to'], {'value': value}).apply(obj)
+
+
+class TestOperation(PatchOperation):
+ """Test value by specified location."""
+
+ def apply(self, obj):
+ value = self.operation['value']
+ subobj, part = self.locate(obj, self.location)
+ assert subobj[part] == value
diff --git a/setup.py b/setup.py
index c9a3900..b7f781f 100644
--- a/setup.py
+++ b/setup.py
@@ -1,7 +1,14 @@
#!/usr/bin/env python
-from distutils.core import setup
+import sys
import re
+import warnings
+try:
+ from setuptools import setup
+ has_setuptools = True
+except ImportError:
+ from distutils.core import setup
+ has_setuptools = False
src = open('jsonpatch.py').read()
metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", src))
@@ -13,6 +20,20 @@ MODULES = (
'jsonpatch',
)
+REQUIREMENTS = []
+if sys.version_info < (2, 6):
+ REQUIREMENTS += ['simplejson']
+
+if has_setuptools:
+ OPTIONS = {
+ 'install_requires': REQUIREMENTS
+ }
+else:
+ if sys.version_info < (2, 6):
+ warnings.warn('No setuptools installed. Be sure that you have '
+ 'json or simplejson package installed')
+ OPTIONS = {}
+
AUTHOR_EMAIL = metadata['author']
VERSION = metadata['version']
WEBSITE = metadata['website']
@@ -30,4 +51,5 @@ setup(name=PACKAGE,
license=LICENSE,
url=WEBSITE,
py_modules=MODULES,
+ **OPTIONS
)
diff --git a/tests.py b/tests.py
index 5b1d17c..82a3e34 100755
--- a/tests.py
+++ b/tests.py
@@ -1,44 +1,137 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
-from __future__ import print_function
import doctest
import unittest
-import sys
+import jsonpatch
-modules = ['jsonpatch']
-coverage_modules = []
-suite = unittest.TestSuite()
+class ApplyPatchTestCase(unittest.TestCase):
-for module in modules:
- m = __import__(module, fromlist=[module])
- coverage_modules.append(m)
- suite.addTest(doctest.DocTestSuite(m))
+ def test_apply_patch_from_string(self):
+ obj = {'foo': 'bar'}
+ patch = '[{"add": "/baz", "value": "qux"}]'
+ res = jsonpatch.apply_patch(obj, patch)
+ self.assertTrue(obj is not res)
+ self.assertTrue('baz' in res)
+ self.assertEqual(res['baz'], 'qux')
-runner = unittest.TextTestRunner(verbosity=2)
+ def test_apply_patch_to_copy(self):
+ obj = {'foo': 'bar'}
+ res = jsonpatch.apply_patch(obj, [{'add': '/baz', 'value': 'qux'}])
+ self.assertTrue(obj is not res)
-try:
- import coverage
-except ImportError:
- coverage = None
+ def test_apply_patch_to_same_instance(self):
+ obj = {'foo': 'bar'}
+ res = jsonpatch.apply_patch(obj, [{'add': '/baz', 'value': 'qux'}],
+ in_place=True)
+ self.assertTrue(obj is res)
-if coverage is not None:
- coverage.erase()
- coverage.start()
+ def test_add_object_key(self):
+ obj = {'foo': 'bar'}
+ res = jsonpatch.apply_patch(obj, [{'add': '/baz', 'value': 'qux'}])
+ self.assertTrue('baz' in res)
+ self.assertEqual(res['baz'], 'qux')
-result = runner.run(suite)
+ def test_add_array_item(self):
+ obj = {'foo': ['bar', 'baz']}
+ res = jsonpatch.apply_patch(obj, [{'add': '/foo/1', 'value': 'qux'}])
+ self.assertEqual(res['foo'], ['bar', 'qux', 'baz'])
-if not result.wasSuccessful():
- sys.exit(1)
+ def test_remove_object_key(self):
+ obj = {'foo': 'bar', 'baz': 'qux'}
+ res = jsonpatch.apply_patch(obj, [{'remove': '/baz'}])
+ self.assertTrue('baz' not in res)
-if coverage is not None:
- coverage.stop()
- coverage.report(coverage_modules)
- coverage.erase()
+ def test_remove_array_item(self):
+ obj = {'foo': ['bar', 'qux', 'baz']}
+ res = jsonpatch.apply_patch(obj, [{'remove': '/foo/1'}])
+ self.assertEqual(res['foo'], ['bar', 'baz'])
-if coverage is None:
- print("""
- No coverage reporting done (Python module "coverage" is missing)
- Please install the python-coverage package to get coverage reporting.
- """, file=sys.stderr)
+ def test_replace_object_key(self):
+ obj = {'foo': 'bar', 'baz': 'qux'}
+ res = jsonpatch.apply_patch(obj, [{'replace': '/baz', 'value': 'boo'}])
+ self.assertTrue(res['baz'], 'boo')
+
+ def test_replace_array_item(self):
+ obj = {'foo': ['bar', 'qux', 'baz']}
+ res = jsonpatch.apply_patch(obj, [{'replace': '/foo/1',
+ 'value': 'boo'}])
+ self.assertEqual(res['foo'], ['bar', 'boo', 'baz'])
+
+ def test_move_object_key(self):
+ obj = {'foo': {'bar': 'baz', 'waldo': 'fred'},
+ 'qux': {'corge': 'grault'}}
+ res = jsonpatch.apply_patch(obj, [{'move': '/foo/waldo',
+ 'to': '/qux/thud'}])
+ self.assertEqual(res, {'qux': {'thud': 'fred', 'corge': 'grault'},
+ 'foo': {'bar': 'baz'}})
+
+ def test_move_array_item(self):
+ obj = {'foo': ['all', 'grass', 'cows', 'eat']}
+ res = jsonpatch.apply_patch(obj, [{'move': '/foo/1', 'to': '/foo/3'}])
+ self.assertEqual(res, {'foo': ['all', 'cows', 'eat', 'grass']})
+
+ def test_test_success(self):
+ obj = {'baz': 'qux', 'foo': ['a', 2, 'c']}
+ jsonpatch.apply_patch(obj, [{'test': '/baz', 'value': 'qux'},
+ {'test': '/foo/1', 'value': 2}])
+
+ def test_test_error(self):
+ obj = {'bar': 'qux'}
+ self.assertRaises(AssertionError,
+ jsonpatch.apply_patch,
+ obj, [{'test': '/bar', 'value': 'bar'}])
+
+
+class MakePatchTestCase(unittest.TestCase):
+
+ def test_apply_patch_to_copy(self):
+ src = {'foo': 'bar', 'boo': 'qux'}
+ dst = {'baz': 'qux', 'foo': 'boo'}
+ patch = jsonpatch.make_patch(src, dst)
+ res = patch.apply(src)
+ self.assertTrue(src is not res)
+
+ def test_apply_patch_to_same_instance(self):
+ src = {'foo': 'bar', 'boo': 'qux'}
+ dst = {'baz': 'qux', 'foo': 'boo'}
+ patch = jsonpatch.make_patch(src, dst)
+ res = patch.apply(src, in_place=True)
+ self.assertTrue(src is res)
+
+ def test_objects(self):
+ src = {'foo': 'bar', 'boo': 'qux'}
+ dst = {'baz': 'qux', 'foo': 'boo'}
+ patch = jsonpatch.make_patch(src, dst)
+ res = patch.apply(src)
+ self.assertEqual(res, dst)
+
+ def test_arrays(self):
+ src = {'numbers': [1, 2, 3], 'other': [1, 3, 4, 5]}
+ dst = {'numbers': [1, 3, 4, 5], 'other': [1, 3, 4]}
+ patch = jsonpatch.make_patch(src, dst)
+ res = patch.apply(src)
+ self.assertEqual(res, dst)
+
+ def test_complex_object(self):
+ src = {'data': [
+ {'foo': 1}, {'bar': [1, 2, 3]}, {'baz': {'1': 1, '2': 2}}
+ ]}
+ dst = {'data': [
+ {'foo': [42]}, {'bar': []}, {'baz': {'boo': 'oom!'}}
+ ]}
+ patch = jsonpatch.make_patch(src, dst)
+ res = patch.apply(src)
+ self.assertEqual(res, dst)
+
+
+def suite():
+ suite = unittest.TestSuite()
+ suite.addTest(doctest.DocTestSuite(jsonpatch))
+ suite.addTest(unittest.makeSuite(ApplyPatchTestCase))
+ suite.addTest(unittest.makeSuite(MakePatchTestCase))
+ return suite
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')