jsonpatch 1.36__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.36/jsonpatch.egg-info → jsonpatch-1.38}/PKG-INFO +1 -1
- {jsonpatch-1.36 → jsonpatch-1.38/jsonpatch.egg-info}/PKG-INFO +1 -1
- {jsonpatch-1.36 → jsonpatch-1.38}/jsonpatch.py +38 -25
- {jsonpatch-1.36 → jsonpatch-1.38}/jsonpatch_cli.py +36 -13
- {jsonpatch-1.36 → jsonpatch-1.38}/property_tests.py +9 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/tests.py +248 -6
- {jsonpatch-1.36 → jsonpatch-1.38}/AUTHORS +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/LICENSE +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/MANIFEST.in +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/README.md +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/ext_tests.py +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/jsonpatch.egg-info/SOURCES.txt +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/jsonpatch.egg-info/dependency_links.txt +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/jsonpatch.egg-info/entry_points.txt +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/jsonpatch.egg-info/requires.txt +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/jsonpatch.egg-info/top_level.txt +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/pyproject.toml +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/pytest.ini +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/requirements.txt +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/setup.cfg +0 -0
- {jsonpatch-1.36 → jsonpatch-1.38}/setup.py +0 -0
- {jsonpatch-1.36 → 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]
|
|
@@ -384,7 +385,7 @@ class MoveOperation(PatchOperation):
|
|
|
384
385
|
|
|
385
386
|
@property
|
|
386
387
|
def from_path(self):
|
|
387
|
-
from_ptr = self.
|
|
388
|
+
from_ptr = self._from_pointer()
|
|
388
389
|
return '/'.join(from_ptr.parts[:-1])
|
|
389
390
|
|
|
390
391
|
@property
|
|
@@ -396,14 +397,14 @@ class MoveOperation(PatchOperation):
|
|
|
396
397
|
self.set_from_part(-1, value)
|
|
397
398
|
|
|
398
399
|
def get_from_part(self, index):
|
|
399
|
-
from_ptr = self.
|
|
400
|
+
from_ptr = self._from_pointer()
|
|
400
401
|
try:
|
|
401
402
|
return int(from_ptr.parts[index])
|
|
402
403
|
except ValueError:
|
|
403
404
|
return from_ptr.parts[index]
|
|
404
405
|
|
|
405
406
|
def set_from_part(self, index, value):
|
|
406
|
-
from_ptr = self.
|
|
407
|
+
from_ptr = self._from_pointer()
|
|
407
408
|
from_ptr.parts[index] = str(value)
|
|
408
409
|
self.operation['from'] = from_ptr.path
|
|
409
410
|
|
|
@@ -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,9 +520,15 @@ 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
|
|
|
525
|
+
if isinstance(patch, (str, bytes, Mapping)):
|
|
526
|
+
raise InvalidJsonPatch("Document is expected to be sequence of operations")
|
|
527
|
+
try:
|
|
528
|
+
self.patch = patch if isinstance(patch, Sequence) else list(patch)
|
|
529
|
+
except TypeError:
|
|
530
|
+
raise InvalidJsonPatch("Document is expected to be sequence of operations")
|
|
531
|
+
|
|
521
532
|
# Verify that the structure of the patch document
|
|
522
533
|
# is correct by retrieving each patch element.
|
|
523
534
|
# Much of the validation is done in the initializer
|
|
@@ -591,11 +602,11 @@ class JsonPatch(object):
|
|
|
591
602
|
one. The resulting patch transforms `src` into `dst`, so it has to be
|
|
592
603
|
applied to `src` (or to a document equal to it).
|
|
593
604
|
|
|
594
|
-
:param src:
|
|
595
|
-
:type src:
|
|
605
|
+
:param src: Source JSON document, including arrays and scalar values.
|
|
606
|
+
:type src: object
|
|
596
607
|
|
|
597
|
-
:param dst:
|
|
598
|
-
:type dst:
|
|
608
|
+
:param dst: Target JSON document, including arrays and scalar values.
|
|
609
|
+
:type dst: object
|
|
599
610
|
|
|
600
611
|
:param optimization: Generate move operations for matching values.
|
|
601
612
|
Set to False to use only add, remove and replace.
|
|
@@ -636,14 +647,16 @@ class JsonPatch(object):
|
|
|
636
647
|
def apply(self, obj, in_place=False):
|
|
637
648
|
"""Applies the patch to a given object.
|
|
638
649
|
|
|
639
|
-
:param obj:
|
|
640
|
-
:type obj:
|
|
650
|
+
:param obj: JSON document, including arrays and scalar values.
|
|
651
|
+
:type obj: object
|
|
641
652
|
|
|
642
653
|
:param in_place: Tweaks the way how patch would be applied - directly to
|
|
643
654
|
specified `obj` or to its copy.
|
|
644
655
|
:type in_place: bool
|
|
645
656
|
|
|
646
|
-
: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.
|
|
647
660
|
"""
|
|
648
661
|
|
|
649
662
|
if not in_place:
|
|
@@ -12,8 +12,8 @@ import argparse
|
|
|
12
12
|
|
|
13
13
|
jsondiff_parser = argparse.ArgumentParser(
|
|
14
14
|
prog='jsondiff', description='Diff two JSON files')
|
|
15
|
-
jsondiff_parser.add_argument('FILE1', type=argparse.FileType('r'))
|
|
16
|
-
jsondiff_parser.add_argument('FILE2', type=argparse.FileType('r'))
|
|
15
|
+
jsondiff_parser.add_argument('FILE1', type=argparse.FileType('r', encoding='utf-8'))
|
|
16
|
+
jsondiff_parser.add_argument('FILE2', type=argparse.FileType('r', encoding='utf-8'))
|
|
17
17
|
jsondiff_parser.add_argument('--indent', type=int, default=None,
|
|
18
18
|
help='Indent output by n spaces')
|
|
19
19
|
jsondiff_parser.add_argument('-u', '--preserve-unicode', action='store_true',
|
|
@@ -43,9 +43,9 @@ def diff_files():
|
|
|
43
43
|
jsonpatch_parser = argparse.ArgumentParser(
|
|
44
44
|
prog='jsonpatch',
|
|
45
45
|
description='Apply a JSON patch on a JSON file')
|
|
46
|
-
jsonpatch_parser.add_argument('ORIGINAL', type=argparse.FileType('r'),
|
|
46
|
+
jsonpatch_parser.add_argument('ORIGINAL', type=argparse.FileType('r', encoding='utf-8'),
|
|
47
47
|
help='Original file')
|
|
48
|
-
jsonpatch_parser.add_argument('PATCH', type=argparse.FileType('r'),
|
|
48
|
+
jsonpatch_parser.add_argument('PATCH', type=argparse.FileType('r', encoding='utf-8'),
|
|
49
49
|
nargs='?', default=sys.stdin,
|
|
50
50
|
help='Patch file (read from stdin if omitted)')
|
|
51
51
|
jsonpatch_parser.add_argument('--indent', type=int, default=None,
|
|
@@ -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
|
|
@@ -86,17 +87,28 @@ def patch_files():
|
|
|
86
87
|
# because atomic renames do not work across mount points.)
|
|
87
88
|
|
|
88
89
|
fd, pathname = tempfile.mkstemp(dir=dirname)
|
|
89
|
-
fp = os.fdopen(fd, 'w')
|
|
90
|
+
fp = os.fdopen(fd, 'w', encoding='utf-8')
|
|
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.
|
|
96
108
|
|
|
97
109
|
if args.backup:
|
|
98
110
|
os.rename(args.ORIGINAL.name, args.ORIGINAL.name + '.orig')
|
|
99
|
-
fp = open(args.ORIGINAL.name, 'w')
|
|
111
|
+
fp = open(args.ORIGINAL.name, 'w', encoding='utf-8')
|
|
100
112
|
atomic = False
|
|
101
113
|
|
|
102
114
|
else:
|
|
@@ -108,15 +120,26 @@ def patch_files():
|
|
|
108
120
|
# By this point we have some sort of file object we can write the
|
|
109
121
|
# modified JSON to.
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
try:
|
|
124
|
+
json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode))
|
|
125
|
+
fp.write('\n')
|
|
126
|
+
if args.in_place:
|
|
127
|
+
fp.close()
|
|
128
|
+
except BaseException:
|
|
129
|
+
if args.in_place:
|
|
130
|
+
# Cleanup must not replace the original output error.
|
|
131
|
+
try:
|
|
132
|
+
fp.close()
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
if atomic:
|
|
136
|
+
try:
|
|
137
|
+
os.unlink(pathname)
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
raise
|
|
113
141
|
|
|
114
142
|
if args.in_place:
|
|
115
|
-
# Close the new file. If we aren't replacing atomically, this
|
|
116
|
-
# is our last step, since everything else is already in place.
|
|
117
|
-
|
|
118
|
-
fp.close()
|
|
119
|
-
|
|
120
143
|
if atomic:
|
|
121
144
|
try:
|
|
122
145
|
# Complete the atomic replace by linking the original
|
|
@@ -324,6 +324,15 @@ class MakePatchProperties(unittest.TestCase):
|
|
|
324
324
|
|
|
325
325
|
class ApplyPatchProperties(unittest.TestCase):
|
|
326
326
|
|
|
327
|
+
@given(st.one_of(json_scalars, st.dictionaries(json_keys, json_docs)))
|
|
328
|
+
@example(patch={})
|
|
329
|
+
@example(patch='')
|
|
330
|
+
def test_non_array_patch_documents_are_rejected(self, patch):
|
|
331
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
332
|
+
jsonpatch.JsonPatch(patch)
|
|
333
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
334
|
+
jsonpatch.JsonPatch.from_string(json.dumps(patch))
|
|
335
|
+
|
|
327
336
|
@given(docs_with_patches())
|
|
328
337
|
def test_does_not_modify_document(self, case):
|
|
329
338
|
doc, patch = case
|
|
@@ -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}}),
|
|
@@ -1533,6 +1578,35 @@ class JsonPointerTests(unittest.TestCase):
|
|
|
1533
1578
|
|
|
1534
1579
|
class JsonPatchCreationTest(unittest.TestCase):
|
|
1535
1580
|
|
|
1581
|
+
def test_creation_rejects_non_sequence_documents(self):
|
|
1582
|
+
for patch in ({}, {'op': 'add', 'path': '/a', 'value': 1},
|
|
1583
|
+
MappingProxyType({}), '', b'', None, True, False,
|
|
1584
|
+
0, 1, 1.5):
|
|
1585
|
+
with self.subTest(patch=patch):
|
|
1586
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
1587
|
+
jsonpatch.JsonPatch(patch)
|
|
1588
|
+
|
|
1589
|
+
def test_serialized_patch_rejects_non_array_documents(self):
|
|
1590
|
+
for patch in ({}, {'op': 'add', 'path': '/a', 'value': 1},
|
|
1591
|
+
'', 'text', None, True, False, 0, 1, 1.5):
|
|
1592
|
+
for serialized in (json.dumps(patch), json.dumps(patch).encode()):
|
|
1593
|
+
with self.subTest(patch=serialized):
|
|
1594
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
1595
|
+
jsonpatch.JsonPatch.from_string(serialized)
|
|
1596
|
+
doc = {'a': 'unchanged'}
|
|
1597
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
1598
|
+
jsonpatch.apply_patch(doc, serialized, in_place=True)
|
|
1599
|
+
self.assertEqual(doc, {'a': 'unchanged'})
|
|
1600
|
+
|
|
1601
|
+
def test_creation_preserves_operation_sequences(self):
|
|
1602
|
+
for operations in ([], (),
|
|
1603
|
+
[{'op': 'add', 'path': '/a', 'value': 1}],
|
|
1604
|
+
({'op': 'add', 'path': '/a', 'value': 1},)):
|
|
1605
|
+
with self.subTest(operations=operations):
|
|
1606
|
+
patch = jsonpatch.JsonPatch(operations)
|
|
1607
|
+
self.assertIs(patch.patch, operations)
|
|
1608
|
+
self.assertEqual(patch.apply({}), {'a': 1} if operations else {})
|
|
1609
|
+
|
|
1536
1610
|
def test_creation_fails_with_invalid_patch(self):
|
|
1537
1611
|
invalid_patches = [
|
|
1538
1612
|
{ 'path': '/foo', 'value': 'bar'},
|
|
@@ -1676,6 +1750,34 @@ class OperationLocationTests(unittest.TestCase):
|
|
|
1676
1750
|
self.assertEqual(operation, {'op': 'move', 'from': '/c~1d/0', 'path': '/b'})
|
|
1677
1751
|
self.assertEqual(op.apply({'c/d': ['x']}), {'c/d': [], 'b': 'x'})
|
|
1678
1752
|
|
|
1753
|
+
def test_from_path_with_pointer_object(self):
|
|
1754
|
+
for pointer_cls in (jsonpointer.JsonPointer, CustomJsonPointer):
|
|
1755
|
+
with self.subTest(pointer_cls=pointer_cls):
|
|
1756
|
+
op = jsonpatch.MoveOperation(
|
|
1757
|
+
{'op': 'move', 'from': pointer_cls('/a/1/b'), 'path': '/c'},
|
|
1758
|
+
pointer_cls=pointer_cls)
|
|
1759
|
+
self.assertEqual(op.from_path, 'a/1')
|
|
1760
|
+
|
|
1761
|
+
def test_from_parts_with_pointer_object(self):
|
|
1762
|
+
for pointer_cls in (jsonpointer.JsonPointer, CustomJsonPointer):
|
|
1763
|
+
with self.subTest(pointer_cls=pointer_cls):
|
|
1764
|
+
op = jsonpatch.MoveOperation(
|
|
1765
|
+
{'op': 'move', 'from': pointer_cls('/a/1/b'), 'path': '/c'},
|
|
1766
|
+
pointer_cls=pointer_cls)
|
|
1767
|
+
self.assertEqual(op.from_key, 'b')
|
|
1768
|
+
self.assertEqual(op.get_from_part(1), 1)
|
|
1769
|
+
|
|
1770
|
+
def test_set_from_key_with_pointer_object(self):
|
|
1771
|
+
for pointer_cls in (jsonpointer.JsonPointer, CustomJsonPointer):
|
|
1772
|
+
with self.subTest(pointer_cls=pointer_cls):
|
|
1773
|
+
operation = {'op': 'move', 'from': pointer_cls('/a/1'),
|
|
1774
|
+
'path': '/b'}
|
|
1775
|
+
op = jsonpatch.MoveOperation(operation, pointer_cls=pointer_cls)
|
|
1776
|
+
op.from_key = 0
|
|
1777
|
+
self.assertEqual(operation['from'], '/a/0')
|
|
1778
|
+
self.assertEqual(op.apply({'a': ['x', 'y']}),
|
|
1779
|
+
{'a': ['y'], 'b': 'x'})
|
|
1780
|
+
|
|
1679
1781
|
|
|
1680
1782
|
class CustomJsonPointer(jsonpointer.JsonPointer):
|
|
1681
1783
|
pass
|
|
@@ -1836,28 +1938,35 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1836
1938
|
self.addCleanup(tmp.cleanup)
|
|
1837
1939
|
self.dir = tmp.name
|
|
1838
1940
|
|
|
1839
|
-
def write(self, name, value):
|
|
1941
|
+
def write(self, name, value, ensure_ascii=True):
|
|
1840
1942
|
path = os.path.join(self.dir, name)
|
|
1841
1943
|
with open(path, 'w', encoding='utf-8') as f:
|
|
1842
|
-
json.dump(value, f)
|
|
1944
|
+
json.dump(value, f, ensure_ascii=ensure_ascii)
|
|
1843
1945
|
return path
|
|
1844
1946
|
|
|
1845
1947
|
def read(self, path):
|
|
1846
1948
|
with open(path, encoding='utf-8') as f:
|
|
1847
1949
|
return json.load(f)
|
|
1848
1950
|
|
|
1849
|
-
def
|
|
1951
|
+
def run_python(self, code, *args, stdin='', utf8=True):
|
|
1850
1952
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
1851
1953
|
# the scripts have to import this jsonpatch, not an installed one
|
|
1852
1954
|
pythonpath = [here] + [p for p in [os.environ.get('PYTHONPATH')] if p]
|
|
1853
1955
|
env = dict(os.environ, PYTHONPATH=os.pathsep.join(pythonpath),
|
|
1854
1956
|
PYTHONUTF8='1')
|
|
1957
|
+
if not utf8:
|
|
1958
|
+
# Exercise locale-dependent file I/O while keeping pipes in UTF-8.
|
|
1959
|
+
env.update(PYTHONUTF8='0', PYTHONIOENCODING='utf-8',
|
|
1960
|
+
LC_ALL='C', PYTHONCOERCECLOCALE='0')
|
|
1855
1961
|
return subprocess.run(
|
|
1856
|
-
[sys.executable, '-c',
|
|
1857
|
-
'import jsonpatch_cli; jsonpatch_cli.{0}_main()'.format(script)]
|
|
1858
|
-
+ list(args),
|
|
1962
|
+
[sys.executable, '-c', code] + list(args),
|
|
1859
1963
|
input=stdin, capture_output=True, encoding='utf-8', env=env)
|
|
1860
1964
|
|
|
1965
|
+
def run_script(self, script, *args, stdin='', utf8=True):
|
|
1966
|
+
return self.run_python(
|
|
1967
|
+
'import jsonpatch_cli; jsonpatch_cli.{0}_main()'.format(script),
|
|
1968
|
+
*args, stdin=stdin, utf8=utf8)
|
|
1969
|
+
|
|
1861
1970
|
def test_jsondiff(self):
|
|
1862
1971
|
src = self.write('a.json', {'a': [1, 2], 'b': 0})
|
|
1863
1972
|
dst = self.write('b.json', {'a': [1, 2, 3], 'c': 100})
|
|
@@ -1887,6 +1996,18 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1887
1996
|
self.assertIn('"\\u00e4"', self.run_script('jsondiff', src, dst).stdout)
|
|
1888
1997
|
self.assertIn('"ä"', self.run_script('jsondiff', '-u', src, dst).stdout)
|
|
1889
1998
|
|
|
1999
|
+
def test_jsondiff_utf8_files_without_utf8_mode(self):
|
|
2000
|
+
src = self.write('a.json', {'a': 'old', 'unchanged': 'ä'},
|
|
2001
|
+
ensure_ascii=False)
|
|
2002
|
+
dst = self.write('b.json', {'a': '🚀', 'unchanged': 'ä'},
|
|
2003
|
+
ensure_ascii=False)
|
|
2004
|
+
res = self.run_script('jsondiff', src, dst, utf8=False)
|
|
2005
|
+
self.assertEqual(res.returncode, 1, res.stderr)
|
|
2006
|
+
patch = json.loads(res.stdout)
|
|
2007
|
+
self.assertEqual(patch, [{'op': 'replace', 'path': '/a', 'value': '🚀'}])
|
|
2008
|
+
self.assertEqual(jsonpatch.apply_patch(self.read(src), patch),
|
|
2009
|
+
self.read(dst))
|
|
2010
|
+
|
|
1890
2011
|
def test_jsonpatch(self):
|
|
1891
2012
|
doc = self.write('doc.json', {'a': [1, 2], 'b': 0})
|
|
1892
2013
|
patch = self.write('patch.json', [{'op': 'add', 'path': '/a/-', 'value': 3},
|
|
@@ -1903,6 +2024,17 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1903
2024
|
self.assertEqual(res.returncode, 0, res.stderr)
|
|
1904
2025
|
self.assertEqual(json.loads(res.stdout), {'a': 2})
|
|
1905
2026
|
|
|
2027
|
+
def test_jsonpatch_utf8_files_without_utf8_mode(self):
|
|
2028
|
+
doc = self.write('doc.json', {'a': 'ä'}, ensure_ascii=False)
|
|
2029
|
+
patch = self.write('patch.json', [
|
|
2030
|
+
{'op': 'test', 'path': '/a', 'value': 'ä'},
|
|
2031
|
+
{'op': 'replace', 'path': '/a', 'value': '🚀'},
|
|
2032
|
+
], ensure_ascii=False)
|
|
2033
|
+
res = self.run_script('jsonpatch', doc, patch, utf8=False)
|
|
2034
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
2035
|
+
self.assertEqual(json.loads(res.stdout), {'a': '🚀'})
|
|
2036
|
+
self.assertEqual(self.read(doc), {'a': 'ä'})
|
|
2037
|
+
|
|
1906
2038
|
def test_jsonpatch_indent_preserve_unicode(self):
|
|
1907
2039
|
doc = self.write('doc.json', {'a': 'x'})
|
|
1908
2040
|
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 'ä'}])
|
|
@@ -1920,6 +2052,28 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1920
2052
|
# the temporary file has been moved over the original
|
|
1921
2053
|
self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
|
|
1922
2054
|
|
|
2055
|
+
def test_jsonpatch_in_place_utf8_without_utf8_mode(self):
|
|
2056
|
+
for atomic in (True, False):
|
|
2057
|
+
with self.subTest(atomic=atomic):
|
|
2058
|
+
doc = self.write('doc.json', {'a': 'old'})
|
|
2059
|
+
patch = self.write('patch.json', [
|
|
2060
|
+
{'op': 'replace', 'path': '/a', 'value': '🚀'},
|
|
2061
|
+
])
|
|
2062
|
+
if atomic:
|
|
2063
|
+
code = 'import jsonpatch_cli; jsonpatch_cli.jsonpatch_main()'
|
|
2064
|
+
else:
|
|
2065
|
+
code = (
|
|
2066
|
+
'from unittest import mock\n'
|
|
2067
|
+
'import jsonpatch_cli\n'
|
|
2068
|
+
'with mock.patch("tempfile.mkstemp", side_effect=OSError):\n'
|
|
2069
|
+
' jsonpatch_cli.jsonpatch_main()\n'
|
|
2070
|
+
)
|
|
2071
|
+
res = self.run_python(code, '-i', '-u', doc, patch, utf8=False)
|
|
2072
|
+
self.assertEqual((res.returncode, res.stdout), (0, ''), res.stderr)
|
|
2073
|
+
self.assertEqual(self.read(doc), {'a': '🚀'})
|
|
2074
|
+
self.assertEqual(sorted(os.listdir(self.dir)),
|
|
2075
|
+
['doc.json', 'patch.json'])
|
|
2076
|
+
|
|
1923
2077
|
# 2026-10-11: Windows cannot replace the original while its input is open.
|
|
1924
2078
|
def test_jsonpatch_in_place_closes_original_before_replace(self):
|
|
1925
2079
|
doc = self.write('doc.json', {'a': 1})
|
|
@@ -1956,6 +2110,94 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1956
2110
|
self.assertEqual(self.read(doc), {'a': 2})
|
|
1957
2111
|
self.assertEqual(self.read(doc + '.orig'), {'a': 1})
|
|
1958
2112
|
|
|
2113
|
+
def test_jsonpatch_in_place_cleans_temp_on_output_error(self):
|
|
2114
|
+
for phase in ('dump', 'newline', 'close', 'dump_and_close'):
|
|
2115
|
+
with self.subTest(phase=phase):
|
|
2116
|
+
tmp = tempfile.TemporaryDirectory()
|
|
2117
|
+
self.addCleanup(tmp.cleanup)
|
|
2118
|
+
self.dir = tmp.name
|
|
2119
|
+
doc = self.write('doc.json', {'a': 1})
|
|
2120
|
+
patch = self.write('patch.json', [])
|
|
2121
|
+
args = jsonpatch_cli.jsonpatch_parser.parse_args(['-i', doc, patch])
|
|
2122
|
+
self.addCleanup(args.ORIGINAL.close)
|
|
2123
|
+
self.addCleanup(args.PATCH.close)
|
|
2124
|
+
failure = OSError('output failed')
|
|
2125
|
+
opened = []
|
|
2126
|
+
fdopen = os.fdopen
|
|
2127
|
+
|
|
2128
|
+
def open_output(fd, mode, **kwargs):
|
|
2129
|
+
stream = fdopen(fd, mode, **kwargs)
|
|
2130
|
+
self.addCleanup(stream.close)
|
|
2131
|
+
opened.append(stream)
|
|
2132
|
+
proxy = mock.Mock(wraps=stream)
|
|
2133
|
+
if phase == 'newline':
|
|
2134
|
+
proxy.write.side_effect = lambda value: (
|
|
2135
|
+
stream.write(value) if value != '\n' else fail())
|
|
2136
|
+
if phase in ('close', 'dump_and_close'):
|
|
2137
|
+
def close():
|
|
2138
|
+
stream.close()
|
|
2139
|
+
raise failure if phase == 'close' else OSError('close failed')
|
|
2140
|
+
proxy.close.side_effect = close
|
|
2141
|
+
return proxy
|
|
2142
|
+
|
|
2143
|
+
def fail():
|
|
2144
|
+
raise failure
|
|
2145
|
+
|
|
2146
|
+
def dump(value, stream, **kwargs):
|
|
2147
|
+
stream.write('{')
|
|
2148
|
+
if phase in ('dump', 'dump_and_close'):
|
|
2149
|
+
raise failure
|
|
2150
|
+
|
|
2151
|
+
with mock.patch.object(jsonpatch_cli.jsonpatch_parser, 'parse_args',
|
|
2152
|
+
return_value=args), \
|
|
2153
|
+
mock.patch('os.fdopen', side_effect=open_output), \
|
|
2154
|
+
mock.patch('json.dump', side_effect=dump):
|
|
2155
|
+
with self.assertRaises(OSError) as caught:
|
|
2156
|
+
jsonpatch_cli.patch_files()
|
|
2157
|
+
self.assertIs(caught.exception, failure)
|
|
2158
|
+
self.assertTrue(opened[0].closed)
|
|
2159
|
+
self.assertEqual(self.read(doc), {'a': 1})
|
|
2160
|
+
self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
|
|
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
|
+
|
|
1959
2201
|
def test_jsonpatch_in_place_preserves_original_on_replace_error(self):
|
|
1960
2202
|
doc = self.write('doc.json', {'a': 1})
|
|
1961
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
|