jsonpatch 1.37__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.37
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.37
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.37'
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]
@@ -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,13 +520,12 @@ 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
 
521
525
  if isinstance(patch, (str, bytes, Mapping)):
522
526
  raise InvalidJsonPatch("Document is expected to be sequence of operations")
523
527
  try:
524
- patch_iter = iter(patch)
528
+ self.patch = patch if isinstance(patch, Sequence) else list(patch)
525
529
  except TypeError:
526
530
  raise InvalidJsonPatch("Document is expected to be sequence of operations")
527
531
 
@@ -529,7 +533,7 @@ class JsonPatch(object):
529
533
  # is correct by retrieving each patch element.
530
534
  # Much of the validation is done in the initializer
531
535
  # though some is delayed until the patch is applied.
532
- for op in patch_iter:
536
+ for op in self.patch:
533
537
  # We're only checking for strings in the following check
534
538
  # for two reasons:
535
539
  #
@@ -598,11 +602,11 @@ class JsonPatch(object):
598
602
  one. The resulting patch transforms `src` into `dst`, so it has to be
599
603
  applied to `src` (or to a document equal to it).
600
604
 
601
- :param src: Data source document object.
602
- :type src: dict
605
+ :param src: Source JSON document, including arrays and scalar values.
606
+ :type src: object
603
607
 
604
- :param dst: Data target document object.
605
- :type dst: dict
608
+ :param dst: Target JSON document, including arrays and scalar values.
609
+ :type dst: object
606
610
 
607
611
  :param optimization: Generate move operations for matching values.
608
612
  Set to False to use only add, remove and replace.
@@ -643,14 +647,16 @@ class JsonPatch(object):
643
647
  def apply(self, obj, in_place=False):
644
648
  """Applies the patch to a given object.
645
649
 
646
- :param obj: Document object.
647
- :type obj: dict
650
+ :param obj: JSON document, including arrays and scalar values.
651
+ :type obj: object
648
652
 
649
653
  :param in_place: Tweaks the way how patch would be applied - directly to
650
654
  specified `obj` or to its copy.
651
655
  :type in_place: bool
652
656
 
653
- :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.
654
660
  """
655
661
 
656
662
  if not in_place:
@@ -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
@@ -90,6 +91,17 @@ def patch_files():
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.
@@ -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}}),
@@ -2114,6 +2159,45 @@ class CommandLineTests(unittest.TestCase):
2114
2159
  self.assertEqual(self.read(doc), {'a': 1})
2115
2160
  self.assertEqual(sorted(os.listdir(self.dir)), ['doc.json', 'patch.json'])
2116
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
+
2117
2201
  def test_jsonpatch_in_place_preserves_original_on_replace_error(self):
2118
2202
  doc = self.write('doc.json', {'a': 1})
2119
2203
  patch = self.write('patch.json', [{'op': 'replace', 'path': '/a', 'value': 2}])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes