jsonpatch 1.35__tar.gz → 1.37__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.35/jsonpatch.egg-info → jsonpatch-1.37}/PKG-INFO +1 -1
- {jsonpatch-1.35 → jsonpatch-1.37/jsonpatch.egg-info}/PKG-INFO +1 -1
- {jsonpatch-1.35 → jsonpatch-1.37}/jsonpatch.py +25 -7
- {jsonpatch-1.35 → jsonpatch-1.37}/jsonpatch_cli.py +30 -20
- {jsonpatch-1.35 → jsonpatch-1.37}/property_tests.py +18 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/tests.py +234 -11
- {jsonpatch-1.35 → jsonpatch-1.37}/AUTHORS +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/LICENSE +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/MANIFEST.in +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/README.md +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/ext_tests.py +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/jsonpatch.egg-info/SOURCES.txt +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/jsonpatch.egg-info/dependency_links.txt +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/jsonpatch.egg-info/entry_points.txt +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/jsonpatch.egg-info/requires.txt +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/jsonpatch.egg-info/top_level.txt +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/pyproject.toml +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/pytest.ini +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/requirements.txt +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/setup.cfg +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/setup.py +0 -0
- {jsonpatch-1.35 → jsonpatch-1.37}/tests.js +0 -0
|
@@ -54,7 +54,7 @@ _EXACT_MATCH_LIMIT = 500 ** 2
|
|
|
54
54
|
|
|
55
55
|
# Will be parsed by setup.py to determine package metadata
|
|
56
56
|
__author__ = 'Stefan Kögl <stefan@skoegl.net>'
|
|
57
|
-
__version__ = '1.
|
|
57
|
+
__version__ = '1.37'
|
|
58
58
|
__website__ = 'https://github.com/stefankoegl/python-json-patch'
|
|
59
59
|
__license__ = 'BSD-3-Clause'
|
|
60
60
|
|
|
@@ -384,7 +384,7 @@ class MoveOperation(PatchOperation):
|
|
|
384
384
|
|
|
385
385
|
@property
|
|
386
386
|
def from_path(self):
|
|
387
|
-
from_ptr = self.
|
|
387
|
+
from_ptr = self._from_pointer()
|
|
388
388
|
return '/'.join(from_ptr.parts[:-1])
|
|
389
389
|
|
|
390
390
|
@property
|
|
@@ -396,14 +396,14 @@ class MoveOperation(PatchOperation):
|
|
|
396
396
|
self.set_from_part(-1, value)
|
|
397
397
|
|
|
398
398
|
def get_from_part(self, index):
|
|
399
|
-
from_ptr = self.
|
|
399
|
+
from_ptr = self._from_pointer()
|
|
400
400
|
try:
|
|
401
401
|
return int(from_ptr.parts[index])
|
|
402
402
|
except ValueError:
|
|
403
403
|
return from_ptr.parts[index]
|
|
404
404
|
|
|
405
405
|
def set_from_part(self, index, value):
|
|
406
|
-
from_ptr = self.
|
|
406
|
+
from_ptr = self._from_pointer()
|
|
407
407
|
from_ptr.parts[index] = str(value)
|
|
408
408
|
self.operation['from'] = from_ptr.path
|
|
409
409
|
|
|
@@ -518,11 +518,18 @@ class JsonPatch(object):
|
|
|
518
518
|
self.patch = patch
|
|
519
519
|
self.pointer_cls = pointer_cls
|
|
520
520
|
|
|
521
|
+
if isinstance(patch, (str, bytes, Mapping)):
|
|
522
|
+
raise InvalidJsonPatch("Document is expected to be sequence of operations")
|
|
523
|
+
try:
|
|
524
|
+
patch_iter = iter(patch)
|
|
525
|
+
except TypeError:
|
|
526
|
+
raise InvalidJsonPatch("Document is expected to be sequence of operations")
|
|
527
|
+
|
|
521
528
|
# Verify that the structure of the patch document
|
|
522
529
|
# is correct by retrieving each patch element.
|
|
523
530
|
# Much of the validation is done in the initializer
|
|
524
531
|
# though some is delayed until the patch is applied.
|
|
525
|
-
for op in
|
|
532
|
+
for op in patch_iter:
|
|
526
533
|
# We're only checking for strings in the following check
|
|
527
534
|
# for two reasons:
|
|
528
535
|
#
|
|
@@ -597,6 +604,10 @@ class JsonPatch(object):
|
|
|
597
604
|
:param dst: Data target document object.
|
|
598
605
|
:type dst: dict
|
|
599
606
|
|
|
607
|
+
:param optimization: Generate move operations for matching values.
|
|
608
|
+
Set to False to use only add, remove and replace.
|
|
609
|
+
:type optimization: bool
|
|
610
|
+
|
|
600
611
|
:param dumps: A function of one argument that produces a serialized
|
|
601
612
|
JSON string.
|
|
602
613
|
:type dumps: Callable
|
|
@@ -614,7 +625,8 @@ class JsonPatch(object):
|
|
|
614
625
|
True
|
|
615
626
|
"""
|
|
616
627
|
json_dumper = dumps or cls.json_dumper
|
|
617
|
-
builder = DiffBuilder(src, dst, json_dumper, pointer_cls=pointer_cls
|
|
628
|
+
builder = DiffBuilder(src, dst, json_dumper, pointer_cls=pointer_cls,
|
|
629
|
+
optimization=optimization)
|
|
618
630
|
builder._compare_values((), None, src, dst)
|
|
619
631
|
ops = list(builder.execute())
|
|
620
632
|
return cls(ops, pointer_cls=pointer_cls)
|
|
@@ -670,9 +682,11 @@ class JsonPatch(object):
|
|
|
670
682
|
|
|
671
683
|
class DiffBuilder(object):
|
|
672
684
|
|
|
673
|
-
def __init__(self, src_doc, dst_doc, dumps=json.dumps, pointer_cls=JsonPointer
|
|
685
|
+
def __init__(self, src_doc, dst_doc, dumps=json.dumps, pointer_cls=JsonPointer,
|
|
686
|
+
optimization=True):
|
|
674
687
|
self.dumps = dumps
|
|
675
688
|
self.pointer_cls = pointer_cls
|
|
689
|
+
self.optimization = optimization
|
|
676
690
|
self.index_storage = [{}, {}]
|
|
677
691
|
self.__root = root = []
|
|
678
692
|
self.src_doc = src_doc
|
|
@@ -693,10 +707,14 @@ class DiffBuilder(object):
|
|
|
693
707
|
return id(value)
|
|
694
708
|
|
|
695
709
|
def store_index(self, value, index, st):
|
|
710
|
+
if not self.optimization:
|
|
711
|
+
return
|
|
696
712
|
storage = self.index_storage[st]
|
|
697
713
|
storage.setdefault(self._move_key(value), []).append(index)
|
|
698
714
|
|
|
699
715
|
def take_index(self, value, st):
|
|
716
|
+
if not self.optimization:
|
|
717
|
+
return None
|
|
700
718
|
stored = self.index_storage[st].get(self._move_key(value))
|
|
701
719
|
if stored:
|
|
702
720
|
return stored.pop()
|
|
@@ -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,
|
|
@@ -86,7 +86,7 @@ def patch_files():
|
|
|
86
86
|
# because atomic renames do not work across mount points.)
|
|
87
87
|
|
|
88
88
|
fd, pathname = tempfile.mkstemp(dir=dirname)
|
|
89
|
-
fp = os.fdopen(fd, 'w')
|
|
89
|
+
fp = os.fdopen(fd, 'w', encoding='utf-8')
|
|
90
90
|
atomic = True
|
|
91
91
|
|
|
92
92
|
except OSError:
|
|
@@ -96,7 +96,7 @@ def patch_files():
|
|
|
96
96
|
|
|
97
97
|
if args.backup:
|
|
98
98
|
os.rename(args.ORIGINAL.name, args.ORIGINAL.name + '.orig')
|
|
99
|
-
fp = open(args.ORIGINAL.name, 'w')
|
|
99
|
+
fp = open(args.ORIGINAL.name, 'w', encoding='utf-8')
|
|
100
100
|
atomic = False
|
|
101
101
|
|
|
102
102
|
else:
|
|
@@ -108,15 +108,26 @@ def patch_files():
|
|
|
108
108
|
# By this point we have some sort of file object we can write the
|
|
109
109
|
# modified JSON to.
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
try:
|
|
112
|
+
json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode))
|
|
113
|
+
fp.write('\n')
|
|
114
|
+
if args.in_place:
|
|
115
|
+
fp.close()
|
|
116
|
+
except BaseException:
|
|
117
|
+
if args.in_place:
|
|
118
|
+
# Cleanup must not replace the original output error.
|
|
119
|
+
try:
|
|
120
|
+
fp.close()
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
if atomic:
|
|
124
|
+
try:
|
|
125
|
+
os.unlink(pathname)
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
raise
|
|
113
129
|
|
|
114
130
|
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
131
|
if atomic:
|
|
121
132
|
try:
|
|
122
133
|
# Complete the atomic replace by linking the original
|
|
@@ -126,12 +137,11 @@ def patch_files():
|
|
|
126
137
|
if args.backup:
|
|
127
138
|
os.link(args.ORIGINAL.name, args.ORIGINAL.name + '.orig')
|
|
128
139
|
os.chmod(pathname, os.stat(args.ORIGINAL.name).st_mode)
|
|
129
|
-
os.
|
|
140
|
+
os.replace(pathname, args.ORIGINAL.name)
|
|
130
141
|
|
|
131
142
|
except OSError:
|
|
132
|
-
#
|
|
133
|
-
# replace
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
os.rename(pathname, args.ORIGINAL.name)
|
|
143
|
+
# Preserve the original if backup, permissions, or replacement
|
|
144
|
+
# fails. replace() can overwrite an existing file on Windows
|
|
145
|
+
# without unlinking it first.
|
|
146
|
+
os.unlink(pathname)
|
|
147
|
+
raise
|
|
@@ -290,6 +290,15 @@ class MakePatchProperties(unittest.TestCase):
|
|
|
290
290
|
def test_roundtrip(self, docs):
|
|
291
291
|
self.check_roundtrip(*docs)
|
|
292
292
|
|
|
293
|
+
@given(doc_pairs)
|
|
294
|
+
def test_roundtrip_without_move_optimization(self, docs):
|
|
295
|
+
src, dst = docs
|
|
296
|
+
patch = jsonpatch.JsonPatch.from_diff(src, dst, optimization=False)
|
|
297
|
+
note(patch.patch)
|
|
298
|
+
self.assertTrue(all(op['op'] in ('add', 'remove', 'replace')
|
|
299
|
+
for op in patch))
|
|
300
|
+
assert_json_equal(patch.apply(src), dst)
|
|
301
|
+
|
|
293
302
|
@given(safe_doc_pairs)
|
|
294
303
|
# the diff considered e.g. 1 and true equal, #180
|
|
295
304
|
@example(docs=([0], [False]))
|
|
@@ -315,6 +324,15 @@ class MakePatchProperties(unittest.TestCase):
|
|
|
315
324
|
|
|
316
325
|
class ApplyPatchProperties(unittest.TestCase):
|
|
317
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
|
+
|
|
318
336
|
@given(docs_with_patches())
|
|
319
337
|
def test_does_not_modify_document(self, case):
|
|
320
338
|
doc, patch = case
|
|
@@ -21,6 +21,25 @@ from unittest import mock
|
|
|
21
21
|
|
|
22
22
|
class ApplyPatchTestCase(unittest.TestCase):
|
|
23
23
|
|
|
24
|
+
def test_diff_without_optimization_uses_basic_operations(self):
|
|
25
|
+
documents = [
|
|
26
|
+
({'old': {'value': 1}}, {'new': {'value': 1}}),
|
|
27
|
+
([1, 2, 3], [3, 1, 2]),
|
|
28
|
+
([{'a': 1}, {'b': 2}], [{'b': 2}, {'a': 1}]),
|
|
29
|
+
({'a/b': [1, 2]}, {'~key': [1, 2]}),
|
|
30
|
+
]
|
|
31
|
+
for src, dst in documents:
|
|
32
|
+
with self.subTest(src=src, dst=dst):
|
|
33
|
+
original = copy.deepcopy(src)
|
|
34
|
+
patch = jsonpatch.JsonPatch.from_diff(
|
|
35
|
+
src, dst, optimization=False)
|
|
36
|
+
self.assertTrue(all(operation['op'] in
|
|
37
|
+
('add', 'remove', 'replace')
|
|
38
|
+
for operation in patch))
|
|
39
|
+
self.assertEqual(patch.apply(src), dst)
|
|
40
|
+
self.assertEqual(src, original)
|
|
41
|
+
|
|
42
|
+
|
|
24
43
|
def test_js_file(self):
|
|
25
44
|
with open('./tests.js', 'r') as f:
|
|
26
45
|
tests = json.load(f)
|
|
@@ -1514,6 +1533,35 @@ class JsonPointerTests(unittest.TestCase):
|
|
|
1514
1533
|
|
|
1515
1534
|
class JsonPatchCreationTest(unittest.TestCase):
|
|
1516
1535
|
|
|
1536
|
+
def test_creation_rejects_non_sequence_documents(self):
|
|
1537
|
+
for patch in ({}, {'op': 'add', 'path': '/a', 'value': 1},
|
|
1538
|
+
MappingProxyType({}), '', b'', None, True, False,
|
|
1539
|
+
0, 1, 1.5):
|
|
1540
|
+
with self.subTest(patch=patch):
|
|
1541
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
1542
|
+
jsonpatch.JsonPatch(patch)
|
|
1543
|
+
|
|
1544
|
+
def test_serialized_patch_rejects_non_array_documents(self):
|
|
1545
|
+
for patch in ({}, {'op': 'add', 'path': '/a', 'value': 1},
|
|
1546
|
+
'', 'text', None, True, False, 0, 1, 1.5):
|
|
1547
|
+
for serialized in (json.dumps(patch), json.dumps(patch).encode()):
|
|
1548
|
+
with self.subTest(patch=serialized):
|
|
1549
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
1550
|
+
jsonpatch.JsonPatch.from_string(serialized)
|
|
1551
|
+
doc = {'a': 'unchanged'}
|
|
1552
|
+
with self.assertRaises(jsonpatch.InvalidJsonPatch):
|
|
1553
|
+
jsonpatch.apply_patch(doc, serialized, in_place=True)
|
|
1554
|
+
self.assertEqual(doc, {'a': 'unchanged'})
|
|
1555
|
+
|
|
1556
|
+
def test_creation_preserves_operation_sequences(self):
|
|
1557
|
+
for operations in ([], (),
|
|
1558
|
+
[{'op': 'add', 'path': '/a', 'value': 1}],
|
|
1559
|
+
({'op': 'add', 'path': '/a', 'value': 1},)):
|
|
1560
|
+
with self.subTest(operations=operations):
|
|
1561
|
+
patch = jsonpatch.JsonPatch(operations)
|
|
1562
|
+
self.assertIs(patch.patch, operations)
|
|
1563
|
+
self.assertEqual(patch.apply({}), {'a': 1} if operations else {})
|
|
1564
|
+
|
|
1517
1565
|
def test_creation_fails_with_invalid_patch(self):
|
|
1518
1566
|
invalid_patches = [
|
|
1519
1567
|
{ 'path': '/foo', 'value': 'bar'},
|
|
@@ -1657,6 +1705,34 @@ class OperationLocationTests(unittest.TestCase):
|
|
|
1657
1705
|
self.assertEqual(operation, {'op': 'move', 'from': '/c~1d/0', 'path': '/b'})
|
|
1658
1706
|
self.assertEqual(op.apply({'c/d': ['x']}), {'c/d': [], 'b': 'x'})
|
|
1659
1707
|
|
|
1708
|
+
def test_from_path_with_pointer_object(self):
|
|
1709
|
+
for pointer_cls in (jsonpointer.JsonPointer, CustomJsonPointer):
|
|
1710
|
+
with self.subTest(pointer_cls=pointer_cls):
|
|
1711
|
+
op = jsonpatch.MoveOperation(
|
|
1712
|
+
{'op': 'move', 'from': pointer_cls('/a/1/b'), 'path': '/c'},
|
|
1713
|
+
pointer_cls=pointer_cls)
|
|
1714
|
+
self.assertEqual(op.from_path, 'a/1')
|
|
1715
|
+
|
|
1716
|
+
def test_from_parts_with_pointer_object(self):
|
|
1717
|
+
for pointer_cls in (jsonpointer.JsonPointer, CustomJsonPointer):
|
|
1718
|
+
with self.subTest(pointer_cls=pointer_cls):
|
|
1719
|
+
op = jsonpatch.MoveOperation(
|
|
1720
|
+
{'op': 'move', 'from': pointer_cls('/a/1/b'), 'path': '/c'},
|
|
1721
|
+
pointer_cls=pointer_cls)
|
|
1722
|
+
self.assertEqual(op.from_key, 'b')
|
|
1723
|
+
self.assertEqual(op.get_from_part(1), 1)
|
|
1724
|
+
|
|
1725
|
+
def test_set_from_key_with_pointer_object(self):
|
|
1726
|
+
for pointer_cls in (jsonpointer.JsonPointer, CustomJsonPointer):
|
|
1727
|
+
with self.subTest(pointer_cls=pointer_cls):
|
|
1728
|
+
operation = {'op': 'move', 'from': pointer_cls('/a/1'),
|
|
1729
|
+
'path': '/b'}
|
|
1730
|
+
op = jsonpatch.MoveOperation(operation, pointer_cls=pointer_cls)
|
|
1731
|
+
op.from_key = 0
|
|
1732
|
+
self.assertEqual(operation['from'], '/a/0')
|
|
1733
|
+
self.assertEqual(op.apply({'a': ['x', 'y']}),
|
|
1734
|
+
{'a': ['y'], 'b': 'x'})
|
|
1735
|
+
|
|
1660
1736
|
|
|
1661
1737
|
class CustomJsonPointer(jsonpointer.JsonPointer):
|
|
1662
1738
|
pass
|
|
@@ -1817,28 +1893,35 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1817
1893
|
self.addCleanup(tmp.cleanup)
|
|
1818
1894
|
self.dir = tmp.name
|
|
1819
1895
|
|
|
1820
|
-
def write(self, name, value):
|
|
1896
|
+
def write(self, name, value, ensure_ascii=True):
|
|
1821
1897
|
path = os.path.join(self.dir, name)
|
|
1822
1898
|
with open(path, 'w', encoding='utf-8') as f:
|
|
1823
|
-
json.dump(value, f)
|
|
1899
|
+
json.dump(value, f, ensure_ascii=ensure_ascii)
|
|
1824
1900
|
return path
|
|
1825
1901
|
|
|
1826
1902
|
def read(self, path):
|
|
1827
1903
|
with open(path, encoding='utf-8') as f:
|
|
1828
1904
|
return json.load(f)
|
|
1829
1905
|
|
|
1830
|
-
def
|
|
1906
|
+
def run_python(self, code, *args, stdin='', utf8=True):
|
|
1831
1907
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
1832
1908
|
# the scripts have to import this jsonpatch, not an installed one
|
|
1833
1909
|
pythonpath = [here] + [p for p in [os.environ.get('PYTHONPATH')] if p]
|
|
1834
1910
|
env = dict(os.environ, PYTHONPATH=os.pathsep.join(pythonpath),
|
|
1835
1911
|
PYTHONUTF8='1')
|
|
1912
|
+
if not utf8:
|
|
1913
|
+
# Exercise locale-dependent file I/O while keeping pipes in UTF-8.
|
|
1914
|
+
env.update(PYTHONUTF8='0', PYTHONIOENCODING='utf-8',
|
|
1915
|
+
LC_ALL='C', PYTHONCOERCECLOCALE='0')
|
|
1836
1916
|
return subprocess.run(
|
|
1837
|
-
[sys.executable, '-c',
|
|
1838
|
-
'import jsonpatch_cli; jsonpatch_cli.{0}_main()'.format(script)]
|
|
1839
|
-
+ list(args),
|
|
1917
|
+
[sys.executable, '-c', code] + list(args),
|
|
1840
1918
|
input=stdin, capture_output=True, encoding='utf-8', env=env)
|
|
1841
1919
|
|
|
1920
|
+
def run_script(self, script, *args, stdin='', utf8=True):
|
|
1921
|
+
return self.run_python(
|
|
1922
|
+
'import jsonpatch_cli; jsonpatch_cli.{0}_main()'.format(script),
|
|
1923
|
+
*args, stdin=stdin, utf8=utf8)
|
|
1924
|
+
|
|
1842
1925
|
def test_jsondiff(self):
|
|
1843
1926
|
src = self.write('a.json', {'a': [1, 2], 'b': 0})
|
|
1844
1927
|
dst = self.write('b.json', {'a': [1, 2, 3], 'c': 100})
|
|
@@ -1868,6 +1951,18 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1868
1951
|
self.assertIn('"\\u00e4"', self.run_script('jsondiff', src, dst).stdout)
|
|
1869
1952
|
self.assertIn('"ä"', self.run_script('jsondiff', '-u', src, dst).stdout)
|
|
1870
1953
|
|
|
1954
|
+
def test_jsondiff_utf8_files_without_utf8_mode(self):
|
|
1955
|
+
src = self.write('a.json', {'a': 'old', 'unchanged': 'ä'},
|
|
1956
|
+
ensure_ascii=False)
|
|
1957
|
+
dst = self.write('b.json', {'a': '🚀', 'unchanged': 'ä'},
|
|
1958
|
+
ensure_ascii=False)
|
|
1959
|
+
res = self.run_script('jsondiff', src, dst, utf8=False)
|
|
1960
|
+
self.assertEqual(res.returncode, 1, res.stderr)
|
|
1961
|
+
patch = json.loads(res.stdout)
|
|
1962
|
+
self.assertEqual(patch, [{'op': 'replace', 'path': '/a', 'value': '🚀'}])
|
|
1963
|
+
self.assertEqual(jsonpatch.apply_patch(self.read(src), patch),
|
|
1964
|
+
self.read(dst))
|
|
1965
|
+
|
|
1871
1966
|
def test_jsonpatch(self):
|
|
1872
1967
|
doc = self.write('doc.json', {'a': [1, 2], 'b': 0})
|
|
1873
1968
|
patch = self.write('patch.json', [{'op': 'add', 'path': '/a/-', 'value': 3},
|
|
@@ -1884,6 +1979,17 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1884
1979
|
self.assertEqual(res.returncode, 0, res.stderr)
|
|
1885
1980
|
self.assertEqual(json.loads(res.stdout), {'a': 2})
|
|
1886
1981
|
|
|
1982
|
+
def test_jsonpatch_utf8_files_without_utf8_mode(self):
|
|
1983
|
+
doc = self.write('doc.json', {'a': 'ä'}, ensure_ascii=False)
|
|
1984
|
+
patch = self.write('patch.json', [
|
|
1985
|
+
{'op': 'test', 'path': '/a', 'value': 'ä'},
|
|
1986
|
+
{'op': 'replace', 'path': '/a', 'value': '🚀'},
|
|
1987
|
+
], ensure_ascii=False)
|
|
1988
|
+
res = self.run_script('jsonpatch', doc, patch, utf8=False)
|
|
1989
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
1990
|
+
self.assertEqual(json.loads(res.stdout), {'a': '🚀'})
|
|
1991
|
+
self.assertEqual(self.read(doc), {'a': 'ä'})
|
|
1992
|
+
|
|
1887
1993
|
def test_jsonpatch_indent_preserve_unicode(self):
|
|
1888
1994
|
doc = self.write('doc.json', {'a': 'x'})
|
|
1889
1995
|
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 'ä'}])
|
|
@@ -1901,22 +2007,44 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1901
2007
|
# the temporary file has been moved over the original
|
|
1902
2008
|
self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
|
|
1903
2009
|
|
|
2010
|
+
def test_jsonpatch_in_place_utf8_without_utf8_mode(self):
|
|
2011
|
+
for atomic in (True, False):
|
|
2012
|
+
with self.subTest(atomic=atomic):
|
|
2013
|
+
doc = self.write('doc.json', {'a': 'old'})
|
|
2014
|
+
patch = self.write('patch.json', [
|
|
2015
|
+
{'op': 'replace', 'path': '/a', 'value': '🚀'},
|
|
2016
|
+
])
|
|
2017
|
+
if atomic:
|
|
2018
|
+
code = 'import jsonpatch_cli; jsonpatch_cli.jsonpatch_main()'
|
|
2019
|
+
else:
|
|
2020
|
+
code = (
|
|
2021
|
+
'from unittest import mock\n'
|
|
2022
|
+
'import jsonpatch_cli\n'
|
|
2023
|
+
'with mock.patch("tempfile.mkstemp", side_effect=OSError):\n'
|
|
2024
|
+
' jsonpatch_cli.jsonpatch_main()\n'
|
|
2025
|
+
)
|
|
2026
|
+
res = self.run_python(code, '-i', '-u', doc, patch, utf8=False)
|
|
2027
|
+
self.assertEqual((res.returncode, res.stdout), (0, ''), res.stderr)
|
|
2028
|
+
self.assertEqual(self.read(doc), {'a': '🚀'})
|
|
2029
|
+
self.assertEqual(sorted(os.listdir(self.dir)),
|
|
2030
|
+
['doc.json', 'patch.json'])
|
|
2031
|
+
|
|
1904
2032
|
# 2026-10-11: Windows cannot replace the original while its input is open.
|
|
1905
|
-
def
|
|
2033
|
+
def test_jsonpatch_in_place_closes_original_before_replace(self):
|
|
1906
2034
|
doc = self.write('doc.json', {'a': 1})
|
|
1907
2035
|
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 2}])
|
|
1908
2036
|
parser = jsonpatch_cli.jsonpatch_parser
|
|
1909
2037
|
args = parser.parse_args(['-i', doc, patch])
|
|
1910
2038
|
self.addCleanup(args.ORIGINAL.close)
|
|
1911
2039
|
self.addCleanup(args.PATCH.close)
|
|
1912
|
-
|
|
2040
|
+
replace = os.replace
|
|
1913
2041
|
|
|
1914
|
-
def
|
|
2042
|
+
def replace_closed_original(source, target):
|
|
1915
2043
|
self.assertTrue(args.ORIGINAL.closed)
|
|
1916
|
-
return
|
|
2044
|
+
return replace(source, target)
|
|
1917
2045
|
|
|
1918
2046
|
with mock.patch.object(parser, 'parse_args', return_value=args), \
|
|
1919
|
-
mock.patch('os.
|
|
2047
|
+
mock.patch('os.replace', side_effect=replace_closed_original):
|
|
1920
2048
|
jsonpatch_cli.patch_files()
|
|
1921
2049
|
self.assertEqual(self.read(doc), {'a': 2})
|
|
1922
2050
|
|
|
@@ -1937,6 +2065,101 @@ class CommandLineTests(unittest.TestCase):
|
|
|
1937
2065
|
self.assertEqual(self.read(doc), {'a': 2})
|
|
1938
2066
|
self.assertEqual(self.read(doc + '.orig'), {'a': 1})
|
|
1939
2067
|
|
|
2068
|
+
def test_jsonpatch_in_place_cleans_temp_on_output_error(self):
|
|
2069
|
+
for phase in ('dump', 'newline', 'close', 'dump_and_close'):
|
|
2070
|
+
with self.subTest(phase=phase):
|
|
2071
|
+
tmp = tempfile.TemporaryDirectory()
|
|
2072
|
+
self.addCleanup(tmp.cleanup)
|
|
2073
|
+
self.dir = tmp.name
|
|
2074
|
+
doc = self.write('doc.json', {'a': 1})
|
|
2075
|
+
patch = self.write('patch.json', [])
|
|
2076
|
+
args = jsonpatch_cli.jsonpatch_parser.parse_args(['-i', doc, patch])
|
|
2077
|
+
self.addCleanup(args.ORIGINAL.close)
|
|
2078
|
+
self.addCleanup(args.PATCH.close)
|
|
2079
|
+
failure = OSError('output failed')
|
|
2080
|
+
opened = []
|
|
2081
|
+
fdopen = os.fdopen
|
|
2082
|
+
|
|
2083
|
+
def open_output(fd, mode, **kwargs):
|
|
2084
|
+
stream = fdopen(fd, mode, **kwargs)
|
|
2085
|
+
self.addCleanup(stream.close)
|
|
2086
|
+
opened.append(stream)
|
|
2087
|
+
proxy = mock.Mock(wraps=stream)
|
|
2088
|
+
if phase == 'newline':
|
|
2089
|
+
proxy.write.side_effect = lambda value: (
|
|
2090
|
+
stream.write(value) if value != '\n' else fail())
|
|
2091
|
+
if phase in ('close', 'dump_and_close'):
|
|
2092
|
+
def close():
|
|
2093
|
+
stream.close()
|
|
2094
|
+
raise failure if phase == 'close' else OSError('close failed')
|
|
2095
|
+
proxy.close.side_effect = close
|
|
2096
|
+
return proxy
|
|
2097
|
+
|
|
2098
|
+
def fail():
|
|
2099
|
+
raise failure
|
|
2100
|
+
|
|
2101
|
+
def dump(value, stream, **kwargs):
|
|
2102
|
+
stream.write('{')
|
|
2103
|
+
if phase in ('dump', 'dump_and_close'):
|
|
2104
|
+
raise failure
|
|
2105
|
+
|
|
2106
|
+
with mock.patch.object(jsonpatch_cli.jsonpatch_parser, 'parse_args',
|
|
2107
|
+
return_value=args), \
|
|
2108
|
+
mock.patch('os.fdopen', side_effect=open_output), \
|
|
2109
|
+
mock.patch('json.dump', side_effect=dump):
|
|
2110
|
+
with self.assertRaises(OSError) as caught:
|
|
2111
|
+
jsonpatch_cli.patch_files()
|
|
2112
|
+
self.assertIs(caught.exception, failure)
|
|
2113
|
+
self.assertTrue(opened[0].closed)
|
|
2114
|
+
self.assertEqual(self.read(doc), {'a': 1})
|
|
2115
|
+
self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
|
|
2116
|
+
|
|
2117
|
+
def test_jsonpatch_in_place_preserves_original_on_replace_error(self):
|
|
2118
|
+
doc = self.write('doc.json', {'a': 1})
|
|
2119
|
+
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 2}])
|
|
2120
|
+
parser = jsonpatch_cli.jsonpatch_parser
|
|
2121
|
+
args = parser.parse_args(['-i', doc, patch])
|
|
2122
|
+
self.addCleanup(args.ORIGINAL.close)
|
|
2123
|
+
self.addCleanup(args.PATCH.close)
|
|
2124
|
+
|
|
2125
|
+
with mock.patch.object(parser, 'parse_args', return_value=args), \
|
|
2126
|
+
mock.patch('os.rename', side_effect=PermissionError('cannot replace')), \
|
|
2127
|
+
mock.patch('os.replace', side_effect=PermissionError('cannot replace')):
|
|
2128
|
+
with self.assertRaises(PermissionError):
|
|
2129
|
+
jsonpatch_cli.patch_files()
|
|
2130
|
+
|
|
2131
|
+
self.assertTrue(os.path.exists(doc))
|
|
2132
|
+
self.assertEqual(self.read(doc), {'a': 1})
|
|
2133
|
+
self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
|
|
2134
|
+
|
|
2135
|
+
def test_jsonpatch_in_place_preserves_original_on_chmod_error(self):
|
|
2136
|
+
doc = self.write('doc.json', {'a': 1})
|
|
2137
|
+
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 2}])
|
|
2138
|
+
parser = jsonpatch_cli.jsonpatch_parser
|
|
2139
|
+
args = parser.parse_args(['-i', doc, patch])
|
|
2140
|
+
self.addCleanup(args.ORIGINAL.close)
|
|
2141
|
+
self.addCleanup(args.PATCH.close)
|
|
2142
|
+
|
|
2143
|
+
with mock.patch.object(parser, 'parse_args', return_value=args), \
|
|
2144
|
+
mock.patch('os.chmod', side_effect=PermissionError('cannot chmod')):
|
|
2145
|
+
with self.assertRaises(PermissionError):
|
|
2146
|
+
jsonpatch_cli.patch_files()
|
|
2147
|
+
|
|
2148
|
+
self.assertEqual(self.read(doc), {'a': 1})
|
|
2149
|
+
self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
|
|
2150
|
+
|
|
2151
|
+
def test_jsonpatch_in_place_existing_backup(self):
|
|
2152
|
+
doc = self.write('doc.json', {'a': 1})
|
|
2153
|
+
patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 2}])
|
|
2154
|
+
backup = self.write('doc.json.orig', {'previous': True})
|
|
2155
|
+
res = self.run_script('jsonpatch', '-i', '-b', doc, patch)
|
|
2156
|
+
|
|
2157
|
+
self.assertNotEqual(res.returncode, 0)
|
|
2158
|
+
self.assertEqual(self.read(doc), {'a': 1})
|
|
2159
|
+
self.assertEqual(self.read(backup), {'previous': True})
|
|
2160
|
+
self.assertEqual(sorted(os.listdir(self.dir)),
|
|
2161
|
+
['doc.json', 'doc.json.orig', 'patch.json'])
|
|
2162
|
+
|
|
1940
2163
|
def test_jsonpatch_in_place_failing_patch(self):
|
|
1941
2164
|
# the patch is applied before the file is replaced, so it is kept
|
|
1942
2165
|
doc = self.write('doc.json', {'a': 1})
|
|
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
|