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'}