diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 1e89c7d..faf9705 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -8,7 +8,10 @@ Creating a Patch ---------------- Patches can be created in two ways. One way is to explicitly create a -``JsonPatch`` object from a list of operations. For convenience, the method +``JsonPatch`` object from an iterable of operation dictionaries, such as a +list, tuple, iterator, or generator. Non-sequence iterables are materialized +before validation so that the patch can be applied repeatedly. +For convenience, the method ``JsonPatch.from_string()`` accepts a string, parses it and constructs the patch object from it. diff --git a/jsonpatch.py b/jsonpatch.py index 3123eca..5595ffd 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -37,7 +37,8 @@ import copy import functools import json -from collections.abc import Mapping, MutableMapping, MutableSequence, Sequence +from collections.abc import (Mapping, MutableMapping, MutableSequence, + Sequence) from difflib import SequenceMatcher from types import MappingProxyType @@ -100,13 +101,13 @@ def multidict(ordered_pairs): def apply_patch(doc, patch, in_place=False, pointer_cls=JsonPointer): - """Apply list of patches to specified json document. + """Apply patch operations to the specified JSON document. :param doc: Document object. :type doc: dict - :param patch: JSON patch as list of dicts or raw JSON-encoded string. - :type patch: list or str + :param patch: Iterable of operation dicts or a raw JSON-encoded string. + :type patch: iterable or str :param in_place: While ``True`` patch will modify target document. By default patch will be applied to document copy. @@ -457,7 +458,11 @@ def apply(self, obj): class JsonPatch(object): - """A JSON Patch is a list of Patch Operations. + """A JSON Patch is an iterable of Patch Operations. + + Accepts sequences, iterators, generators, and other iterables of operation + dictionaries. Non-sequence inputs are materialized before validation so + the resulting patch can be applied repeatedly. >>> patch = JsonPatch([ ... {'op': 'add', 'path': '/foo', 'value': 'bar'}, @@ -515,7 +520,7 @@ class JsonPatch(object): }) def __init__(self, patch, pointer_cls=JsonPointer): - self.patch = patch + self.patch = patch if isinstance(patch, Sequence) else list(patch) self.pointer_cls = pointer_cls # Verify that the structure of the patch document diff --git a/tests.py b/tests.py index 537b091..dc63432 100755 --- a/tests.py +++ b/tests.py @@ -21,6 +21,51 @@ class ApplyPatchTestCase(unittest.TestCase): + def test_operation_iterators_remain_reusable_after_validation(self): + operations = [ + {'op': 'add', 'path': '/a', 'value': 1}, + {'op': 'replace', 'path': '/a', 'value': 2}, + ] + class OneShotIterable: + def __init__(self, ops): + self.iterator = iter(ops) + + def __iter__(self): + return self.iterator + + for name, factory in ( + ('iterator', iter), + ('generator', lambda ops: (op for op in ops)), + ('one-shot iterable', OneShotIterable), + ): + with self.subTest(name=name): + patch = jsonpatch.JsonPatch(factory(operations)) + self.assertEqual(list(patch), operations) + self.assertEqual(json.loads(patch.to_string()), operations) + self.assertEqual(patch.apply({}), {'a': 2}) + self.assertEqual(patch.apply({}), {'a': 2}) + + def test_empty_operation_iterator_is_false(self): + patch = jsonpatch.JsonPatch(iter([])) + self.assertFalse(patch) + self.assertEqual(patch.to_string(), '[]') + + def test_invalid_operation_iterator_is_rejected(self): + operations = iter([ + {'op': 'add', 'path': '/a', 'value': 1}, + {'op': 'not-an-operation', 'path': '/a'}, + ]) + with self.assertRaises(jsonpatch.InvalidJsonPatch): + jsonpatch.JsonPatch(operations) + + def test_sequence_operations_preserve_contents(self): + operations = [{'op': 'add', 'path': '/a', 'value': 1}] + for sequence in (operations, tuple(operations)): + with self.subTest(sequence=type(sequence).__name__): + patch = jsonpatch.JsonPatch(sequence) + self.assertEqual(list(patch), operations) + self.assertEqual(patch.apply({}), {'a': 1}) + def test_diff_without_optimization_uses_basic_operations(self): documents = [ ({'old': {'value': 1}}, {'new': {'value': 1}}),