From 98fb2397e54cea301f34d8ddd47a6a3719b7f269 Mon Sep 17 00:00:00 2001 From: Edson Henrique Date: Mon, 26 Oct 2026 16:56:05 -0400 Subject: [PATCH 1/8] fix: compare JSON Patch test values by JSON type --- CHANGELOG.md | 6 +++ jsonpath/patch.py | 41 +++++++++++++- tests/test_json_patch_rfc6902.py | 92 ++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) 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..1b3013e 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -14,6 +14,8 @@ from typing import Mapping from typing import MutableMapping from typing import MutableSequence +from typing import Set +from typing import Tuple from typing import TypeVar from typing import Union @@ -269,7 +271,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 +824,40 @@ 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: + """Compare JSON values without treating booleans as numbers.""" + pending = [(left, right)] + seen: Set[Tuple[int, int]] = set() + while pending: + left, right = pending.pop() + if isinstance(left, bool) or isinstance(right, bool): + if left is not right: + return False + continue + + children: Iterable[Tuple[object, object]] + if isinstance(left, list) and isinstance(right, list): + if len(left) != len(right): + return False + if left is right: + continue + children = zip(left, right) + elif isinstance(left, Mapping) and isinstance(right, Mapping): + if left.keys() != right.keys(): + return False + if left is right: + continue + children = ((left[key], right[key]) for key in left) + else: + # Scalar subclasses can define inequality independently of equality. + if left == right: + continue + return False + + pair = (id(left), id(right)) + if pair not in seen: + seen.add(pair) + pending.extend(children) + return True diff --git a/tests/test_json_patch_rfc6902.py b/tests/test_json_patch_rfc6902.py index 4ce201b..c0fd3bc 100644 --- a/tests/test_json_patch_rfc6902.py +++ b/tests/test_json_patch_rfc6902.py @@ -33,6 +33,7 @@ import copy import dataclasses +import json import re from operator import attrgetter from typing import Dict @@ -198,3 +199,94 @@ def test_json_patch_constructor(case: Case) -> None: patch = JSONPatch([case.op]) assert len(patch.ops) == 1 assert patch.apply(copy.deepcopy(case.data)) == case.want + + +@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 = {"value": [1, True]} + right = {"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 = {"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 = {"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 From b21bd02c973c68c34d65141c8ccc337562ee024d Mon Sep 17 00:00:00 2001 From: Edson Henrique Date: Tue, 27 Oct 2026 16:15:08 -0400 Subject: [PATCH 2/8] fix: follow maintainer container equality policy --- jsonpath/patch.py | 55 ++++++++----- tests/test_json_patch_equality.py | 123 ++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 tests/test_json_patch_equality.py diff --git a/jsonpath/patch.py b/jsonpath/patch.py index 1b3013e..d7c627e 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -14,6 +14,7 @@ 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 @@ -826,38 +827,58 @@ def _remove(*, data: _DataT, path: JSONPointer, op: str) -> object: return obj -def _equal(left: object, right: object) -> bool: +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 isinstance(left, bool) or isinstance(right, bool): - if left is not right: - return False + if left is right: continue - children: Iterable[Tuple[object, object]] - if isinstance(left, list) and isinstance(right, list): + 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) and isinstance(right, Sequence): + if not ( + issubclass(type(left), type(right)) + or issubclass(type(right), type(left)) + ): + return False if len(left) != len(right): return False - if left is right: - continue - children = zip(left, right) + seen.add(pair) + pending.extend(zip(left, right)) elif isinstance(left, Mapping) and isinstance(right, Mapping): + if not ( + issubclass(type(left), type(right)) + or issubclass(type(right), type(left)) + ): + return False if left.keys() != right.keys(): return False - if left is right: - continue - children = ((left[key], right[key]) for key in left) + if {key for key in left if type(key) is bool} != { + key for key in right if type(key) is bool + }: + return False + seen.add(pair) + pending.extend((left[key], right[key]) for key in left) + elif isinstance(left, (Sequence, Mapping)) or isinstance( + right, (Sequence, Mapping) + ): + return False else: # Scalar subclasses can define inequality independently of equality. if left == right: continue return False - - pair = (id(left), id(right)) - if pair not in seen: - seen.add(pair) - pending.extend(children) return True diff --git a/tests/test_json_patch_equality.py b/tests/test_json_patch_equality.py new file mode 100644 index 0000000..719b31c --- /dev/null +++ b/tests/test_json_patch_equality.py @@ -0,0 +1,123 @@ +"""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), + ], +) +@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) From ceea31e0716d8109bd16f5e7a0635b35b7a7e888 Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 28 Oct 2026 06:45:23 +0000 Subject: [PATCH 3/8] Move patch tests out of the rfc6902 module. `test_json_patch_rfc6902.py` contains tests derived from the RFC and carries a copy of the IETF lisense. --- tests/test_json_patch.py | 92 ++++++++++++++++++++++++++++++++ tests/test_json_patch_rfc6902.py | 92 -------------------------------- 2 files changed, 92 insertions(+), 92 deletions(-) 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_rfc6902.py b/tests/test_json_patch_rfc6902.py index c0fd3bc..4ce201b 100644 --- a/tests/test_json_patch_rfc6902.py +++ b/tests/test_json_patch_rfc6902.py @@ -33,7 +33,6 @@ import copy import dataclasses -import json import re from operator import attrgetter from typing import Dict @@ -199,94 +198,3 @@ def test_json_patch_constructor(case: Case) -> None: patch = JSONPatch([case.op]) assert len(patch.ops) == 1 assert patch.apply(copy.deepcopy(case.data)) == case.want - - -@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 = {"value": [1, True]} - right = {"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 = {"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 = {"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 From 2caac5e85aabd57768fe63e3416a9a1c1347836f Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 28 Oct 2026 06:50:58 +0000 Subject: [PATCH 4/8] Suppress some pyright errors. We treat Mypy as the canonical type checker, but strive to please other common type chekers too. --- jsonpath/patch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/jsonpath/patch.py b/jsonpath/patch.py index d7c627e..c08bd87 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 @@ -850,7 +851,7 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 if isinstance(left, Sequence) and isinstance(right, Sequence): if not ( - issubclass(type(left), type(right)) + issubclass(type(left), type(right)) # pyright: ignore[reportUnknownArgumentType] or issubclass(type(right), type(left)) ): return False @@ -858,6 +859,7 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 return False seen.add(pair) pending.extend(zip(left, right)) + elif isinstance(left, Mapping) and isinstance(right, Mapping): if not ( issubclass(type(left), type(right)) @@ -872,6 +874,7 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 return False seen.add(pair) pending.extend((left[key], right[key]) for key in left) + elif isinstance(left, (Sequence, Mapping)) or isinstance( right, (Sequence, Mapping) ): From 4b115d3cc687da11acd84ec29838c9723357ee20 Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 28 Oct 2026 07:15:18 +0000 Subject: [PATCH 5/8] Check `len()` before `isinstance()`. `len()` is cheaper. --- jsonpath/patch.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/jsonpath/patch.py b/jsonpath/patch.py index c08bd87..ad6ef30 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -850,28 +850,38 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 continue if isinstance(left, Sequence) and isinstance(right, Sequence): + if len(left) != len(right): + return False + if not ( - issubclass(type(left), type(right)) # pyright: ignore[reportUnknownArgumentType] + issubclass(type(left), type(right)) or issubclass(type(right), type(left)) ): return False - if len(left) != len(right): - return False + seen.add(pair) pending.extend(zip(left, right)) elif isinstance(left, Mapping) and isinstance(right, Mapping): + if len(left) != len(right): + return False + if not ( issubclass(type(left), type(right)) or issubclass(type(right), type(left)) ): return False + if left.keys() != right.keys(): return False + + # These sets will have a maximum size of 2 each, {True, False}. + # But we're still iterating all of left and all of right, again. if {key for key in left if type(key) is bool} != { key for key in right if type(key) is bool }: return False + seen.add(pair) pending.extend((left[key], right[key]) for key in left) From c7b99ecbce109a8b1a57928604cb96d63dcbcbdc Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 28 Oct 2026 07:36:11 +0000 Subject: [PATCH 6/8] Try hard to iterate left and right mapping just once --- jsonpath/patch.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/jsonpath/patch.py b/jsonpath/patch.py index ad6ef30..890248d 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -832,6 +832,7 @@ 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: @@ -875,20 +876,29 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 if left.keys() != right.keys(): return False - # These sets will have a maximum size of 2 each, {True, False}. - # But we're still iterating all of left and all of right, again. - if {key for key in left if type(key) is bool} != { - key for key in right if type(key) is bool - }: - return False + right_keys: dict[object, object] = {key: key for key in right} # pyright: ignore[reportUnknownVariableType] seen.add(pair) - pending.extend((left[key], right[key]) for key in left) + 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 + + type_left_key = type(left_key) # pyright: ignore[reportUnknownVariableType] + type_right_key = type(right_key) + if ( + type_left_key is bool or type_right_key is bool + ) and type_left_key is not type_right_key: + return False + + pending.append((left_value, right[right_key])) elif isinstance(left, (Sequence, Mapping)) or isinstance( right, (Sequence, Mapping) ): return False + else: # Scalar subclasses can define inequality independently of equality. if left == right: From 6dcd88085080ce7edbff08dc5d17fcec8dbfa225 Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 28 Oct 2026 07:45:34 +0000 Subject: [PATCH 7/8] Reduce then number of `isinstance()` calls. --- jsonpath/patch.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/jsonpath/patch.py b/jsonpath/patch.py index 890248d..c371003 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -850,7 +850,10 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 if pair in seen: continue - if isinstance(left, Sequence) and isinstance(right, Sequence): + if isinstance(left, Sequence): + if not isinstance(right, Sequence): + return False + if len(left) != len(right): return False @@ -863,7 +866,10 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 seen.add(pair) pending.extend(zip(left, right)) - elif isinstance(left, Mapping) and isinstance(right, Mapping): + elif isinstance(left, Mapping): + if not isinstance(right, Mapping): + return False + if len(left) != len(right): return False @@ -873,9 +879,6 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 ): return False - if left.keys() != right.keys(): - return False - right_keys: dict[object, object] = {key: key for key in right} # pyright: ignore[reportUnknownVariableType] seen.add(pair) @@ -894,14 +897,10 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 pending.append((left_value, right[right_key])) - elif isinstance(left, (Sequence, Mapping)) or isinstance( - right, (Sequence, Mapping) - ): - return False - else: # Scalar subclasses can define inequality independently of equality. if left == right: continue return False + return True From 376eb4d435146361f136086842c191df603c179f Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 28 Oct 2026 07:50:54 +0000 Subject: [PATCH 8/8] Handle composite mapping keys containing `bool` --- jsonpath/patch.py | 10 +++------- tests/test_json_patch_equality.py | 1 + 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/jsonpath/patch.py b/jsonpath/patch.py index c371003..b85034e 100644 --- a/jsonpath/patch.py +++ b/jsonpath/patch.py @@ -882,19 +882,15 @@ def _equal(left: object, right: object) -> bool: # noqa: PLR0911, PLR0912 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 - type_left_key = type(left_key) # pyright: ignore[reportUnknownVariableType] - type_right_key = type(right_key) - if ( - type_left_key is bool or type_right_key is bool - ) and type_left_key is not type_right_key: - return False - + # Handle bool and composite keys too. + pending.append((left_key, right_key)) pending.append((left_value, right[right_key])) else: diff --git a/tests/test_json_patch_equality.py b/tests/test_json_patch_equality.py index 719b31c..cddd29d 100644 --- a/tests/test_json_patch_equality.py +++ b/tests/test_json_patch_equality.py @@ -56,6 +56,7 @@ class OtherDictValue(Dict[str, object]): (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])