diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7adb6..3d090aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Python JSONPath Change Log +# Unreleased + +**Fixes** + +- JSON Patch `test` now distinguishes booleans from numbers, including inside arrays and objects, while preserving numeric equality such as `1` and `1.0`. + # Version 2.2.2 **Fixes** diff --git a/jsonpath/patch.py b/jsonpath/patch.py index b56a7e7..b85034e 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -1,4 +1,5 @@ """JSON Patch, as per RFC 6902.""" +# pyright: reportUnknownArgumentType=false from __future__ import annotations @@ -14,6 +15,9 @@ from typing import Mapping from typing import MutableMapping from typing import MutableSequence +from typing import Sequence +from typing import Set +from typing import Tuple from typing import TypeVar from typing import Union @@ -269,7 +273,7 @@ def apply( ) -> Union[MutableSequence[object], MutableMapping[str, object]]: """Apply this patch operation to _data_.""" _, obj = self.path.resolve_parent(data) - if not obj == self.value: + if not _equal(obj, self.value): raise JSONPatchTestFailure return data @@ -822,3 +826,77 @@ def _remove(*, data: _DataT, path: JSONPointer, op: str) -> object: f"unexpected operation on {parent.__class__.__name__!r}", ) return obj + + +def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 + """Compare JSON values without treating booleans as numbers.""" + pending = [(left, right)] + seen: Set[Tuple[int, int]] = set() + + while pending: + left, right = pending.pop() + if left is right: + continue + + if type(left) is bool or type(right) is bool: + return False + + if isinstance(left, (str, bytes)) or isinstance(right, (str, bytes)): + if left == right: + continue + return False + + pair = (id(left), id(right)) + if pair in seen: + continue + + if isinstance(left, Sequence): + if not isinstance(right, Sequence): + return False + + if len(left) != len(right): + return False + + if not ( + issubclass(type(left), type(right)) + or issubclass(type(right), type(left)) + ): + return False + + seen.add(pair) + pending.extend(zip(left, right)) + + elif isinstance(left, Mapping): + if not isinstance(right, Mapping): + return False + + if len(left) != len(right): + return False + + if not ( + issubclass(type(left), type(right)) + or issubclass(type(right), type(left)) + ): + return False + + right_keys: dict[object, object] = {key: key for key in right} # pyright: ignore[reportUnknownVariableType] + + seen.add(pair) + + for left_key, left_value in left.items(): # pyright: ignore[reportUnknownVariableType] + right_key = right_keys.get(left_key, UNDEFINED) + + if right_key is UNDEFINED: + return False + + # Handle bool and composite keys too. + pending.append((left_key, right_key)) + pending.append((left_value, right[right_key])) + + else: + # Scalar subclasses can define inequality independently of equality. + if left == right: + continue + return False + + return True diff --git a/tests/test_json_patch.py b/tests/test_json_patch.py index 1e6c94f..48b7bdd 100644 --- a/tests/test_json_patch.py +++ b/tests/test_json_patch.py @@ -17,6 +17,7 @@ from jsonpath import JSONPatch from jsonpath import patch from jsonpath.exceptions import JSONPatchError +from jsonpath.exceptions import JSONPatchTestFailure class MockMapping(Mapping): # type: ignore @@ -466,3 +467,94 @@ def test_patched_does_not_mutate_data() -> None: assert data == {"a": {"b": {"c": 1}}} assert patched_data == {"a": {"b": {"c": 42, "d": 2}}} + + +@pytest.mark.parametrize( + ("actual", "expected", "matches"), + [ + (True, 1, False), + (1, True, False), + (False, 0, False), + (0, False, False), + (True, 1.0, False), + (0.0, False, False), + ([True], [1], False), + ({"nested": False}, {"nested": 0}, False), + ({"nested": [{"flag": True}]}, {"nested": [{"flag": 1}]}, False), + (None, None, True), + (True, True, True), + (False, False, True), + (1, 1.0, True), + (0, -0.0, True), + ("value", "value", True), + ([True, 1, None], [True, 1.0, None], True), + ({"b": [2], "a": False}, {"a": False, "b": [2.0]}, True), + ([1, 2], [2, 1], False), + ({"a": True}, {"b": True}, False), + ([1], [1, 2], False), + ], +) +@pytest.mark.parametrize("serialized", [False, True]) +def test_test_op_json_equality( + actual: object, expected: object, *, matches: bool, serialized: bool +) -> None: + """Test uses JSON equality, including inside arrays and objects.""" + patch = ( + JSONPatch(json.dumps([{"op": "test", "path": "/value", "value": expected}])) + if serialized + else JSONPatch().test("/value", expected) + ) + data = {"value": actual} + if matches: + assert patch.apply(data) is data + else: + with pytest.raises(JSONPatchTestFailure): + patch.apply(data) + + +def test_test_op_deep_json_equality() -> None: + """Nested JSON comparisons do not consume a Python frame per level.""" + actual: object = 1 + expected: object = 1.0 + for _ in range(600): + actual = [actual] + expected = [expected] + data = {"value": actual} + assert JSONPatch().test("/value", expected).apply(data) is data + + +def test_test_op_shared_containers() -> None: + """Repeated references compare by their JSON contents.""" + left: dict[str, object] = {"value": [1, True]} + right: dict[str, object] = {"value": [1.0, True]} + data = {"value": [left, left]} + assert JSONPatch().test("/value", [right, right]).apply(data) is data + + +def test_test_op_boolean_mismatch_stops_patch() -> None: + """A failing boolean/number comparison stops later operations.""" + data: dict[str, object] = {"flag": True, "name": "original"} + patch = JSONPatch().test("/flag", 1).replace("/name", "changed") + with pytest.raises(JSONPatchTestFailure, match=re.escape("test failed (test:0)")): + patch.apply(data) + assert data == {"flag": True, "name": "original"} + + +def test_test_op_boolean_mismatch_is_atomic() -> None: + """Atomic application preserves input when a later comparison fails.""" + data: dict[str, object] = {"flag": True, "name": "original"} + patch = JSONPatch().replace("/name", "changed").test("/flag", 1) + with pytest.raises(JSONPatchTestFailure): + patch.atomic(data) + assert data == {"flag": True, "name": "original"} + + +def test_test_op_uses_equality_for_string_subclasses() -> None: + """The Python builder keeps equality semantics for string subclasses.""" + + class StringValue(str): + def __ne__(self, _other: object) -> bool: + return True + + data = {"value": StringValue("same")} + assert JSONPatch().test("/value", "same").apply(data) is data diff --git a/tests/test_json_patch_equality.py b/tests/test_json_patch_equality.py new file mode 100644 index 0000000..cddd29d --- /dev/null +++ b/tests/test_json_patch_equality.py @@ -0,0 +1,124 @@ +"""JSON Patch equality for Python containers.""" + +from collections import OrderedDict +from collections import UserDict +from collections import UserList +from collections import UserString +from types import MappingProxyType +from typing import Dict +from typing import List + +import pytest + +from jsonpath import JSONPatch +from jsonpath.exceptions import JSONPatchTestFailure + + +class ListValue(List[object]): + pass + + +class OtherListValue(List[object]): + pass + + +class DictValue(Dict[str, object]): + pass + + +class OtherDictValue(Dict[str, object]): + pass + + +@pytest.mark.parametrize( + ("actual", "expected", "matches"), + [ + ((True,), (1,), False), + (([False],), ([0],), False), + (UserList([True]), UserList([1]), False), + (UserList([1, False]), UserList([1.0, False]), True), + ((1, False), (1.0, False), True), + ([1], (1,), False), + ([1], 1, False), + ({"a": 1}, 1, False), + ([], (), False), + ([1], UserList([1]), False), + (ListValue([1, True]), [1.0, True], True), + (ListValue([1]), OtherListValue([1]), False), + ({"a": 1}, UserDict({"a": 1}), False), + ({"a": 1}, MappingProxyType({"a": 1}), False), + (UserDict({"a": True}), UserDict({"a": 1}), False), + (UserDict({"a": 1}), UserDict({"a": 1.0}), True), + (DictValue(a=1), {"a": 1.0}, True), + (DictValue(a=1), OtherDictValue(a=1), False), + (OrderedDict([("a", 1), ("b", True)]), {"b": True, "a": 1.0}, True), + ({1: "foo"}, {1.0: "foo"}, True), + (UserString("value"), "value", True), + (bytearray(b"value"), b"value", True), + ("value", b"value", False), + ({(True, False): "a"}, {(1, 0): "a"}, False), + ], +) +@pytest.mark.parametrize("reverse", [False, True]) +def test_python_container_equality( + actual: object, expected: object, *, matches: bool, reverse: bool +) -> None: + if reverse: + actual, expected = expected, actual + data = {"value": actual} + patch = JSONPatch().test("/value", expected) + if matches: + assert patch.apply(data) is data + else: + with pytest.raises(JSONPatchTestFailure): + patch.apply(data) + + +@pytest.mark.parametrize(("boolean", "number"), [(True, 1), (False, 0), (True, 1.0)]) +@pytest.mark.parametrize("value", ["foo", True, 1]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_boolean_mapping_key_is_not_numeric( + *, boolean: bool, number: float, value: object, reverse: bool +) -> None: + actual = {boolean: value} + expected = {number: value} + left, right = (expected, actual) if reverse else (actual, expected) + with pytest.raises(JSONPatchTestFailure): + JSONPatch().test("/value", right).apply({"value": left}) + + +@pytest.mark.parametrize("key", [False, True]) +@pytest.mark.parametrize("value", ["foo", True, 1]) +def test_matching_boolean_mapping_keys(value: object, *, key: bool) -> None: + data = {"value": {key: value}} + assert JSONPatch().test("/value", {key: value}).apply(data) is data + + +def test_deep_python_sequences() -> None: + actual: object = True + expected: object = 1 + for _ in range(600): + actual = UserList([actual]) + expected = UserList([expected]) + with pytest.raises(JSONPatchTestFailure): + JSONPatch().test("/value", expected).apply({"value": actual}) + + +def test_recursive_python_sequences() -> None: + actual: UserList[object] = UserList() + expected: UserList[object] = UserList() + actual.append(actual) + expected.append(expected) + data = {"value": actual} + assert JSONPatch().test("/value", expected).apply(data) is data + expected.append(False) + with pytest.raises(JSONPatchTestFailure): + JSONPatch().test("/value", expected).apply(data) + + +def test_identical_python_scalar() -> None: + value = float("nan") + data = {"value": value} + assert JSONPatch().test("/value", value).apply(data) is data + with pytest.raises(JSONPatchTestFailure): + JSONPatch().test("/value", float("nan")).apply(data)