Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down
80 changes: 79 additions & 1 deletion jsonpath/patch.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""JSON Patch, as per RFC 6902."""
# pyright: reportUnknownArgumentType=false

from __future__ import annotations

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
92 changes: 92 additions & 0 deletions tests/test_json_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
124 changes: 124 additions & 0 deletions tests/test_json_patch_equality.py
Original file line number Diff line number Diff line change
@@ -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)
Loading