jsonpatch 1.36__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.36
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.36
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.36'
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
  #
@@ -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
@@ -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
@@ -1533,6 +1533,35 @@ class JsonPointerTests(unittest.TestCase):
1533
1533
 
1534
1534
  class JsonPatchCreationTest(unittest.TestCase):
1535
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
+
1536
1565
  def test_creation_fails_with_invalid_patch(self):
1537
1566
  invalid_patches = [
1538
1567
  { 'path': '/foo', 'value': 'bar'},
@@ -1676,6 +1705,34 @@ class OperationLocationTests(unittest.TestCase):
1676
1705
  self.assertEqual(operation, {'op': 'move', 'from': '/c~1d/0', 'path': '/b'})
1677
1706
  self.assertEqual(op.apply({'c/d': ['x']}), {'c/d': [], 'b': 'x'})
1678
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
+
1679
1736
 
1680
1737
  class CustomJsonPointer(jsonpointer.JsonPointer):
1681
1738
  pass
@@ -1836,28 +1893,35 @@ class CommandLineTests(unittest.TestCase):
1836
1893
  self.addCleanup(tmp.cleanup)
1837
1894
  self.dir = tmp.name
1838
1895
 
1839
- def write(self, name, value):
1896
+ def write(self, name, value, ensure_ascii=True):
1840
1897
  path = os.path.join(self.dir, name)
1841
1898
  with open(path, 'w', encoding='utf-8') as f:
1842
- json.dump(value, f)
1899
+ json.dump(value, f, ensure_ascii=ensure_ascii)
1843
1900
  return path
1844
1901
 
1845
1902
  def read(self, path):
1846
1903
  with open(path, encoding='utf-8') as f:
1847
1904
  return json.load(f)
1848
1905
 
1849
- def run_script(self, script, *args, stdin=''):
1906
+ def run_python(self, code, *args, stdin='', utf8=True):
1850
1907
  here = os.path.dirname(os.path.abspath(__file__))
1851
1908
  # the scripts have to import this jsonpatch, not an installed one
1852
1909
  pythonpath = [here] + [p for p in [os.environ.get('PYTHONPATH')] if p]
1853
1910
  env = dict(os.environ, PYTHONPATH=os.pathsep.join(pythonpath),
1854
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')
1855
1916
  return subprocess.run(
1856
- [sys.executable, '-c',
1857
- 'import jsonpatch_cli; jsonpatch_cli.{0}_main()'.format(script)]
1858
- + list(args),
1917
+ [sys.executable, '-c', code] + list(args),
1859
1918
  input=stdin, capture_output=True, encoding='utf-8', env=env)
1860
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
+
1861
1925
  def test_jsondiff(self):
1862
1926
  src = self.write('a.json', {'a': [1, 2], 'b': 0})
1863
1927
  dst = self.write('b.json', {'a': [1, 2, 3], 'c': 100})
@@ -1887,6 +1951,18 @@ class CommandLineTests(unittest.TestCase):
1887
1951
  self.assertIn('"\\u00e4"', self.run_script('jsondiff', src, dst).stdout)
1888
1952
  self.assertIn('"ä"', self.run_script('jsondiff', '-u', src, dst).stdout)
1889
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
+
1890
1966
  def test_jsonpatch(self):
1891
1967
  doc = self.write('doc.json', {'a': [1, 2], 'b': 0})
1892
1968
  patch = self.write('patch.json', [{'op': 'add', 'path': '/a/-', 'value': 3},
@@ -1903,6 +1979,17 @@ class CommandLineTests(unittest.TestCase):
1903
1979
  self.assertEqual(res.returncode, 0, res.stderr)
1904
1980
  self.assertEqual(json.loads(res.stdout), {'a': 2})
1905
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
+
1906
1993
  def test_jsonpatch_indent_preserve_unicode(self):
1907
1994
  doc = self.write('doc.json', {'a': 'x'})
1908
1995
  patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 'ä'}])
@@ -1920,6 +2007,28 @@ class CommandLineTests(unittest.TestCase):
1920
2007
  # the temporary file has been moved over the original
1921
2008
  self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
1922
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
+
1923
2032
  # 2026-10-11: Windows cannot replace the original while its input is open.
1924
2033
  def test_jsonpatch_in_place_closes_original_before_replace(self):
1925
2034
  doc = self.write('doc.json', {'a': 1})
@@ -1956,6 +2065,55 @@ class CommandLineTests(unittest.TestCase):
1956
2065
  self.assertEqual(self.read(doc), {'a': 2})
1957
2066
  self.assertEqual(self.read(doc + '.orig'), {'a': 1})
1958
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
+
1959
2117
  def test_jsonpatch_in_place_preserves_original_on_replace_error(self):
1960
2118
  doc = self.write('doc.json', {'a': 1})
1961
2119
  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