From 12574b542cf496639747c1908506d921ffb4b46b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:04:17 +0000 Subject: [PATCH] Only reject '-' in replace when it refers to an array (#212) ReplaceOperation.apply rejected every path ending in '-', including paths where '-' names an object member. RFC 6901 gives '-' a special meaning only for arrays, so make_patch could emit a replace of an object member '-' that apply then refused. Move the check into the array branch. Replacing a missing '-' member of an object now raises JsonPatchConflict, like any other missing member, instead of InvalidJsonPatch. Drop the expectedFailure markers from test_roundtrip and test_replace_any_existing_location, which pass now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AFifAtR66mm468E2xYSRmw --- jsonpatch.py | 8 +++++--- property_tests.py | 6 ++---- tests.py | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/jsonpatch.py b/jsonpatch.py index 5383aec..c8084da 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -304,10 +304,12 @@ def apply(self, obj): if part is None: return value - if part == "-": - raise InvalidJsonPatch("'path' with '-' can't be applied to 'replace' operation") - if isinstance(subobj, MutableSequence): + # '-' only refers to the (nonexistent) element after the end of + # an array; for an object it is an ordinary member name + if part == "-": + raise InvalidJsonPatch("'path' with '-' can't be applied to 'replace' operation") + if part >= len(subobj) or part < 0: raise JsonPatchConflict("can't replace outside of list") diff --git a/property_tests.py b/property_tests.py index 9e92ebf..e4a24bb 100644 --- a/property_tests.py +++ b/property_tests.py @@ -284,9 +284,8 @@ def test_diff_of_equal_documents_is_empty(self, doc): self.assertEqual(list(jsonpatch.make_patch(doc, copy.deepcopy(doc))), []) - @unittest.expectedFailure @given(doc_pairs) - # replace of the object member '-' is rejected + # replace of the object member '-' was rejected, #212 @example(docs=({'-': 0}, {'-': 1})) def test_roundtrip(self, docs): self.check_roundtrip(*docs) @@ -417,9 +416,8 @@ def test_move_into_own_child_fails(self, case): jsonpatch.apply_patch( doc, [{'op': 'move', 'from': source, 'path': target}]) - # '-' is rejected even where it is an object key, not an array index - @unittest.expectedFailure @given(docs_with_locations(), json_docs) + # '-' was rejected even where it is an object key, not an array index, #212 @example(case=({'-': None}, ['-']), value=0) def test_replace_any_existing_location(self, case, value): doc, parts = case diff --git a/tests.py b/tests.py index af42fbc..f7a8287 100755 --- a/tests.py +++ b/tests.py @@ -95,6 +95,19 @@ def test_replace_object_key(self): res = jsonpatch.apply_patch(obj, [{'op': 'replace', 'path': '/baz', 'value': 'boo'}]) self.assertTrue(res['baz'], 'boo') + def test_replace_object_key_dash(self): + # '-' only has a special meaning for arrays (#212) + obj = {'foo': {'-': 'bar', 'baz': 'qux'}} + res = jsonpatch.apply_patch(obj, [{'op': 'replace', 'path': '/foo/-', + 'value': 'boo'}]) + self.assertEqual(res, {'foo': {'-': 'boo', 'baz': 'qux'}}) + + def test_replace_array_dash(self): + obj = {'foo': ['bar', 'qux']} + with self.assertRaises(jsonpatch.InvalidJsonPatch): + jsonpatch.apply_patch(obj, [{'op': 'replace', 'path': '/foo/-', + 'value': 'boo'}]) + def test_replace_whole_document(self): obj = {'foo': 'bar'} res = jsonpatch.apply_patch(obj, [{'op': 'replace', 'path': '', 'value': {'baz': 'qux'}}]) @@ -847,6 +860,11 @@ def test_issue_179(self): with self.subTest(old=old, new=new): self.assertMakesPatch(old, new) + def test_issue_212(self): + """A changed object member named '-' is replaced, not rejected.""" + self.assertMakesPatch({'-': 0}, {'-': 1}) + self.assertMakesPatch({'a': {'-': [1]}}, {'a': {'-': [2]}}) + def test_move_with_numeric_object_keys(self): """Object keys that look like array indices are not shifted.""" self.assertMakesPatch({'0': None, 'a': []}, {'1': [], 'a': [None]}) @@ -1308,6 +1326,10 @@ def test_replace_operation_structure(self): jsonpatch.ReplaceOperation({'path': '/'}).apply({}) with self.assertRaises(jsonpatch.InvalidJsonPatch): + jsonpatch.ReplaceOperation({'path': '/top/-', 'value': 'foo'}).apply({'top': ['value']}) + + # for an object, '-' is an ordinary member name (#212) + with self.assertRaises(jsonpatch.JsonPatchConflict): jsonpatch.ReplaceOperation({'path': '/top/-', 'value': 'foo'}).apply({'top': {'inner': 'value'}}) with self.assertRaises(jsonpatch.JsonPatchConflict):