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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonpatch
3
- Version: 1.35
3
+ Version: 1.37
4
4
  Summary: Apply JSON-Patches (RFC 6902)
5
5
  Home-page: https://github.com/stefankoegl/python-json-patch
6
6
  Author: Stefan Kögl
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonpatch
3
- Version: 1.35
3
+ Version: 1.37
4
4
  Summary: Apply JSON-Patches (RFC 6902)
5
5
  Home-page: https://github.com/stefankoegl/python-json-patch
6
6
  Author: Stefan Kögl
@@ -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.35'
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.pointer_cls(self.operation['from'])
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.pointer_cls(self.operation['from'])
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.pointer_cls(self.operation['from'])
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 self.patch:
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
- json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode))
112
- fp.write('\n')
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.rename(pathname, args.ORIGINAL.name)
140
+ os.replace(pathname, args.ORIGINAL.name)
130
141
 
131
142
  except OSError:
132
- # In the event we could not actually do the atomic
133
- # replace, unlink the original to move it out of the
134
- # way and finally move the temporary file into place.
135
-
136
- os.unlink(args.ORIGINAL.name)
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 run_script(self, script, *args, stdin=''):
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 test_jsonpatch_in_place_closes_original_before_rename(self):
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
- rename = os.rename
2040
+ replace = os.replace
1913
2041
 
1914
- def rename_closed_original(source, target):
2042
+ def replace_closed_original(source, target):
1915
2043
  self.assertTrue(args.ORIGINAL.closed)
1916
- return rename(source, target)
2044
+ return replace(source, target)
1917
2045
 
1918
2046
  with mock.patch.object(parser, 'parse_args', return_value=args), \
1919
- mock.patch('os.rename', side_effect=rename_closed_original):
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