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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonpatch
3
- Version: 1.36
3
+ Version: 1.38
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.36
3
+ Version: 1.38
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
@@ -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, Sequence
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.36'
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 list of patches to specified json document.
104
+ """Apply patch operations to the specified JSON document.
104
105
 
105
- :param doc: Document object.
106
- :type doc: dict
106
+ :param doc: JSON document, including arrays and scalar values.
107
+ :type doc: object
107
108
 
108
- :param patch: JSON patch as list of dicts or raw JSON-encoded string.
109
- :type patch: list or str
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: Patched document object.
119
- :rtype: dict
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: Data source document object.
149
- :type src: dict
149
+ :param src: Source JSON document, including arrays and scalar values.
150
+ :type src: object
150
151
 
151
- :param dst: Data target document object.
152
- :type dst: dict
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.pointer_cls(self.operation['from'])
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.pointer_cls(self.operation['from'])
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.pointer_cls(self.operation['from'])
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 a list of Patch Operations.
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: Data source document object.
595
- :type src: dict
605
+ :param src: Source JSON document, including arrays and scalar values.
606
+ :type src: object
596
607
 
597
- :param dst: Data target document object.
598
- :type dst: dict
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: Document object.
640
- :type obj: dict
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: Modified `obj`.
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
- json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode))
112
- fp.write('\n')
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 run_script(self, script, *args, stdin=''):
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