From ccb52288605e79bcb25845fac5c8d74861cd4364 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:06:04 +0000 Subject: [PATCH] Hash patch operations by their 'op' and 'path' (#217) PatchOperation.__hash__ hashed a frozenset of all members, so hashing a patch raised TypeError when an operation's value was an array or object. Such patches couldn't go into sets or serve as dict keys. Hash only 'op' and 'path'. They are always hashable, since 'op' must be a string and 'path' a valid pointer, and operations that compare equal have equal 'op' and 'path', so their hashes stay equal. 'from' is left out: it is only validated when a move or copy is applied, and the RFC has other operations ignore it, so it can hold an array or object too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011nVTdpJFksiRYmZDJrV9SM --- jsonpatch.py | 4 +++- property_tests.py | 2 -- tests.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 5383aec..47e99a0 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -190,7 +190,9 @@ def apply(self, obj): raise NotImplementedError('should implement the patch operation.') def __hash__(self): - return hash(frozenset(self.operation.items())) + # 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'])) def __eq__(self, other): if not isinstance(other, PatchOperation): diff --git a/property_tests.py b/property_tests.py index 9e92ebf..bbd086c 100644 --- a/property_tests.py +++ b/property_tests.py @@ -450,8 +450,6 @@ def test_string_roundtrip(self, operations): self.assertEqual(jsonpatch.JsonPatch.from_string(patch.to_string()), patch) - # hashing fails for operations whose value is an array or object - @unittest.expectedFailure @given(patches) @example(operations=[{'op': 'add', 'path': '/a', 'value': []}]) def test_equal_patches_have_equal_hashes(self, operations): diff --git a/tests.py b/tests.py index af42fbc..9f6e8d0 100755 --- a/tests.py +++ b/tests.py @@ -375,6 +375,20 @@ def test_patch_hash_unequal(self): self.assertNotEqual(hash(patch1), hash(patch2)) + def test_patch_hash_container_values(self): + for value in ([], {}, [1, {'b': []}], {'b': [1]}): + patch1 = jsonpatch.JsonPatch([{'op': 'add', 'path': '/a', 'value': value}]) + patch2 = jsonpatch.JsonPatch([{'op': 'add', 'path': '/a', 'value': copy.deepcopy(value)}]) + self.assertEqual(hash(patch1), hash(patch2)) + self.assertEqual(len({patch1, patch2}), 1) + + + def test_patch_hash_ignored_container_member(self): + # members an operation doesn't use are ignored, whatever their value + patch = jsonpatch.JsonPatch([{'op': 'add', 'path': '/a', 'value': 1, 'from': []}]) + self.assertIn(patch, {patch}) + + def test_patch_neq_other_objs(self): p = [{'op': 'test', 'path': '/test'}] patch = jsonpatch.JsonPatch(p)