From 5759ad6ad0e7d80ed4233b8da08782879bfc6d6f Mon Sep 17 00:00:00 2001 From: Apeksha_2022 <70309060+Apeksha9389@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:37:59 +0530 Subject: [PATCH 1/2] Preserve one-shot patch operation iterators during validation --- jsonpatch.py | 5 +++-- tests.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 3123eca..1e21ad8 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 (Iterator, Mapping, MutableMapping, MutableSequence, + Sequence) from difflib import SequenceMatcher from types import MappingProxyType @@ -515,7 +516,7 @@ class JsonPatch(object): }) def __init__(self, patch, pointer_cls=JsonPointer): - self.patch = patch + self.patch = list(patch) if isinstance(patch, Iterator) else patch self.pointer_cls = pointer_cls # Verify that the structure of the patch document diff --git a/tests.py b/tests.py index 537b091..ffb7af7 100755 --- a/tests.py +++ b/tests.py @@ -21,6 +21,36 @@ 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}, + ] + for factory in (iter, lambda ops: (op for op in ops)): + with self.subTest(factory=factory): + 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_list_operations_keep_the_original_container(self): + operations = [{'op': 'add', 'path': '/a', 'value': 1}] + self.assertIs(jsonpatch.JsonPatch(operations).patch, operations) + def test_diff_without_optimization_uses_basic_operations(self): documents = [ ({'old': {'value': 1}}, {'new': {'value': 1}}), From 504f40ef83defb4c41944610ec2151b8fbc39b37 Mon Sep 17 00:00:00 2001 From: Apeksha_2022 <70309060+Apeksha9389@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:29:29 +0530 Subject: [PATCH 2/2] fix: preserve custom one-shot patch iterables --- doc/tutorial.rst | 5 ++++- jsonpatch.py | 16 ++++++++++------ tests.py | 23 +++++++++++++++++++---- 3 files changed, 33 insertions(+), 11 deletions(-) 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 1e21ad8..5595ffd 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -37,7 +37,7 @@ import copy import functools import json -from collections.abc import (Iterator, Mapping, MutableMapping, MutableSequence, +from collections.abc import (Mapping, MutableMapping, MutableSequence, Sequence) from difflib import SequenceMatcher from types import MappingProxyType @@ -101,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. @@ -458,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'}, @@ -516,7 +520,7 @@ class JsonPatch(object): }) def __init__(self, patch, pointer_cls=JsonPointer): - self.patch = list(patch) if isinstance(patch, Iterator) else 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 ffb7af7..dc63432 100755 --- a/tests.py +++ b/tests.py @@ -26,8 +26,19 @@ def test_operation_iterators_remain_reusable_after_validation(self): {'op': 'add', 'path': '/a', 'value': 1}, {'op': 'replace', 'path': '/a', 'value': 2}, ] - for factory in (iter, lambda ops: (op for op in ops)): - with self.subTest(factory=factory): + 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) @@ -47,9 +58,13 @@ def test_invalid_operation_iterator_is_rejected(self): with self.assertRaises(jsonpatch.InvalidJsonPatch): jsonpatch.JsonPatch(operations) - def test_list_operations_keep_the_original_container(self): + def test_sequence_operations_preserve_contents(self): operations = [{'op': 'add', 'path': '/a', 'value': 1}] - self.assertIs(jsonpatch.JsonPatch(operations).patch, operations) + 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 = [