From 224d70c8664e7f486382c8de4be0d37634c540c5 Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Fri, 24 Jul 2026 23:33:18 -0400 Subject: [PATCH] Fix test operation to respect JSON types (bool vs number) RFC 6902 requires the test operation to only consider values equal when they are of the same JSON type. Because Python treats bool as a subclass of int (True == 1, False == 0), a test against the boolean true/false literal wrongly succeeded against the numbers 1/0 (and vice versa). Introduce a small recursive equality helper that keeps booleans distinct from numbers while still comparing numbers numerically (1 == 1.0) and recursing into arrays and objects. Wire it into TestOperation.apply and add regression tests. --- jsonpatch.py | 30 +++++++++++++++++++++++++++++- tests.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/jsonpatch.py b/jsonpatch.py index d3fc26d..a9564ec 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -98,6 +98,34 @@ class JsonPatchTestFailed(JsonPatchException, AssertionError): """ A Test operation failed """ +def _compare_json_values(left, right): + """Compare two JSON values for equality as required by RFC 6902. + + Two values are only considered equal if they are of the same JSON + type. This needs special handling because in Python ``bool`` is a + subclass of ``int`` (so ``True == 1`` and ``False == 0``), whereas in + JSON the ``true``/``false`` literals are a distinct type from numbers. + Containers are compared recursively so the same rule applies to values + nested inside arrays and objects. + """ + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) \ + and left == right + + if isinstance(left, MutableMapping) and isinstance(right, MutableMapping): + return left.keys() == right.keys() and all( + _compare_json_values(left[key], right[key]) for key in left + ) + + if isinstance(left, MutableSequence) and isinstance(right, MutableSequence): + return len(left) == len(right) and all( + _compare_json_values(lval, rval) + for lval, rval in zip(left, right) + ) + + return left == right + + def multidict(ordered_pairs): """Convert duplicate keys values to lists.""" # read all values into lists @@ -468,7 +496,7 @@ def apply(self, obj): raise InvalidJsonPatch( "The operation does not contain a 'value' member") - if val != value: + if not _compare_json_values(val, value): msg = '{0} ({1}) is not equal to tested value {2} ({3})' raise JsonPatchTestFailed(msg.format(val, type(val), value, type(value))) diff --git a/tests.py b/tests.py index d9eea92..175ac82 100755 --- a/tests.py +++ b/tests.py @@ -222,6 +222,41 @@ def test_test_noval_not_existing_nested(self): jsonpatch.apply_patch, obj, [{'op': 'test', 'path': '/baz/qx'}]) + def test_test_bool_and_int_not_equal(self): + # RFC 6902 requires the tested value to be of the same JSON type, + # so the number 1 must not test-equal the boolean true (in Python + # True == 1), and likewise 0 must not test-equal false. + self.assertRaises(jsonpatch.JsonPatchTestFailed, + jsonpatch.apply_patch, + {'baz': 1}, [{'op': 'test', 'path': '/baz', 'value': True}]) + self.assertRaises(jsonpatch.JsonPatchTestFailed, + jsonpatch.apply_patch, + {'baz': True}, [{'op': 'test', 'path': '/baz', 'value': 1}]) + self.assertRaises(jsonpatch.JsonPatchTestFailed, + jsonpatch.apply_patch, + {'baz': 0}, [{'op': 'test', 'path': '/baz', 'value': False}]) + + def test_test_bool_matches_bool(self): + # Same-typed boolean values still test-equal. + obj = {'baz': True} + res = jsonpatch.apply_patch(obj, [{'op': 'test', 'path': '/baz', 'value': True}]) + self.assertEqual(res, obj) + + def test_test_int_and_float_equal(self): + # Numbers are compared numerically, so 1 and 1.0 test-equal. + obj = {'baz': 1} + res = jsonpatch.apply_patch(obj, [{'op': 'test', 'path': '/baz', 'value': 1.0}]) + self.assertEqual(res, obj) + + def test_test_bool_and_int_nested_not_equal(self): + # The same-type rule also applies to values nested in containers. + self.assertRaises(jsonpatch.JsonPatchTestFailed, + jsonpatch.apply_patch, + {'baz': [1]}, [{'op': 'test', 'path': '/baz', 'value': [True]}]) + self.assertRaises(jsonpatch.JsonPatchTestFailed, + jsonpatch.apply_patch, + {'baz': {'q': 1}}, [{'op': 'test', 'path': '/baz', 'value': {'q': True}}]) + def test_unrecognized_element(self): obj = {'foo': 'bar', 'baz': 'qux'}