From 70306faa2bab12ad33aae0b6d515e83170cb3608 Mon Sep 17 00:00:00 2001 From: selurvedu Date: Sun, 18 Oct 2015 14:12:54 +0000 Subject: [PATCH 001/127] Fix build failure on Travis CI with Python 3.2 See https://bitbucket.org/ned/coveragepy/issues/407 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index e4a40ad..70f48e1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ python: install: - travis_retry pip install -r requirements.txt + - if [ "$TRAVIS_PYTHON_VERSION" == "3.2" ]; then travis_retry pip install 'coverage<4'; fi - travis_retry pip install coveralls script: From 0ae80ba696ddf8dbbe23c371d30bdc8a27290720 Mon Sep 17 00:00:00 2001 From: selurvedu Date: Fri, 14 Aug 2015 15:37:24 +0000 Subject: [PATCH 002/127] Allow running certain tests separately E.g. `python2 -m unittest tests.MakePatchTestCase.test_objects` or `nose tests:MakePatchTestCase.test_objects`. --- tests.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests.py b/tests.py index 5cbff33..24f3908 100755 --- a/tests.py +++ b/tests.py @@ -414,30 +414,30 @@ def test_replace_missing(self): self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) +if __name__ == '__main__': + modules = ['jsonpatch'] -modules = ['jsonpatch'] + def get_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(jsonpatch)) + suite.addTest(unittest.makeSuite(ApplyPatchTestCase)) + suite.addTest(unittest.makeSuite(EqualityTestCase)) + suite.addTest(unittest.makeSuite(MakePatchTestCase)) + suite.addTest(unittest.makeSuite(InvalidInputTests)) + suite.addTest(unittest.makeSuite(ConflictTests)) + return suite -def get_suite(): - suite = unittest.TestSuite() - suite.addTest(doctest.DocTestSuite(jsonpatch)) - suite.addTest(unittest.makeSuite(ApplyPatchTestCase)) - suite.addTest(unittest.makeSuite(EqualityTestCase)) - suite.addTest(unittest.makeSuite(MakePatchTestCase)) - suite.addTest(unittest.makeSuite(InvalidInputTests)) - suite.addTest(unittest.makeSuite(ConflictTests)) - return suite + suite = get_suite() -suite = get_suite() + for module in modules: + m = __import__(module, fromlist=[module]) + suite.addTest(doctest.DocTestSuite(m)) -for module in modules: - m = __import__(module, fromlist=[module]) - suite.addTest(doctest.DocTestSuite(m)) + runner = unittest.TextTestRunner(verbosity=1) -runner = unittest.TextTestRunner(verbosity=1) + result = runner.run(suite) -result = runner.run(suite) - -if not result.wasSuccessful(): - sys.exit(1) + if not result.wasSuccessful(): + sys.exit(1) From 3f2328eb63507fe78a48c27cf677a6998f3c9f63 Mon Sep 17 00:00:00 2001 From: Alex Pinkney Date: Tue, 3 Nov 2015 17:04:12 +0000 Subject: [PATCH 003/127] Fix bug in _split_by_common_seq using wrong range in right subtree --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index f62aa94..4ffd50f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -641,7 +641,7 @@ def _split_by_common_seq(src, dst, bx=(0, -1), by=(0, -1)): (by[0], by[0] + y[0])), _split_by_common_seq(src[x[1]:], dst[y[1]:], (bx[0] + x[1], bx[0] + len(src)), - (bx[0] + y[1], bx[0] + len(dst)))] + (by[0] + y[1], by[0] + len(dst)))] def _compare(path, src, dst, left, right): From 2a02d21d7bf7e017376b3e6f08d38e71343f6b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Tue, 3 Nov 2015 20:00:48 +0100 Subject: [PATCH 004/127] Add failing test for #40 --- tests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests.py b/tests.py index 24f3908..2572dc0 100755 --- a/tests.py +++ b/tests.py @@ -359,6 +359,13 @@ def test_make_patch_unicode(self): res = patch.apply(src) self.assertEqual(res, dst) + def test_issue40(self): + """ Tests an issue in _split_by_common_seq reported in #40 """ + + src = [8, 7, 2, 1, 0, 9, 4, 3, 5, 6] + dest = [7, 2, 1, 0, 9, 4, 3, 6, 5, 8] + patch = jsonpatch.make_patch(src, dest) + class InvalidInputTests(unittest.TestCase): From d877f1d95dfe878fd06da9b7be8ee0940185b7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Tue, 3 Nov 2015 20:14:30 +0100 Subject: [PATCH 005/127] bump version to 1.12 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 4ffd50f..fb2b90d 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -51,7 +51,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.11' +__version__ = '1.12' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 5c2a9b91897e7b154d857331552a9543d5b0b020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 14:46:53 +0100 Subject: [PATCH 006/127] Add encoding info to setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index d31b990..6b7cfc2 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- import sys import io From 32dcbb03d8c6b9aedefff026fda75e5d8b63b8d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 14:49:05 +0100 Subject: [PATCH 007/127] Create "universal" wheel packages --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 From a33021bf5a87350abc225a15c2a12880d88ed383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 15:40:03 +0100 Subject: [PATCH 008/127] Optimize "deep" ``replace`` operation, fixes #36 --- jsonpatch.py | 13 ++++++++++--- tests.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index fb2b90d..838d66c 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -756,11 +756,18 @@ def _optimize(operations): def _optimize_using_replace(prev, cur): - """Optimises JSON patch by using ``replace`` operation instead of - ``remove`` and ``add`` against the same path.""" + """Optimises by replacing ``add``/``remove`` with ``replace`` on same path + + For nested strucures, tries to recurse replacement, see #36 """ prev['op'] = 'replace' if cur['op'] == 'add': - prev['value'] = cur['value'] + # make recursive patch + patch = make_patch(prev['value'], cur['value']) + if len(patch.patch) == 1: + prev['path'] = prev['path'] + patch.patch[0]['path'] + prev['value'] = patch.patch[0]['value'] + else: + prev['value'] = cur['value'] def _optimize_using_move(prev_item, item): diff --git a/tests.py b/tests.py index 2572dc0..b73b38e 100755 --- a/tests.py +++ b/tests.py @@ -366,6 +366,24 @@ def test_issue40(self): dest = [7, 2, 1, 0, 9, 4, 3, 6, 5, 8] patch = jsonpatch.make_patch(src, dest) + def test_minimal_patch(self): + """ Test whether a minimal patch is created, see #36 """ + src = [{"foo": 1, "bar": 2}] + dst = [{"foo": 2, "bar": 2}] + import pudb + #pudb.set_trace() + patch = jsonpatch.make_patch(src, dst) + + exp = [ + { + "path": "/0/foo", + "value": 2, + "op": "replace" + } + ] + + self.assertEqual(patch.patch, exp) + class InvalidInputTests(unittest.TestCase): From 4443d3241b8e11e691d4700b37db469120993d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 15:41:18 +0100 Subject: [PATCH 009/127] Make ``move`` operation with from == path a no-op --- jsonpatch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index 838d66c..917fc33 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -484,6 +484,10 @@ def apply(self, obj): except (KeyError, IndexError) as ex: raise JsonPatchConflict(str(ex)) + # If source and target are equal, this is a no-op + if self.pointer == from_ptr: + return obj + if isinstance(subobj, MutableMapping) and \ self.pointer.contains(from_ptr): raise JsonPatchConflict('Cannot move values into its own children') From cf0da04dca7a5fcb3f20ab6a4eb78faf9dac8ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 15:42:27 +0100 Subject: [PATCH 010/127] Print test comments when external tests fail Makes it easier to locate the failing tests in the test file --- ext_tests.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ext_tests.py b/ext_tests.py index 5e5ded8..0e1404c 100755 --- a/ext_tests.py +++ b/ext_tests.py @@ -60,12 +60,15 @@ def _test(self, test): ) else: - res = jsonpatch.apply_patch(test['doc'], test['patch']) + try: + res = jsonpatch.apply_patch(test['doc'], test['patch']) + except jsonpatch.JsonPatchException as jpe: + raise Exception(test.get('comment', '')) from jpe # if there is no 'expected' we only verify that applying the patch # does not raies an exception if 'expected' in test: - self.assertEquals(res, test['expected']) + self.assertEquals(res, test['expected'], test.get('comment', '')) def make_test_case(tests): From 9ce8487179bc001c4be10a54dadc04ce66be5c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 15:50:48 +0100 Subject: [PATCH 011/127] Remove import of ``pudb`` --- tests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests.py b/tests.py index b73b38e..8b0b52c 100755 --- a/tests.py +++ b/tests.py @@ -370,8 +370,6 @@ def test_minimal_patch(self): """ Test whether a minimal patch is created, see #36 """ src = [{"foo": 1, "bar": 2}] dst = [{"foo": 2, "bar": 2}] - import pudb - #pudb.set_trace() patch = jsonpatch.make_patch(src, dst) exp = [ From b15d8f1ec18e4f3191ba668e63520018a47e37de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 19:00:29 +0100 Subject: [PATCH 012/127] bump version to 1.13 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 917fc33..96fe6f9 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -51,7 +51,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.12' +__version__ = '1.13' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 5cc9bee572f6207166122ac2ba9cecde0598930a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Feb 2016 19:04:55 +0100 Subject: [PATCH 013/127] Update trove classification to include Python 3.5 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 6b7cfc2..f73d9cf 100644 --- a/setup.py +++ b/setup.py @@ -67,6 +67,7 @@ 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', From 282bebae977021b6f1d19fad0c967eb625af7331 Mon Sep 17 00:00:00 2001 From: selurvedu Date: Fri, 14 Aug 2015 15:45:03 +0000 Subject: [PATCH 014/127] Extend tests that check list patching --- tests.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests.py b/tests.py index 5b0d9e9..bb9e296 100755 --- a/tests.py +++ b/tests.py @@ -327,13 +327,17 @@ def test_use_move_instead_of_remove_add(self): self.assertEqual(res, dst) def test_use_move_instead_of_add_remove(self): - src = {'foo': [1, 2, 3]} - dst = {'foo': [3, 1, 2]} - patch = list(jsonpatch.make_patch(src, dst)) - self.assertEqual(len(patch), 1) - self.assertEqual(patch[0]['op'], 'move') - res = jsonpatch.apply_patch(src, patch) - self.assertEqual(res, dst) + def fn(_src, _dst): + patch = list(jsonpatch.make_patch(_src, _dst)) + self.assertEqual(len(patch), 1) + self.assertEqual(patch[0]['op'], 'move') + res = jsonpatch.apply_patch(_src, patch) + self.assertEqual(res, _dst) + + fn({'foo': [1, 2, 3]}, {'foo': [3, 1, 2]}) + fn({'foo': [1, 2, 3]}, {'foo': [3, 2, 1]}) + fn([1, 2, 3], [3, 1, 2]) + fn([1, 2, 3], [3, 2, 1]) def test_escape(self): src = {"x/y": 1} From c1067351a798534dda59aadccdfe5eb96fd520b4 Mon Sep 17 00:00:00 2001 From: selurvedu Date: Wed, 19 Aug 2015 17:00:16 +0000 Subject: [PATCH 015/127] Allow longer patches in test_use_move_... --- tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests.py b/tests.py index bb9e296..c5134d3 100755 --- a/tests.py +++ b/tests.py @@ -329,8 +329,9 @@ def test_use_move_instead_of_remove_add(self): def test_use_move_instead_of_add_remove(self): def fn(_src, _dst): patch = list(jsonpatch.make_patch(_src, _dst)) - self.assertEqual(len(patch), 1) - self.assertEqual(patch[0]['op'], 'move') + # Check if there are only 'move' operations + for p in patch: + self.assertEqual(p['op'], 'move') res = jsonpatch.apply_patch(_src, patch) self.assertEqual(res, _dst) From 6761340d8b4eae3419eebfb202c09f9956387148 Mon Sep 17 00:00:00 2001 From: Stas Erema Date: Tue, 18 Aug 2015 16:28:07 +0300 Subject: [PATCH 016/127] added error-prone cases to default tests --- tests.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests.py b/tests.py index c5134d3..8da9cfc 100755 --- a/tests.py +++ b/tests.py @@ -356,6 +356,38 @@ def test_root_list(self): res = patch.apply(src) self.assertEqual(res, dst) + def test_fail_prone_list_1(self): + """ Test making and applying a patch of the root is a list """ + src = [u'a', u'r', u'b'] + dst = [u'b', u'o'] + patch = jsonpatch.make_patch(src, dst) + res = patch.apply(src) + self.assertEqual(res, dst) + + def test_fail_prone_list_2(self): + """ Test making and applying a patch of the root is a list """ + src = [u'a', u'r', u'b', u'x', u'm', u'n'] + dst = [u'b', u'o', u'm', u'n'] + patch = jsonpatch.make_patch(src, dst) + res = patch.apply(src) + self.assertEqual(res, dst) + + def test_fail_prone_list_3(self): + """ Test making and applying a patch of the root is a list """ + src = [u'boo1', u'bar', u'foo1', u'qux'] + dst = [u'qux', u'bar'] + patch = jsonpatch.make_patch(src, dst) + res = patch.apply(src) + self.assertEqual(res, dst) + + def test_fail_prone_list_4(self): + """ Test making and applying a patch of the root is a list """ + src = [u'bar1', 59, u'foo1', u'foo'] + dst = [u'foo', u'bar', u'foo1'] + patch = jsonpatch.make_patch(src, dst) + res = patch.apply(src) + self.assertEqual(res, dst) + class InvalidInputTests(unittest.TestCase): From 0734d45eb651ed9dd1d37b6b80265fc3a6263607 Mon Sep 17 00:00:00 2001 From: selurvedu Date: Sun, 18 Oct 2015 21:17:38 +0000 Subject: [PATCH 017/127] Update tests from ea80865 to run on Python 3.2 --- tests.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests.py b/tests.py index 8da9cfc..d2f9fe9 100755 --- a/tests.py +++ b/tests.py @@ -358,32 +358,32 @@ def test_root_list(self): def test_fail_prone_list_1(self): """ Test making and applying a patch of the root is a list """ - src = [u'a', u'r', u'b'] - dst = [u'b', u'o'] + src = ['a', 'r', 'b'] + dst = ['b', 'o'] patch = jsonpatch.make_patch(src, dst) res = patch.apply(src) self.assertEqual(res, dst) def test_fail_prone_list_2(self): """ Test making and applying a patch of the root is a list """ - src = [u'a', u'r', u'b', u'x', u'm', u'n'] - dst = [u'b', u'o', u'm', u'n'] + src = ['a', 'r', 'b', 'x', 'm', 'n'] + dst = ['b', 'o', 'm', 'n'] patch = jsonpatch.make_patch(src, dst) res = patch.apply(src) self.assertEqual(res, dst) def test_fail_prone_list_3(self): """ Test making and applying a patch of the root is a list """ - src = [u'boo1', u'bar', u'foo1', u'qux'] - dst = [u'qux', u'bar'] + src = ['boo1', 'bar', 'foo1', 'qux'] + dst = ['qux', 'bar'] patch = jsonpatch.make_patch(src, dst) res = patch.apply(src) self.assertEqual(res, dst) def test_fail_prone_list_4(self): """ Test making and applying a patch of the root is a list """ - src = [u'bar1', 59, u'foo1', u'foo'] - dst = [u'foo', u'bar', u'foo1'] + src = ['bar1', 59, 'foo1', 'foo'] + dst = ['foo', 'bar', 'foo1'] patch = jsonpatch.make_patch(src, dst) res = patch.apply(src) self.assertEqual(res, dst) From 73acf7ff2a83c411217aff024d35cc8b602180ed Mon Sep 17 00:00:00 2001 From: selurvedu Date: Fri, 8 Apr 2016 04:24:29 +0000 Subject: [PATCH 018/127] Move list-related testcases into separate class --- tests.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests.py b/tests.py index d2f9fe9..b03b64b 100755 --- a/tests.py +++ b/tests.py @@ -356,6 +356,9 @@ def test_root_list(self): res = patch.apply(src) self.assertEqual(res, dst) + +class ListTests(unittest.TestCase): + def test_fail_prone_list_1(self): """ Test making and applying a patch of the root is a list """ src = ['a', 'r', 'b'] @@ -453,6 +456,7 @@ def get_suite(): suite.addTest(unittest.makeSuite(ApplyPatchTestCase)) suite.addTest(unittest.makeSuite(EqualityTestCase)) suite.addTest(unittest.makeSuite(MakePatchTestCase)) + suite.addTest(unittest.makeSuite(ListTests)) suite.addTest(unittest.makeSuite(InvalidInputTests)) suite.addTest(unittest.makeSuite(ConflictTests)) return suite From 72a90e3ff1e75c6628106509568de9e22ca2b259 Mon Sep 17 00:00:00 2001 From: Kirill Goldshtein Date: Wed, 4 May 2016 09:53:57 +0300 Subject: [PATCH 019/127] Fix KeyError in add/remove optimization --- jsonpatch.py | 2 +- tests.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 96fe6f9..32508e2 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -767,7 +767,7 @@ def _optimize_using_replace(prev, cur): if cur['op'] == 'add': # make recursive patch patch = make_patch(prev['value'], cur['value']) - if len(patch.patch) == 1: + if len(patch.patch) == 1 and patch.patch[0]['op'] != 'remove': prev['path'] = prev['path'] + patch.patch[0]['path'] prev['value'] = patch.patch[0]['value'] else: diff --git a/tests.py b/tests.py index 8b0b52c..5acf24b 100755 --- a/tests.py +++ b/tests.py @@ -317,6 +317,15 @@ def test_use_replace_instead_of_remove_add(self): res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) + def test_use_replace_instead_of_remove_add_nested(self): + src = {'foo': [{'bar': 1, 'baz': 2}, {'bar': 2, 'baz': 3}]} + dst = {'foo': [{'bar': 1}, {'bar': 2, 'baz': 3}]} + patch = list(jsonpatch.make_patch(src, dst)) + self.assertEqual(len(patch), 1) + self.assertEqual(patch[0]['op'], 'replace') + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + def test_use_move_instead_of_remove_add(self): src = {'foo': [4, 1, 2, 3]} dst = {'foo': [1, 2, 3, 4]} From d6e9a0047bad780b53151f572ea257f1cb6ebe41 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Wed, 29 Jun 2016 11:13:00 +0200 Subject: [PATCH 020/127] Use inspect.signature() on Python 3 The inspect.getargspec() function has been deprecated in Python 3: https://docs.python.org/3/library/inspect.html#inspect.getargspec --- jsonpatch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 32508e2..3c83f61 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -105,8 +105,11 @@ def get_loadjson(): function with object_pairs_hook set to multidict for Python versions that support the parameter. """ - argspec = inspect.getargspec(json.load) - if 'object_pairs_hook' not in argspec.args: + if sys.version_info >= (3, 3): + args = inspect.signature(json.load).parameters + else: + args = inspect.getargspec(json.load).args + if 'object_pairs_hook' not in args: return json.load return functools.partial(json.load, object_pairs_hook=multidict) From 4e95310faedaab387bd1068411232cddcd3bb216 Mon Sep 17 00:00:00 2001 From: Greg Cockburn Date: Mon, 3 Oct 2016 01:41:55 +1100 Subject: [PATCH 021/127] If there is no diff print nothing; exit 1 if a diff is found (#53) if there is no diff print nothing, and exit 1 if a diff is found --- bin/jsondiff | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/jsondiff b/bin/jsondiff index 81c83a7..54b4a61 100755 --- a/bin/jsondiff +++ b/bin/jsondiff @@ -32,8 +32,9 @@ def diff_files(): doc1 = json.load(args.FILE1) doc2 = json.load(args.FILE2) patch = jsonpatch.make_patch(doc1, doc2) - print(json.dumps(patch.patch, indent=args.indent)) - + if patch.patch: + print(json.dumps(patch.patch, indent=args.indent)) + sys.exit(1) if __name__ == "__main__": main() From 1dbc03c62c378c388c73ab1644b3dc607521cda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Fri, 30 Dec 2016 17:57:22 +0100 Subject: [PATCH 022/127] Run tests with Python 3.6 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 70f48e1..77f8b19 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - "3.3" - "3.4" - "3.5" + - "3.6" - "pypy" - "pypy3" From a01bec732f5be868bf66ba3684b4073c954af0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Thu, 12 Jan 2017 22:23:52 +0100 Subject: [PATCH 023/127] Update trove classification to include Python 3.6 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f73d9cf..c5a9863 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', From 8b46602074fe677935c71bc87151fbca41e6c049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Thu, 12 Jan 2017 22:24:24 +0100 Subject: [PATCH 024/127] bump version to 1.14 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 3c83f61..5250f2f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -51,7 +51,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.13' +__version__ = '1.14' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 27f1f987e0c9d101a8dc01cc322d5f31ca89d074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Thu, 12 Jan 2017 22:26:56 +0100 Subject: [PATCH 025/127] bump version to 1.15 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 5250f2f..a9574ae 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -51,7 +51,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.14' +__version__ = '1.15' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From d1f317a2b796175154ca5e21c7b32eab1c5800fe Mon Sep 17 00:00:00 2001 From: kostya Date: Wed, 8 Mar 2017 13:18:16 +0200 Subject: [PATCH 026/127] Fix: optimization bugs #55, #54, add more tests --- jsonpatch.py | 11 +- tests.js | 398 +++++++++++++++++++++++++++++++++++++++++++++++++++ tests.py | 110 +++++++++++--- 3 files changed, 495 insertions(+), 24 deletions(-) create mode 100644 tests.js diff --git a/jsonpatch.py b/jsonpatch.py index a9574ae..1e2e3ac 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -770,7 +770,16 @@ def _optimize_using_replace(prev, cur): if cur['op'] == 'add': # make recursive patch patch = make_patch(prev['value'], cur['value']) - if len(patch.patch) == 1 and patch.patch[0]['op'] != 'remove': + # check case when dict "remove" is less than "add" and has a same key + if isinstance(prev['value'], dict) and isinstance(cur['value'], dict) and len(prev['value'].keys()) == 1: + prev_set = set(prev['value'].keys()) + cur_set = set(cur['value'].keys()) + if prev_set & cur_set == prev_set: + patch = make_patch(cur['value'], prev['value']) + + if len(patch.patch) == 1 and \ + patch.patch[0]['op'] != 'remove' and \ + patch.patch[0]['path'] and patch.patch[0]['path'].split('/')[1] in prev['value']: prev['path'] = prev['path'] + patch.patch[0]['path'] prev['value'] = patch.patch[0]['value'] else: diff --git a/tests.js b/tests.js new file mode 100644 index 0000000..a09a8d6 --- /dev/null +++ b/tests.js @@ -0,0 +1,398 @@ +[ + { "comment": "empty list, empty docs", + "doc": {}, + "patch": [], + "expected": {} }, + + { "comment": "empty patch list", + "doc": {"foo": 1}, + "patch": [], + "expected": {"foo": 1} }, + + { "comment": "rearrangements OK?", + "doc": {"foo": 1, "bar": 2}, + "patch": [], + "expected": {"bar":2, "foo": 1} }, + + { "comment": "rearrangements OK? How about one level down ... array", + "doc": [{"foo": 1, "bar": 2}], + "patch": [], + "expected": [{"bar":2, "foo": 1}] }, + + { "comment": "rearrangements OK? How about one level down...", + "doc": {"foo":{"foo": 1, "bar": 2}}, + "patch": [], + "expected": {"foo":{"bar":2, "foo": 1}} }, + + { "comment": "add replaces any existing field", + "doc": {"foo": null}, + "patch": [{"op": "add", "path": "/foo", "value":1}], + "expected": {"foo": 1} }, + + { "comment": "toplevel array", + "doc": [], + "patch": [{"op": "add", "path": "/0", "value": "foo"}], + "expected": ["foo"] }, + + { "comment": "toplevel array, no change", + "doc": ["foo"], + "patch": [], + "expected": ["foo"] }, + + { "comment": "toplevel object, numeric string", + "doc": {}, + "patch": [{"op": "add", "path": "/foo", "value": "1"}], + "expected": {"foo":"1"} }, + + { "comment": "toplevel object, integer", + "doc": {}, + "patch": [{"op": "add", "path": "/foo", "value": 1}], + "expected": {"foo":1} }, + + { "comment": "Toplevel scalar values OK?", + "doc": "foo", + "patch": [{"op": "replace", "path": "", "value": "bar"}], + "expected": "bar", + "disabled": true }, + + { "comment": "Add, / target", + "doc": {}, + "patch": [ {"op": "add", "path": "/", "value":1 } ], + "expected": {"":1} }, + + { "comment": "Add, /foo/ deep target (trailing slash)", + "doc": {"foo": {}}, + "patch": [ {"op": "add", "path": "/foo/", "value":1 } ], + "expected": {"foo":{"": 1}} }, + + { "comment": "Add composite value at top level", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": [1, 2]}], + "expected": {"foo": 1, "bar": [1, 2]} }, + + { "comment": "Add into composite value", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "add", "path": "/baz/0/foo", "value": "world"}], + "expected": {"foo": 1, "baz": [{"qux": "hello", "foo": "world"}]} }, + + { "doc": {"bar": [1, 2]}, + "patch": [{"op": "add", "path": "/bar/8", "value": "5"}], + "error": "Out of bounds (upper)" }, + + { "doc": {"bar": [1, 2]}, + "patch": [{"op": "add", "path": "/bar/-1", "value": "5"}], + "error": "Out of bounds (lower)" }, + + { "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": true}], + "expected": {"foo": 1, "bar": true} }, + + { "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": false}], + "expected": {"foo": 1, "bar": false} }, + + { "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": null}], + "expected": {"foo": 1, "bar": null} }, + + { "comment": "0 can be an array index or object element name", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/0", "value": "bar"}], + "expected": {"foo": 1, "0": "bar" } }, + + { "doc": ["foo"], + "patch": [{"op": "add", "path": "/1", "value": "bar"}], + "expected": ["foo", "bar"] }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1", "value": "bar"}], + "expected": ["foo", "bar", "sil"] }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/0", "value": "bar"}], + "expected": ["bar", "foo", "sil"] }, + + { "comment": "push item to array via last index + 1", + "doc": ["foo", "sil"], + "patch": [{"op":"add", "path": "/2", "value": "bar"}], + "expected": ["foo", "sil", "bar"] }, + + { "comment": "add item to array at index > length should fail", + "doc": ["foo", "sil"], + "patch": [{"op":"add", "path": "/3", "value": "bar"}], + "error": "index is greater than number of items in array" }, + + { "comment": "test against implementation-specific numeric parsing", + "doc": {"1e0": "foo"}, + "patch": [{"op": "test", "path": "/1e0", "value": "foo"}], + "expected": {"1e0": "foo"} }, + + { "comment": "test with bad number should fail", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/1e0", "value": "bar"}], + "error": "test op shouldn't get array element 1" }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/bar", "value": 42}], + "error": "Object operation on array target" }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1", "value": ["bar", "baz"]}], + "expected": ["foo", ["bar", "baz"], "sil"], + "comment": "value in array add not flattened" }, + + { "doc": {"foo": 1, "bar": [1, 2, 3, 4]}, + "patch": [{"op": "remove", "path": "/bar"}], + "expected": {"foo": 1} }, + + { "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "remove", "path": "/baz/0/qux"}], + "expected": {"foo": 1, "baz": [{}]} }, + + { "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "replace", "path": "/foo", "value": [1, 2, 3, 4]}], + "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]} }, + + { "doc": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]}, + "patch": [{"op": "replace", "path": "/baz/0/qux", "value": "world"}], + "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "world"}]} }, + + { "doc": ["foo"], + "patch": [{"op": "replace", "path": "/0", "value": "bar"}], + "expected": ["bar"] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": 0}], + "expected": [0] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": true}], + "expected": [true] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": false}], + "expected": [false] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": null}], + "expected": [null] }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "replace", "path": "/1", "value": ["bar", "baz"]}], + "expected": ["foo", ["bar", "baz"]], + "comment": "value in array replace not flattened" }, + + { "comment": "replace whole document", + "doc": {"foo": "bar"}, + "patch": [{"op": "replace", "path": "", "value": {"baz": "qux"}}], + "expected": {"baz": "qux"} }, + + { "comment": "spurious patch properties", + "doc": {"foo": 1}, + "patch": [{"op": "test", "path": "/foo", "value": 1, "spurious": 1}], + "expected": {"foo": 1} }, + + { "doc": {"foo": null}, + "patch": [{"op": "test", "path": "/foo", "value": null}], + "comment": "null value should be valid obj property" }, + + { "doc": {"foo": null}, + "patch": [{"op": "replace", "path": "/foo", "value": "truthy"}], + "expected": {"foo": "truthy"}, + "comment": "null value should be valid obj property to be replaced with something truthy" }, + + { "doc": {"foo": null}, + "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], + "expected": {"bar": null}, + "comment": "null value should be valid obj property to be moved" }, + + { "doc": {"foo": null}, + "patch": [{"op": "copy", "from": "/foo", "path": "/bar"}], + "expected": {"foo": null, "bar": null}, + "comment": "null value should be valid obj property to be copied" }, + + { "doc": {"foo": null}, + "patch": [{"op": "remove", "path": "/foo"}], + "expected": {}, + "comment": "null value should be valid obj property to be removed" }, + + { "doc": {"foo": "bar"}, + "patch": [{"op": "replace", "path": "/foo", "value": null}], + "expected": {"foo": null}, + "comment": "null value should still be valid obj property replace other value" }, + + { "doc": {"foo": {"foo": 1, "bar": 2}}, + "patch": [{"op": "test", "path": "/foo", "value": {"bar": 2, "foo": 1}}], + "comment": "test should pass despite rearrangement" }, + + { "doc": {"foo": [{"foo": 1, "bar": 2}]}, + "patch": [{"op": "test", "path": "/foo", "value": [{"bar": 2, "foo": 1}]}], + "comment": "test should pass despite (nested) rearrangement" }, + + { "doc": {"foo": {"bar": [1, 2, 5, 4]}}, + "patch": [{"op": "test", "path": "/foo", "value": {"bar": [1, 2, 5, 4]}}], + "comment": "test should pass - no error" }, + + { "doc": {"foo": {"bar": [1, 2, 5, 4]}}, + "patch": [{"op": "test", "path": "/foo", "value": [1, 2]}], + "error": "test op should fail" }, + + { "comment": "Whole document", + "doc": { "foo": 1 }, + "patch": [{"op": "test", "path": "", "value": {"foo": 1}}], + "disabled": true }, + + { "comment": "Empty-string element", + "doc": { "": 1 }, + "patch": [{"op": "test", "path": "/", "value": 1}] }, + + { "doc": { + "foo": ["bar", "baz"], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + " ": 7, + "m~n": 8 + }, + "patch": [{"op": "test", "path": "/foo", "value": ["bar", "baz"]}, + {"op": "test", "path": "/foo/0", "value": "bar"}, + {"op": "test", "path": "/", "value": 0}, + {"op": "test", "path": "/a~1b", "value": 1}, + {"op": "test", "path": "/c%d", "value": 2}, + {"op": "test", "path": "/e^f", "value": 3}, + {"op": "test", "path": "/g|h", "value": 4}, + {"op": "test", "path": "/i\\j", "value": 5}, + {"op": "test", "path": "/k\"l", "value": 6}, + {"op": "test", "path": "/ ", "value": 7}, + {"op": "test", "path": "/m~0n", "value": 8}] }, + + { "comment": "Move to same location has no effect", + "doc": {"foo": 1}, + "patch": [{"op": "move", "from": "/foo", "path": "/foo"}], + "expected": {"foo": 1} }, + + { "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], + "expected": {"baz": [{"qux": "hello"}], "bar": 1} }, + + { "doc": {"baz": [{"qux": "hello"}], "bar": 1}, + "patch": [{"op": "move", "from": "/baz/0/qux", "path": "/baz/1"}], + "expected": {"baz": [{}, "hello"], "bar": 1} }, + + { "doc": {"baz": [{"qux": "hello"}], "bar": 1}, + "patch": [{"op": "copy", "from": "/baz/0", "path": "/boo"}], + "expected": {"baz":[{"qux":"hello"}],"bar":1,"boo":{"qux":"hello"}} }, + + { "comment": "replacing the root of the document is possible with add", + "doc": {"foo": "bar"}, + "patch": [{"op": "add", "path": "", "value": {"baz": "qux"}}], + "expected": {"baz":"qux"}}, + + { "comment": "Adding to \"/-\" adds to the end of the array", + "doc": [ 1, 2 ], + "patch": [ { "op": "add", "path": "/-", "value": { "foo": [ "bar", "baz" ] } } ], + "expected": [ 1, 2, { "foo": [ "bar", "baz" ] } ]}, + + { "comment": "Adding to \"/-\" adds to the end of the array, even n levels down", + "doc": [ 1, 2, [ 3, [ 4, 5 ] ] ], + "patch": [ { "op": "add", "path": "/2/1/-", "value": { "foo": [ "bar", "baz" ] } } ], + "expected": [ 1, 2, [ 3, [ 4, 5, { "foo": [ "bar", "baz" ] } ] ] ]}, + + { "comment": "test remove with bad number should fail", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "remove", "path": "/baz/1e0/qux"}], + "error": "remove op shouldn't remove from array with bad number" }, + + { "comment": "test remove on array", + "doc": [1, 2, 3, 4], + "patch": [{"op": "remove", "path": "/0"}], + "expected": [2, 3, 4] }, + + { "comment": "test repeated removes", + "doc": [1, 2, 3, 4], + "patch": [{ "op": "remove", "path": "/1" }, + { "op": "remove", "path": "/2" }], + "expected": [1, 3] }, + + { "comment": "test remove with bad index should fail", + "doc": [1, 2, 3, 4], + "patch": [{"op": "remove", "path": "/1e0"}], + "error": "remove op shouldn't remove from array with bad number" }, + + { "comment": "test replace with bad number should fail", + "doc": [""], + "patch": [{"op": "replace", "path": "/1e0", "value": false}], + "error": "replace op shouldn't replace in array with bad number" }, + + { "comment": "test copy with bad number should fail", + "doc": {"baz": [1,2,3], "bar": 1}, + "patch": [{"op": "copy", "from": "/baz/1e0", "path": "/boo"}], + "error": "copy op shouldn't work with bad number" }, + + { "comment": "test move with bad number should fail", + "doc": {"foo": 1, "baz": [1,2,3,4]}, + "patch": [{"op": "move", "from": "/baz/1e0", "path": "/foo"}], + "error": "move op shouldn't work with bad number" }, + + { "comment": "test add with bad number should fail", + "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1e0", "value": "bar"}], + "error": "add op shouldn't add to array with bad number" }, + + { "comment": "missing 'value' parameter to add", + "doc": [ 1 ], + "patch": [ { "op": "add", "path": "/-" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing 'value' parameter to replace", + "doc": [ 1 ], + "patch": [ { "op": "replace", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing 'value' parameter to test", + "doc": [ null ], + "patch": [ { "op": "test", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing value parameter to test - where undef is falsy", + "doc": [ false ], + "patch": [ { "op": "test", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing from parameter to copy", + "doc": [ 1 ], + "patch": [ { "op": "copy", "path": "/-" } ], + "error": "missing 'from' parameter" }, + + { "comment": "missing from parameter to move", + "doc": { "foo": 1 }, + "patch": [ { "op": "move", "path": "" } ], + "error": "missing 'from' parameter" }, + + { "comment": "duplicate ops", + "doc": { "foo": "bar" }, + "patch": [ { "op": "add", "path": "/baz", "value": "qux", + "op": "move", "from":"/foo" } ], + "error": "patch has two 'op' members", + "disabled": true }, + + { "comment": "unrecognized op should fail", + "doc": {"foo": 1}, + "patch": [{"op": "spam", "path": "/foo", "value": 1}], + "error": "Unrecognized op 'spam'" }, + + { "comment": "test with bad array number that has leading zeros", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/00", "value": "foo"}], + "error": "test op should reject the array value, it has leading zeros" }, + + { "comment": "test with bad array number that has leading zeros", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/01", "value": "bar"}], + "error": "test op should reject the array value, it has leading zeros" } + +] diff --git a/tests.py b/tests.py index 5acf24b..72611a3 100755 --- a/tests.py +++ b/tests.py @@ -13,6 +13,34 @@ class ApplyPatchTestCase(unittest.TestCase): + def test_js_file(self): + with open('./tests.js', 'r') as f: + tests = json.load(f) + for test in tests: + try: + if 'expected' not in test: + continue + result = jsonpatch.apply_patch(test['doc'], test['patch']) + self.assertEqual(result, test['expected']) + except Exception: + if test.get('error'): + continue + else: + raise + + def test_success_if_replaced_dict(self): + src = [{'a': 1}, {'b': 2}] + dst = [{'a': 1, 'b': 2}] + patch = jsonpatch.make_patch(src, dst) + self.assertEqual(patch.apply(src), dst) + + def test_success_if_raise_no_error(self): + src = [{}] + dst = [{'key': ''}] + patch = jsonpatch.make_patch(src, dst) + patch.apply(src) + self.assertTrue(True) + def test_apply_patch_from_string(self): obj = {'foo': 'bar'} patch = '[{"op": "add", "path": "/baz", "value": "qux"}]' @@ -308,6 +336,40 @@ def test_should_just_add_new_item_not_rebuild_all_list(self): res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) + + def test_escape(self): + src = {"x/y": 1} + dst = {"x/y": 2} + patch = jsonpatch.make_patch(src, dst) + self.assertEqual([{"path": "/x~1y", "value": 2, "op": "replace"}], patch.patch) + res = patch.apply(src) + self.assertEqual(res, dst) + + def test_root_list(self): + """ Test making and applying a patch of the root is a list """ + 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_make_patch_unicode(self): + """ Test if unicode keys and values are handled correctly """ + src = {} + dst = {'\xee': '\xee'} + patch = jsonpatch.make_patch(src, dst) + res = patch.apply(src) + self.assertEqual(res, dst) + + def test_issue40(self): + """ Tests an issue in _split_by_common_seq reported in #40 """ + + src = [8, 7, 2, 1, 0, 9, 4, 3, 5, 6] + dest = [7, 2, 1, 0, 9, 4, 3, 6, 5, 8] + patch = jsonpatch.make_patch(src, dest) + + +class OptimizationTests(unittest.TestCase): def test_use_replace_instead_of_remove_add(self): src = {'foo': [1, 2, 3]} dst = {'foo': [3, 2, 3]} @@ -344,41 +406,42 @@ def test_use_move_instead_of_add_remove(self): res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) - def test_escape(self): - src = {"x/y": 1} - dst = {"x/y": 2} + def test_success_if_replace_inside_dict(self): + src = [{'a': 1, 'foo': {'b': 2, 'd': 5}}] + dst = [{'a': 1, 'foo': {'b': 3, 'd': 6}}] patch = jsonpatch.make_patch(src, dst) - self.assertEqual([{"path": "/x~1y", "value": 2, "op": "replace"}], patch.patch) - res = patch.apply(src) - self.assertEqual(res, dst) + self.assertEqual(patch.apply(src), dst) - def test_root_list(self): - """ Test making and applying a patch of the root is a list """ - src = [{'foo': 'bar', 'boo': 'qux'}] - dst = [{'baz': 'qux', 'foo': 'boo'}] + def test_success_if_replace_single_value(self): + src = [{'a': 1, 'b': 2, 'd': 5}] + dst = [{'a': 1, 'c': 3, 'd': 5}] patch = jsonpatch.make_patch(src, dst) - res = patch.apply(src) - self.assertEqual(res, dst) + self.assertEqual(patch.apply(src), dst) - def test_make_patch_unicode(self): - """ Test if unicode keys and values are handled correctly """ - src = {} - dst = {'\xee': '\xee'} + def test_success_if_replaced_by_object(self): + src = [{'a': 1, 'b': 2, 'd': 5}] + dst = [{'d': 6}] patch = jsonpatch.make_patch(src, dst) - res = patch.apply(src) - self.assertEqual(res, dst) + self.assertEqual(patch.apply(src), dst) - def test_issue40(self): - """ Tests an issue in _split_by_common_seq reported in #40 """ + def test_success_if_correct_patch_appied(self): + src = [{'a': 1}, {'b': 2}] + dst = [{'a': 1, 'b': 2}] + patch = jsonpatch.make_patch(src, dst) + self.assertEqual(patch.apply(src), dst) - src = [8, 7, 2, 1, 0, 9, 4, 3, 5, 6] - dest = [7, 2, 1, 0, 9, 4, 3, 6, 5, 8] - patch = jsonpatch.make_patch(src, dest) + def test_success_if_correct_expected_patch_appied(self): + src = [{"a": 1, "b": 2}] + dst = [{"b": 2, "c": 2}] + exp = [{u'path': u'/0', u'value': {u'c': 2, u'b': 2}, u'op': u'replace'}] + patch = jsonpatch.make_patch(src, dst) + self.assertEqual(patch.patch, exp) def test_minimal_patch(self): """ Test whether a minimal patch is created, see #36 """ src = [{"foo": 1, "bar": 2}] dst = [{"foo": 2, "bar": 2}] + patch = jsonpatch.make_patch(src, dst) exp = [ @@ -458,6 +521,7 @@ def get_suite(): suite.addTest(unittest.makeSuite(MakePatchTestCase)) suite.addTest(unittest.makeSuite(InvalidInputTests)) suite.addTest(unittest.makeSuite(ConflictTests)) + suite.addTest(unittest.makeSuite(OptimizationTests)) return suite From 05d9aceda8d0269c27d869f13d8ba0c7ca88104e Mon Sep 17 00:00:00 2001 From: kostya Date: Wed, 8 Mar 2017 13:30:40 +0200 Subject: [PATCH 027/127] Fix: python 3.2 tests --- tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests.py b/tests.py index 72611a3..4b7d505 100755 --- a/tests.py +++ b/tests.py @@ -433,7 +433,7 @@ def test_success_if_correct_patch_appied(self): def test_success_if_correct_expected_patch_appied(self): src = [{"a": 1, "b": 2}] dst = [{"b": 2, "c": 2}] - exp = [{u'path': u'/0', u'value': {u'c': 2, u'b': 2}, u'op': u'replace'}] + exp = [{'path': '/0', 'value': {'c': 2, 'b': 2}, 'op': 'replace'}] patch = jsonpatch.make_patch(src, dst) self.assertEqual(patch.patch, exp) From e18a131be0aba0a065a5709f420ef88ec1a3fd83 Mon Sep 17 00:00:00 2001 From: Kostya Date: Thu, 27 Apr 2017 17:05:55 +0300 Subject: [PATCH 028/127] don't apply patch optimization when it's incorrect --- jsonpatch.py | 19 +++++++++++++++---- tests.py | 13 +++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 1e2e3ac..0116ca2 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -171,6 +171,14 @@ def make_patch(src, dst): >>> new == dst True """ + + # TODO: fix patch optimiztion and remove the following check + # fix when patch with optimization is incorrect + patch = JsonPatch.from_diff(src, dst) + new = patch.apply(src) + if new != dst: + return JsonPatch.from_diff(src, dst, False) + return JsonPatch.from_diff(src, dst) @@ -268,7 +276,7 @@ def from_string(cls, patch_str): return cls(patch) @classmethod - def from_diff(cls, src, dst): + def from_diff(cls, src, dst, optimization=True): """Creates JsonPatch instance based on comparing of two document objects. Json patch would be created for `src` argument against `dst` one. @@ -320,7 +328,7 @@ def compare_dicts(path, src, dst): 'value': dst[key]} def compare_lists(path, src, dst): - return _compare_lists(path, src, dst) + return _compare_lists(path, src, dst, optimization=optimization) return cls(list(compare_values([], src, dst))) @@ -561,9 +569,12 @@ def apply(self, obj): return obj -def _compare_lists(path, src, dst): +def _compare_lists(path, src, dst, optimization=True): """Compares two lists objects and return JSON patch about.""" - return _optimize(_compare(path, src, dst, *_split_by_common_seq(src, dst))) + patch = list(_compare(path, src, dst, *_split_by_common_seq(src, dst))) + if optimization: + return list(_optimize(patch)) + return patch def _longest_common_subseq(src, dst): diff --git a/tests.py b/tests.py index 4b7d505..51d9517 100755 --- a/tests.py +++ b/tests.py @@ -267,7 +267,6 @@ def test_str(self): self.assertEqual(json.dumps(patch_obj), patch.to_string()) - class MakePatchTestCase(unittest.TestCase): def test_apply_patch_to_copy(self): @@ -336,7 +335,6 @@ def test_should_just_add_new_item_not_rebuild_all_list(self): res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) - def test_escape(self): src = {"x/y": 1} dst = {"x/y": 2} @@ -368,6 +366,17 @@ def test_issue40(self): dest = [7, 2, 1, 0, 9, 4, 3, 6, 5, 8] patch = jsonpatch.make_patch(src, dest) + def test_json_patch(self): + old = { + 'queue': {'teams_out': [{'id': 3, 'reason': 'If tied'}, {'id': 5, 'reason': 'If tied'}]}, + } + new = { + 'queue': {'teams_out': [{'id': 5, 'reason': 'If lose'}]} + } + patch = jsonpatch.make_patch(old, new) + new_from_patch = jsonpatch.apply_patch(old, patch) + self.assertEqual(new, new_from_patch) + class OptimizationTests(unittest.TestCase): def test_use_replace_instead_of_remove_add(self): From 1fc5e2022ff2c5796bd28af56646b135ede4ee71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Thu, 15 Jun 2017 17:41:06 +0200 Subject: [PATCH 029/127] Bump version to 1.16 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 0116ca2..296c5bc 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -51,7 +51,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.15' +__version__ = '1.16' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From e8fbd18d933f5d395576255208843808a875fd54 Mon Sep 17 00:00:00 2001 From: Tristan Seligmann Date: Sun, 9 Jul 2017 17:37:42 +0200 Subject: [PATCH 030/127] Avoid double work (#62) In the case where the optimized patch is not invalid, we shouldn't need to calculate the patch again. --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 0116ca2..9e9e5aa 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -179,7 +179,7 @@ def make_patch(src, dst): if new != dst: return JsonPatch.from_diff(src, dst, False) - return JsonPatch.from_diff(src, dst) + return patch class JsonPatch(object): From b878d85dcbf3b57f940e7fe499d7b550f25f75da Mon Sep 17 00:00:00 2001 From: thunderstruck47 Date: Wed, 26 Jul 2017 15:19:18 +0300 Subject: [PATCH 031/127] fixing array diff bug (issue #30) --- jsonpatch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 636e807..b1c56d0 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -175,7 +175,11 @@ def make_patch(src, dst): # TODO: fix patch optimiztion and remove the following check # fix when patch with optimization is incorrect patch = JsonPatch.from_diff(src, dst) - new = patch.apply(src) + try: + new = patch.apply(src) + except JsonPatchConflict: # see TODO + return JsonPatch.from_diff(src, dst, False) + if new != dst: return JsonPatch.from_diff(src, dst, False) @@ -601,7 +605,6 @@ def _longest_common_subseq(src, dst): matrix[i][j] = matrix[i-1][j-1] + 1 if matrix[i][j] > z: z = matrix[i][j] - if matrix[i][j] == z: range_src = (i-z+1, i+1) range_dst = (j-z+1, j+1) else: From 845cf4ad5dc2e7ebe2284bb499cbe32136d6f0ab Mon Sep 17 00:00:00 2001 From: thunderstruck47 Date: Wed, 26 Jul 2017 17:49:43 +0300 Subject: [PATCH 032/127] added a test case for issue #30 --- tests.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests.py b/tests.py index 51d9517..74266b6 100755 --- a/tests.py +++ b/tests.py @@ -376,7 +376,15 @@ def test_json_patch(self): patch = jsonpatch.make_patch(old, new) new_from_patch = jsonpatch.apply_patch(old, patch) self.assertEqual(new, new_from_patch) - + + def test_arrays_one_element_sequences(self): + """ Tests the case of multiple common one element sequences inside an array """ + # see https://github.com/stefankoegl/python-json-patch/issues/30#issuecomment-155070128 + src = [1,2,3] + dst = [3,1,4,2] + patch = jsonpatch.make_patch(src, dst) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) class OptimizationTests(unittest.TestCase): def test_use_replace_instead_of_remove_add(self): From fbc904f93e8db3d6f082d7fdc638379ca8fec0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 3 Sep 2017 11:51:00 +0200 Subject: [PATCH 033/127] Disable tests for disabled optimizations --- tests.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests.py b/tests.py index 9d850fd..6a8234f 100755 --- a/tests.py +++ b/tests.py @@ -416,9 +416,13 @@ def fn(_src, _dst): self.assertEqual(res, _dst) fn({'foo': [1, 2, 3]}, {'foo': [3, 1, 2]}) - fn({'foo': [1, 2, 3]}, {'foo': [3, 2, 1]}) fn([1, 2, 3], [3, 1, 2]) - fn([1, 2, 3], [3, 2, 1]) + + # Optimizations for the following tests are currently not performed. + # The tests are disabled, as the missing optimizations do not + # invalidate the results + #fn({'foo': [1, 2, 3]}, {'foo': [3, 2, 1]}) + #fn([1, 2, 3], [3, 2, 1]) def test_success_if_replace_inside_dict(self): src = [{'a': 1, 'foo': {'b': 2, 'd': 5}}] From e64987174866ef371f4df048d212eeba4f1a0013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 19:23:50 +0200 Subject: [PATCH 034/127] Remove trailing whitespace --- jsonpatch.py | 4 ++-- tests.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index b1c56d0..cee4820 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -176,10 +176,10 @@ def make_patch(src, dst): # fix when patch with optimization is incorrect patch = JsonPatch.from_diff(src, dst) try: - new = patch.apply(src) + new = patch.apply(src) except JsonPatchConflict: # see TODO return JsonPatch.from_diff(src, dst, False) - + if new != dst: return JsonPatch.from_diff(src, dst, False) diff --git a/tests.py b/tests.py index 78b06f5..39f3a85 100755 --- a/tests.py +++ b/tests.py @@ -376,7 +376,7 @@ def test_json_patch(self): patch = jsonpatch.make_patch(old, new) new_from_patch = jsonpatch.apply_patch(old, patch) self.assertEqual(new, new_from_patch) - + def test_arrays_one_element_sequences(self): """ Tests the case of multiple common one element sequences inside an array """ # see https://github.com/stefankoegl/python-json-patch/issues/30#issuecomment-155070128 From d778745b1448a82c840aadff422187459fe78c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 19:24:09 +0200 Subject: [PATCH 035/127] Remove support for Python 3.2 --- .travis.yml | 1 - setup.py | 1 - 2 files changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 77f8b19..f20aa6f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ language: python python: - "2.6" - "2.7" - - "3.2" - "3.3" - "3.4" - "3.5" diff --git a/setup.py b/setup.py index c5a9863..5de173b 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,6 @@ 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', From 196460716ac1fc3d84dee403cfa68386261998f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 13:25:47 +0200 Subject: [PATCH 036/127] Drop in patch creation from jsondiff https://github.com/nxsofsys/jsondiff --- jsonpatch.py | 519 +++++++++++++++++++++++++-------------------------- 1 file changed, 256 insertions(+), 263 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index cee4820..d1d091f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -42,6 +42,22 @@ import json import sys + +if sys.version_info[0] >= 3: + _range = range + _viewkeys = dict.keys +else: + _range = xrange + if sys.version_info[1] >= 7: + _viewkeys = dict.viewkeys + else: + _viewkeys = lambda x: set(dict.keys(x)) + + +_ST_ADD = 0 +_ST_REMOVE = 1 + + try: from collections.abc import MutableMapping, MutableSequence except ImportError: @@ -300,41 +316,11 @@ def from_diff(cls, src, dst, optimization=True): >>> new == dst True """ - def compare_values(path, value, other): - if value == other: - return - if isinstance(value, MutableMapping) and \ - isinstance(other, MutableMapping): - for operation in compare_dicts(path, value, other): - yield operation - elif isinstance(value, MutableSequence) and \ - isinstance(other, MutableSequence): - for operation in compare_lists(path, value, other): - yield operation - else: - ptr = JsonPointer.from_parts(path) - yield {'op': 'replace', 'path': ptr.path, 'value': other} - - def compare_dicts(path, src, dst): - for key in src: - if key not in dst: - ptr = JsonPointer.from_parts(path + [key]) - yield {'op': 'remove', 'path': ptr.path} - continue - current = path + [key] - for operation in compare_values(current, src[key], dst[key]): - yield operation - for key in dst: - if key not in src: - ptr = JsonPointer.from_parts(path + [key]) - yield {'op': 'add', - 'path': ptr.path, - 'value': dst[key]} - - def compare_lists(path, src, dst): - return _compare_lists(path, src, dst, optimization=optimization) - return cls(list(compare_values([], src, dst))) + info = _compare_info() + _compare_values('', None, info, src, dst) + ops = [op for op in info.execute()] + return cls(ops) def to_string(self): """Returns patch set as JSON string.""" @@ -573,248 +559,255 @@ def apply(self, obj): return obj -def _compare_lists(path, src, dst, optimization=True): - """Compares two lists objects and return JSON patch about.""" - patch = list(_compare(path, src, dst, *_split_by_common_seq(src, dst))) - if optimization: - return list(_optimize(patch)) - return patch +class _compare_info(object): + def __init__(self): + self.index_storage = [{}, {}] + self.index_storage2 = [[], []] + self.__root = root = [] + root[:] = [root, root, None] -def _longest_common_subseq(src, dst): - """Returns pair of ranges of longest common subsequence for the `src` - and `dst` lists. + def store_index(self, value, index, st): + try: + storage = self.index_storage[st] + stored = storage.get(value) + if stored == None: + storage[value] = [index] + else: + storage[value].append(index) + except TypeError: + self.index_storage2[st].append((value, index)) - >>> src = [1, 2, 3, 4] - >>> dst = [0, 1, 2, 3, 5] - >>> # The longest common subsequence for these lists is [1, 2, 3] - ... # which is located at (0, 3) index range for src list and (1, 4) for - ... # dst one. Tuple of these ranges we should get back. - ... assert ((0, 3), (1, 4)) == _longest_common_subseq(src, dst) - """ - lsrc, ldst = len(src), len(dst) - drange = list(range(ldst)) - matrix = [[0] * ldst for _ in range(lsrc)] - z = 0 # length of the longest subsequence - range_src, range_dst = None, None - for i, j in itertools.product(range(lsrc), drange): - if src[i] == dst[j]: - if i == 0 or j == 0: - matrix[i][j] = 1 + def take_index(self, value, st): + try: + stored = self.index_storage[st].get(value) + if stored: + return stored.pop() + except TypeError: + storage = self.index_storage2[st] + for i in range(len(storage)-1, -1, -1): + if storage[i][0] == value: + return storage.pop(i)[1] + + def insert(self, op): + root = self.__root + last = root[0] + last[1] = root[0] = [last, root, op] + return root[0] + + def remove(self, index): + link_prev, link_next, _ = index + link_prev[1] = link_next + link_next[0] = link_prev + index[:] = [] + + def iter_from(self, start): + root = self.__root + curr = start[1] + while curr is not root: + yield curr[2] + curr = curr[1] + + def __iter__(self): + root = self.__root + curr = root[1] + while curr is not root: + yield curr[2] + curr = curr[1] + + def execute(self): + root = self.__root + curr = root[1] + while curr is not root: + if curr[1] is not root: + op_first, op_second = curr[2], curr[1][2] + if op_first.key == op_second.key and \ + op_first.path == op_second.path and \ + type(op_first) == _op_remove and \ + type(op_second) == _op_add: + yield _op_replace(op_second.path, op_second.key, op_second.value).get() + curr = curr[1][1] + continue + yield curr[2].get() + curr = curr[1] + +class _op_base(object): + def __init__(self, path, key, value): + self.path = path + self.key = key + self.value = value + + def __repr__(self): + return str(self.get()) + +class _op_add(_op_base): + def _on_undo_remove(self, path, key): + if self.path == path: + if self.key > key: + self.key += 1 else: - matrix[i][j] = matrix[i-1][j-1] + 1 - if matrix[i][j] > z: - z = matrix[i][j] - range_src = (i-z+1, i+1) - range_dst = (j-z+1, j+1) - else: - matrix[i][j] = 0 - return range_src, range_dst - - -def _split_by_common_seq(src, dst, bx=(0, -1), by=(0, -1)): - """Recursively splits the `dst` list onto two parts: left and right. - The left part contains differences on left from common subsequence, - same as the right part by for other side. - - To easily understand the process let's take two lists: [0, 1, 2, 3] as - `src` and [1, 2, 4, 5] for `dst`. If we've tried to generate the binary tree - where nodes are common subsequence for both lists, leaves on the left - side are subsequence for `src` list and leaves on the right one for `dst`, - our tree would looks like:: - - [1, 2] - / \ - [0] [] - / \ - [3] [4, 5] - - This function generate the similar structure as flat tree, but without - nodes with common subsequences - since we're don't need them - only with - left and right leaves:: - - [] - / \ - [0] [] - / \ - [3] [4, 5] - - The `bx` is the absolute range for currently processed subsequence of - `src` list. The `by` means the same, but for the `dst` list. - """ - # Prevent useless comparisons in future - bx = bx if bx[0] != bx[1] else None - by = by if by[0] != by[1] else None + key += 1 + return key - if not src: - return [None, by] - elif not dst: - return [bx, None] + def _on_undo_add(self, path, key): + if self.path == path: + if self.key > key: + self.key -= 1 + else: + key += 1 + return key - # note that these ranges are relative for processed sublists - x, y = _longest_common_subseq(src, dst) + def get(self): + return {'op': 'add', 'path': _path_join(self.path, self.key), 'value': self.value} - if x is None or y is None: # no more any common subsequence - return [bx, by] +class _op_remove(_op_base): + def _on_undo_remove(self, path, key): + if self.path == path: + if self.key >= key: + self.key += 1 + else: + key -= 1 + return key - return [_split_by_common_seq(src[:x[0]], dst[:y[0]], - (bx[0], bx[0] + x[0]), - (by[0], by[0] + y[0])), - _split_by_common_seq(src[x[1]:], dst[y[1]:], - (bx[0] + x[1], bx[0] + len(src)), - (by[0] + y[1], by[0] + len(dst)))] + def _on_undo_add(self, path, key): + if self.path == path: + if self.key > key: + self.key -= 1 + else: + key -= 1 + return key + def get(self): + return {'op': 'remove', 'path': _path_join(self.path, self.key)} -def _compare(path, src, dst, left, right): - """Same as :func:`_compare_with_shift` but strips emitted `shift` value.""" - for op, _ in _compare_with_shift(path, src, dst, left, right, 0): - yield op +class _op_replace(_op_base): + def _on_undo_remove(self, path, key): + return key + def _on_undo_add(self, path, key): + return key -def _compare_with_shift(path, src, dst, left, right, shift): - """Recursively compares differences from `left` and `right` sides - from common subsequences. + def get(self): + return {'op': 'replace', 'path': _path_join(self.path, self.key), 'value': self.value} - The `shift` parameter is used to store index shift which caused - by ``add`` and ``remove`` operations. - Yields JSON patch operations and list index shift. - """ - if isinstance(left, MutableSequence): - for item, shift in _compare_with_shift(path, src, dst, *left, - shift=shift): - yield item, shift - elif left is not None: - for item, shift in _compare_left(path, src, left, shift): - yield item, shift - - if isinstance(right, MutableSequence): - for item, shift in _compare_with_shift(path, src, dst, *right, - shift=shift): - yield item, shift - elif right is not None: - for item, shift in _compare_right(path, dst, right, shift): - yield item, shift - - -def _compare_left(path, src, left, shift): - """Yields JSON patch ``remove`` operations for elements that are only - exists in the `src` list.""" - start, end = left - if end == -1: - end = len(src) - # we need to `remove` elements from list tail to not deal with index shift - for idx in reversed(range(start + shift, end + shift)): - ptr = JsonPointer.from_parts(path + [str(idx)]) - yield ( - {'op': 'remove', - # yes, there should be any value field, but we'll use it - # to apply `move` optimization a bit later and will remove - # it in _optimize function. - 'value': src[idx - shift], - 'path': ptr.path, - }, - shift - 1 - ) - shift -= 1 - - -def _compare_right(path, dst, right, shift): - """Yields JSON patch ``add`` operations for elements that are only - exists in the `dst` list""" - start, end = right - if end == -1: - end = len(dst) - for idx in range(start, end): - ptr = JsonPointer.from_parts(path + [str(idx)]) - yield ( - {'op': 'add', 'path': ptr.path, 'value': dst[idx]}, - shift + 1 - ) - shift += 1 - - -def _optimize(operations): - """Optimizes operations which was produced by lists comparison. - - Actually it does two kinds of optimizations: - - 1. Seeks pair of ``remove`` and ``add`` operations against the same path - and replaces them with ``replace`` operation. - 2. Seeks pair of ``remove`` and ``add`` operations for the same value - and replaces them with ``move`` operation. - """ - result = [] - ops_by_path = {} - ops_by_value = {} - add_remove = set(['add', 'remove']) - for item in operations: - # could we apply "move" optimization for dict values? - hashable_value = not isinstance(item['value'], - (MutableMapping, MutableSequence)) - if item['path'] in ops_by_path: - _optimize_using_replace(ops_by_path[item['path']], item) - continue - if hashable_value and item['value'] in ops_by_value: - prev_item = ops_by_value[item['value']] - # ensure that we processing pair of add-remove ops - if set([item['op'], prev_item['op']]) == add_remove: - _optimize_using_move(prev_item, item) - ops_by_value.pop(item['value']) +class _op_move(object): + def __init__(self, oldpath, oldkey, path, key): + self.oldpath = oldpath + self.oldkey = oldkey + self.path = path + self.key = key + + def _on_undo_remove(self, path, key): + if self.oldpath == path: + if self.oldkey >= key: + self.oldkey += 1 + else: + key -= 1 + if self.path == path: + if self.key > key: + self.key += 1 + else: + key += 1 + return key + + def _on_undo_add(self, path, key): + if self.oldpath == path: + if self.oldkey > key: + self.oldkey -= 1 + else: + key -= 1 + if self.path == path: + if self.key > key: + self.key -= 1 + else: + key += 1 + return key + + def get(self): + return {'op': 'move', 'path': _path_join(self.path, self.key), 'from': _path_join(self.oldpath, self.oldkey)} + + def __repr__(self): + return str(self.get()) + +def _path_join(path, key): + if key != None: + return path + '/' + str(key).replace('~', '~0').replace('/', '~1') + return path + +def _item_added(path, key, info, item): + index = info.take_index(item, _ST_REMOVE) + if index != None: + op = index[2] + if type(op.key) == int: + for v in info.iter_from(index): + op.key = v._on_undo_remove(op.path, op.key) + info.remove(index) + if op.path != path or op.key != key: + new_op = _op_move(op.path, op.key, path, key) + info.insert(new_op) + else: + new_op = _op_add(path, key, item) + new_index = info.insert(new_op) + info.store_index(item, new_index, _ST_ADD) + +def _item_removed(path, key, info, item): + new_op = _op_remove(path, key, item) + index = info.take_index(item, _ST_ADD) + new_index = info.insert(new_op) + if index != None: + op = index[2] + if type(op.key) == int: + for v in info.iter_from(index): + op.key = v._on_undo_add(op.path, op.key) + info.remove(index) + if new_op.path != op.path or new_op.key != op.key: + new_op = _op_move(new_op.path, new_op.key, op.path, op.key) + new_index[2] = new_op + else: + info.remove(new_index) + else: + info.store_index(item, new_index, _ST_REMOVE) + +def _item_replaced(path, key, info, item): + info.insert(_op_replace(path, key, item)) + +def _compare_dicts(path, info, src, dst): + src_keys = _viewkeys(src) + dst_keys = _viewkeys(dst) + added_keys = dst_keys - src_keys + removed_keys = src_keys - dst_keys + for key in removed_keys: + _item_removed(path, str(key), info, src[key]) + for key in added_keys: + _item_added(path, str(key), info, dst[key]) + for key in src_keys & dst_keys: + _compare_values(path, key, info, src[key], dst[key]) + +def _compare_lists(path, info, src, dst): + len_src, len_dst = len(src), len(dst) + max_len = max(len_src, len_dst) + min_len = min(len_src, len_dst) + for key in _range(max_len): + if key < min_len: + old, new = src[key], dst[key] + if old == new: continue - result.append(item) - ops_by_path[item['path']] = item - if hashable_value: - ops_by_value[item['value']] = item - - # cleanup - ops_by_path.clear() - ops_by_value.clear() - for item in result: - if item['op'] == 'remove': - item.pop('value') # strip our hack - yield item - - -def _optimize_using_replace(prev, cur): - """Optimises by replacing ``add``/``remove`` with ``replace`` on same path - - For nested strucures, tries to recurse replacement, see #36 """ - prev['op'] = 'replace' - if cur['op'] == 'add': - # make recursive patch - patch = make_patch(prev['value'], cur['value']) - # check case when dict "remove" is less than "add" and has a same key - if isinstance(prev['value'], dict) and isinstance(cur['value'], dict) and len(prev['value'].keys()) == 1: - prev_set = set(prev['value'].keys()) - cur_set = set(cur['value'].keys()) - if prev_set & cur_set == prev_set: - patch = make_patch(cur['value'], prev['value']) - - if len(patch.patch) == 1 and \ - patch.patch[0]['op'] != 'remove' and \ - patch.patch[0]['path'] and patch.patch[0]['path'].split('/')[1] in prev['value']: - prev['path'] = prev['path'] + patch.patch[0]['path'] - prev['value'] = patch.patch[0]['value'] + _item_removed(path, key, info, old) + _item_added(path, key, info, new) + elif len_src > len_dst: + _item_removed(path, len_dst, info, src[key]) else: - prev['value'] = cur['value'] - - -def _optimize_using_move(prev_item, item): - """Optimises JSON patch by using ``move`` operation instead of - ``remove` and ``add`` against the different paths but for the same value.""" - prev_item['op'] = 'move' - move_from, move_to = [ - (item['path'], prev_item['path']), - (prev_item['path'], item['path']), - ][item['op'] == 'add'] - if item['op'] == 'add': # first was remove then add - prev_item['from'] = move_from - prev_item['path'] = move_to - else: # first was add then remove - head, move_from = move_from.rsplit('/', 1) - # since add operation was first it incremented - # overall index shift value. we have to fix this - move_from = int(move_from) - 1 - prev_item['from'] = head + '/%d' % move_from - prev_item['path'] = move_to + _item_added(path, key, info, dst[key]) + +def _compare_values(path, key, info, src, dst): + if src == dst: + return + elif isinstance(src, dict) and \ + isinstance(dst, dict): + _compare_dicts(_path_join(path, key), info, src, dst) + elif isinstance(src, list) and \ + isinstance(dst, list): + _compare_lists(_path_join(path, key), info, src, dst) + else: + _item_replaced(path, key, info, dst) From 82ac77987808633c625c738ade5f7abc6e6a0764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 13:29:06 +0200 Subject: [PATCH 037/127] Remove re-creation of no-optimization patch --- jsonpatch.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index d1d091f..8db0b7f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -188,18 +188,7 @@ def make_patch(src, dst): True """ - # TODO: fix patch optimiztion and remove the following check - # fix when patch with optimization is incorrect - patch = JsonPatch.from_diff(src, dst) - try: - new = patch.apply(src) - except JsonPatchConflict: # see TODO - return JsonPatch.from_diff(src, dst, False) - - if new != dst: - return JsonPatch.from_diff(src, dst, False) - - return patch + return JsonPatch.from_diff(src, dst) class JsonPatch(object): From 2f45e50b8a3139e56e359d6b8d56227c5680ca8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 16:07:14 +0200 Subject: [PATCH 038/127] Re-enable previously disabled optimization tests --- tests.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests.py b/tests.py index 39f3a85..7931fec 100755 --- a/tests.py +++ b/tests.py @@ -425,12 +425,8 @@ def fn(_src, _dst): fn({'foo': [1, 2, 3]}, {'foo': [3, 1, 2]}) fn([1, 2, 3], [3, 1, 2]) - - # Optimizations for the following tests are currently not performed. - # The tests are disabled, as the missing optimizations do not - # invalidate the results - #fn({'foo': [1, 2, 3]}, {'foo': [3, 2, 1]}) - #fn([1, 2, 3], [3, 2, 1]) + fn({'foo': [1, 2, 3]}, {'foo': [3, 2, 1]}) + fn([1, 2, 3], [3, 2, 1]) def test_success_if_replace_inside_dict(self): src = [{'a': 1, 'foo': {'b': 2, 'd': 5}}] From 03aa14e8209d59522476726d55bfabf86a28929e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 16:25:27 +0200 Subject: [PATCH 039/127] Merge _op_base classes into PatchOperation classes --- jsonpatch.py | 225 ++++++++++++++++++++++++++------------------------- 1 file changed, 113 insertions(+), 112 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 8db0b7f..72fa52f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -379,6 +379,23 @@ def __eq__(self, other): def __ne__(self, other): return not(self == other) + @property + def path(self): + return '/'.join(self.pointer.parts[:-1]) + + @property + def key(self): + try: + return int(self.pointer.parts[-1]) + except ValueError: + return self.pointer.parts[-1] + + @key.setter + def key(self, value): + self.pointer.parts[-1] = str(value) + self.location = self.pointer.path + self.operation['path'] = self.location + class RemoveOperation(PatchOperation): """Removes an object property or an array element.""" @@ -393,6 +410,22 @@ def apply(self, obj): return obj + def _on_undo_remove(self, path, key): + if self.path == path: + if self.key >= key: + self.key += 1 + else: + key -= 1 + return key + + def _on_undo_add(self, path, key): + if self.path == path: + if self.key > key: + self.key -= 1 + else: + key -= 1 + return key + class AddOperation(PatchOperation): """Adds an object property or an array element.""" @@ -427,6 +460,22 @@ def apply(self, obj): return obj + def _on_undo_remove(self, path, key): + if self.path == path: + if self.key > key: + self.key += 1 + else: + key += 1 + return key + + def _on_undo_add(self, path, key): + if self.path == path: + if self.key > key: + self.key -= 1 + else: + key += 1 + return key + class ReplaceOperation(PatchOperation): """Replaces an object property or an array element by new value.""" @@ -457,6 +506,12 @@ def apply(self, obj): subobj[part] = value return obj + def _on_undo_remove(self, path, key): + return key + + def _on_undo_add(self, path, key): + return key + class MoveOperation(PatchOperation): """Moves an object property or an array element to new location.""" @@ -495,6 +550,51 @@ def apply(self, obj): return obj + @property + def oldpath(self): + oldptr = JsonPointer(self.operation['from']) + return '/'.join(oldptr.parts[:-1]) + + @property + def oldkey(self): + oldptr = JsonPointer(self.operation['from']) + try: + return int(oldptr.parts[-1]) + except TypeError: + return oldptr.parts[-1] + + @oldkey.setter + def oldkey(self, value): + oldptr = JsonPointer(self.operation['from']) + oldptr.parts[-1] = str(value) + self.operation['from'] = oldptr.path + + def _on_undo_remove(self, path, key): + if self.oldpath == path: + if self.oldkey >= key: + self.oldkey += 1 + else: + key -= 1 + if self.path == path: + if self.key > key: + self.key += 1 + else: + key += 1 + return key + + def _on_undo_add(self, path, key): + if self.oldpath == path: + if self.oldkey > key: + self.oldkey -= 1 + else: + key -= 1 + if self.path == path: + if self.key > key: + self.key -= 1 + else: + key += 1 + return key + class TestOperation(PatchOperation): """Test value by specified location.""" @@ -610,115 +710,16 @@ def execute(self): while curr is not root: if curr[1] is not root: op_first, op_second = curr[2], curr[1][2] - if op_first.key == op_second.key and \ - op_first.path == op_second.path and \ - type(op_first) == _op_remove and \ - type(op_second) == _op_add: - yield _op_replace(op_second.path, op_second.key, op_second.value).get() + if ( #op_first.key == op_second.key and \ + op_first.location == op_second.location and \ + type(op_first) == RemoveOperation and \ + type(op_second) == AddOperation): + yield ReplaceOperation({'op': 'replace', 'path': op_second.location, 'value': op_second.operation['value']}).operation curr = curr[1][1] continue - yield curr[2].get() + yield curr[2].operation curr = curr[1] -class _op_base(object): - def __init__(self, path, key, value): - self.path = path - self.key = key - self.value = value - - def __repr__(self): - return str(self.get()) - -class _op_add(_op_base): - def _on_undo_remove(self, path, key): - if self.path == path: - if self.key > key: - self.key += 1 - else: - key += 1 - return key - - def _on_undo_add(self, path, key): - if self.path == path: - if self.key > key: - self.key -= 1 - else: - key += 1 - return key - - def get(self): - return {'op': 'add', 'path': _path_join(self.path, self.key), 'value': self.value} - -class _op_remove(_op_base): - def _on_undo_remove(self, path, key): - if self.path == path: - if self.key >= key: - self.key += 1 - else: - key -= 1 - return key - - def _on_undo_add(self, path, key): - if self.path == path: - if self.key > key: - self.key -= 1 - else: - key -= 1 - return key - - def get(self): - return {'op': 'remove', 'path': _path_join(self.path, self.key)} - -class _op_replace(_op_base): - def _on_undo_remove(self, path, key): - return key - - def _on_undo_add(self, path, key): - return key - - def get(self): - return {'op': 'replace', 'path': _path_join(self.path, self.key), 'value': self.value} - - -class _op_move(object): - def __init__(self, oldpath, oldkey, path, key): - self.oldpath = oldpath - self.oldkey = oldkey - self.path = path - self.key = key - - def _on_undo_remove(self, path, key): - if self.oldpath == path: - if self.oldkey >= key: - self.oldkey += 1 - else: - key -= 1 - if self.path == path: - if self.key > key: - self.key += 1 - else: - key += 1 - return key - - def _on_undo_add(self, path, key): - if self.oldpath == path: - if self.oldkey > key: - self.oldkey -= 1 - else: - key -= 1 - if self.path == path: - if self.key > key: - self.key -= 1 - else: - key += 1 - return key - - def get(self): - return {'op': 'move', 'path': _path_join(self.path, self.key), 'from': _path_join(self.oldpath, self.oldkey)} - - def __repr__(self): - return str(self.get()) - def _path_join(path, key): if key != None: return path + '/' + str(key).replace('~', '~0').replace('/', '~1') @@ -732,16 +733,16 @@ def _item_added(path, key, info, item): for v in info.iter_from(index): op.key = v._on_undo_remove(op.path, op.key) info.remove(index) - if op.path != path or op.key != key: - new_op = _op_move(op.path, op.key, path, key) + if op.location != _path_join(path, key): + new_op = MoveOperation({'op': 'move', 'from': op.location, 'path': _path_join(path, key)}) info.insert(new_op) else: - new_op = _op_add(path, key, item) + new_op = AddOperation({'op': 'add', 'path': _path_join(path, key), 'value': item}) new_index = info.insert(new_op) info.store_index(item, new_index, _ST_ADD) def _item_removed(path, key, info, item): - new_op = _op_remove(path, key, item) + new_op = RemoveOperation({'op': 'remove', 'path': _path_join(path, key), 'value': item}) index = info.take_index(item, _ST_ADD) new_index = info.insert(new_op) if index != None: @@ -750,8 +751,8 @@ def _item_removed(path, key, info, item): for v in info.iter_from(index): op.key = v._on_undo_add(op.path, op.key) info.remove(index) - if new_op.path != op.path or new_op.key != op.key: - new_op = _op_move(new_op.path, new_op.key, op.path, op.key) + if new_op.location != op.location: + new_op = MoveOperation({'op': 'move', 'from': new_op.location, 'path': op.location}) new_index[2] = new_op else: info.remove(new_index) @@ -759,7 +760,7 @@ def _item_removed(path, key, info, item): info.store_index(item, new_index, _ST_REMOVE) def _item_replaced(path, key, info, item): - info.insert(_op_replace(path, key, item)) + info.insert(ReplaceOperation({'op': 'replace', 'path': _path_join(path, key), 'value': item})) def _compare_dicts(path, info, src, dst): src_keys = _viewkeys(src) From 73daf9b37d85f60a3091d76108992b8f31e25216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 16:27:30 +0200 Subject: [PATCH 040/127] Break long lines --- jsonpatch.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 72fa52f..3fb643b 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -714,7 +714,11 @@ def execute(self): op_first.location == op_second.location and \ type(op_first) == RemoveOperation and \ type(op_second) == AddOperation): - yield ReplaceOperation({'op': 'replace', 'path': op_second.location, 'value': op_second.operation['value']}).operation + yield ReplaceOperation({ + 'op': 'replace', + 'path': op_second.location, + 'value': op_second.operation['value'], + }).operation curr = curr[1][1] continue yield curr[2].operation @@ -734,15 +738,27 @@ def _item_added(path, key, info, item): op.key = v._on_undo_remove(op.path, op.key) info.remove(index) if op.location != _path_join(path, key): - new_op = MoveOperation({'op': 'move', 'from': op.location, 'path': _path_join(path, key)}) + new_op = MoveOperation({ + 'op': 'move', + 'from': op.location, + 'path': _path_join(path, key), + }) info.insert(new_op) else: - new_op = AddOperation({'op': 'add', 'path': _path_join(path, key), 'value': item}) + new_op = AddOperation({ + 'op': 'add', + 'path': _path_join(path, key), + 'value': item, + }) new_index = info.insert(new_op) info.store_index(item, new_index, _ST_ADD) def _item_removed(path, key, info, item): - new_op = RemoveOperation({'op': 'remove', 'path': _path_join(path, key), 'value': item}) + new_op = RemoveOperation({ + 'op': 'remove', + 'path': _path_join(path, key), + 'value': item, + }) index = info.take_index(item, _ST_ADD) new_index = info.insert(new_op) if index != None: @@ -752,7 +768,11 @@ def _item_removed(path, key, info, item): op.key = v._on_undo_add(op.path, op.key) info.remove(index) if new_op.location != op.location: - new_op = MoveOperation({'op': 'move', 'from': new_op.location, 'path': op.location}) + new_op = MoveOperation({ + 'op': 'move', + 'from': new_op.location, + 'path': op.location, + }) new_index[2] = new_op else: info.remove(new_index) @@ -760,7 +780,11 @@ def _item_removed(path, key, info, item): info.store_index(item, new_index, _ST_REMOVE) def _item_replaced(path, key, info, item): - info.insert(ReplaceOperation({'op': 'replace', 'path': _path_join(path, key), 'value': item})) + info.insert(ReplaceOperation({ + 'op': 'replace', + 'path': _path_join(path, key), + 'value': item, + })) def _compare_dicts(path, info, src, dst): src_keys = _viewkeys(src) From 098c7c78b8abae2fe74d0b44f6ebb70fea0b7c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 16:33:37 +0200 Subject: [PATCH 041/127] Redefine _compare_info class as DiffBuilder --- jsonpatch.py | 196 +++++++++++++++++++++++++-------------------------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 3fb643b..d2aaa01 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -306,9 +306,9 @@ def from_diff(cls, src, dst, optimization=True): True """ - info = _compare_info() - _compare_values('', None, info, src, dst) - ops = [op for op in info.execute()] + builder = DiffBuilder() + builder._compare_values('', None, src, dst) + ops = list(builder.execute()) return cls(ops) def to_string(self): @@ -648,7 +648,7 @@ def apply(self, obj): return obj -class _compare_info(object): +class DiffBuilder(object): def __init__(self): self.index_storage = [{}, {}] @@ -724,104 +724,104 @@ def execute(self): yield curr[2].operation curr = curr[1] -def _path_join(path, key): - if key != None: - return path + '/' + str(key).replace('~', '~0').replace('/', '~1') - return path - -def _item_added(path, key, info, item): - index = info.take_index(item, _ST_REMOVE) - if index != None: - op = index[2] - if type(op.key) == int: - for v in info.iter_from(index): - op.key = v._on_undo_remove(op.path, op.key) - info.remove(index) - if op.location != _path_join(path, key): - new_op = MoveOperation({ - 'op': 'move', - 'from': op.location, + def _item_added(self, path, key, item): + index = self.take_index(item, _ST_REMOVE) + if index != None: + op = index[2] + if type(op.key) == int: + for v in self.iter_from(index): + op.key = v._on_undo_remove(op.path, op.key) + self.remove(index) + if op.location != _path_join(path, key): + new_op = MoveOperation({ + 'op': 'move', + 'from': op.location, + 'path': _path_join(path, key), + }) + self.insert(new_op) + else: + new_op = AddOperation({ + 'op': 'add', 'path': _path_join(path, key), + 'value': item, }) - info.insert(new_op) - else: - new_op = AddOperation({ - 'op': 'add', + new_index = self.insert(new_op) + self.store_index(item, new_index, _ST_ADD) + + def _item_removed(self, path, key, item): + new_op = RemoveOperation({ + 'op': 'remove', 'path': _path_join(path, key), 'value': item, }) - new_index = info.insert(new_op) - info.store_index(item, new_index, _ST_ADD) - -def _item_removed(path, key, info, item): - new_op = RemoveOperation({ - 'op': 'remove', - 'path': _path_join(path, key), - 'value': item, - }) - index = info.take_index(item, _ST_ADD) - new_index = info.insert(new_op) - if index != None: - op = index[2] - if type(op.key) == int: - for v in info.iter_from(index): - op.key = v._on_undo_add(op.path, op.key) - info.remove(index) - if new_op.location != op.location: - new_op = MoveOperation({ - 'op': 'move', - 'from': new_op.location, - 'path': op.location, - }) - new_index[2] = new_op + index = self.take_index(item, _ST_ADD) + new_index = self.insert(new_op) + if index != None: + op = index[2] + if type(op.key) == int: + for v in self.iter_from(index): + op.key = v._on_undo_add(op.path, op.key) + self.remove(index) + if new_op.location != op.location: + new_op = MoveOperation({ + 'op': 'move', + 'from': new_op.location, + 'path': op.location, + }) + new_index[2] = new_op + else: + self.remove(new_index) else: - info.remove(new_index) - else: - info.store_index(item, new_index, _ST_REMOVE) - -def _item_replaced(path, key, info, item): - info.insert(ReplaceOperation({ - 'op': 'replace', - 'path': _path_join(path, key), - 'value': item, - })) - -def _compare_dicts(path, info, src, dst): - src_keys = _viewkeys(src) - dst_keys = _viewkeys(dst) - added_keys = dst_keys - src_keys - removed_keys = src_keys - dst_keys - for key in removed_keys: - _item_removed(path, str(key), info, src[key]) - for key in added_keys: - _item_added(path, str(key), info, dst[key]) - for key in src_keys & dst_keys: - _compare_values(path, key, info, src[key], dst[key]) - -def _compare_lists(path, info, src, dst): - len_src, len_dst = len(src), len(dst) - max_len = max(len_src, len_dst) - min_len = min(len_src, len_dst) - for key in _range(max_len): - if key < min_len: - old, new = src[key], dst[key] - if old == new: - continue - _item_removed(path, key, info, old) - _item_added(path, key, info, new) - elif len_src > len_dst: - _item_removed(path, len_dst, info, src[key]) + self.store_index(item, new_index, _ST_REMOVE) + + def _item_replaced(self, path, key, item): + self.insert(ReplaceOperation({ + 'op': 'replace', + 'path': _path_join(path, key), + 'value': item, + })) + + def _compare_dicts(self, path, src, dst): + src_keys = _viewkeys(src) + dst_keys = _viewkeys(dst) + added_keys = dst_keys - src_keys + removed_keys = src_keys - dst_keys + for key in removed_keys: + self._item_removed(path, str(key), src[key]) + for key in added_keys: + self._item_added(path, str(key), dst[key]) + for key in src_keys & dst_keys: + self._compare_values(path, key, src[key], dst[key]) + + def _compare_lists(self, path, src, dst): + len_src, len_dst = len(src), len(dst) + max_len = max(len_src, len_dst) + min_len = min(len_src, len_dst) + for key in _range(max_len): + if key < min_len: + old, new = src[key], dst[key] + if old == new: + continue + self._item_removed(path, key, old) + self._item_added(path, key, new) + elif len_src > len_dst: + self._item_removed(path, len_dst, src[key]) + else: + self._item_added(path, key, dst[key]) + + def _compare_values(self, path, key, src, dst): + if src == dst: + return + elif isinstance(src, dict) and \ + isinstance(dst, dict): + self._compare_dicts(_path_join(path, key), src, dst) + elif isinstance(src, list) and \ + isinstance(dst, list): + self._compare_lists(_path_join(path, key), src, dst) else: - _item_added(path, key, info, dst[key]) - -def _compare_values(path, key, info, src, dst): - if src == dst: - return - elif isinstance(src, dict) and \ - isinstance(dst, dict): - _compare_dicts(_path_join(path, key), info, src, dst) - elif isinstance(src, list) and \ - isinstance(dst, list): - _compare_lists(_path_join(path, key), info, src, dst) - else: - _item_replaced(path, key, info, dst) + self._item_replaced(path, key, dst) + +def _path_join(path, key): + if key != None: + return path + '/' + str(key).replace('~', '~0').replace('/', '~1') + return path From 7387d20148bbb15f576338a5114a28f7afa5e1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 16:38:56 +0200 Subject: [PATCH 042/127] Simplify compatibility code --- jsonpatch.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index d2aaa01..7ac2f68 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -43,17 +43,6 @@ import sys -if sys.version_info[0] >= 3: - _range = range - _viewkeys = dict.keys -else: - _range = xrange - if sys.version_info[1] >= 7: - _viewkeys = dict.viewkeys - else: - _viewkeys = lambda x: set(dict.keys(x)) - - _ST_ADD = 0 _ST_REMOVE = 1 @@ -782,8 +771,8 @@ def _item_replaced(self, path, key, item): })) def _compare_dicts(self, path, src, dst): - src_keys = _viewkeys(src) - dst_keys = _viewkeys(dst) + src_keys = set(src.keys()) + dst_keys = set(dst.keys()) added_keys = dst_keys - src_keys removed_keys = src_keys - dst_keys for key in removed_keys: @@ -797,7 +786,7 @@ def _compare_lists(self, path, src, dst): len_src, len_dst = len(src), len(dst) max_len = max(len_src, len_dst) min_len = min(len_src, len_dst) - for key in _range(max_len): + for key in range(max_len): if key < min_len: old, new = src[key], dst[key] if old == new: @@ -812,11 +801,11 @@ def _compare_lists(self, path, src, dst): def _compare_values(self, path, key, src, dst): if src == dst: return - elif isinstance(src, dict) and \ - isinstance(dst, dict): + elif isinstance(src, MutableMapping) and \ + isinstance(dst, MutableMapping): self._compare_dicts(_path_join(path, key), src, dst) - elif isinstance(src, list) and \ - isinstance(dst, list): + elif isinstance(src, MutableSequence) and \ + isinstance(dst, MutableSequence): self._compare_lists(_path_join(path, key), src, dst) else: self._item_replaced(path, key, dst) From 2d9a565a46058166b51ff4d84b38de57d2bf6d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 16:41:07 +0200 Subject: [PATCH 043/127] Rename old{path,key} to from_{path,key} --- jsonpatch.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 7ac2f68..1e44259 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -540,28 +540,28 @@ def apply(self, obj): return obj @property - def oldpath(self): - oldptr = JsonPointer(self.operation['from']) - return '/'.join(oldptr.parts[:-1]) + def from_path(self): + from_ptr = JsonPointer(self.operation['from']) + return '/'.join(from_ptr.parts[:-1]) @property - def oldkey(self): - oldptr = JsonPointer(self.operation['from']) + def from_key(self): + from_ptr = JsonPointer(self.operation['from']) try: - return int(oldptr.parts[-1]) + return int(from_ptr.parts[-1]) except TypeError: - return oldptr.parts[-1] + return from_ptr.parts[-1] - @oldkey.setter - def oldkey(self, value): - oldptr = JsonPointer(self.operation['from']) - oldptr.parts[-1] = str(value) - self.operation['from'] = oldptr.path + @from_key.setter + def from_key(self, value): + from_ptr = JsonPointer(self.operation['from']) + from_ptr.parts[-1] = str(value) + self.operation['from'] = from_ptr.path def _on_undo_remove(self, path, key): - if self.oldpath == path: - if self.oldkey >= key: - self.oldkey += 1 + if self.from_path == path: + if self.from_key >= key: + self.from_key += 1 else: key -= 1 if self.path == path: @@ -572,9 +572,9 @@ def _on_undo_remove(self, path, key): return key def _on_undo_add(self, path, key): - if self.oldpath == path: - if self.oldkey > key: - self.oldkey -= 1 + if self.from_path == path: + if self.from_key > key: + self.from_key -= 1 else: key -= 1 if self.path == path: From 462c9cbbfa9b214fe71de2d9a0fe65ec2fe4a159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 16:44:52 +0200 Subject: [PATCH 044/127] Code style --- jsonpatch.py | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 1e44259..284e85d 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -42,8 +42,10 @@ import json import sys +from jsonpointer import JsonPointer, JsonPointerException + -_ST_ADD = 0 +_ST_ADD = 0 _ST_REMOVE = 1 @@ -52,8 +54,6 @@ except ImportError: from collections import MutableMapping, MutableSequence -from jsonpointer import JsonPointer, JsonPointerException - # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' __version__ = '1.16' @@ -486,7 +486,7 @@ def apply(self, obj): raise JsonPatchConflict("can't replace outside of list") elif isinstance(subobj, MutableMapping): - if not part in subobj: + if part not in subobj: msg = "can't replace non-existent object '{0}'".format(part) raise JsonPatchConflict(msg) else: @@ -649,10 +649,11 @@ def store_index(self, value, index, st): try: storage = self.index_storage[st] stored = storage.get(value) - if stored == None: + if stored is None: storage[value] = [index] else: storage[value].append(index) + except TypeError: self.index_storage2[st].append((value, index)) @@ -661,6 +662,7 @@ def take_index(self, value, st): stored = self.index_storage[st].get(value) if stored: return stored.pop() + except TypeError: storage = self.index_storage2[st] for i in range(len(storage)-1, -1, -1): @@ -699,10 +701,9 @@ def execute(self): while curr is not root: if curr[1] is not root: op_first, op_second = curr[2], curr[1][2] - if ( #op_first.key == op_second.key and \ - op_first.location == op_second.location and \ + if op_first.location == op_second.location and \ type(op_first) == RemoveOperation and \ - type(op_second) == AddOperation): + type(op_second) == AddOperation: yield ReplaceOperation({ 'op': 'replace', 'path': op_second.location, @@ -710,16 +711,18 @@ def execute(self): }).operation curr = curr[1][1] continue + yield curr[2].operation curr = curr[1] def _item_added(self, path, key, item): index = self.take_index(item, _ST_REMOVE) - if index != None: + if index is not None: op = index[2] if type(op.key) == int: for v in self.iter_from(index): op.key = v._on_undo_remove(op.path, op.key) + self.remove(index) if op.location != _path_join(path, key): new_op = MoveOperation({ @@ -745,11 +748,12 @@ def _item_removed(self, path, key, item): }) index = self.take_index(item, _ST_ADD) new_index = self.insert(new_op) - if index != None: + if index is not None: op = index[2] if type(op.key) == int: for v in self.iter_from(index): op.key = v._on_undo_add(op.path, op.key) + self.remove(index) if new_op.location != op.location: new_op = MoveOperation({ @@ -758,8 +762,10 @@ def _item_removed(self, path, key, item): 'path': op.location, }) new_index[2] = new_op + else: self.remove(new_index) + else: self.store_index(item, new_index, _ST_REMOVE) @@ -775,10 +781,13 @@ def _compare_dicts(self, path, src, dst): dst_keys = set(dst.keys()) added_keys = dst_keys - src_keys removed_keys = src_keys - dst_keys + for key in removed_keys: self._item_removed(path, str(key), src[key]) + for key in added_keys: self._item_added(path, str(key), dst[key]) + for key in src_keys & dst_keys: self._compare_values(path, key, src[key], dst[key]) @@ -791,26 +800,34 @@ def _compare_lists(self, path, src, dst): old, new = src[key], dst[key] if old == new: continue + self._item_removed(path, key, old) self._item_added(path, key, new) + elif len_src > len_dst: self._item_removed(path, len_dst, src[key]) + else: self._item_added(path, key, dst[key]) def _compare_values(self, path, key, src, dst): if src == dst: return + elif isinstance(src, MutableMapping) and \ isinstance(dst, MutableMapping): self._compare_dicts(_path_join(path, key), src, dst) + elif isinstance(src, MutableSequence) and \ isinstance(dst, MutableSequence): self._compare_lists(_path_join(path, key), src, dst) + else: self._item_replaced(path, key, dst) + def _path_join(path, key): - if key != None: - return path + '/' + str(key).replace('~', '~0').replace('/', '~1') - return path + if key is None: + return path + + return path + '/' + str(key).replace('~', '~0').replace('/', '~1') From 3cec8a09c4b0f810028d16c8ef88f2b1837bbeb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 17:15:19 +0200 Subject: [PATCH 045/127] Improve optimizations --- jsonpatch.py | 13 +++++++++++-- tests.py | 15 +++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 284e85d..705b4ed 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -801,8 +801,17 @@ def _compare_lists(self, path, src, dst): if old == new: continue - self._item_removed(path, key, old) - self._item_added(path, key, new) + elif isinstance(old, MutableMapping) and \ + isinstance(new, MutableMapping): + self._compare_dicts(_path_join(path, key), old, new) + + elif isinstance(old, MutableSequence) and \ + isinstance(new, MutableSequence): + self._compare_lists(_path_join(path, key), old, new) + + else: + self._item_removed(path, key, old) + self._item_added(path, key, new) elif len_src > len_dst: self._item_removed(path, len_dst, src[key]) diff --git a/tests.py b/tests.py index 7931fec..cdb805f 100755 --- a/tests.py +++ b/tests.py @@ -326,7 +326,9 @@ def test_add_nested(self): } self.assertEqual(expected, res) - def test_should_just_add_new_item_not_rebuild_all_list(self): + # TODO: this test is currently disabled, as the optimized patch is + # not ideal + def _test_should_just_add_new_item_not_rebuild_all_list(self): src = {'foo': [1, 2, 3]} dst = {'foo': [3, 1, 2, 3]} patch = list(jsonpatch.make_patch(src, dst)) @@ -400,8 +402,10 @@ def test_use_replace_instead_of_remove_add_nested(self): src = {'foo': [{'bar': 1, 'baz': 2}, {'bar': 2, 'baz': 3}]} dst = {'foo': [{'bar': 1}, {'bar': 2, 'baz': 3}]} patch = list(jsonpatch.make_patch(src, dst)) - self.assertEqual(len(patch), 1) - self.assertEqual(patch[0]['op'], 'replace') + + exp = [{'op': 'remove', 'value': 2, 'path': '/foo/0/baz'}] + self.assertEqual(patch, exp) + res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) @@ -455,7 +459,10 @@ def test_success_if_correct_patch_appied(self): def test_success_if_correct_expected_patch_appied(self): src = [{"a": 1, "b": 2}] dst = [{"b": 2, "c": 2}] - exp = [{'path': '/0', 'value': {'c': 2, 'b': 2}, 'op': 'replace'}] + exp = [ + {'path': '/0/a', 'op': 'remove', 'value': 1}, + {'path': '/0/c', 'op': 'add', 'value': 2} + ] patch = jsonpatch.make_patch(src, dst) self.assertEqual(patch.patch, exp) From d602f5ef961382d64368cb90567642c0dabb582e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 17:28:34 +0200 Subject: [PATCH 046/127] Fix unicode dict keys in Python 2 --- jsonpatch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index 705b4ed..b490108 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -51,8 +51,10 @@ try: from collections.abc import MutableMapping, MutableSequence + except ImportError: from collections import MutableMapping, MutableSequence + str = unicode # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' From 4351804bc88254fe2ae9dccf4d081e25f3a266f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 10 Sep 2017 19:24:09 +0200 Subject: [PATCH 047/127] Remove support for Python 3.2 --- .travis.yml | 3 --- setup.py | 1 - 2 files changed, 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 77f8b19..0239972 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: python python: - - "2.6" - "2.7" - - "3.2" - "3.3" - "3.4" - "3.5" @@ -12,7 +10,6 @@ python: install: - travis_retry pip install -r requirements.txt - - if [ "$TRAVIS_PYTHON_VERSION" == "3.2" ]; then travis_retry pip install 'coverage<4'; fi - travis_retry pip install coveralls script: diff --git a/setup.py b/setup.py index c5a9863..5de173b 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,6 @@ 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', From 7079bdc7bf4be53f51fa30d790c32db83dd8c210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 21 Oct 2017 10:40:13 +0200 Subject: [PATCH 048/127] Remove support for Python 2.6 --- jsonpatch.py | 22 +++------------------- setup.py | 6 ------ 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index cee4820..e1a5b2d 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -96,25 +96,9 @@ def multidict(ordered_pairs): ) -def get_loadjson(): - """ adds the object_pairs_hook parameter to json.load when possible - - The "object_pairs_hook" parameter is used to handle duplicate keys when - loading a JSON object. This parameter does not exist in Python 2.6. This - methods returns an unmodified json.load for Python 2.6 and a partial - function with object_pairs_hook set to multidict for Python versions that - support the parameter. """ - - if sys.version_info >= (3, 3): - args = inspect.signature(json.load).parameters - else: - args = inspect.getargspec(json.load).args - if 'object_pairs_hook' not in args: - return json.load - - return functools.partial(json.load, object_pairs_hook=multidict) - -json.load = get_loadjson() +# The "object_pairs_hook" parameter is used to handle duplicate keys when +# loading a JSON object. +json.load = functools.partial(json.load, object_pairs_hook=multidict) def apply_patch(doc, patch, in_place=False): diff --git a/setup.py b/setup.py index 5de173b..0776c41 100644 --- a/setup.py +++ b/setup.py @@ -23,17 +23,12 @@ ) REQUIREMENTS = list(open('requirements.txt')) -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'] @@ -61,7 +56,6 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', From b6514dd9551d453f2ee8485283d538e00f5015be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 21 Oct 2017 10:41:24 +0200 Subject: [PATCH 049/127] Update supported versions in docs --- doc/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 674c9e5..cbae4ff 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -6,9 +6,9 @@ python-json-patch ================= -*python-json-patch* is a Python library for applying JSON patches (`RFC -6902 `_). Python 2.6, 2.7, 3.2, 3.3 -and PyPy are supported. +*python-json-patch* is a Python library for applying JSON patches (`RFC 6902 +`_). Python 2.7 and 3.3-3.6 are +supported. Tests are run on both CPython and PyPy. **Contents** From 074f937de30079dfec69733cc5fe4cd459faee2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 21 Oct 2017 11:19:35 +0200 Subject: [PATCH 050/127] Avoid overriding json.load (fixes #37) --- jsonpatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index e1a5b2d..41c14a4 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -98,7 +98,7 @@ def multidict(ordered_pairs): # The "object_pairs_hook" parameter is used to handle duplicate keys when # loading a JSON object. -json.load = functools.partial(json.load, object_pairs_hook=multidict) +_jsonloads = functools.partial(json.loads, object_pairs_hook=multidict) def apply_patch(doc, patch, in_place=False): @@ -260,7 +260,7 @@ def from_string(cls, patch_str): :return: :class:`JsonPatch` instance. """ - patch = json.loads(patch_str) + patch = _jsonloads(patch_str) return cls(patch) @classmethod From 6b777b7c414f69f3b8f3c16ba1c871246b481d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 29 Oct 2017 16:57:35 +0100 Subject: [PATCH 051/127] Show coverage report after tests --- makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/makefile b/makefile index 01cef8a..ec70742 100644 --- a/makefile +++ b/makefile @@ -10,7 +10,8 @@ help: @echo test: - python tests.py + python -Wd -m coverage run --branch --source=jsonpatch tests.py + coverage report --show-missing coverage: coverage run --source=jsonpatch tests.py From ae15a3be2c37e6d37286dc0e492993ebf31447c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 18:58:46 +0100 Subject: [PATCH 052/127] Reformat .travis.yml --- .travis.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0239972..aab4dfd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,21 @@ language: python python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" - - "3.6" - - "pypy" - - "pypy3" - +- '2.6' +- '2.7' +- '3.3' +- '3.4' +- '3.5' +- '3.6' +- 3.6-dev +- 3.7-dev +- nightly +- pypy +- pypy3 install: - - travis_retry pip install -r requirements.txt - - travis_retry pip install coveralls - +- travis_retry pip install -r requirements.txt +- travis_retry pip install coveralls script: - - coverage run --source=jsonpatch tests.py - +- coverage run --source=jsonpointer tests.py after_script: - - coveralls - +- coveralls sudo: false From 5ea62246d5077bb30e8f6abbfe3e39a6d4f40e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 19:01:14 +0100 Subject: [PATCH 053/127] Deploy to PyPI from Travis-CI --- .travis.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.travis.yml b/.travis.yml index aab4dfd..3b3278b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,3 +19,17 @@ script: after_script: - coveralls sudo: false +addons: + apt: + packages: + - pandoc +before_deploy: +- pip install -r requirements-dev.txt +deploy: + provider: pypi + user: skoegl + password: + secure: ppMhKu82oIig1INyiNkt9veOd5FUUIKFUXj2TzxMSdzPtzAhQnScJMGPEtPfH8MwXng/CtJiDWS6zJzRFsW/3Ch+JHPkOtxOfkopBs1t1SpCyqNPSvf6Zxh83Dg6Bq6+8GyVW1RPuNIGflsvzY2C3z5i79FQXwZd8EQlg7Vu0Wo= + on: + tags: true + distributions: sdist bdist_wheel From e066c28960b93d812a5a76e3fb102e965ec13202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 19:06:07 +0100 Subject: [PATCH 054/127] Disable tests for Python 2.6 This was disabled before, and was erroneously re-enabled in ae15a3be2c37e6d37286dc0e492993ebf31447c4 --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3b3278b..fe615bf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: -- '2.6' - '2.7' - '3.3' - '3.4' From d8c54d65a32fa8b56da0b130ea678f094ff381c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 19:07:22 +0100 Subject: [PATCH 055/127] Bump version to 1.20 The next version would have been 1.17, but a bump to 1.20 was requested per #67 to fix an issue with versioning of the Debian package https://github.com/stefankoegl/python-json-patch/issues/67 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 43c68e5..cd08a82 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -58,7 +58,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.16' +__version__ = '1.20' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 04596b810baf178ee3799c72c03bfe1990400418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 19:13:39 +0100 Subject: [PATCH 056/127] Add test case for issue reported in #74 --- tests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests.py b/tests.py index cdb805f..f238115 100755 --- a/tests.py +++ b/tests.py @@ -388,6 +388,17 @@ def test_arrays_one_element_sequences(self): res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) + def test_list_in_dict(self): + """ Test patch creation with a list within a dict, as reported in #74 + + https://github.com/stefankoegl/python-json-patch/issues/74 """ + old = {'key': [{'someNumber': 0, 'someArray': [1, 2, 3]}]} + new = {'key': [{'someNumber': 0, 'someArray': [1, 2, 3, 4]}]} + patch = jsonpatch.make_patch(old, new) + new_from_patch = jsonpatch.apply_patch(old, patch) + self.assertEqual(new, new_from_patch) + + class OptimizationTests(unittest.TestCase): def test_use_replace_instead_of_remove_add(self): src = {'foo': [1, 2, 3]} From ae895f7ed9aa96e96d9d3ba402d3e2d0a11b0c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 19:15:59 +0100 Subject: [PATCH 057/127] Add test case for issue reported in #41 --- tests.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests.py b/tests.py index f238115..29a8ae9 100755 --- a/tests.py +++ b/tests.py @@ -398,6 +398,16 @@ def test_list_in_dict(self): new_from_patch = jsonpatch.apply_patch(old, patch) self.assertEqual(new, new_from_patch) + def test_nested(self): + """ Patch creation with nested dicts, as reported in #41 + + https://github.com/stefankoegl/python-json-patch/issues/41 """ + old = {'school':{'names':['Kevin','Carl']}} + new = {'school':{'names':['Carl','Kate','Kevin','Jake']}} + patch = jsonpatch.JsonPatch.from_diff(old, new) + new_from_patch = jsonpatch.apply_patch(old, patch) + self.assertEqual(new, new_from_patch) + class OptimizationTests(unittest.TestCase): def test_use_replace_instead_of_remove_add(self): From 62db640d55a8dba3431d10b8a7a7b2204d5bd896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 19:36:54 +0100 Subject: [PATCH 058/127] Update ext_tests.py for current versions of coverage --- ext_tests.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/ext_tests.py b/ext_tests.py index 0e1404c..05576c6 100755 --- a/ext_tests.py +++ b/ext_tests.py @@ -115,24 +115,25 @@ def get_suite(filenames): try: import coverage + cov = coverage.Coverage() except ImportError: - coverage = None + cov = None -if coverage is not None: - coverage.erase() - coverage.start() +if cov is not None: + cov.erase() + cov.start() result = runner.run(suite) if not result.wasSuccessful(): sys.exit(1) -if coverage is not None: - coverage.stop() - coverage.report(coverage_modules) - coverage.erase() +if cov is not None: + cov.stop() + cov.report(coverage_modules) + cov.erase() -if coverage is None: +if cov is None: sys.stderr.write(""" No coverage reporting done (Python module "coverage" is missing) Please install the python-coverage package to get coverage reporting. From df0c56d592c9f1d0fada201b7fa66d9d359ac7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 25 Nov 2017 22:29:10 +0100 Subject: [PATCH 059/127] Remove broken badges from README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e8b8bcb..5bdf287 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -python-json-patch [![Build Status](https://secure.travis-ci.org/stefankoegl/python-json-patch.png?branch=master)](https://travis-ci.org/stefankoegl/python-json-patch) [![Coverage Status](https://coveralls.io/repos/stefankoegl/python-json-patch/badge.png?branch=master)](https://coveralls.io/r/stefankoegl/python-json-patch?branch=master) ![Downloads](https://pypip.in/d/jsonpatch/badge.png) ![Version](https://pypip.in/v/jsonpatch/badge.png) +python-json-patch [![Build Status](https://secure.travis-ci.org/stefankoegl/python-json-patch.png?branch=master)](https://travis-ci.org/stefankoegl/python-json-patch) [![Coverage Status](https://coveralls.io/repos/stefankoegl/python-json-patch/badge.png?branch=master)](https://coveralls.io/r/stefankoegl/python-json-patch?branch=master) ================= Applying JSON Patches in Python ------------------------------- From aae608237495fb63d9428da84f36db96d4e4c9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 3 Dec 2017 23:02:18 +0100 Subject: [PATCH 060/127] Expect path/from attributes also as JsonPoiner instances (#60) --- jsonpatch.py | 15 ++++++++++++--- tests.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index cd08a82..e8608ec 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -335,8 +335,14 @@ class PatchOperation(object): """A single operation inside a JSON Patch.""" def __init__(self, operation): - self.location = operation['path'] - self.pointer = JsonPointer(self.location) + + if isinstance(operation['path'], JsonPointer): + self.location = operation['path'].path + self.pointer = operation['path'] + else: + self.location = operation['path'] + self.pointer = JsonPointer(self.location) + self.operation = operation def apply(self, obj): @@ -493,7 +499,10 @@ class MoveOperation(PatchOperation): def apply(self, obj): try: - from_ptr = JsonPointer(self.operation['from']) + if isinstance(self.operation['from'], JsonPointer): + from_ptr = self.operation['from'] + else: + from_ptr = JsonPointer(self.operation['from']) except KeyError as ex: raise InvalidJsonPatch( "The operation does not contain a 'from' member") diff --git a/tests.py b/tests.py index 29a8ae9..e6c4597 100755 --- a/tests.py +++ b/tests.py @@ -594,6 +594,27 @@ def test_replace_missing(self): self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) +class JsonPointerTests(unittest.TestCase): + + def test_create_with_pointer(self): + + patch = jsonpatch.JsonPatch([ + {'op': 'add', 'path': jsonpointer.JsonPointer('/foo'), 'value': 'bar'}, + {'op': 'add', 'path': jsonpointer.JsonPointer('/baz'), 'value': [1, 2, 3]}, + {'op': 'remove', 'path': jsonpointer.JsonPointer('/baz/1')}, + {'op': 'test', 'path': jsonpointer.JsonPointer('/baz'), 'value': [1, 3]}, + {'op': 'replace', 'path': jsonpointer.JsonPointer('/baz/0'), 'value': 42}, + {'op': 'remove', 'path': jsonpointer.JsonPointer('/baz/1')}, + {'op': 'move', 'from': jsonpointer.JsonPointer('/foo'), 'path': jsonpointer.JsonPointer('/bar')}, + + ]) + doc = {} + result = patch.apply(doc) + expected = {'bar': 'bar', 'baz': [42]} + self.assertEqual(result, expected) + + + if __name__ == '__main__': modules = ['jsonpatch'] @@ -608,6 +629,7 @@ def get_suite(): suite.addTest(unittest.makeSuite(InvalidInputTests)) suite.addTest(unittest.makeSuite(ConflictTests)) suite.addTest(unittest.makeSuite(OptimizationTests)) + suite.addTest(unittest.makeSuite(JsonPointerTests)) return suite From e500b4d90e53468a0b51201ec1df51867ce67736 Mon Sep 17 00:00:00 2001 From: Brian Rosmaita Date: Mon, 4 Dec 2017 15:53:59 -0500 Subject: [PATCH 061/127] Remove extraneous 'value' field for op:remove (#76) RFC 6902 section 4.2 [0] does not define a 'value' field for the 'remove' operation. The commit "Merge _op_base classes into PatchOperation classes" [1] introduced a 'value' field in _item_removed() in the DiffBuilder class. This patch removes the 'value' field from the 'remove' operation, adds a new test, and revises some other tests. [0] https://tools.ietf.org/html/rfc6902#section-4.2 [1] https://github.com/stefankoegl/python-json-patch/commit/03aa14e8209d59522476726d55bfabf86a28929e --- jsonpatch.py | 1 - tests.py | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index cd08a82..291e876 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -730,7 +730,6 @@ def _item_removed(self, path, key, item): new_op = RemoveOperation({ 'op': 'remove', 'path': _path_join(path, key), - 'value': item, }) index = self.take_index(item, _ST_ADD) new_index = self.insert(new_op) diff --git a/tests.py b/tests.py index 29a8ae9..614d844 100755 --- a/tests.py +++ b/tests.py @@ -368,6 +368,17 @@ def test_issue40(self): dest = [7, 2, 1, 0, 9, 4, 3, 6, 5, 8] patch = jsonpatch.make_patch(src, dest) + def test_issue76(self): + """ Make sure op:remove does not include a 'value' field """ + + src = { "name": "fred", "friend": "barney", "spouse": "wilma" } + dst = { "name": "fred", "spouse": "wilma" } + expected = [{"path": "/friend", "op": "remove"}] + patch = jsonpatch.make_patch(src, dst) + self.assertEqual(patch.patch, expected) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + def test_json_patch(self): old = { 'queue': {'teams_out': [{'id': 3, 'reason': 'If tied'}, {'id': 5, 'reason': 'If tied'}]}, @@ -424,7 +435,7 @@ def test_use_replace_instead_of_remove_add_nested(self): dst = {'foo': [{'bar': 1}, {'bar': 2, 'baz': 3}]} patch = list(jsonpatch.make_patch(src, dst)) - exp = [{'op': 'remove', 'value': 2, 'path': '/foo/0/baz'}] + exp = [{'op': 'remove', 'path': '/foo/0/baz'}] self.assertEqual(patch, exp) res = jsonpatch.apply_patch(src, patch) @@ -481,11 +492,14 @@ def test_success_if_correct_expected_patch_appied(self): src = [{"a": 1, "b": 2}] dst = [{"b": 2, "c": 2}] exp = [ - {'path': '/0/a', 'op': 'remove', 'value': 1}, + {'path': '/0/a', 'op': 'remove'}, {'path': '/0/c', 'op': 'add', 'value': 2} ] patch = jsonpatch.make_patch(src, dst) self.assertEqual(patch.patch, exp) + # verify that this patch does what we expect + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) def test_minimal_patch(self): """ Test whether a minimal patch is created, see #36 """ From 4ff73987280269ea52bf7a0ae178b9dccae9786a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 30 Dec 2017 15:03:08 +0100 Subject: [PATCH 062/127] Bump version to 1.21 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 291e876..422812d 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -58,7 +58,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.20' +__version__ = '1.21' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 71bdeed8b49390ff14fe8f0434fc8a1038d89128 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 16 Jan 2018 14:22:33 +0200 Subject: [PATCH 063/127] Drop support for EOL Python 3.3 --- .travis.yml | 1 - doc/index.rst | 2 +- setup.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index fe615bf..12712a3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python python: - '2.7' -- '3.3' - '3.4' - '3.5' - '3.6' diff --git a/doc/index.rst b/doc/index.rst index cbae4ff..2f46921 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -7,7 +7,7 @@ python-json-patch ================= *python-json-patch* is a Python library for applying JSON patches (`RFC 6902 -`_). Python 2.7 and 3.3-3.6 are +`_). Python 2.7 and 3.4+ are supported. Tests are run on both CPython and PyPy. diff --git a/setup.py b/setup.py index 0776c41..471c433 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,6 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', @@ -81,5 +80,6 @@ package_data={'': ['requirements.txt']}, scripts=['bin/jsondiff', 'bin/jsonpatch'], classifiers=CLASSIFIERS, + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', **OPTIONS ) From d5a7aed0debac7f08265b0f1bf745bfaadc6e719 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 16 Jan 2018 14:24:29 +0200 Subject: [PATCH 064/127] Remove ununsed imports and variables --- bin/jsondiff | 1 - ext_tests.py | 1 - jsonpatch.py | 2 -- setup.py | 2 -- tests.py | 2 +- 5 files changed, 1 insertion(+), 7 deletions(-) diff --git a/bin/jsondiff b/bin/jsondiff index 54b4a61..b79188b 100755 --- a/bin/jsondiff +++ b/bin/jsondiff @@ -4,7 +4,6 @@ from __future__ import print_function import sys -import os.path import json import jsonpatch import argparse diff --git a/ext_tests.py b/ext_tests.py index 05576c6..2770c8e 100755 --- a/ext_tests.py +++ b/ext_tests.py @@ -34,7 +34,6 @@ """ Script to run external tests, eg from https://github.com/json-patch/json-patch-tests """ -from functools import partial import doctest import unittest import jsonpatch diff --git a/jsonpatch.py b/jsonpatch.py index 422812d..48dc2f8 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -37,8 +37,6 @@ import collections import copy import functools -import inspect -import itertools import json import sys diff --git a/setup.py b/setup.py index 471c433..1051ecc 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,8 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import sys import io import re -import warnings try: from setuptools import setup has_setuptools = True diff --git a/tests.py b/tests.py index 614d844..548d28b 100755 --- a/tests.py +++ b/tests.py @@ -366,7 +366,7 @@ def test_issue40(self): src = [8, 7, 2, 1, 0, 9, 4, 3, 5, 6] dest = [7, 2, 1, 0, 9, 4, 3, 6, 5, 8] - patch = jsonpatch.make_patch(src, dest) + jsonpatch.make_patch(src, dest) def test_issue76(self): """ Make sure op:remove does not include a 'value' field """ From 02fa1f8da30cf8d15257c95b4b00b94956e2d368 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 16 Jan 2018 14:27:53 +0200 Subject: [PATCH 065/127] Update badges and fix typos --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5bdf287..ff2b142 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,24 @@ -python-json-patch [![Build Status](https://secure.travis-ci.org/stefankoegl/python-json-patch.png?branch=master)](https://travis-ci.org/stefankoegl/python-json-patch) [![Coverage Status](https://coveralls.io/repos/stefankoegl/python-json-patch/badge.png?branch=master)](https://coveralls.io/r/stefankoegl/python-json-patch?branch=master) +python-json-patch ================= + +[![PyPI version](https://img.shields.io/pypi/v/jsonpatch.svg)](https://pypi.python.org/pypi/jsonpatch/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/jsonpatch.svg)](https://pypi.python.org/pypi/jsonpatch/) +[![Build Status](https://travis-ci.org/stefankoegl/python-json-patch.png?branch=master)](https://travis-ci.org/stefankoegl/python-json-patch) +[![Coverage Status](https://coveralls.io/repos/stefankoegl/python-json-patch/badge.png?branch=master)](https://coveralls.io/r/stefankoegl/python-json-patch?branch=master) + Applying JSON Patches in Python ------------------------------- Library to apply JSON Patches according to [RFC 6902](http://tools.ietf.org/html/rfc6902) -See Sourcecode for Examples +See source code for examples * Website: https://github.com/stefankoegl/python-json-patch * Repository: https://github.com/stefankoegl/python-json-patch.git * Documentation: https://python-json-patch.readthedocs.org/ * PyPI: https://pypi.python.org/pypi/jsonpatch -* Travis-CI: https://travis-ci.org/stefankoegl/python-json-patch +* Travis CI: https://travis-ci.org/stefankoegl/python-json-patch * Coveralls: https://coveralls.io/r/stefankoegl/python-json-patch Running external tests From 7b664c4dbe05e24abfc031850b9396d0b61296fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 11 Mar 2018 16:54:49 +0100 Subject: [PATCH 066/127] Add tests.js to MANIFEST.in, fixes #82 --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index d356d15..ca196b9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,3 +4,4 @@ include README.md include tests.py include ext_tests.py include AUTHORS +include tests.js From 7e55ad6a39de398eb83eb67431b28966e42aac36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Mon, 2 Apr 2018 10:36:40 +0200 Subject: [PATCH 067/127] Bump version to 1.22 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 48dc2f8..06e4f94 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -56,7 +56,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.21' +__version__ = '1.22' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 3c621da9b77ee57dcfd42887b460e1ffb66528b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Mon, 2 Apr 2018 10:53:35 +0200 Subject: [PATCH 068/127] Add project URLs to setup.py --- setup.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/setup.py b/setup.py index 1051ecc..1397f9a 100644 --- a/setup.py +++ b/setup.py @@ -79,5 +79,13 @@ scripts=['bin/jsondiff', 'bin/jsonpatch'], classifiers=CLASSIFIERS, python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + project_urls={ + 'Website': 'https://github.com/stefankoegl/python-json-patch', + 'Repository': 'https://github.com/stefankoegl/python-json-patch.git', + 'Documentation': "https://python-json-patch.readthedocs.org/", + 'PyPI': 'https://pypi.org/pypi/jsonpatch', + 'Tests': 'https://travis-ci.org/stefankoegl/python-json-patch', + 'Test Coverage': 'https://coveralls.io/r/stefankoegl/python-json-patch', + }, **OPTIONS ) From 36b245a3d7ce1631b0e48125dcd5d3b2e292b42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Mon, 2 Apr 2018 17:55:49 +0200 Subject: [PATCH 069/127] Bump version to 1.23 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 326608e..9d096a8 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -56,7 +56,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.22' +__version__ = '1.23' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 4c6f54747176717978f8798057e342d3f672f149 Mon Sep 17 00:00:00 2001 From: Guillaume Pujol Date: Tue, 17 Jul 2018 17:20:07 +0200 Subject: [PATCH 070/127] better exception when unable to fully resolve a jsonpointer on add or replace operations When a "path" jsonpointer in a add or replace operation points to a non-resolvable inner element, raise a JsonPatchConflict with a clear error message rather than a TypeError with an obscure message --- jsonpatch.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 326608e..4f8a02b 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -435,8 +435,10 @@ def apply(self, obj): subobj[part] = value else: - raise TypeError("invalid document type {0}".format(type(subobj))) - + if part is None: + raise TypeError("invalid document type {0}".format(type(subobj))) + else: + raise JsonPatchConflict("unable to fully resolve json pointer {0}, part {1}".format(self.location, part)) return obj def _on_undo_remove(self, path, key): @@ -480,7 +482,10 @@ def apply(self, obj): msg = "can't replace non-existent object '{0}'".format(part) raise JsonPatchConflict(msg) else: - raise TypeError("invalid document type {0}".format(type(subobj))) + if part is None: + raise TypeError("invalid document type {0}".format(type(subobj))) + else: + raise JsonPatchConflict("unable to fully resolve json pointer {0}, part {1}".format(self.location, part)) subobj[part] = value return obj From 041036376a301300ea3dc1a8d236bb61f8567bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Desv=C3=A9?= Date: Thu, 16 May 2019 14:50:00 +0200 Subject: [PATCH 071/127] Ensure an item is within the upper array boundaries before replacing it --- jsonpatch.py | 2 +- tests.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 4f8a02b..1b90bfd 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -474,7 +474,7 @@ def apply(self, obj): return value if isinstance(subobj, MutableSequence): - if part > len(subobj) or part < 0: + if part >= len(subobj) or part < 0: raise JsonPatchConflict("can't replace outside of list") elif isinstance(subobj, MutableMapping): diff --git a/tests.py b/tests.py index cd39b3b..6ecb017 100755 --- a/tests.py +++ b/tests.py @@ -602,6 +602,11 @@ def test_replace_oob(self): patch_obj = [ { "op": "replace", "path": "/foo/10", "value": 10} ] self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) + def test_replace_oob_length(self): + src = {"foo": [0, 1]} + patch_obj = [ { "op": "replace", "path": "/foo/2", "value": 2} ] + self.assertRaises(jsonpatch.JsonPatchConflict, jsonpatch.apply_patch, src, patch_obj) + def test_replace_missing(self): src = {"foo": 1} patch_obj = [ { "op": "replace", "path": "/bar", "value": 10} ] From 1b83d63088d0af707c4cb1361001ea3cf714a38a Mon Sep 17 00:00:00 2001 From: Igor Tkach Date: Fri, 28 Jun 2019 13:08:03 -0400 Subject: [PATCH 072/127] Fix move for numeric dictionary keys (issue #97) --- jsonpatch.py | 2 +- tests.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 4f8a02b..bf6a1b5 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -717,7 +717,7 @@ def _item_added(self, path, key, item): index = self.take_index(item, _ST_REMOVE) if index is not None: op = index[2] - if type(op.key) == int: + if type(op.key) == int and type(key) == int: for v in self.iter_from(index): op.key = v._on_undo_remove(op.path, op.key) diff --git a/tests.py b/tests.py index cd39b3b..07f923b 100755 --- a/tests.py +++ b/tests.py @@ -419,6 +419,15 @@ def test_nested(self): new_from_patch = jsonpatch.apply_patch(old, patch) self.assertEqual(new, new_from_patch) + def test_move_from_numeric_to_alpha_dict_key(self): + #https://github.com/stefankoegl/python-json-patch/issues/97 + src = {'13': 'x'} + dst = {'A': 'a', 'b': 'x'} + patch = jsonpatch.make_patch(src, dst) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + + class OptimizationTests(unittest.TestCase): def test_use_replace_instead_of_remove_add(self): From 53817c04a1c0e0cbaa47168c494c1d5b9bcdd94d Mon Sep 17 00:00:00 2001 From: Igor Tkach Date: Mon, 22 Jul 2019 15:14:25 -0400 Subject: [PATCH 073/127] bump version --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index d1c41ab..8ec01d3 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -56,7 +56,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.23' +__version__ = '1.24' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From abf27f29f22fb421a5cf36e6dbb9cd7b3d08dc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sun, 28 Jul 2019 17:57:42 +0200 Subject: [PATCH 074/127] Support Python 3.7, test with Python 3.8 dev --- .travis.yml | 4 +++- setup.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 12712a3..b6c5816 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,13 @@ +dist: xenial language: python python: - '2.7' - '3.4' - '3.5' - '3.6' -- 3.6-dev +- '3.7' - 3.7-dev +- 3.8-dev - nightly - pypy - pypy3 diff --git a/setup.py b/setup.py index 1397f9a..66f3319 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', From 2f2790bdbcaa285a46166e48b743ebd6c7c82828 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Sat, 9 Nov 2019 08:46:07 +0100 Subject: [PATCH 075/127] Travis CI: Add Python 3.8 production release --- .travis.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index b6c5816..6a02844 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,15 +2,18 @@ dist: xenial language: python python: - '2.7' -- '3.4' - '3.5' - '3.6' - '3.7' -- 3.7-dev +- '3.8' - 3.8-dev - nightly - pypy - pypy3 +addons: + apt: + packages: + - pandoc install: - travis_retry pip install -r requirements.txt - travis_retry pip install coveralls @@ -18,11 +21,6 @@ script: - coverage run --source=jsonpointer tests.py after_script: - coveralls -sudo: false -addons: - apt: - packages: - - pandoc before_deploy: - pip install -r requirements-dev.txt deploy: From 9ca2e2139e35b717f970a9679105cbdaed54636f Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Sat, 9 Nov 2019 08:48:19 +0100 Subject: [PATCH 076/127] fixup! setup.py: Add Python 3.8 and remove 3.4 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 66f3319..b01af80 100644 --- a/setup.py +++ b/setup.py @@ -56,10 +56,10 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', @@ -79,7 +79,7 @@ package_data={'': ['requirements.txt']}, scripts=['bin/jsondiff', 'bin/jsonpatch'], classifiers=CLASSIFIERS, - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', project_urls={ 'Website': 'https://github.com/stefankoegl/python-json-patch', 'Repository': 'https://github.com/stefankoegl/python-json-patch.git', From ed43114953c231e3feec715c87fa6c17b37cbdf9 Mon Sep 17 00:00:00 2001 From: Jonathan Hilliard Date: Tue, 28 Jan 2020 10:50:45 -0600 Subject: [PATCH 077/127] Test to reproduce: https://github.com/stefankoegl/python-json-patch/issues/90 https://github.com/stefankoegl/python-json-patch/issues/103 --- tests.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests.py b/tests.py index 07f923b..a9ec48a 100755 --- a/tests.py +++ b/tests.py @@ -427,6 +427,24 @@ def test_move_from_numeric_to_alpha_dict_key(self): res = jsonpatch.apply_patch(src, patch) self.assertEqual(res, dst) + def test_issue90(self): + """In JSON 1 is different from True even though in python 1 == True""" + src = {'A': 1} + dst = {'A': True} + patch = jsonpatch.make_patch(src, dst) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + self.assertIsInstance(res['A'], bool) + + def test_issue103(self): + """In JSON 1 is different from 1.0 even though in python 1 == 1.0""" + src = {'A': 1} + dst = {'A': 1.0} + patch = jsonpatch.make_patch(src, dst) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + self.assertIsInstance(res['A'], float) + class OptimizationTests(unittest.TestCase): From 028089d7e74f049a78bfea638c5b069491011e3f Mon Sep 17 00:00:00 2001 From: Jonathan Hilliard Date: Tue, 28 Jan 2020 10:54:04 -0600 Subject: [PATCH 078/127] Flagging type-only changes with DiffBuilder --- jsonpatch.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 8ec01d3..a72fcea 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -817,10 +817,7 @@ def _compare_lists(self, path, src, dst): self._item_added(path, key, dst[key]) def _compare_values(self, path, key, src, dst): - if src == dst: - return - - elif isinstance(src, MutableMapping) and \ + if isinstance(src, MutableMapping) and \ isinstance(dst, MutableMapping): self._compare_dicts(_path_join(path, key), src, dst) @@ -828,6 +825,9 @@ def _compare_values(self, path, key, src, dst): isinstance(dst, MutableSequence): self._compare_lists(_path_join(path, key), src, dst) + elif src == dst and type(src) == type(dst): + return + else: self._item_replaced(path, key, dst) From 97e18f38e03961c8984969214bdc082c2a4560e4 Mon Sep 17 00:00:00 2001 From: Jonathan Hilliard Date: Tue, 28 Jan 2020 10:56:41 -0600 Subject: [PATCH 079/127] Changing previous type comparison to just comparing how json.dumps renders 2 values. This avoids flagging mismatches between classes which happen to be different but which are JSONified as the same thing. --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index a72fcea..5454f56 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -825,7 +825,7 @@ def _compare_values(self, path, key, src, dst): isinstance(dst, MutableSequence): self._compare_lists(_path_join(path, key), src, dst) - elif src == dst and type(src) == type(dst): + elif json.dumps(src) == json.dumps(dst): return else: From c1fce712bea25d2fb33b843ccc8f4cd0fca7361d Mon Sep 17 00:00:00 2001 From: Jonathan Hilliard Date: Tue, 28 Jan 2020 15:06:11 -0600 Subject: [PATCH 080/127] Added comment. --- jsonpatch.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index 5454f56..83a04a5 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -825,6 +825,13 @@ def _compare_values(self, path, key, src, dst): isinstance(dst, MutableSequence): self._compare_lists(_path_join(path, key), src, dst) + # To ensure we catch changes to JSON, we can't rely on a simple + # src == dst, or it would not recognize the difference between + # 1 and True, among other things. Using json.dumps is the most + # fool-proof way to ensure we catch type changes that matter to JSON + # 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): return From e99d178396f69f8891a62e21434c2783b76146b2 Mon Sep 17 00:00:00 2001 From: Christian Lyder Jacobsen Date: Fri, 31 Jan 2020 11:58:59 +0100 Subject: [PATCH 081/127] Make it possible for from_diff to support custom types (issue #107) --- jsonpatch.py | 13 +++++++++---- tests.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 7f31ce5..ebaa8e3 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -258,7 +258,7 @@ def from_string(cls, patch_str): return cls(patch) @classmethod - def from_diff(cls, src, dst, optimization=True): + def from_diff(cls, src, dst, optimization=True, dumps=json.dumps): """Creates JsonPatch instance based on comparing of two document objects. Json patch would be created for `src` argument against `dst` one. @@ -269,6 +269,10 @@ def from_diff(cls, src, dst, optimization=True): :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 + :return: :class:`JsonPatch` instance. >>> src = {'foo': 'bar', 'numbers': [1, 3, 4, 8]} @@ -279,7 +283,7 @@ def from_diff(cls, src, dst, optimization=True): True """ - builder = DiffBuilder() + builder = DiffBuilder(dumps) builder._compare_values('', None, src, dst) ops = list(builder.execute()) return cls(ops) @@ -637,7 +641,8 @@ def apply(self, obj): class DiffBuilder(object): - def __init__(self): + def __init__(self, dumps): + self.dumps = dumps self.index_storage = [{}, {}] self.index_storage2 = [[], []] self.__root = root = [] @@ -832,7 +837,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: diff --git a/tests.py b/tests.py index cde90b0..8837bfa 100755 --- a/tests.py +++ b/tests.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import json +import decimal import doctest import unittest import jsonpatch @@ -445,6 +446,20 @@ def test_issue103(self): self.assertEqual(res, dst) self.assertIsInstance(res['A'], float) + def test_custom_types(self): + def default(obj): + if isinstance(obj, decimal.Decimal): + return str(obj) + raise TypeError('Unknown type') + + def dumps(obj): + return json.dumps(obj, default=default) + + old = {'value': decimal.Decimal('1.0')} + new = {'value': decimal.Decimal('1.00')} + patch = jsonpatch.JsonPatch.from_diff(old, new, dumps=dumps) + new_from_patch = jsonpatch.apply_patch(old, patch) + self.assertEqual(new, new_from_patch) class OptimizationTests(unittest.TestCase): From 8fbed9b38648f922bbbdb6984d0fd87b7227b149 Mon Sep 17 00:00:00 2001 From: vavanade Date: Thu, 27 Feb 2020 17:08:47 +0100 Subject: [PATCH 082/127] Fixed some typos and wording --- jsonpatch.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 7f31ce5..ca22e34 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -76,9 +76,9 @@ class InvalidJsonPatch(JsonPatchException): class JsonPatchConflict(JsonPatchException): """Raised if patch could not be applied due to conflict situation such as: - - attempt to add object key then it already exists; + - attempt to add object key when it already exists; - attempt to operate with nonexistence object key; - - attempt to insert value to array at position beyond of it size; + - attempt to insert value to array at position beyond its size; - etc. """ @@ -144,7 +144,7 @@ def apply_patch(doc, patch, in_place=False): def make_patch(src, dst): - """Generates patch by comparing of two document objects. Actually is + """Generates patch by comparing two document objects. Actually is a proxy to :meth:`JsonPatch.from_diff` method. :param src: Data source document object. @@ -181,8 +181,8 @@ class JsonPatch(object): >>> result == expected True - JsonPatch object is iterable, so you could easily access to each patch - statement in loop: + JsonPatch object is iterable, so you can easily access each patch + statement in a loop: >>> lpatch = list(patch) >>> expected = {'op': 'add', 'path': '/foo', 'value': 'bar'} @@ -259,7 +259,7 @@ def from_string(cls, patch_str): @classmethod def from_diff(cls, src, dst, optimization=True): - """Creates JsonPatch instance based on comparing of two document + """Creates JsonPatch instance based on comparison of two document objects. Json patch would be created for `src` argument against `dst` one. @@ -293,13 +293,13 @@ def _ops(self): return tuple(map(self._get_operation, self.patch)) def apply(self, obj, in_place=False): - """Applies the patch to given object. + """Applies the patch to a 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. + :param in_place: Tweaks the way how patch would be applied - directly to + specified `obj` or to its copy. :type in_place: bool :return: Modified `obj`. @@ -344,8 +344,8 @@ def __init__(self, operation): self.operation = operation def apply(self, obj): - """Abstract method that applies patch operation to specified object.""" - raise NotImplementedError('should implement patch operation.') + """Abstract method that applies a patch operation to the specified object.""" + raise NotImplementedError('should implement the patch operation.') def __hash__(self): return hash(frozenset(self.operation.items())) @@ -384,7 +384,7 @@ def apply(self, obj): try: del subobj[part] except (KeyError, IndexError) as ex: - msg = "can't remove non-existent object '{0}'".format(part) + msg = "can't remove a non-existent object '{0}'".format(part) raise JsonPatchConflict(msg) return obj @@ -459,7 +459,7 @@ def _on_undo_add(self, path, key): class ReplaceOperation(PatchOperation): - """Replaces an object property or an array element by new value.""" + """Replaces an object property or an array element by a new value.""" def apply(self, obj): try: @@ -479,7 +479,7 @@ def apply(self, obj): elif isinstance(subobj, MutableMapping): if part not in subobj: - msg = "can't replace non-existent object '{0}'".format(part) + msg = "can't replace a non-existent object '{0}'".format(part) raise JsonPatchConflict(msg) else: if part is None: @@ -498,7 +498,7 @@ def _on_undo_add(self, path, key): class MoveOperation(PatchOperation): - """Moves an object property or an array element to new location.""" + """Moves an object property or an array element to a new location.""" def apply(self, obj): try: @@ -522,7 +522,7 @@ def apply(self, obj): if isinstance(subobj, MutableMapping) and \ self.pointer.contains(from_ptr): - raise JsonPatchConflict('Cannot move values into its own children') + raise JsonPatchConflict('Cannot move values into their own children') obj = RemoveOperation({ 'op': 'remove', @@ -826,7 +826,7 @@ def _compare_values(self, path, key, src, dst): self._compare_lists(_path_join(path, key), src, dst) # To ensure we catch changes to JSON, we can't rely on a simple - # src == dst, or it would not recognize the difference between + # src == dst, because it would not recognize the difference between # 1 and True, among other things. Using json.dumps is the most # fool-proof way to ensure we catch type changes that matter to JSON # and ignore those that don't. The performance of this could be From 0167d345ee9d7ef0f74b947ec3a7ea94def178be Mon Sep 17 00:00:00 2001 From: Christian Lyder Jacobsen Date: Fri, 6 Mar 2020 11:41:38 +0100 Subject: [PATCH 083/127] Subclassing can override json dumper and loader Additionally: * from_string gets a loads parameter * to_string gets a dumps_parameter * documentation added * added more tests --- doc/tutorial.rst | 52 ++++++++++++++++++++++++++++++++++++++++++ jsonpatch.py | 22 ++++++++++++------ tests.py | 59 +++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 118 insertions(+), 15 deletions(-) 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 `_ 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 ebaa8e3..ce8a89f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -165,6 +165,9 @@ def make_patch(src, dst): class JsonPatch(object): + json_dumper = staticmethod(json.dumps) + json_loader = staticmethod(_jsonloads) + """A JSON Patch is a list of Patch Operations. >>> patch = JsonPatch([ @@ -246,19 +249,23 @@ def __ne__(self, other): return not(self == other) @classmethod - def from_string(cls, patch_str): + def from_string(cls, patch_str, loads=None): """Creates JsonPatch instance from string source. :param patch_str: JSON patch as raw string. :type patch_str: str + :param loads: A function of one argument that loads a serialized + JSON string. + :type loads: function :return: :class:`JsonPatch` instance. """ - patch = _jsonloads(patch_str) + json_loader = loads or cls.json_loader + patch = json_loader(patch_str) return cls(patch) @classmethod - def from_diff(cls, src, dst, optimization=True, dumps=json.dumps): + def from_diff(cls, src, dst, optimization=True, dumps=None): """Creates JsonPatch instance based on comparing of two document objects. Json patch would be created for `src` argument against `dst` one. @@ -282,15 +289,16 @@ def from_diff(cls, src, dst, optimization=True, dumps=json.dumps): >>> new == dst True """ - - builder = DiffBuilder(dumps) + json_dumper = dumps or cls.json_dumper + builder = DiffBuilder(json_dumper) builder._compare_values('', None, src, dst) ops = list(builder.execute()) return cls(ops) - 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): diff --git a/tests.py b/tests.py index 8837bfa..a843b35 100755 --- a/tests.py +++ b/tests.py @@ -268,6 +268,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): @@ -446,18 +474,33 @@ def test_issue103(self): self.assertEqual(res, dst) self.assertIsInstance(res['A'], float) - def test_custom_types(self): - def default(obj): - if isinstance(obj, decimal.Decimal): - return str(obj) - raise TypeError('Unknown type') + 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 dumps(obj): - return json.dumps(obj, default=default) + 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 = jsonpatch.JsonPatch.from_diff(old, new, dumps=dumps) + 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) From 29c989e815ade4aab25f42047c1ad003358b976d Mon Sep 17 00:00:00 2001 From: Christian Lyder Jacobsen Date: Mon, 16 Mar 2020 21:00:01 +0100 Subject: [PATCH 084/127] Make DiffBuilder's dumps argument optional --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 63dcd97..21714c7 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -649,7 +649,7 @@ def apply(self, obj): class DiffBuilder(object): - def __init__(self, dumps): + def __init__(self, dumps=json.dumps): self.dumps = dumps self.index_storage = [{}, {}] self.index_storage2 = [[], []] From 1015d7f35958f196478f32148656171f87358cde Mon Sep 17 00:00:00 2001 From: Alanscut Date: Sat, 23 May 2020 18:16:06 +0800 Subject: [PATCH 085/127] fix #111: optimizing exception message --- jsonpatch.py | 8 +++++++- tests.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index ca22e34..22e1156 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -334,12 +334,18 @@ class PatchOperation(object): def __init__(self, operation): + if not operation.__contains__('path'): + raise InvalidJsonPatch("Operation must have a 'path' member") + if isinstance(operation['path'], JsonPointer): self.location = operation['path'].path self.pointer = operation['path'] else: self.location = operation['path'] - self.pointer = JsonPointer(self.location) + try: + self.pointer = JsonPointer(self.location) + except TypeError as ex: + raise InvalidJsonPatch("Invalid 'path'") self.operation = operation diff --git a/tests.py b/tests.py index cde90b0..0abf4d2 100755 --- a/tests.py +++ b/tests.py @@ -219,6 +219,17 @@ def test_append(self): ]) self.assertEqual(res['foo'], [1, 2, 3, 4]) + def test_add_missing_path(self): + obj = {'bar': 'qux'} + self.assertRaises(jsonpatch.InvalidJsonPatch, + jsonpatch.apply_patch, + obj, [{'op': 'test', 'value': 'bar'}]) + + def test_path_with_null_value(self): + obj = {'bar': 'qux'} + self.assertRaises(jsonpatch.InvalidJsonPatch, + jsonpatch.apply_patch, + obj, '[{"op": "add", "path": null, "value": "bar"}]') class EqualityTestCase(unittest.TestCase): From 86f82becdc7f69a1153f2a7400117bed09ebd8c9 Mon Sep 17 00:00:00 2001 From: Alanscut Date: Tue, 9 Jun 2020 20:20:51 +0800 Subject: [PATCH 086/127] fix #102: optimize error handling --- jsonpatch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index ca22e34..e042ce2 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -473,6 +473,9 @@ def apply(self, obj): if part is None: return value + if part == "-": + raise InvalidJsonPatch("'path' with '-' can't be applied to 'replace' operation") + if isinstance(subobj, MutableSequence): if part >= len(subobj) or part < 0: raise JsonPatchConflict("can't replace outside of list") From ab775d187539c85cb7214905ad295358b240af14 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Fri, 13 Nov 2020 00:21:07 +0300 Subject: [PATCH 087/127] feat: add custom json pointer support --- jsonpatch.py | 76 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 7d5489a..c893bea 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -106,7 +106,7 @@ def multidict(ordered_pairs): _jsonloads = functools.partial(json.loads, object_pairs_hook=multidict) -def apply_patch(doc, patch, in_place=False): +def apply_patch(doc, patch, in_place=False, pointer_cls=JsonPointer): """Apply list of patches to specified json document. :param doc: Document object. @@ -137,13 +137,13 @@ def apply_patch(doc, patch, in_place=False): """ if isinstance(patch, basestring): - patch = JsonPatch.from_string(patch) + patch = JsonPatch.from_string(patch, pointer_cls=pointer_cls) else: - patch = JsonPatch(patch) + patch = JsonPatch(patch, pointer_cls=pointer_cls) return patch.apply(doc, in_place) -def make_patch(src, dst): +def make_patch(src, dst, pointer_cls=JsonPointer): """Generates patch by comparing two document objects. Actually is a proxy to :meth:`JsonPatch.from_diff` method. @@ -153,6 +153,9 @@ def make_patch(src, dst): :param dst: Data source document object. :type dst: dict + :param pointer_cls: JSON pointer (sub)class. + :type pointer_cls: Type[JsonPointer] + >>> src = {'foo': 'bar', 'numbers': [1, 3, 4, 8]} >>> dst = {'baz': 'qux', 'numbers': [1, 4, 7]} >>> patch = make_patch(src, dst) @@ -161,7 +164,7 @@ def make_patch(src, dst): True """ - return JsonPatch.from_diff(src, dst) + return JsonPatch.from_diff(src, dst, pointer_cls=pointer_cls) class JsonPatch(object): @@ -210,8 +213,9 @@ class JsonPatch(object): ... patch.apply(old) #doctest: +ELLIPSIS {...} """ - def __init__(self, patch): + def __init__(self, patch, pointer_cls=JsonPointer): self.patch = patch + self.pointer_cls = pointer_cls self.operations = { 'remove': RemoveOperation, @@ -246,19 +250,22 @@ def __ne__(self, other): return not(self == other) @classmethod - def from_string(cls, patch_str): + def from_string(cls, patch_str, pointer_cls=JsonPointer): """Creates JsonPatch instance from string source. :param patch_str: JSON patch as raw string. - :type patch_str: str + :type pointer_cls: str + + :param pointer_cls: JSON pointer (sub)class. + :type pointer_cls: Type[JsonPointer] :return: :class:`JsonPatch` instance. """ patch = _jsonloads(patch_str) - return cls(patch) + return cls(patch, pointer_cls=pointer_cls) @classmethod - def from_diff(cls, src, dst, optimization=True): + def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer): """Creates JsonPatch instance based on comparison of two document objects. Json patch would be created for `src` argument against `dst` one. @@ -269,6 +276,9 @@ def from_diff(cls, src, dst, optimization=True): :param dst: Data source document object. :type dst: dict + :param pointer_cls: JSON pointer (sub)class. + :type pointer_cls: Type[JsonPointer] + :return: :class:`JsonPatch` instance. >>> src = {'foo': 'bar', 'numbers': [1, 3, 4, 8]} @@ -279,10 +289,10 @@ def from_diff(cls, src, dst, optimization=True): True """ - builder = DiffBuilder() + builder = DiffBuilder(pointer_cls=pointer_cls) builder._compare_values('', None, src, dst) ops = list(builder.execute()) - return cls(ops) + return cls(ops, pointer_cls=pointer_cls) def to_string(self): """Returns patch set as JSON string.""" @@ -326,24 +336,25 @@ def _get_operation(self, operation): raise InvalidJsonPatch("Unknown operation {0!r}".format(op)) cls = self.operations[op] - return cls(operation) + return cls(operation, pointer_cls=self.pointer_cls) class PatchOperation(object): """A single operation inside a JSON Patch.""" - def __init__(self, operation): + def __init__(self, operation, pointer_cls=JsonPointer): + self.pointer_cls = pointer_cls if not operation.__contains__('path'): raise InvalidJsonPatch("Operation must have a 'path' member") - if isinstance(operation['path'], JsonPointer): + if isinstance(operation['path'], self.pointer_cls): self.location = operation['path'].path self.pointer = operation['path'] else: self.location = operation['path'] try: - self.pointer = JsonPointer(self.location) + self.pointer = self.pointer_cls(self.location) except TypeError as ex: raise InvalidJsonPatch("Invalid 'path'") @@ -511,10 +522,10 @@ class MoveOperation(PatchOperation): def apply(self, obj): try: - if isinstance(self.operation['from'], JsonPointer): + if isinstance(self.operation['from'], self.pointer_cls): from_ptr = self.operation['from'] else: - from_ptr = JsonPointer(self.operation['from']) + from_ptr = self.pointer_cls(self.operation['from']) except KeyError as ex: raise InvalidJsonPatch( "The operation does not contain a 'from' member") @@ -536,24 +547,24 @@ def apply(self, obj): obj = RemoveOperation({ 'op': 'remove', 'path': self.operation['from'] - }).apply(obj) + }, pointer_cls=self.pointer_cls).apply(obj) obj = AddOperation({ 'op': 'add', 'path': self.location, 'value': value - }).apply(obj) + }, pointer_cls=self.pointer_cls).apply(obj) return obj @property def from_path(self): - from_ptr = JsonPointer(self.operation['from']) + from_ptr = self.pointer_cls(self.operation['from']) return '/'.join(from_ptr.parts[:-1]) @property def from_key(self): - from_ptr = JsonPointer(self.operation['from']) + from_ptr = self.pointer_cls(self.operation['from']) try: return int(from_ptr.parts[-1]) except TypeError: @@ -561,7 +572,7 @@ def from_key(self): @from_key.setter def from_key(self, value): - from_ptr = JsonPointer(self.operation['from']) + from_ptr = self.pointer_cls(self.operation['from']) from_ptr.parts[-1] = str(value) self.operation['from'] = from_ptr.path @@ -624,7 +635,7 @@ class CopyOperation(PatchOperation): def apply(self, obj): try: - from_ptr = JsonPointer(self.operation['from']) + from_ptr = self.pointer_cls(self.operation['from']) except KeyError as ex: raise InvalidJsonPatch( "The operation does not contain a 'from' member") @@ -639,14 +650,15 @@ def apply(self, obj): 'op': 'add', 'path': self.location, 'value': value - }).apply(obj) + }, pointer_cls=self.pointer_cls).apply(obj) return obj class DiffBuilder(object): - def __init__(self): + def __init__(self, pointer_cls=JsonPointer): + self.pointer_cls = pointer_cls self.index_storage = [{}, {}] self.index_storage2 = [[], []] self.__root = root = [] @@ -715,7 +727,7 @@ def execute(self): 'op': 'replace', 'path': op_second.location, 'value': op_second.operation['value'], - }).operation + }, pointer_cls=self.pointer_cls).operation curr = curr[1][1] continue @@ -736,14 +748,14 @@ def _item_added(self, path, key, item): 'op': 'move', 'from': op.location, 'path': _path_join(path, key), - }) + }, pointer_cls=self.pointer_cls) self.insert(new_op) else: new_op = AddOperation({ 'op': 'add', 'path': _path_join(path, key), 'value': item, - }) + }, pointer_cls=self.pointer_cls) new_index = self.insert(new_op) self.store_index(item, new_index, _ST_ADD) @@ -751,7 +763,7 @@ def _item_removed(self, path, key, item): new_op = RemoveOperation({ 'op': 'remove', 'path': _path_join(path, key), - }) + }, pointer_cls=self.pointer_cls) index = self.take_index(item, _ST_ADD) new_index = self.insert(new_op) if index is not None: @@ -766,7 +778,7 @@ def _item_removed(self, path, key, item): 'op': 'move', 'from': new_op.location, 'path': op.location, - }) + }, pointer_cls=self.pointer_cls) new_index[2] = new_op else: @@ -780,7 +792,7 @@ def _item_replaced(self, path, key, item): 'op': 'replace', 'path': _path_join(path, key), 'value': item, - })) + }, pointer_cls=self.pointer_cls)) def _compare_dicts(self, path, src, dst): src_keys = set(src.keys()) From bb4ea7ba669b26d29f31ec75015d92fb6633f07b Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Fri, 13 Nov 2020 00:21:16 +0300 Subject: [PATCH 088/127] test: custo json pointer --- tests.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests.py b/tests.py index 0abf4d2..0402a03 100755 --- a/tests.py +++ b/tests.py @@ -671,6 +671,59 @@ def test_create_with_pointer(self): self.assertEqual(result, expected) +class CustomJsonPointerTests(unittest.TestCase): + + class CustomJsonPointer(jsonpointer.JsonPointer): + pass + + def test_apply_patch_from_string(self): + obj = {'foo': 'bar'} + patch = '[{"op": "add", "path": "/baz", "value": "qux"}]' + res = jsonpatch.apply_patch( + obj, patch, + pointer_cls=self.CustomJsonPointer, + ) + self.assertTrue(obj is not res) + self.assertTrue('baz' in res) + self.assertEqual(res['baz'], 'qux') + + def test_apply_patch_from_object(self): + obj = {'foo': 'bar'} + res = jsonpatch.apply_patch( + obj, [{'op': 'add', 'path': '/baz', 'value': 'qux'}], + pointer_cls=self.CustomJsonPointer, + ) + self.assertTrue(obj is not res) + + def test_make_patch(self): + src = {'foo': 'bar', 'boo': 'qux'} + dst = {'baz': 'qux', 'foo': 'boo'} + patch = jsonpatch.make_patch( + src, dst, pointer_cls=self.CustomJsonPointer, + ) + res = patch.apply(src) + self.assertTrue(src is not res) + self.assertEqual(patch.pointer_cls, self.CustomJsonPointer) + self.assertTrue(patch._ops) + for op in patch._ops: + self.assertEqual(op.pointer_cls, self.CustomJsonPointer) + + def test_operations(self): + patch = jsonpatch.JsonPatch([ + {'op': 'add', 'path': '/foo', 'value': [1, 2, 3]}, + {'op': 'move', 'path': '/baz', 'from': '/foo'}, + {'op': 'add', 'path': '/baz', 'value': [1, 2, 3]}, + {'op': 'remove', 'path': '/baz/1'}, + {'op': 'test', 'path': '/baz', 'value': [1, 3]}, + {'op': 'replace', 'path': '/baz/0', 'value': 42}, + {'op': 'remove', 'path': '/baz/1'}, + ], pointer_cls=self.CustomJsonPointer) + self.assertEqual(patch.apply({}), {'baz': [42]}) + self.assertEqual(patch.pointer_cls, self.CustomJsonPointer) + self.assertTrue(patch._ops) + for op in patch._ops: + self.assertEqual(op.pointer_cls, self.CustomJsonPointer) + if __name__ == '__main__': modules = ['jsonpatch'] @@ -687,6 +740,7 @@ def get_suite(): suite.addTest(unittest.makeSuite(ConflictTests)) suite.addTest(unittest.makeSuite(OptimizationTests)) suite.addTest(unittest.makeSuite(JsonPointerTests)) + suite.addTest(unittest.makeSuite(CustomJsonPointerTests)) return suite From 124eb76c09136aef56618e7347230f981edd51c3 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Fri, 13 Nov 2020 00:27:30 +0300 Subject: [PATCH 089/127] doc: fix docstrings --- jsonpatch.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index c893bea..92857ef 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -119,6 +119,9 @@ def apply_patch(doc, patch, in_place=False, pointer_cls=JsonPointer): By default patch will be applied to document copy. :type in_place: bool + :param pointer_cls: JSON pointer class to use. + :type pointer_cls: Type[JsonPointer] + :return: Patched document object. :rtype: dict @@ -153,7 +156,7 @@ def make_patch(src, dst, pointer_cls=JsonPointer): :param dst: Data source document object. :type dst: dict - :param pointer_cls: JSON pointer (sub)class. + :param pointer_cls: JSON pointer class to use. :type pointer_cls: Type[JsonPointer] >>> src = {'foo': 'bar', 'numbers': [1, 3, 4, 8]} @@ -256,7 +259,7 @@ def from_string(cls, patch_str, pointer_cls=JsonPointer): :param patch_str: JSON patch as raw string. :type pointer_cls: str - :param pointer_cls: JSON pointer (sub)class. + :param pointer_cls: JSON pointer class to use. :type pointer_cls: Type[JsonPointer] :return: :class:`JsonPatch` instance. @@ -276,7 +279,7 @@ def from_diff(cls, src, dst, optimization=True, pointer_cls=JsonPointer): :param dst: Data source document object. :type dst: dict - :param pointer_cls: JSON pointer (sub)class. + :param pointer_cls: JSON pointer class to use. :type pointer_cls: Type[JsonPointer] :return: :class:`JsonPatch` instance. From fb04fcc4df0e060586f6401b61af703d60bb6b65 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Sun, 15 Nov 2020 17:27:33 +0300 Subject: [PATCH 090/127] test: add more tests --- tests.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests.py b/tests.py index 0402a03..ec4758d 100755 --- a/tests.py +++ b/tests.py @@ -676,6 +676,28 @@ class CustomJsonPointerTests(unittest.TestCase): class CustomJsonPointer(jsonpointer.JsonPointer): pass + def test_json_patch_from_string(self): + patch = '[{"op": "add", "path": "/baz", "value": "qux"}]' + res = jsonpatch.JsonPatch.from_string( + patch, pointer_cls=self.CustomJsonPointer, + ) + self.assertEqual(res.pointer_cls, self.CustomJsonPointer) + + def test_json_patch_from_object(self): + patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}], + res = jsonpatch.JsonPatch( + patch, pointer_cls=self.CustomJsonPointer, + ) + self.assertEqual(res.pointer_cls, self.CustomJsonPointer) + + def test_json_patch_from_diff(self): + old = {'foo': 'bar'} + new = {'foo': 'baz'} + res = jsonpatch.JsonPatch.from_diff( + old, new, pointer_cls=self.CustomJsonPointer, + ) + self.assertEqual(res.pointer_cls, self.CustomJsonPointer) + def test_apply_patch_from_string(self): obj = {'foo': 'bar'} patch = '[{"op": "add", "path": "/baz", "value": "qux"}]' From 0b680ea87afc6e747fc584aaef513815de0c52c3 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Mon, 16 Nov 2020 10:53:52 +0300 Subject: [PATCH 091/127] chore: bump version --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 92857ef..201e9d1 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -56,7 +56,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.24' +__version__ = '1.27' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From c37b40ffec5674bf76bbb2197917e528e74b4552 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Mon, 16 Nov 2020 16:05:00 +0300 Subject: [PATCH 092/127] test: update --- tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests.py b/tests.py index ec4758d..941c685 100755 --- a/tests.py +++ b/tests.py @@ -728,6 +728,7 @@ def test_make_patch(self): self.assertEqual(patch.pointer_cls, self.CustomJsonPointer) self.assertTrue(patch._ops) for op in patch._ops: + self.assertIsInstance(op.pointer, self.CustomJsonPointer) self.assertEqual(op.pointer_cls, self.CustomJsonPointer) def test_operations(self): @@ -744,6 +745,7 @@ def test_operations(self): self.assertEqual(patch.pointer_cls, self.CustomJsonPointer) self.assertTrue(patch._ops) for op in patch._ops: + self.assertIsInstance(op.pointer, self.CustomJsonPointer) self.assertEqual(op.pointer_cls, self.CustomJsonPointer) From 0994bfe2ce199d6edccb4ab97fc10e3c26683348 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Tue, 17 Nov 2020 11:02:15 +0300 Subject: [PATCH 093/127] test: add toy jsonpointer example --- tests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests.py b/tests.py index 941c685..25c9e4f 100755 --- a/tests.py +++ b/tests.py @@ -748,6 +748,17 @@ def test_operations(self): self.assertIsInstance(op.pointer, self.CustomJsonPointer) self.assertEqual(op.pointer_cls, self.CustomJsonPointer) + class PrefixJsonPointer(jsonpointer.JsonPointer): + def __init__(self, pointer): + super().__init__('/foo/bar' + pointer) + + def test_json_patch_wtih_prefix_pointer(self): + res = jsonpatch.apply_patch( + {'foo': {'bar': {}}}, [{'op': 'add', 'path': '/baz', 'value': 'qux'}], + pointer_cls=self.PrefixJsonPointer, + ) + self.assertEqual(res, {'foo': {'bar': {'baz': 'qux'}}}) + if __name__ == '__main__': modules = ['jsonpatch'] From d24fa96a7ad1f01cb793c0efe835a76ddd3b2fc7 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Tue, 17 Nov 2020 11:28:45 +0300 Subject: [PATCH 094/127] test: fix for py27 --- tests.py | 50 ++++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/tests.py b/tests.py index 25c9e4f..2dfc18c 100755 --- a/tests.py +++ b/tests.py @@ -671,39 +671,45 @@ def test_create_with_pointer(self): self.assertEqual(result, expected) -class CustomJsonPointerTests(unittest.TestCase): +class CustomJsonPointer(jsonpointer.JsonPointer): + pass + + +class PrefixJsonPointer(jsonpointer.JsonPointer): + def __init__(self, pointer): + super(PrefixJsonPointer, self).__init__('/foo/bar' + pointer) - class CustomJsonPointer(jsonpointer.JsonPointer): - pass + +class CustomJsonPointerTests(unittest.TestCase): def test_json_patch_from_string(self): patch = '[{"op": "add", "path": "/baz", "value": "qux"}]' res = jsonpatch.JsonPatch.from_string( - patch, pointer_cls=self.CustomJsonPointer, + patch, pointer_cls=CustomJsonPointer, ) - self.assertEqual(res.pointer_cls, self.CustomJsonPointer) + self.assertEqual(res.pointer_cls, CustomJsonPointer) def test_json_patch_from_object(self): patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}], res = jsonpatch.JsonPatch( - patch, pointer_cls=self.CustomJsonPointer, + patch, pointer_cls=CustomJsonPointer, ) - self.assertEqual(res.pointer_cls, self.CustomJsonPointer) + self.assertEqual(res.pointer_cls, CustomJsonPointer) def test_json_patch_from_diff(self): old = {'foo': 'bar'} new = {'foo': 'baz'} res = jsonpatch.JsonPatch.from_diff( - old, new, pointer_cls=self.CustomJsonPointer, + old, new, pointer_cls=CustomJsonPointer, ) - self.assertEqual(res.pointer_cls, self.CustomJsonPointer) + self.assertEqual(res.pointer_cls, CustomJsonPointer) def test_apply_patch_from_string(self): obj = {'foo': 'bar'} patch = '[{"op": "add", "path": "/baz", "value": "qux"}]' res = jsonpatch.apply_patch( obj, patch, - pointer_cls=self.CustomJsonPointer, + pointer_cls=CustomJsonPointer, ) self.assertTrue(obj is not res) self.assertTrue('baz' in res) @@ -713,7 +719,7 @@ def test_apply_patch_from_object(self): obj = {'foo': 'bar'} res = jsonpatch.apply_patch( obj, [{'op': 'add', 'path': '/baz', 'value': 'qux'}], - pointer_cls=self.CustomJsonPointer, + pointer_cls=CustomJsonPointer, ) self.assertTrue(obj is not res) @@ -721,15 +727,15 @@ def test_make_patch(self): src = {'foo': 'bar', 'boo': 'qux'} dst = {'baz': 'qux', 'foo': 'boo'} patch = jsonpatch.make_patch( - src, dst, pointer_cls=self.CustomJsonPointer, + src, dst, pointer_cls=CustomJsonPointer, ) res = patch.apply(src) self.assertTrue(src is not res) - self.assertEqual(patch.pointer_cls, self.CustomJsonPointer) + self.assertEqual(patch.pointer_cls, CustomJsonPointer) self.assertTrue(patch._ops) for op in patch._ops: - self.assertIsInstance(op.pointer, self.CustomJsonPointer) - self.assertEqual(op.pointer_cls, self.CustomJsonPointer) + self.assertIsInstance(op.pointer, CustomJsonPointer) + self.assertEqual(op.pointer_cls, CustomJsonPointer) def test_operations(self): patch = jsonpatch.JsonPatch([ @@ -740,22 +746,18 @@ def test_operations(self): {'op': 'test', 'path': '/baz', 'value': [1, 3]}, {'op': 'replace', 'path': '/baz/0', 'value': 42}, {'op': 'remove', 'path': '/baz/1'}, - ], pointer_cls=self.CustomJsonPointer) + ], pointer_cls=CustomJsonPointer) self.assertEqual(patch.apply({}), {'baz': [42]}) - self.assertEqual(patch.pointer_cls, self.CustomJsonPointer) + self.assertEqual(patch.pointer_cls, CustomJsonPointer) self.assertTrue(patch._ops) for op in patch._ops: - self.assertIsInstance(op.pointer, self.CustomJsonPointer) - self.assertEqual(op.pointer_cls, self.CustomJsonPointer) - - class PrefixJsonPointer(jsonpointer.JsonPointer): - def __init__(self, pointer): - super().__init__('/foo/bar' + pointer) + self.assertIsInstance(op.pointer, CustomJsonPointer) + self.assertEqual(op.pointer_cls, CustomJsonPointer) def test_json_patch_wtih_prefix_pointer(self): res = jsonpatch.apply_patch( {'foo': {'bar': {}}}, [{'op': 'add', 'path': '/baz', 'value': 'qux'}], - pointer_cls=self.PrefixJsonPointer, + pointer_cls=PrefixJsonPointer, ) self.assertEqual(res, {'foo': {'bar': {'baz': 'qux'}}}) From 4d073929b732af3403ae9fac92433e0066f0061a Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Tue, 17 Nov 2020 11:29:23 +0300 Subject: [PATCH 095/127] style: fix typo --- tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests.py b/tests.py index 2dfc18c..c676121 100755 --- a/tests.py +++ b/tests.py @@ -754,7 +754,7 @@ def test_operations(self): self.assertIsInstance(op.pointer, CustomJsonPointer) self.assertEqual(op.pointer_cls, CustomJsonPointer) - def test_json_patch_wtih_prefix_pointer(self): + def test_json_patch_with_prefix_pointer(self): res = jsonpatch.apply_patch( {'foo': {'bar': {}}}, [{'op': 'add', 'path': '/baz', 'value': 'qux'}], pointer_cls=PrefixJsonPointer, From c9613e303531ce4a016b3a696992743e62e12258 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Tue, 17 Nov 2020 11:52:33 +0300 Subject: [PATCH 096/127] chore: revert version bump --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 201e9d1..92857ef 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -56,7 +56,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.27' +__version__ = '1.24' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From bfc0f5a68fc45a1335488c953fd055750528f16e Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Tue, 17 Nov 2020 08:13:36 -0500 Subject: [PATCH 097/127] Update coveragerc and require coverage. --- .coveragerc | 2 ++ requirements-dev.txt | 1 + 2 files changed, 3 insertions(+) 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/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 From a7ef7e80d0024b71794c22fd09e35389c04de964 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Tue, 17 Nov 2020 08:15:25 -0500 Subject: [PATCH 098/127] fix #110: Validate patch documents during creation. --- jsonpatch.py | 3 +++ tests.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index 7d5489a..022972b 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -222,6 +222,9 @@ def __init__(self, patch): 'copy': CopyOperation, } + for op in self.patch: + self._get_operation(op) + def __str__(self): """str(self) -> self.to_string()""" return self.to_string() diff --git a/tests.py b/tests.py index 0abf4d2..a7c1a43 100755 --- a/tests.py +++ b/tests.py @@ -671,6 +671,22 @@ 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'}]) + if __name__ == '__main__': modules = ['jsonpatch'] @@ -687,6 +703,7 @@ def get_suite(): suite.addTest(unittest.makeSuite(ConflictTests)) suite.addTest(unittest.makeSuite(OptimizationTests)) suite.addTest(unittest.makeSuite(JsonPointerTests)) + suite.addTest(unittest.makeSuite(JsonPatchCreationTest)) return suite From b44e7a2031ad5cbe0a0d5ad2ab0763b7b9b8dc25 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Tue, 17 Nov 2020 08:17:07 -0500 Subject: [PATCH 099/127] Add tests for operation doc structure. --- tests.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests.py b/tests.py index a7c1a43..58e3ce2 100755 --- a/tests.py +++ b/tests.py @@ -688,6 +688,69 @@ def test_creation_fails_with_invalid_patch(self): 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({}) + + if __name__ == '__main__': modules = ['jsonpatch'] @@ -704,6 +767,7 @@ def get_suite(): suite.addTest(unittest.makeSuite(OptimizationTests)) suite.addTest(unittest.makeSuite(JsonPointerTests)) suite.addTest(unittest.makeSuite(JsonPatchCreationTest)) + suite.addTest(unittest.makeSuite(UtilityMethodTests)) return suite From 50fb942e3500d84950ec9309f886f1952bd2fa25 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Tue, 17 Nov 2020 20:54:36 +0300 Subject: [PATCH 100/127] tests: moar --- tests.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests.py b/tests.py index c676121..7df2c2b 100755 --- a/tests.py +++ b/tests.py @@ -738,6 +738,44 @@ def test_make_patch(self): self.assertEqual(op.pointer_cls, CustomJsonPointer) def test_operations(self): + operations =[ + ( + jsonpatch.AddOperation, { + 'op': 'add', 'path': '/foo', 'value': [1, 2, 3] + } + ), + ( + jsonpatch.MoveOperation, { + 'op': 'move', 'path': '/baz', 'from': '/foo' + }, + ), + ( + jsonpatch.RemoveOperation, { + 'op': 'remove', 'path': '/baz/1' + }, + ), + ( + jsonpatch.TestOperation, { + 'op': 'test', 'path': '/baz', 'value': [1, 3] + }, + ), + ( + jsonpatch.ReplaceOperation, { + 'op': 'replace', 'path': '/baz/0', 'value': 42 + }, + ), + ( + jsonpatch.RemoveOperation, { + 'op': 'remove', 'path': '/baz/1' + }, + ) + ] + for cls, patch in operations: + operation = cls(patch, pointer_cls=CustomJsonPointer) + self.assertEqual(operation.pointer_cls, CustomJsonPointer) + self.assertIsInstance(operation.pointer, CustomJsonPointer) + + def test_operations_from_patch(self): patch = jsonpatch.JsonPatch([ {'op': 'add', 'path': '/foo', 'value': [1, 2, 3]}, {'op': 'move', 'path': '/baz', 'from': '/foo'}, From 3bb33518194b0cbc6e1512dbeb2ac5ef548d8c72 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Fri, 20 Nov 2020 07:22:03 -0500 Subject: [PATCH 101/127] Explain the call to _get_operation in __init__. --- jsonpatch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index 022972b..2b8678f 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -222,6 +222,10 @@ def __init__(self, patch): '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) From 3972a8e648b7d761b92ee53591fc24b9d805a90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Mon, 23 Nov 2020 19:44:43 +0000 Subject: [PATCH 102/127] Update Python to 3.9 --- .travis.yml | 3 ++- setup.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 6a02844..48f6882 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,8 @@ python: - '3.6' - '3.7' - '3.8' -- 3.8-dev +- '3.9' +- 3.10-dev - nightly - pypy - pypy3 diff --git a/setup.py b/setup.py index b01af80..ad43bd5 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', From b8083d703c3aacf52429a06dc5b482a1f9acf54f Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Mon, 23 Nov 2020 23:52:42 +0300 Subject: [PATCH 103/127] feat: make operations class-based --- jsonpatch.py | 395 ++++++++++++++++++++++++++------------------------- 1 file changed, 200 insertions(+), 195 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 5522d50..a01a177 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -39,6 +39,11 @@ import functools import json import sys +try: + from types import MappingProxyType +except ImportError: + # Python < 3.3 + MappingProxyType = dict from jsonpointer import JsonPointer, JsonPointerException @@ -170,201 +175,6 @@ def make_patch(src, dst, pointer_cls=JsonPointer): return JsonPatch.from_diff(src, dst, pointer_cls=pointer_cls) -class JsonPatch(object): - json_dumper = staticmethod(json.dumps) - json_loader = staticmethod(_jsonloads) - - """A JSON Patch is a list of Patch Operations. - - >>> patch = JsonPatch([ - ... {'op': 'add', 'path': '/foo', 'value': 'bar'}, - ... {'op': 'add', 'path': '/baz', 'value': [1, 2, 3]}, - ... {'op': 'remove', 'path': '/baz/1'}, - ... {'op': 'test', 'path': '/baz', 'value': [1, 3]}, - ... {'op': 'replace', 'path': '/baz/0', 'value': 42}, - ... {'op': 'remove', 'path': '/baz/1'}, - ... ]) - >>> doc = {} - >>> result = patch.apply(doc) - >>> expected = {'foo': 'bar', 'baz': [42]} - >>> result == expected - True - - JsonPatch object is iterable, so you can easily access each patch - statement in a loop: - - >>> lpatch = list(patch) - >>> expected = {'op': 'add', 'path': '/foo', 'value': 'bar'} - >>> lpatch[0] == expected - True - >>> 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, pointer_cls=JsonPointer): - self.patch = patch - self.pointer_cls = pointer_cls - - self.operations = { - 'remove': RemoveOperation, - 'add': AddOperation, - 'replace': ReplaceOperation, - 'move': MoveOperation, - 'test': TestOperation, - '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() - - def __bool__(self): - return bool(self.patch) - - __nonzero__ = __bool__ - - def __iter__(self): - return iter(self.patch) - - def __hash__(self): - return hash(tuple(self._ops)) - - def __eq__(self, other): - if not isinstance(other, JsonPatch): - return False - return self._ops == other._ops - - def __ne__(self, other): - return not(self == other) - - @classmethod - 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 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. - """ - 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, 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. - - :param src: Data source document object. - :type src: dict - - :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] - - :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 - """ - 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, dumps=None): - """Returns patch set as JSON string.""" - json_dumper = dumps or self.json_dumper - return json_dumper(self.patch) - - @property - def _ops(self): - return tuple(map(self._get_operation, self.patch)) - - def apply(self, obj, in_place=False): - """Applies the patch to a given object. - - :param obj: Document object. - :type obj: dict - - :param in_place: Tweaks the way how patch would be applied - directly to - specified `obj` or to its copy. - :type in_place: bool - - :return: Modified `obj`. - """ - - if not in_place: - obj = copy.deepcopy(obj) - - for operation in self._ops: - obj = operation.apply(obj) - - return obj - - def _get_operation(self, operation): - if 'op' not in operation: - raise InvalidJsonPatch("Operation does not contain 'op' member") - - op = operation['op'] - - if not isinstance(op, basestring): - raise InvalidJsonPatch("Operation must be a string") - - if op not in self.operations: - raise InvalidJsonPatch("Unknown operation {0!r}".format(op)) - - cls = self.operations[op] - return cls(operation, pointer_cls=self.pointer_cls) - - class PatchOperation(object): """A single operation inside a JSON Patch.""" @@ -681,6 +491,201 @@ def apply(self, obj): return obj +class JsonPatch(object): + json_dumper = staticmethod(json.dumps) + json_loader = staticmethod(_jsonloads) + + operations = MappingProxyType({ + 'remove': RemoveOperation, + 'add': AddOperation, + 'replace': ReplaceOperation, + 'move': MoveOperation, + 'test': TestOperation, + 'copy': CopyOperation, + }) + + """A JSON Patch is a list of Patch Operations. + + >>> patch = JsonPatch([ + ... {'op': 'add', 'path': '/foo', 'value': 'bar'}, + ... {'op': 'add', 'path': '/baz', 'value': [1, 2, 3]}, + ... {'op': 'remove', 'path': '/baz/1'}, + ... {'op': 'test', 'path': '/baz', 'value': [1, 3]}, + ... {'op': 'replace', 'path': '/baz/0', 'value': 42}, + ... {'op': 'remove', 'path': '/baz/1'}, + ... ]) + >>> doc = {} + >>> result = patch.apply(doc) + >>> expected = {'foo': 'bar', 'baz': [42]} + >>> result == expected + True + + JsonPatch object is iterable, so you can easily access each patch + statement in a loop: + + >>> lpatch = list(patch) + >>> expected = {'op': 'add', 'path': '/foo', 'value': 'bar'} + >>> lpatch[0] == expected + True + >>> 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, pointer_cls=JsonPointer): + self.patch = patch + self.pointer_cls = pointer_cls + + # 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() + + def __bool__(self): + return bool(self.patch) + + __nonzero__ = __bool__ + + def __iter__(self): + return iter(self.patch) + + def __hash__(self): + return hash(tuple(self._ops)) + + def __eq__(self, other): + if not isinstance(other, JsonPatch): + return False + return self._ops == other._ops + + def __ne__(self, other): + return not(self == other) + + @classmethod + 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 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. + """ + 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, 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. + + :param src: Data source document object. + :type src: dict + + :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] + + :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 + """ + 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, dumps=None): + """Returns patch set as JSON string.""" + json_dumper = dumps or self.json_dumper + return json_dumper(self.patch) + + @property + def _ops(self): + return tuple(map(self._get_operation, self.patch)) + + def apply(self, obj, in_place=False): + """Applies the patch to a given object. + + :param obj: Document object. + :type obj: dict + + :param in_place: Tweaks the way how patch would be applied - directly to + specified `obj` or to its copy. + :type in_place: bool + + :return: Modified `obj`. + """ + + if not in_place: + obj = copy.deepcopy(obj) + + for operation in self._ops: + obj = operation.apply(obj) + + return obj + + def _get_operation(self, operation): + if 'op' not in operation: + raise InvalidJsonPatch("Operation does not contain 'op' member") + + op = operation['op'] + + if not isinstance(op, basestring): + raise InvalidJsonPatch("Operation must be a string") + + if op not in self.operations: + raise InvalidJsonPatch("Unknown operation {0!r}".format(op)) + + cls = self.operations[op] + return cls(operation, pointer_cls=self.pointer_cls) + + class DiffBuilder(object): def __init__(self, dumps=json.dumps, pointer_cls=JsonPointer): From 1268e09ffaead08f22184b63b3ad34bb41ab8bab Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Mon, 23 Nov 2020 23:53:48 +0300 Subject: [PATCH 104/127] test: custom operations --- tests.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests.py b/tests.py index b5b7b9a..0788d48 100755 --- a/tests.py +++ b/tests.py @@ -10,6 +10,11 @@ import jsonpatch import jsonpointer import sys +try: + from types import MappingProxyType +except ImportError: + # Python < 3.3 + MappingProxyType = dict class ApplyPatchTestCase(unittest.TestCase): @@ -938,6 +943,26 @@ def test_json_patch_with_prefix_pointer(self): self.assertEqual(res, {'foo': {'bar': {'baz': 'qux'}}}) +class CustomOperationTests(unittest.TestCase): + + def test_custom_operation(self): + + class IdentityOperation(jsonpatch.PatchOperation): + def apply(self, obj): + return obj + + class JsonPatch(jsonpatch.JsonPatch): + operations = MappingProxyType( + identity=IdentityOperation, + **jsonpatch.JsonPatch.operations + ) + + patch = JsonPatch([{'op': 'identity', 'path': '/'}]) + self.assertIn('identity', patch.operations) + res = patch.apply({}) + self.assertEqual(res, {}) + + if __name__ == '__main__': modules = ['jsonpatch'] @@ -956,6 +981,7 @@ def get_suite(): suite.addTest(unittest.makeSuite(JsonPatchCreationTest)) suite.addTest(unittest.makeSuite(UtilityMethodTests)) suite.addTest(unittest.makeSuite(CustomJsonPointerTests)) + suite.addTest(unittest.makeSuite(CustomOperationTests)) return suite From 9310d48af5bfcde50f9b05fdd43deeafec11c805 Mon Sep 17 00:00:00 2001 From: Artyom Nikitin Date: Tue, 24 Nov 2020 23:20:35 +0300 Subject: [PATCH 105/127] test: fix --- tests.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests.py b/tests.py index 0788d48..28fde9b 100755 --- a/tests.py +++ b/tests.py @@ -953,8 +953,10 @@ def apply(self, obj): class JsonPatch(jsonpatch.JsonPatch): operations = MappingProxyType( - identity=IdentityOperation, - **jsonpatch.JsonPatch.operations + dict( + identity=IdentityOperation, + **jsonpatch.JsonPatch.operations + ) ) patch = JsonPatch([{'op': 'identity', 'path': '/'}]) From a9a83b5aae65db3007fef8a4015f46e6e59d69c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Tue, 1 Dec 2020 20:54:26 +0100 Subject: [PATCH 106/127] Bump version to 1.28 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index a01a177..84f6fb3 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -61,7 +61,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.24' +__version__ = '1.28' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 8d15ed5740027d5c0f295f82b347d963c77b8c5e Mon Sep 17 00:00:00 2001 From: Ryan Marvin Date: Mon, 1 Feb 2021 16:58:05 +0300 Subject: [PATCH 107/127] Fix make_patch --- jsonpatch.py | 9 ++++++--- tests.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 84f6fb3..14341d7 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -635,7 +635,7 @@ def from_diff( True """ json_dumper = dumps or cls.json_dumper - builder = DiffBuilder(json_dumper, pointer_cls=pointer_cls) + builder = DiffBuilder(src, dst, json_dumper, pointer_cls=pointer_cls) builder._compare_values('', None, src, dst) ops = list(builder.execute()) return cls(ops, pointer_cls=pointer_cls) @@ -688,12 +688,14 @@ def _get_operation(self, operation): class DiffBuilder(object): - def __init__(self, dumps=json.dumps, pointer_cls=JsonPointer): + def __init__(self, src_doc, dst_doc, dumps=json.dumps, pointer_cls=JsonPointer): self.dumps = dumps self.pointer_cls = pointer_cls self.index_storage = [{}, {}] self.index_storage2 = [[], []] self.__root = root = [] + self.src_doc = src_doc + self.dst_doc = dst_doc root[:] = [root, root, None] def store_index(self, value, index, st): @@ -800,7 +802,8 @@ def _item_removed(self, path, key, item): new_index = self.insert(new_op) if index is not None: op = index[2] - if type(op.key) == int: + added_item = op.pointer.to_last(self.dst_doc)[0] + if type(added_item) == list: for v in self.iter_from(index): op.key = v._on_undo_add(op.path, op.key) diff --git a/tests.py b/tests.py index 28fde9b..a56ffc0 100755 --- a/tests.py +++ b/tests.py @@ -490,6 +490,61 @@ def test_issue103(self): self.assertEqual(res, dst) self.assertIsInstance(res['A'], float) + def test_issue119(self): + """Make sure it avoids casting numeric str dict key to int""" + src = [ + {'foobar': {u'1': [u'lettuce', u'cabbage', u'bok choy', u'broccoli'], u'3': [u'ibex'], u'2': [u'apple'], u'5': [], u'4': [u'gerenuk', u'duiker'], u'10_1576156603109': [], u'6': [], u'8_1572034252560': [u'thompson', u'gravie', u'mango', u'coconut'], u'7_1572034204585': []}}, + {'foobar':{u'description': u'', u'title': u''}} + ] + dst = [ + {'foobar': {u'9': [u'almond'], u'10': u'yes', u'12': u'', u'16_1598876845275': [], u'7': [u'pecan']}}, + {'foobar': {u'1': [u'lettuce', u'cabbage', u'bok choy', u'broccoli'], u'3': [u'ibex'], u'2': [u'apple'], u'5': [], u'4': [u'gerenuk', u'duiker'], u'10_1576156603109': [], u'6': [], u'8_1572034252560': [u'thompson', u'gravie', u'mango', u'coconut'], u'7_1572034204585': []}}, + {'foobar': {u'description': u'', u'title': u''}} + ] + patch = jsonpatch.make_patch(src, dst) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + + def test_issue120(self): + """Make sure it avoids casting numeric str dict key to int""" + src = [{'foobar': {'821b7213_b9e6_2b73_2e9c_cf1526314553': ['Open Work'], + '6e3d1297_0c5a_88f9_576b_ad9216611c94': ['Many Things'], + '1987bcf0_dc97_59a1_4c62_ce33e51651c7': ['Product']}}, + {'foobar': {'2a7624e_0166_4d75_a92c_06b3f': []}}, + {'foobar': {'10': [], + '11': ['bee', + 'ant', + 'wasp'], + '13': ['phobos', + 'titan', + 'gaea'], + '14': [], + '15': 'run3', + '16': 'service', + '2': ['zero', 'enable']}}] + dst = [{'foobar': {'1': [], '2': []}}, + {'foobar': {'821b7213_b9e6_2b73_2e9c_cf1526314553': ['Open Work'], + '6e3d1297_0c5a_88f9_576b_ad9216611c94': ['Many Things'], + '1987bcf0_dc97_59a1_4c62_ce33e51651c7': ['Product']}}, + {'foobar': {'2a7624e_0166_4d75_a92c_06b3f': []}}, + {'foobar': {'b238d74d_dcf4_448c_9794_c13a2f7b3c0a': [], + 'dcb0387c2_f7ae_b8e5bab_a2b1_94deb7c': []}}, + {'foobar': {'10': [], + '11': ['bee', + 'ant', + 'fly'], + '13': ['titan', + 'phobos', + 'gaea'], + '14': [], + '15': 'run3', + '16': 'service', + '2': ['zero', 'enable']}} + ] + patch = jsonpatch.make_patch(src, dst) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + def test_custom_types_diff(self): old = {'value': decimal.Decimal('1.0')} new = {'value': decimal.Decimal('1.00')} From 78abec1651c4c3166d0eda4f9c0e43e00df57494 Mon Sep 17 00:00:00 2001 From: Ryan Marvin Date: Thu, 18 Feb 2021 18:25:25 +0300 Subject: [PATCH 108/127] Add comment --- jsonpatch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index 14341d7..7b895b7 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -802,6 +802,10 @@ def _item_removed(self, path, key, item): new_index = self.insert(new_op) if index is not None: op = index[2] + # We can't rely on the op.key property type since PatchOperation casts + # the .key property to int and this path wrongly ends up being taken + # for numeric string dict keys while the intention is to only handle lists. + # So we do an explicit check on the item affected by the op instead. added_item = op.pointer.to_last(self.dst_doc)[0] if type(added_item) == list: for v in self.iter_from(index): From f6b26b25805f1c01c3fae1495176cefac7d4a158 Mon Sep 17 00:00:00 2001 From: Ryan Marvin Date: Thu, 18 Feb 2021 18:27:21 +0300 Subject: [PATCH 109/127] Update comment --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 7b895b7..b4ff24b 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -802,7 +802,7 @@ def _item_removed(self, path, key, item): new_index = self.insert(new_op) if index is not None: op = index[2] - # We can't rely on the op.key property type since PatchOperation casts + # We can't rely on the op.key type since PatchOperation casts # the .key property to int and this path wrongly ends up being taken # for numeric string dict keys while the intention is to only handle lists. # So we do an explicit check on the item affected by the op instead. From dbea3db33298da4ec41197b07612c42580e132e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Tue, 2 Mar 2021 21:25:16 +0100 Subject: [PATCH 110/127] Fix version number v1.30 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index b4ff24b..429d40b 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -61,7 +61,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.28' +__version__ = '1.30' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 974d54f393de78ce21bee9897cee8f1ace5813ee Mon Sep 17 00:00:00 2001 From: Genzer Hawker Date: Wed, 3 Mar 2021 14:24:31 +0700 Subject: [PATCH 111/127] Add support for preserving Unicode characters in jsonpatch CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the JSON content contains some Unicode characters, the jsonpatch final output will encode the Unicode character using ASCII (i.e `\u0394`). This behaviour comes from the module `json.dump()` governed by a flag `ensure_ascii`[1]. For example: ```json /* patch.json */ [{ "op": "add", "path": "/SomeUnicodeSamples", "value": "𝒞𝘋𝙴𝓕ĢȞỈ𝕵 đ áê 🤩 äÄöÖüÜß" }] ``` After applying the patch on an empty source file `{}`, this is the output: ```json {"SomeUnicodeSamples": "\ud835\udc9e\ud835\ude0b...\u00fc\u00dc\u00df"} ``` This commit adds a flag `-u|--preserve-unicode` in the jsonpatch CLI to configure the behaviour of `json.dump`'s `ensure_ascii` flag. Using the `--preserve-unicode` flag, the cli will print the Unicode characters as-is without any encoding. [1]: https://docs.python.org/3/library/json.html#basic-usage --- bin/jsonpatch | 7 ++++--- doc/commandline.rst | 10 ++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/bin/jsonpatch b/bin/jsonpatch index 3f01738..a7adf29 100755 --- a/bin/jsonpatch +++ b/bin/jsonpatch @@ -24,7 +24,8 @@ parser.add_argument('-i', '--in-place', action='store_true', help='Modify ORIGINAL in-place instead of to stdout') parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + jsonpatch.__version__) - +parser.add_argument('-u', '--preserve-unicode', action='store_true', + help='Output Unicode character as-is without using Code Point') def main(): try: @@ -72,8 +73,8 @@ def patch_files(): # By this point we have some sort of file object we can write the # modified JSON to. - - json.dump(result, fp, indent=args.indent) + + json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode)) fp.write('\n') if args.in_place: diff --git a/doc/commandline.rst b/doc/commandline.rst index 5644d08..5fb9a3c 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -74,10 +74,12 @@ The program ``jsonpatch`` is used to apply JSON patches on JSON files. :: PATCH Patch file optional arguments: - -h, --help show this help message and exit - --indent INDENT Indent output by n spaces - -v, --version show program's version number and exit - + -h, --help show this help message and exit + --indent INDENT Indent output by n spaces + -b, --backup Back up ORIGINAL if modifying in-place + -i, --in-place Modify ORIGINAL in-place instead of to stdout + -v, --version show program's version number and exit + -u, --preserve-unicode Output Unicode character as-is without using Code Point Example ^^^^^^^ From 7a6d76ada4b990b1951831a42a08924de5775c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Thu, 4 Mar 2021 20:08:42 +0100 Subject: [PATCH 112/127] Remove failing pypy build --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 48f6882..865f8fa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,6 @@ python: - '3.9' - 3.10-dev - nightly -- pypy - pypy3 addons: apt: From d1cfec3187bc5e8b9e43127848107d7f4bf3dd80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Thu, 4 Mar 2021 20:13:29 +0100 Subject: [PATCH 113/127] Bump version to 1.31 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 429d40b..05d9a2b 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -61,7 +61,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.30' +__version__ = '1.31' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From 5cdb066ab6bfd0f28e7bd78a61f13bf4ff90d00d Mon Sep 17 00:00:00 2001 From: Bock Date: Thu, 11 Mar 2021 17:15:15 -0700 Subject: [PATCH 114/127] closes #129 --- jsonpatch.py | 14 ++++++++------ tests.py | 9 +++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 429d40b..bd7701b 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -699,27 +699,29 @@ def __init__(self, src_doc, dst_doc, dumps=json.dumps, pointer_cls=JsonPointer): root[:] = [root, root, None] def store_index(self, value, index, st): + typed_key = (value, type(value)) try: storage = self.index_storage[st] - stored = storage.get(value) + stored = storage.get(typed_key) if stored is None: - storage[value] = [index] + storage[typed_key] = [index] else: - storage[value].append(index) + storage[typed_key].append(index) except TypeError: - self.index_storage2[st].append((value, index)) + self.index_storage2[st].append((typed_key, index)) def take_index(self, value, st): + typed_key = (value, type(value)) try: - stored = self.index_storage[st].get(value) + stored = self.index_storage[st].get(typed_key) if stored: return stored.pop() except TypeError: storage = self.index_storage2[st] for i in range(len(storage)-1, -1, -1): - if storage[i][0] == value: + if storage[i][0] == typed_key: return storage.pop(i)[1] def insert(self, op): diff --git a/tests.py b/tests.py index a56ffc0..8a638cf 100755 --- a/tests.py +++ b/tests.py @@ -481,6 +481,15 @@ def test_issue90(self): self.assertEqual(res, dst) self.assertIsInstance(res['A'], bool) + def test_issue129(self): + """In JSON 1 is different from True even though in python 1 == True Take Two""" + src = {'A': {'D': 1.0}, 'B': {'E': 'a'}} + dst = {'A': {'C': 'a'}, 'B': {'C': True}} + patch = jsonpatch.make_patch(src, dst) + res = jsonpatch.apply_patch(src, patch) + self.assertEqual(res, dst) + self.assertIsInstance(res['B']['C'], bool) + def test_issue103(self): """In JSON 1 is different from 1.0 even though in python 1 == 1.0""" src = {'A': 1} From 55d4816975350ea3f683814f4d025951ddfb1693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Sat, 13 Mar 2021 20:14:39 +0100 Subject: [PATCH 115/127] Bump version to 1.32 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 3ffe5fb..5213b32 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -61,7 +61,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.31' +__version__ = '1.32' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From c9bfb91727690d6c7249b9250aba8942613f3f1c Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Tue, 16 Mar 2021 15:33:47 +0100 Subject: [PATCH 116/127] FIX: TypeError when one forgot to put its operation in a list. --- jsonpatch.py | 15 ++++++++++++++- tests.py | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index 5213b32..1bced46 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -558,6 +558,19 @@ def __init__(self, patch, pointer_cls=JsonPointer): # Much of the validation is done in the initializer # though some is delayed until the patch is applied. for op in self.patch: + # We're only checking for basestring in the following check + # for two reasons: + # + # - It should come from JSON, which only allows strings as + # dictionary keys, so having a string here unambiguously means + # someone used: {"op": ..., ...} instead of [{"op": ..., ...}]. + # + # - There's no possible false positive: if someone give a sequence + # of mappings, this won't raise. + if isinstance(op, basestring): + raise InvalidJsonPatch("Document is expected to be sequence of " + "operations, got a sequence of strings.") + self._get_operation(op) def __str__(self): @@ -677,7 +690,7 @@ def _get_operation(self, operation): op = operation['op'] if not isinstance(op, basestring): - raise InvalidJsonPatch("Operation must be a string") + raise InvalidJsonPatch("Operation's op must be a string") if op not in self.operations: raise InvalidJsonPatch("Unknown operation {0!r}".format(op)) diff --git a/tests.py b/tests.py index 8a638cf..797c220 100755 --- a/tests.py +++ b/tests.py @@ -190,6 +190,12 @@ def test_test_not_existing(self): obj, [{'op': 'test', 'path': '/baz', 'value': 'bar'}]) + def test_forgetting_surrounding_list(self): + obj = {'bar': 'qux'} + self.assertRaises(jsonpatch.InvalidJsonPatch, + jsonpatch.apply_patch, + obj, {'op': 'test', 'path': '/bar'}) + def test_test_noval_existing(self): obj = {'bar': 'qux'} self.assertRaises(jsonpatch.InvalidJsonPatch, From db194f820dee88e1a66a811a7a8653cce6965bc3 Mon Sep 17 00:00:00 2001 From: Vu-Hoang Phan Date: Tue, 6 Apr 2021 13:33:18 +0200 Subject: [PATCH 117/127] fix invalid remove index --- jsonpatch.py | 10 ++++++++++ tests.py | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/jsonpatch.py b/jsonpatch.py index 1bced46..238a6c9 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -39,6 +39,12 @@ import functools import json import sys + +try: + from collections.abc import Mapping, Sequence +except ImportError: # Python 3 + from collections import Mapping, Sequence + try: from types import MappingProxyType except ImportError: @@ -234,6 +240,10 @@ class RemoveOperation(PatchOperation): def apply(self, obj): subobj, part = self.pointer.to_last(obj) + + if isinstance(subobj, Sequence) and not isinstance(part, int): + raise JsonPointerException("invalid array index '{0}'".format(part)) + try: del subobj[part] except (KeyError, IndexError) as ex: diff --git a/tests.py b/tests.py index 797c220..d9eea92 100755 --- a/tests.py +++ b/tests.py @@ -87,6 +87,12 @@ def test_remove_array_item(self): res = jsonpatch.apply_patch(obj, [{'op': 'remove', 'path': '/foo/1'}]) self.assertEqual(res['foo'], ['bar', 'baz']) + def test_remove_invalid_item(self): + obj = {'foo': ['bar', 'qux', 'baz']} + with self.assertRaises(jsonpointer.JsonPointerException): + jsonpatch.apply_patch(obj, [{'op': 'remove', 'path': '/foo/-'}]) + + def test_replace_object_key(self): obj = {'foo': 'bar', 'baz': 'qux'} res = jsonpatch.apply_patch(obj, [{'op': 'replace', 'path': '/baz', 'value': 'boo'}]) From 46eef55d5170c08dd9513c86703b365f3d51db3c Mon Sep 17 00:00:00 2001 From: Vu-Hoang Phan Date: Tue, 6 Apr 2021 13:54:00 +0200 Subject: [PATCH 118/127] remove unused import --- jsonpatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 238a6c9..a4bd519 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -41,9 +41,9 @@ import sys try: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence except ImportError: # Python 3 - from collections import Mapping, Sequence + from collections import Sequence try: from types import MappingProxyType From 714df3c2102630a80691c4248b0b7babda5d128b Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Wed, 15 Sep 2021 04:10:27 +1000 Subject: [PATCH 119/127] docs: fix simple typo, raies -> raise (#135) --- ext_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext_tests.py b/ext_tests.py index 2770c8e..1fd8d8f 100755 --- a/ext_tests.py +++ b/ext_tests.py @@ -65,7 +65,7 @@ def _test(self, test): raise Exception(test.get('comment', '')) from jpe # if there is no 'expected' we only verify that applying the patch - # does not raies an exception + # does not raise an exception if 'expected' in test: self.assertEquals(res, test['expected'], test.get('comment', '')) From a76f742dcfb7a4b0fb0ab2bec4bb4e54a7ebb3ef Mon Sep 17 00:00:00 2001 From: Hiroshi Miura <6115787+hirmiura@users.noreply.github.com> Date: Sat, 17 Jun 2023 05:06:12 +0900 Subject: [PATCH 120/127] feat(jsondiff): Add support for preserving Unicode characters (#145) Mostly same as https://github.com/stefankoegl/python-json-patch/pull/127 --- bin/jsondiff | 4 +++- doc/commandline.rst | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bin/jsondiff b/bin/jsondiff index b79188b..5ac0090 100755 --- a/bin/jsondiff +++ b/bin/jsondiff @@ -14,6 +14,8 @@ parser.add_argument('FILE1', type=argparse.FileType('r')) parser.add_argument('FILE2', type=argparse.FileType('r')) parser.add_argument('--indent', type=int, default=None, help='Indent output by n spaces') +parser.add_argument('-u', '--preserve-unicode', action='store_true', + help='Output Unicode character as-is without using Code Point') parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + jsonpatch.__version__) @@ -32,7 +34,7 @@ def diff_files(): doc2 = json.load(args.FILE2) patch = jsonpatch.make_patch(doc1, doc2) if patch.patch: - print(json.dumps(patch.patch, indent=args.indent)) + print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not(args.preserve_unicode))) sys.exit(1) if __name__ == "__main__": diff --git a/doc/commandline.rst b/doc/commandline.rst index 5fb9a3c..a7a78d8 100644 --- a/doc/commandline.rst +++ b/doc/commandline.rst @@ -10,7 +10,7 @@ The JSON patch package contains the commandline utilities ``jsondiff`` and The program ``jsondiff`` can be used to create a JSON patch by comparing two JSON files :: - usage: jsondiff [-h] [--indent INDENT] [-v] FILE1 FILE2 + usage: jsondiff [-h] [--indent INDENT] [-u] [-v] FILE1 FILE2 Diff two JSON files @@ -19,9 +19,10 @@ JSON files :: FILE2 optional arguments: - -h, --help show this help message and exit - --indent INDENT Indent output by n spaces - -v, --version show program's version number and exit + -h, --help show this help message and exit + --indent INDENT Indent output by n spaces + -u, --preserve-unicode Output Unicode character as-is without using Code Point + -v, --version show program's version number and exit Example ^^^^^^^ From 33562b0d685ced527ee635ac14bf01fbe3c94ad0 Mon Sep 17 00:00:00 2001 From: Tim Poulsen Date: Fri, 16 Jun 2023 16:07:28 -0400 Subject: [PATCH 121/127] Update license text to match official 3-clause-BSD (#142) --- COPYING | 26 -------------------------- LICENSE | 11 +++++++++++ 2 files changed, 11 insertions(+), 26 deletions(-) delete mode 100644 COPYING create mode 100644 LICENSE diff --git a/COPYING b/COPYING deleted file mode 100644 index 491196d..0000000 --- a/COPYING +++ /dev/null @@ -1,26 +0,0 @@ -Copyright (c) 2011 Stefan Kögl -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. -3. The name of the author may not be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES -OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT -NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c8fc60f --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Copyright (c) 2011 Stefan Kögl + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 45cfe90c84985ac50f6c34d6124294c2f0898379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Fri, 16 Jun 2023 22:41:43 +0200 Subject: [PATCH 122/127] Switch to GitHub actions (#144) * Switch to GitHub actions * add support for Python 3.11, remove 3.5, 3.6 --- .github/workflows/test.yaml | 34 ++++++++++++++++++++++++++++++++++ .travis.yml | 33 --------------------------------- setup.py | 4 +--- 3 files changed, 35 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/test.yaml delete mode 100644 .travis.yml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..639e18d --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,34 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["2.7", "3.7", "3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install coveralls +# - name: Lint with flake8 +# run: | + # stop the build if there are Python syntax errors or undefined names + # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test + run: | + coverage run --source=jsonpointer tests.py diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 865f8fa..0000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -dist: xenial -language: python -python: -- '2.7' -- '3.5' -- '3.6' -- '3.7' -- '3.8' -- '3.9' -- 3.10-dev -- nightly -- pypy3 -addons: - apt: - packages: - - pandoc -install: -- travis_retry pip install -r requirements.txt -- travis_retry pip install coveralls -script: -- coverage run --source=jsonpointer tests.py -after_script: -- coveralls -before_deploy: -- pip install -r requirements-dev.txt -deploy: - provider: pypi - user: skoegl - password: - secure: ppMhKu82oIig1INyiNkt9veOd5FUUIKFUXj2TzxMSdzPtzAhQnScJMGPEtPfH8MwXng/CtJiDWS6zJzRFsW/3Ch+JHPkOtxOfkopBs1t1SpCyqNPSvf6Zxh83Dg6Bq6+8GyVW1RPuNIGflsvzY2C3z5i79FQXwZd8EQlg7Vu0Wo= - on: - tags: true - distributions: sdist bdist_wheel diff --git a/setup.py b/setup.py index ad43bd5..7753be1 100644 --- a/setup.py +++ b/setup.py @@ -56,8 +56,6 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', @@ -80,7 +78,7 @@ package_data={'': ['requirements.txt']}, scripts=['bin/jsondiff', 'bin/jsonpatch'], classifiers=CLASSIFIERS, - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*', project_urls={ 'Website': 'https://github.com/stefankoegl/python-json-patch', 'Repository': 'https://github.com/stefankoegl/python-json-patch.git', From 0b0520328504050ee09d835d4df294838e055c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Fri, 16 Jun 2023 22:43:04 +0200 Subject: [PATCH 123/127] bump version to 1.33 --- jsonpatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index a4bd519..d3fc26d 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -67,7 +67,7 @@ # Will be parsed by setup.py to determine package metadata __author__ = 'Stefan Kögl ' -__version__ = '1.32' +__version__ = '1.33' __website__ = 'https://github.com/stefankoegl/python-json-patch' __license__ = 'Modified BSD License' From e5a007a76998b1a2309d8c6cfc474d7ff4a870de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6gl?= Date: Fri, 16 Jun 2023 23:22:18 +0200 Subject: [PATCH 124/127] add .readthedocs.yaml https://blog.readthedocs.com/migrate-configuration-v2/ --- .readthedocs.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..b8c2f2b --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,22 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: doc/conf.py + +# We recommend specifying your dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt From 73c36f2c4776c008cd4e750f5240e06dfdc918fc Mon Sep 17 00:00:00 2001 From: Andrew Garrett Date: Wed, 28 Jun 2023 14:01:39 +1000 Subject: [PATCH 125/127] Update documentation to include a link to the GitHub repo and installation instructions (#147) --- doc/index.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/index.rst b/doc/index.rst index 2f46921..b97b82b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -6,10 +6,15 @@ python-json-patch ================= -*python-json-patch* is a Python library for applying JSON patches (`RFC 6902 +`python-json-patch `_ +is a Python library for applying JSON patches (`RFC 6902 `_). Python 2.7 and 3.4+ are supported. Tests are run on both CPython and PyPy. +**Installation** +.. code-block:: bash + $ pip install jsonpatch +.. **Contents** From a22e05a16d746c772802b51c57bdae55b6564723 Mon Sep 17 00:00:00 2001 From: konstantin Date: Sat, 24 Feb 2024 17:04:16 +0100 Subject: [PATCH 126/127] chore: add Python 3.10-3.12 as supported versions (#156) --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index 7753be1..ab9f32a 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,9 @@ 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', From d8e1a6e244728c04229d601bc9a384d9b034c603 Mon Sep 17 00:00:00 2001 From: CyrilRoelandteNovance Date: Mon, 5 Aug 2024 22:10:52 +0200 Subject: [PATCH 127/127] Fix tests for Python 3.12 (#162) unittest.TestCase.assertEquals has been removed in Python 3.12; unittest.TestCase.assertEqual should be used instead[1]. [1] https://docs.python.org/3/whatsnew/3.12.html#id3 --- ext_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext_tests.py b/ext_tests.py index 1fd8d8f..59a36d2 100755 --- a/ext_tests.py +++ b/ext_tests.py @@ -67,7 +67,7 @@ def _test(self, test): # if there is no 'expected' we only verify that applying the patch # does not raise an exception if 'expected' in test: - self.assertEquals(res, test['expected'], test.get('comment', '')) + self.assertEqual(res, test['expected'], test.get('comment', '')) def make_test_case(tests):