Skip to content

fix: distinguish booleans from numbers in JSON Patch tests - #139

Merged
jg-rp merged 8 commits into
jg-rp:mainfrom
edsonsims:fix/json-patch-test-typed-equality
Oct 29, 2026
Merged

jg-rp merged 8 commits into
jg-rp:mainfrom
edsonsims:fix/json-patch-test-typed-equality

Conversation

@edsonsims

Copy link
Copy Markdown
Contributor

JSON Patch test currently uses Python equality, so it accepts true as equal to 1, including inside arrays and objects. RFC 6902 section 4.6 requires the same JSON type recursively. I added a type-aware comparison for this operation while preserving numeric equality such as 1 and 1.0, array order and object member-order independence. JSONPath filter comparisons are unchanged.

The new cases expose 20 failures before the fix. All 45 added tests pass afterward, including unchanged controls, deep/shared containers, stopping a later operation, preserving atomic input and retaining equality semantics for Python string subclasses. Full suites pass 2,875 tests with one existing skip on Python 3.10, 3.12 and 3.13. hatch run lint, hatch run typing, hatch run cov and the package build pass; an installed-wheel probe outside the checkout also passes.

@jg-rp

jg-rp commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Thanks @edsonsims.

What do you think of this alternative implementation of _equal? It:

  • handles any Sequence, not just lists.
  • ensures left and right types are related by inheritance when they are both Sequence or both Mapping (list and tuple do not compare equal, for example).
  • covers {True: "foo"} == {1: "foo"}.
  • moves left is right and pair in seen a bit higher up.
def _equal(left: object, right: object) -> bool:  # noqa: PLR0911, PLR0912
    """Compare JSON values strictly 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

        # bool can not be subclassed.
        if type(left) is bool or type(right) is bool:
            if left is not right:
                return False
            continue

        # Remember strings are sequences too.
        if isinstance(left, (str, bytes)):
            if left == right:
                continue
            return False

        pair = (id(left), id(right))
        if pair in seen:
            continue

        if isinstance(left, Sequence):
            if not (
                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):
            if not (
                issubclass(type(left), type(right))
                or issubclass(type(right), type(left))
            ):
                return False

            if left.keys() != right.keys():
                return False

            seen.add(pair)

            for key in left:
                if type(key) is bool and type(right[key]) is not bool:
                    return False
                pending.append((left[key], right[key]))

        else:
            if left == right:
                continue
            return False

    return True

@edsonsims

Copy link
Copy Markdown
Contributor Author

Thanks, I adopted the Sequence/Mapping inheritance checks and earlier identity/seen handling in b21bd02. The mapping check compares the actual Boolean keys on both sides, rather than the value at right[key]. Matching {True: "foo"} maps still pass; True/1 and False/0 keys fail in both directions, including when their values are booleans.

I made the str/bytes equality fallback symmetric, preserving UserString/str and bytearray/bytes comparisons in either direction. This is an explicit exception to the container inheritance rule. The early identity shortcut follows your proposal, including non-reflexive Python objects; these builder extensions are separate from RFC JSON semantics.

Validation: all 73 added cases pass (37 fail against the previous PR head). The full suite passes 2,948 tests with one skip on each tested Python 3.10/3.12/3.13 environment. Ruff, formatting, strict Mypy, the official dual-configuration coverage workflow and installed-wheel checks pass. Iterative deep/cyclic container handling is retained. The PR description is unchanged.

jg-rp added 6 commits October 28, 2026 06:45
`test_json_patch_rfc6902.py` contains tests derived from the RFC and
carries a copy of the IETF lisense.
We treat Mypy as the canonical type checker, but strive to please other
common type chekers too.
@jg-rp

jg-rp commented Oct 28, 2026

Copy link
Copy Markdown
Owner

👍 I've tweaked it a bit (see commit messages).

In the future I plan to make JSON-like equality a configurable option. isinstance() on abstract types is significantly slower than isinstance() on concrete types. In many cases type(x) is dict and type(x) is list is all that's needed, which is faster still.

@jg-rp

jg-rp commented Oct 28, 2026

Copy link
Copy Markdown
Owner

For the record, the performance hit from the current, iterative implementation of _equal() which handles abstract Mapping and Sequence types is substantial.

Test Payload                        | Built-in (==) | Custom        | Slowdown  
--------------------------------------------------------------------------------
Scalar (str)                        |      0.019 µs |      0.124 µs |      6.6x
Small JSON Object                   |      0.060 µs |      2.703 µs |     45.3x
Large Flat Dict (1,000 keys)        |     17.798 µs |    680.552 µs |     38.2x
Large List of Dicts (1,000 items)   |     34.179 µs |   1905.644 µs |     55.8x
Deeply Nested Dict (Depth 100)      |      2.721 µs |    118.225 µs |     43.4x

A recursive alternative that only handles list, dict and scalar types (as you'd get from json.load) does a lot better.

Test Payload                        | Built-in (==) | Custom        | Slowdown  
--------------------------------------------------------------------------------
Scalar (str)                        |      0.019 µs |      0.041 µs |      2.2x
Small JSON Object                   |      0.066 µs |      0.619 µs |      9.3x
Large Flat Dict (1,000 keys)        |     18.204 µs |     88.029 µs |      4.8x
Large List of Dicts (1,000 items)   |     34.119 µs |    334.607 µs |      9.8x
Deeply Nested Dict (Depth 100)      |      2.736 µs |     16.254 µs |      5.9x

An iterative version is somewhere in between.

Test Payload                        | Built-in (==) | Custom        | Slowdown  
--------------------------------------------------------------------------------
Scalar (str)                        |      0.019 µs |      0.038 µs |      2.0x
Small JSON Object                   |      0.061 µs |      1.163 µs |     19.2x
Large Flat Dict (1,000 keys)        |     18.300 µs |    135.691 µs |      7.4x
Large List of Dicts (1,000 items)   |     34.085 µs |    524.315 µs |     15.4x
Deeply Nested Dict (Depth 100)      |      2.737 µs |     33.780 µs |     12.3x

This kind of JSON-like deep equality is not limited to JSON Patch test ops. It will need to be applied to JSONPath == too. In both cases developer's should be able to choose between best performance and strict adherence to the respective specifications.

For now I'm inclined to lean towards correctness and safeness (non-recursive).

@jg-rp
jg-rp merged commit bd91a15 into jg-rp:main Oct 29, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants