jsonpatch 1.37__tar.gz → 1.38__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jsonpatch-1.37/jsonpatch.egg-info → jsonpatch-1.38}/PKG-INFO +1 -1
- {jsonpatch-1.37 → jsonpatch-1.38/jsonpatch.egg-info}/PKG-INFO +1 -1
- {jsonpatch-1.37 → jsonpatch-1.38}/jsonpatch.py +30 -24
- {jsonpatch-1.37 → jsonpatch-1.38}/jsonpatch_cli.py +12 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/tests.py +84 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/AUTHORS +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/LICENSE +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/MANIFEST.in +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/README.md +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/ext_tests.py +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/jsonpatch.egg-info/SOURCES.txt +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/jsonpatch.egg-info/dependency_links.txt +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/jsonpatch.egg-info/entry_points.txt +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/jsonpatch.egg-info/requires.txt +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/jsonpatch.egg-info/top_level.txt +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/property_tests.py +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/pyproject.toml +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/pytest.ini +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/requirements.txt +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/setup.cfg +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/setup.py +0 -0
- {jsonpatch-1.37 → jsonpatch-1.38}/tests.js +0 -0
|
@@ -37,7 +37,8 @@ import collections
|
|
|
37
37
|
import copy
|
|
38
38
|
import functools
|
|
39
39
|
import json
|
|
40
|
-
from collections.abc import Mapping, MutableMapping, MutableSequence,
|
|
40
|
+
from collections.abc import (Mapping, MutableMapping, MutableSequence,
|
|
41
|
+
Sequence)
|
|
41
42
|
from difflib import SequenceMatcher
|
|
42
43
|
from types import MappingProxyType
|
|
43
44
|
|
|
@@ -54,7 +55,7 @@ _EXACT_MATCH_LIMIT = 500 ** 2
|
|
|
54
55
|
|
|
55
56
|
# Will be parsed by setup.py to determine package metadata
|
|
56
57
|
__author__ = 'Stefan Kögl <stefan@skoegl.net>'
|
|
57
|
-
__version__ = '1.
|
|
58
|
+
__version__ = '1.38'
|
|
58
59
|
__website__ = 'https://github.com/stefankoegl/python-json-patch'
|
|
59
60
|
__license__ = 'BSD-3-Clause'
|
|
60
61
|
|
|
@@ -100,13 +101,13 @@ _jsonloads = functools.partial(json.loads, object_pairs_hook=multidict)
|
|
|
100
101
|
|
|
101
102
|
|
|
102
103
|
def apply_patch(doc, patch, in_place=False, pointer_cls=JsonPointer):
|
|
103
|
-
"""Apply
|
|
104
|
+
"""Apply patch operations to the specified JSON document.
|
|
104
105
|
|
|
105
|
-
:param doc:
|
|
106
|
-
:type doc:
|
|
106
|
+
:param doc: JSON document, including arrays and scalar values.
|
|
107
|
+
:type doc: object
|
|
107
108
|
|
|
108
|
-
:param patch:
|
|
109
|
-
:type patch:
|
|
109
|
+
:param patch: Iterable of operation dicts or a raw JSON-encoded string.
|
|
110
|
+
:type patch: iterable or str
|
|
110
111
|
|
|
111
112
|
:param in_place: While ``True`` patch will modify target document.
|
|
112
113
|
By default patch will be applied to document copy.
|
|
@@ -115,8 +116,8 @@ def apply_patch(doc, patch, in_place=False, pointer_cls=JsonPointer):
|
|
|
115
116
|
:param pointer_cls: JSON pointer class to use.
|
|
116
117
|
:type pointer_cls: Type[JsonPointer]
|
|
117
118
|
|
|
118
|
-
:return:
|
|
119
|
-
:rtype:
|
|
119
|
+
:return: Resulting document, which may have a different type from ``doc``.
|
|
120
|
+
:rtype: object
|
|
120
121
|
|
|
121
122
|
>>> doc = {'foo': 'bar'}
|
|
122
123
|
>>> patch = [{'op': 'add', 'path': '/baz', 'value': 'qux'}]
|
|
@@ -145,11 +146,11 @@ def make_patch(src, dst, pointer_cls=JsonPointer):
|
|
|
145
146
|
transforms `src` into `dst`, so it has to be applied to `src` (or to a
|
|
146
147
|
document equal to it).
|
|
147
148
|
|
|
148
|
-
:param src:
|
|
149
|
-
:type src:
|
|
149
|
+
:param src: Source JSON document, including arrays and scalar values.
|
|
150
|
+
:type src: object
|
|
150
151
|
|
|
151
|
-
:param dst:
|
|
152
|
-
:type dst:
|
|
152
|
+
:param dst: Target JSON document, including arrays and scalar values.
|
|
153
|
+
:type dst: object
|
|
153
154
|
|
|
154
155
|
:param pointer_cls: JSON pointer class to use.
|
|
155
156
|
:type pointer_cls: Type[JsonPointer]
|
|
@@ -457,7 +458,11 @@ class CopyOperation(PatchOperation):
|
|
|
457
458
|
|
|
458
459
|
|
|
459
460
|
class JsonPatch(object):
|
|
460
|
-
"""A JSON Patch is
|
|
461
|
+
"""A JSON Patch is an iterable of Patch Operations.
|
|
462
|
+
|
|
463
|
+
Accepts sequences, iterators, generators, and other iterables of operation
|
|
464
|
+
dictionaries. Non-sequence inputs are materialized before validation so
|
|
465
|
+
the resulting patch can be applied repeatedly.
|
|
461
466
|
|
|
462
467
|
>>> patch = JsonPatch([
|
|
463
468
|
... {'op': 'add', 'path': '/foo', 'value': 'bar'},
|
|
@@ -515,13 +520,12 @@ class JsonPatch(object):
|
|
|
515
520
|
})
|
|
516
521
|
|
|
517
522
|
def __init__(self, patch, pointer_cls=JsonPointer):
|
|
518
|
-
self.patch = patch
|
|
519
523
|
self.pointer_cls = pointer_cls
|
|
520
524
|
|
|
521
525
|
if isinstance(patch, (str, bytes, Mapping)):
|
|
522
526
|
raise InvalidJsonPatch("Document is expected to be sequence of operations")
|
|
523
527
|
try:
|
|
524
|
-
|
|
528
|
+
self.patch = patch if isinstance(patch, Sequence) else list(patch)
|
|
525
529
|
except TypeError:
|
|
526
530
|
raise InvalidJsonPatch("Document is expected to be sequence of operations")
|
|
527
531
|
|
|
@@ -529,7 +533,7 @@ class JsonPatch(object):
|
|
|
529
533
|
# is correct by retrieving each patch element.
|
|
530
534
|
# Much of the validation is done in the initializer
|
|
531
535
|
# though some is delayed until the patch is applied.
|
|
532
|
-
for op in
|
|
536
|
+
for op in self.patch:
|
|
533
537
|
# We're only checking for strings in the following check
|
|
534
538
|
# for two reasons:
|
|
535
539
|
#
|
|
@@ -598,11 +602,11 @@ class JsonPatch(object):
|
|
|
598
602
|
one. The resulting patch transforms `src` into `dst`, so it has to be
|
|
599
603
|
applied to `src` (or to a document equal to it).
|
|
600
604
|
|
|
601
|
-
:param src:
|
|
602
|
-
:type src:
|
|
605
|
+
:param src: Source JSON document, including arrays and scalar values.
|
|
606
|
+
:type src: object
|
|
603
607
|
|
|
604
|
-
:param dst:
|
|
605
|
-
:type dst:
|
|
608
|
+
:param dst: Target JSON document, including arrays and scalar values.
|
|
609
|
+
:type dst: object
|
|
606
610
|
|
|
607
611
|
:param optimization: Generate move operations for matching values.
|
|
608
612
|
Set to False to use only add, remove and replace.
|
|
@@ -643,14 +647,16 @@ class JsonPatch(object):
|
|
|
643
647
|
def apply(self, obj, in_place=False):
|
|
644
648
|
"""Applies the patch to a given object.
|
|
645
649
|
|
|
646
|
-
:param obj:
|
|
647
|
-
:type obj:
|
|
650
|
+
:param obj: JSON document, including arrays and scalar values.
|
|
651
|
+
:type obj: object
|
|
648
652
|
|
|
649
653
|
:param in_place: Tweaks the way how patch would be applied - directly to
|
|
650
654
|
specified `obj` or to its copy.
|
|
651
655
|
:type in_place: bool
|
|
652
656
|
|
|
653
|
-
:return:
|
|
657
|
+
:return: Resulting document. Use this return value even with
|
|
658
|
+
``in_place=True``, because a root replacement returns a new
|
|
659
|
+
value rather than modifying the original container.
|
|
654
660
|
"""
|
|
655
661
|
|
|
656
662
|
if not in_place:
|
|
@@ -78,6 +78,7 @@ def patch_files():
|
|
|
78
78
|
args.ORIGINAL.close()
|
|
79
79
|
dirname = os.path.abspath(os.path.dirname(args.ORIGINAL.name))
|
|
80
80
|
|
|
81
|
+
fd = None
|
|
81
82
|
try:
|
|
82
83
|
# Attempt to replace the file atomically. We do this by
|
|
83
84
|
# creating a temporary file in the same directory as the
|
|
@@ -90,6 +91,17 @@ def patch_files():
|
|
|
90
91
|
atomic = True
|
|
91
92
|
|
|
92
93
|
except OSError:
|
|
94
|
+
# mkstemp may have succeeded before fdopen failed. Neither the
|
|
95
|
+
# descriptor nor the temporary file belongs to the fallback.
|
|
96
|
+
if fd is not None:
|
|
97
|
+
try:
|
|
98
|
+
os.close(fd)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
try:
|
|
102
|
+
os.unlink(pathname)
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
93
105
|
# We failed to create the temporary file for an atomic
|
|
94
106
|
# replace, so fall back to non-atomic mode by backing up
|
|
95
107
|
# the original (if desired) and writing a new file.
|
|
@@ -21,6 +21,51 @@ from unittest import mock
|
|
|
21
21
|
|
|
22
22
|
class ApplyPatchTestCase(unittest.TestCase):
|
|
23
23
|
|
|
24
|
+
def test_operation_iterators_remain_reusable_after_validation(self):
|
|
25
|
+
operations = [
|
|
26
|
+
{'op': 'add', 'path': '/a', 'value': 1},
|
|
27
|
+
{'op': 'replace', 'path': '/a', 'value': 2},
|
|
28
|
+
]
|
|
29
|
+
class OneShotIterable:
|
|
30
|
+
def __init__(self, ops):
|
|
31
|
+
self.iterator = iter(ops)
|
|
32
|
+
|
|
33
|
+
def __iter__(self):
|
|
34
|
+
return self.iterator
|
|
35
|
+
|
|
36
|
+
for name, factory in (
|
|
37
|
+
('iterator', iter),
|
|
38
|
+
('generator', lambda ops: (op for op in ops)),
|
|
39
|
+
('one-shot iterable', OneShotIterable),
|
|
40
|
+
):
|
|
41
|
+
with self.subTest(name=name):
|
|
42
|
+
patch = jsonpatch.JsonPatch(factory(operations))
|
|
43
|
+
self.assertEqual(list(patch), operations)
|
|
44
|
+
self.assertEqual(json.loads(patch.to_string()), operations)
|
|
45
|
+
self.assertEqual(patch.apply({}), {'a': 2})
|
|
46
|
+
self.assertEqual(patch.apply({}), {'a': 2})
|
|
47
|
+
|
|
48
|
+
def test_empty_operation_iterator_is_false(self):
|
|
49
|
+
patch = jsonpatch.JsonPatch(iter([]))
|
|
50
|
+
self.assertFalse(patch)
|
|
51
|
+
self.assertEqual(patch.to_string(), '[]')
|
|
52
|
+
|
|
53
|
+
def test_invalid_operation_iterator_is_rejected(self):
|
|
54
|
+
operations = iter([
|
|
55
|
+
{'op': 'add', 'path': '/a', 'value': 1},
|
|
56
|
+
{'op': 'not-an-operation', 'path': '/a'},
|
|
57
|
+
])
|
|
58
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
59
|
+
jsonpatch.JsonPatch(operations)
|
|
60
|
+
|
|
61
|
+
def test_sequence_operations_preserve_contents(self):
|
|
62
|
+
operations = [{'op': 'add', 'path': '/a', 'value': 1}]
|
|
63
|
+
for sequence in (operations, tuple(operations)):
|
|
64
|
+
with self.subTest(sequence=type(sequence).__name__):
|
|
65
|
+
patch = jsonpatch.JsonPatch(sequence)
|
|
66
|
+
self.assertEqual(list(patch), operations)
|
|
67
|
+
self.assertEqual(patch.apply({}), {'a': 1})
|
|
68
|
+
|
|
24
69
|
def test_diff_without_optimization_uses_basic_operations(self):
|
|
25
70
|
documents = [
|
|
26
71
|
({'old': {'value': 1}}, {'new': {'value': 1}}),
|
|
@@ -2114,6 +2159,45 @@ class CommandLineTests(unittest.TestCase):
|
|
|
2114
2159
|
self.assertEqual(self.read(doc), {'a': 1})
|
|
2115
2160
|
self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
|
|
2116
2161
|
|
|
2162
|
+
def test_jsonpatch_in_place_cleans_temp_on_fdopen_error(self):
|
|
2163
|
+
for backup in (False, True):
|
|
2164
|
+
with self.subTest(backup=backup):
|
|
2165
|
+
tmp = tempfile.TemporaryDirectory()
|
|
2166
|
+
self.addCleanup(tmp.cleanup)
|
|
2167
|
+
self.dir = tmp.name
|
|
2168
|
+
doc = self.write('doc.json', {'a': 1})
|
|
2169
|
+
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 2}])
|
|
2170
|
+
parser = jsonpatch_cli.jsonpatch_parser
|
|
2171
|
+
args = parser.parse_args(['-i', doc, patch] + (['-b'] if backup else []))
|
|
2172
|
+
self.addCleanup(args.ORIGINAL.close)
|
|
2173
|
+
self.addCleanup(args.PATCH.close)
|
|
2174
|
+
descriptors = []
|
|
2175
|
+
|
|
2176
|
+
def close_if_open(fd):
|
|
2177
|
+
try:
|
|
2178
|
+
os.close(fd)
|
|
2179
|
+
except OSError:
|
|
2180
|
+
pass
|
|
2181
|
+
|
|
2182
|
+
def fail_fdopen(fd, *args, **kwargs):
|
|
2183
|
+
descriptors.append(fd)
|
|
2184
|
+
self.addCleanup(close_if_open, fd)
|
|
2185
|
+
raise OSError('cannot wrap temporary descriptor')
|
|
2186
|
+
|
|
2187
|
+
with mock.patch.object(parser, 'parse_args', return_value=args), \
|
|
2188
|
+
mock.patch('os.fdopen', side_effect=fail_fdopen):
|
|
2189
|
+
jsonpatch_cli.patch_files()
|
|
2190
|
+
|
|
2191
|
+
self.assertEqual(self.read(doc), {'a': 2})
|
|
2192
|
+
if backup:
|
|
2193
|
+
self.assertEqual(self.read(doc + '.orig'), {'a': 1})
|
|
2194
|
+
with self.assertRaises(OSError):
|
|
2195
|
+
os.fstat(descriptors[0])
|
|
2196
|
+
expected = ['doc.json', 'patch.json']
|
|
2197
|
+
if backup:
|
|
2198
|
+
expected.append('doc.json.orig')
|
|
2199
|
+
self.assertEqual(sorted(os.listdir(self.dir)), sorted(expected))
|
|
2200
|
+
|
|
2117
2201
|
def test_jsonpatch_in_place_preserves_original_on_replace_error(self):
|
|
2118
2202
|
doc = self.write('doc.json', {'a': 1})
|
|
2119
2203
|
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 2}])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|