From 42c84101e36cafe550f3a376bb71260c5d160719 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 10:28:43 +0000 Subject: [PATCH] Serialize and compare patches whose paths are pointer objects The 'path' and 'from' of an operation can be given as pointer objects instead of strings. A JsonPatch created from such operations could be applied, but to_string() raised a TypeError, as its operations still contained the pointer objects, and it was not equal to the same patch given the paths as strings. JsonPatch now replaces pointer objects by their paths in its operations, in copies, so the operations it was created from are unchanged. Patch operations compare and hash by the paths as well. Their setters keep updating the operation they were created from. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01PewzpjVeke4LTQ7Q88nyu6 --- jsonpatch.py | 28 ++++++++++++++--- tests.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 9ff14d5..03a7b31 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -437,6 +437,23 @@ def _reorder(mapping, keys): mapping[key] = mapping.pop(key) +def _plain_operation(operation, pointer_cls): + """Returns operation as a dict whose 'path' and 'from' are strings. + + Both can be given as pointer objects instead. These are replaced by + their paths, so that the operation is JSON-serializable, and can be + compared to one that was given the paths. This is done in a copy; an + operation that is a dict already and has no pointer objects is returned + as it is. + """ + paths = {member: operation[member].path for member in ('path', 'from') + if member in operation and + isinstance(operation[member], pointer_cls)} + if paths: + return dict(operation, **paths) + return operation if isinstance(operation, dict) else dict(operation) + + class PatchOperation: """A single operation inside a JSON Patch.""" @@ -491,13 +508,17 @@ def _apply(self, obj, journal): def __hash__(self): # Other members can hold arrays or objects, which are unhashable. # Operations that compare equal still have equal 'op' and 'path'. - return hash((self.operation.get('op'), self.operation['path'])) + operation = _plain_operation(self.operation, self.pointer_cls) + return hash((operation.get('op'), operation['path'])) def __eq__(self, other): if not isinstance(other, PatchOperation): return False # e.g. tests for 1 and for true behave differently - return _json_equal(self.operation, other.operation) + # and paths are equal to pointer objects for them + return _json_equal( + _plain_operation(self.operation, self.pointer_cls), + _plain_operation(other.operation, other.pointer_cls)) @property def path(self): @@ -962,8 +983,7 @@ def __init__(self, patch, pointer_cls=JsonPointer): self._get_operation(op) # Keep validation errors tied to the original input, but expose # concrete, JSON-serializable operation dictionaries on success. - if not isinstance(op, dict): - self.patch[index] = dict(op) + self.patch[index] = _plain_operation(op, self.pointer_cls) def __str__(self): """str(self) -> self.to_string()""" diff --git a/tests.py b/tests.py index f0d7581..b9ec438 100644 --- a/tests.py +++ b/tests.py @@ -1967,6 +1967,95 @@ def test_copy_from_pointer(self): ]) self.assertEqual(patch.apply({'foo': 1}), {'foo': 1, 'bar': 1}) + def pointer_and_string_operations(self, pointer_cls=jsonpointer.JsonPointer, + paths=('/foo', '/bar/0', '')): + foo, bar, root = paths + with_strings = [ + {'op': 'add', 'path': foo, 'value': [1]}, + {'op': 'move', 'from': foo, 'path': bar}, + {'op': 'copy', 'from': root, 'path': foo}, + {'op': 'copy', 'from': foo, 'path': bar}, + ] + with_pointers = [ + {'op': 'add', 'path': pointer_cls(foo), 'value': [1]}, + {'op': 'move', 'from': pointer_cls(foo), 'path': pointer_cls(bar)}, + {'op': 'copy', 'from': pointer_cls(root), 'path': foo}, + {'op': 'copy', 'from': foo, 'path': pointer_cls(bar)}, + ] + return with_pointers, with_strings + + def test_patch_with_pointers_is_serializable(self): + with_pointers, with_strings = self.pointer_and_string_operations() + patch = jsonpatch.JsonPatch(with_pointers) + self.assertEqual(patch.patch, with_strings) + self.assertEqual(list(patch), with_strings) + self.assertEqual(json.loads(patch.to_string()), with_strings) + self.assertEqual(json.loads(str(patch)), with_strings) + self.assertEqual(jsonpatch.JsonPatch.from_string(patch.to_string()), patch) + + def test_patch_with_pointers_equals_patch_with_strings(self): + with_pointers, with_strings = self.pointer_and_string_operations() + patch = jsonpatch.JsonPatch(with_pointers) + other = jsonpatch.JsonPatch(with_strings) + self.assertEqual(patch, other) + self.assertEqual(hash(patch), hash(other)) + self.assertEqual(len({patch, other}), 1) + doc = {'bar': []} + self.assertEqual(patch.apply(doc), other.apply(doc)) + + def test_patch_with_pointers_does_not_change_the_operations(self): + with_pointers, _ = self.pointer_and_string_operations() + pointers = [(op, member, op[member]) for op in with_pointers + for member in ('path', 'from') + if isinstance(op.get(member), jsonpointer.JsonPointer)] + jsonpatch.JsonPatch(with_pointers).apply({'bar': []}) + self.assertEqual(len(pointers), 5) + for op, member, pointer in pointers: + self.assertIs(op[member], pointer) + + def test_patch_with_pointers_as_read_only_mappings(self): + with_pointers, with_strings = self.pointer_and_string_operations() + patch = jsonpatch.JsonPatch(map(MappingProxyType, with_pointers)) + self.assertEqual(patch.patch, with_strings) + self.assertTrue(all(type(op) is dict for op in patch)) + + def test_patch_with_pointers_of_custom_class(self): + with_pointers, with_strings = self.pointer_and_string_operations( + DotJsonPointer, ('foo', 'bar.0', '')) + patch = jsonpatch.JsonPatch(with_pointers, pointer_cls=DotJsonPointer) + other = jsonpatch.JsonPatch(with_strings, pointer_cls=DotJsonPointer) + self.assertEqual(patch.patch, with_strings) + self.assertEqual(patch, other) + self.assertEqual(patch.apply({'bar': []}), other.apply({'bar': []})) + + def test_operation_with_pointers_equals_operation_with_strings(self): + with_pointers, with_strings = self.pointer_and_string_operations() + for pointers, strings in zip(with_pointers, with_strings): + with self.subTest(operation=strings): + cls = jsonpatch.JsonPatch.operations[strings['op']] + operation, other = cls(pointers), cls(strings) + self.assertEqual(operation, other) + self.assertEqual(other, operation) + self.assertEqual(hash(operation), hash(other)) + self.assertEqual(operation.location, other.location) + # setters change the operation it was created from, so it + # is not replaced by a copy without pointers + self.assertIs(operation.operation, pointers) + self.assertNotEqual( + jsonpatch.RemoveOperation({'op': 'remove', 'path': jsonpointer.JsonPointer('/a')}), + jsonpatch.RemoveOperation({'op': 'remove', 'path': '/b'})) + + def test_operation_setters_with_pointers(self): + pointers = {'op': 'move', 'from': jsonpointer.JsonPointer('/a/b'), + 'path': jsonpointer.JsonPointer('/c/d')} + operation = jsonpatch.MoveOperation(pointers) + operation.key = 'e' + operation.from_key = 'f' + self.assertEqual(pointers, + {'op': 'move', 'from': '/a/f', 'path': '/c/e'}) + self.assertEqual(operation.apply({'a': {'f': 1}, 'c': {}}), + {'a': {}, 'c': {'e': 1}}) + class JsonPatchCreationTest(unittest.TestCase):