"""Apply RFC 6902 JSON Patch documents to fixtures, and print one key=value per line. ONE INTERPRETER START DOES THE WHOLE OF IT. Every python3 start on this arm is a fresh load of one large static image (99-python3 records the cost), so the suite that drives this asserts on the OUTPUT rather than calling python3 once per case. Every line is `key=value`; a key is emitted at most once. The suite reads the keys it names and ignores the rest, so adding a fact here cannot break it. Failure is reported as a key too -- `*_error` -- rather than only as an exit status, because the case that reds should say which stage stopped and why. VALUES ARE EMITTED AS json.dumps WITH SORTED KEYS, NEVER AS A repr. A patched document is a dict or a list, and a Python repr of either carries quoting that is Python's rather than JSON's -- and for a dict it would carry INSERTION ORDER, which after an `add' is a property of where the key was inserted rather than of what the patch produced. sort_keys makes every document assertion a statement about CONTENT. THE PATCH THIS PRODUCES IS EMITTED IN DOCUMENT ORDER, NOT SORTED. The one place order is load-bearing is `make_patch': a patch is a SEQUENCE of operations and reordering it changes what it does, so `made_patch' below is dumped with the operation list intact and only the keys inside each operation sorted. """ import copy import json import sys _seen = set() def emit(key, value): if key in _seen: return _seen.add(key) # One line per fact: the suite greps by key, and an embedded newline would # land as a second, unkeyed line. print("%s=%s" % (key, str(value).replace("\n", " ").replace("\r", " "))) def fail(key, exc): emit(key, "%s: %s" % (type(exc).__name__, exc)) raise SystemExit(1) # ------------------------------------------------------------------ the import try: import jsonpatch from jsonpatch import (apply_patch, make_patch, JsonPatch, JsonPatchException, InvalidJsonPatch, JsonPatchConflict, JsonPatchTestFailed) except Exception as exc: # noqa: BLE001 - reported, not raised fail("import_error", exc) emit("version", jsonpatch.__version__) emit("module_file", getattr(jsonpatch, "__file__", "")) # jsonpointer is the DEPENDENCY THIS PORT SITS ON (BRINTOS-1916), and every # `path' in every operation below is resolved by it. Reporting its version here # is what lets a red in an addressing case be told apart from a red in the patch # logic -- the two packages version independently and the RDEPENDS names no # minimum beyond upstream's `jsonpointer>=1.9'. try: import jsonpointer emit("jsonpointer_version", jsonpointer.__version__) except Exception as exc: # noqa: BLE001 emit("jsonpointer_error", "%s: %s" % (type(exc).__name__, exc)) # The installed metadata, read independently of the module's own __version__. # The two disagreeing means a mixed install -- a dist-info from one version # beside a module from another. try: from importlib.metadata import version as _dist_version emit("dist_version", _dist_version("jsonpatch")) except Exception as exc: # noqa: BLE001 emit("dist_version_error", "%s: %s" % (type(exc).__name__, exc)) # THE PUBLIC NAMES, ONE KEY EACH -- NOT dir() AS A WHOLE. # # This module declares no __all__, so dir() reports what is BOUND rather than # what is exported: `copy', `json', `sys', `re', the `collections.abc' names and # the Python-2 compatibility shims `basestring' and `unicode'. Asserting that # list would pin the implementation's import statements into a case that is # supposed to be about the API, so each name this port promises is asserted on # its own. The six operation CLASSES are included: a caller subclassing one to # add an operation is the documented extension point, and it is the only part of # the surface no behavioural case below would notice the loss of. for _n in ("apply_patch", "make_patch", "JsonPatch", "PatchOperation", "JsonPatchException", "InvalidJsonPatch", "JsonPatchConflict", "JsonPatchTestFailed", "AddOperation", "RemoveOperation", "ReplaceOperation", "MoveOperation", "CopyOperation", "TestOperation"): emit("has_" + _n, "yes" if hasattr(jsonpatch, _n) else "no") # ============================================================================ # THE EXCEPTION HIERARCHY, WHICH IS WHAT A CALLER'S `except' CLAUSE NAMES. # ============================================================================ # # A release that re-based any of these would leave every behavioural case below # passing while silently breaking every caller that catches them, because each # case catches its class BY NAME. The bases are therefore asserted directly. # # JsonPatchTestFailed is the one with two: it is a JsonPatchException AND an # AssertionError, deliberately, so that a failed `test' operation reads as an # assertion failure to a test runner that knows nothing about this library. That # double base is the single most surprising fact about the module's surface and # nothing else here would reveal it. emit("exc_base", JsonPatchException.__mro__[1].__name__) emit("exc_invalid_bases", "|".join(b.__name__ for b in InvalidJsonPatch.__bases__)) emit("exc_conflict_bases", "|".join(b.__name__ for b in JsonPatchConflict.__bases__)) emit("exc_testfailed_bases", "|".join(b.__name__ for b in JsonPatchTestFailed.__bases__)) emit("exc_testfailed_is_assertion", "yes" if issubclass(JsonPatchTestFailed, AssertionError) else "no") def jout(value): """The canonical JSON form: sorted keys, no incidental whitespace.""" return json.dumps(value, sort_keys=True, separators=(",", ":")) def caught(key, fn): """Run `fn'; emit the exception TYPE, or `' when it returns. Both halves matter. A case that only asserted the type would pass on a build where the operation raised for an unrelated reason, and one that only asserted `it raised' would pass where the class changed. """ try: fn() emit(key, "") except Exception as exc: # noqa: BLE001 - the class IS the fact emit(key, type(exc).__name__) # ============================================================================ # THE SIX OPERATIONS, IN ONE PATCH, OVER ONE FIXTURE. # ============================================================================ # # RFC 6902 §4 defines exactly six operations and this patch exercises each once, # in one document, asserted whole. Applying them SEPARATELY would be the weaker # statement: a patch is a sequence, each operation sees what the ones before it # left, and the failure this catches -- an operation that writes correctly but # leaves the document in a state the next one cannot address -- is invisible when # each is applied to a fresh copy. # # The order is chosen so that each operation depends on the one before: `copy' # duplicates what `replace' wrote, `move' relocates what `copy' produced, `test' # asserts what `move' left, and `remove' takes away what `add' put in. SIX_DOC = { "letters": ["a", "b", "c"], "counts": {"a": 1, "b": 2}, "spare": "to-be-removed", "label": "before", } SIX_PATCH = [ {"op": "add", "path": "/letters/1", "value": "b2"}, {"op": "remove", "path": "/spare"}, {"op": "replace", "path": "/label", "value": "after"}, {"op": "copy", "from": "/label", "path": "/label_copy"}, {"op": "move", "from": "/label_copy", "path": "/counts/moved"}, {"op": "test", "path": "/counts/moved", "value": "after"}, ] try: emit("six_result", jout(apply_patch(SIX_DOC, SIX_PATCH))) # ...and the source document is untouched, because in_place defaults False. # Asserted HERE rather than only in the in-place section below, so that the # six-operation result cannot be read as having been produced by mutation. emit("six_source_after", jout(SIX_DOC)) except Exception as exc: # noqa: BLE001 emit("six_error", "%s: %s" % (type(exc).__name__, exc)) # The same patch as a STRING, which is how a patch actually arrives -- over the # wire, out of a file. apply_patch takes either, and the two paths must agree; # only the string path exercises JsonPatch.from_string and its `multidict' object # hook, which is where a duplicate member in an operation would be caught. try: emit("six_from_string", jout(apply_patch(SIX_DOC, json.dumps(SIX_PATCH)))) except Exception as exc: # noqa: BLE001 emit("six_from_string_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # `add' ON AN ARRAY: `-' APPENDS, A NUMBER INSERTS. # ============================================================================ # # §4.1 gives an array index as either a number or `-', and the two do DIFFERENT # things -- `-' appends past the end, a number inserts BEFORE the element there # and shifts the tail right. This is the edge a reader gets wrong by assuming an # index overwrites, which is `replace', not `add'. ARR = {"a": [1, 2, 3]} try: emit("add_dash", jout(apply_patch(ARR, [{"op": "add", "path": "/a/-", "value": 9}]))) emit("add_index", jout(apply_patch(ARR, [{"op": "add", "path": "/a/1", "value": 9}]))) # The index one past the end is legal and equals the `-' form; two past is # not. The boundary is the whole of the rule, so both sides are asserted. emit("add_index_at_end", jout(apply_patch(ARR, [{"op": "add", "path": "/a/3", "value": 9}]))) except Exception as exc: # noqa: BLE001 emit("add_error", "%s: %s" % (type(exc).__name__, exc)) caught("add_index_past_end", lambda: apply_patch(ARR, [{"op": "add", "path": "/a/4", "value": 9}])) # An `add' with no `value' member is MALFORMED rather than conflicting, and comes # out as InvalidJsonPatch. That is a different class from every other failure # here, and it is the one that separates a bad PATCH from a bad TARGET. caught("add_no_value", lambda: apply_patch(ARR, [{"op": "add", "path": "/a/0"}])) # `add' on an object REPLACES an existing member rather than refusing -- §4.1 # says so explicitly, and it is the half a reader expects to conflict. try: emit("add_existing_key", jout(apply_patch({"k": 1}, [{"op": "add", "path": "/k", "value": 2}]))) except Exception as exc: # noqa: BLE001 emit("add_existing_key_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # `remove' ON AN ARRAY SHIFTS THE TAIL. # ============================================================================ # # Removing element 0 of a three-element array does not leave a hole and does not # leave the remaining indices where they were: what was /a/2 is now /a/1. A # patch whose later operations address by index is reading a document this one # renumbered, which is why the two-operation form is asserted as well as the # single removal. try: emit("remove_shift", jout(apply_patch({"a": [1, 2, 3]}, [{"op": "remove", "path": "/a/0"}]))) emit("remove_then_index", jout(apply_patch({"a": [1, 2, 3]}, [{"op": "remove", "path": "/a/0"}, {"op": "replace", "path": "/a/1", "value": 99}]))) except Exception as exc: # noqa: BLE001 emit("remove_error", "%s: %s" % (type(exc).__name__, exc)) caught("remove_missing", lambda: apply_patch({"a": 1}, [{"op": "remove", "path": "/nope"}])) # ============================================================================ # `replace' REQUIRES THE PATH TO EXIST ALREADY. # ============================================================================ # # This is the ONLY difference between `replace' and `add' on an object member, # and it is the whole reason both operations exist. `add' on a missing key # creates it (asserted above); `replace' on one conflicts. try: emit("replace_existing", jout(apply_patch({"k": 1}, [{"op": "replace", "path": "/k", "value": 2}]))) except Exception as exc: # noqa: BLE001 emit("replace_error", "%s: %s" % (type(exc).__name__, exc)) caught("replace_missing", lambda: apply_patch({"k": 1}, [{"op": "replace", "path": "/nope", "value": 2}])) # `-' has no meaning for `replace' -- there is nothing after the last element to # replace -- and this implementation refuses it as a MALFORMED patch rather than # as a conflict, which is the classification a caller branches on. caught("replace_dash", lambda: apply_patch({"a": [1]}, [{"op": "replace", "path": "/a/-", "value": 2}])) # ============================================================================ # `move' INTO A LOCATION UNDER ITS OWN SOURCE IS AN ERROR. # ============================================================================ # # §4.4 forbids it, and the reason is that the operation is defined as a `remove' # followed by an `add' -- the remove would take the destination away with the # source. The naive implementation does not notice and produces a document with # the moved subtree missing entirely, so this is the case that separates a # correct implementation from one that merely composes the two operations. try: emit("move_ok", jout(apply_patch({"a": {"x": 1}, "b": {}}, [{"op": "move", "from": "/a/x", "path": "/b/y"}]))) except Exception as exc: # noqa: BLE001 emit("move_error", "%s: %s" % (type(exc).__name__, exc)) caught("move_into_own_child", lambda: apply_patch({"a": {"x": 1}}, [{"op": "move", "from": "/a", "path": "/a/x"}])) # The MESSAGE, because the class alone does not say the conflict was detected # for this reason rather than by the destination happening not to resolve. try: apply_patch({"a": {"x": 1}}, [{"op": "move", "from": "/a", "path": "/a/x"}]) emit("move_into_own_child_msg", "") except Exception as exc: # noqa: BLE001 emit("move_into_own_child_msg", str(exc)) # `move' to the SAME location is a documented no-op rather than an error, which # is the other end of the same rule and would otherwise look like a bug. try: emit("move_self", jout(apply_patch({"a": 1}, [{"op": "move", "from": "/a", "path": "/a"}]))) except Exception as exc: # noqa: BLE001 emit("move_self_error", "%s: %s" % (type(exc).__name__, exc)) caught("move_missing_source", lambda: apply_patch({"a": 1}, [{"op": "move", "from": "/nope", "path": "/b"}])) # ============================================================================ # `copy' LEAVES THE SOURCE IN PLACE -- AND DEEP-COPIES IT. # ============================================================================ # # The source surviving is what distinguishes `copy' from `move' and is the # obvious half. The half that is not obvious, and that no document comparison # would show, is that the copied value is a DEEP copy: a shallow one aliases the # two locations, so a later operation writing through one is visible through the # other. The aliasing case drives exactly that -- copy, then write into the # copy, then assert the ORIGINAL is unchanged. try: emit("copy_result", jout(apply_patch({"src": {"n": 1}}, [{"op": "copy", "from": "/src", "path": "/dst"}]))) emit("copy_aliasing", jout(apply_patch({"src": {"n": 1}}, [{"op": "copy", "from": "/src", "path": "/dst"}, {"op": "replace", "path": "/dst/n", "value": 2}]))) except Exception as exc: # noqa: BLE001 emit("copy_error", "%s: %s" % (type(exc).__name__, exc)) caught("copy_missing_source", lambda: apply_patch({"a": 1}, [{"op": "copy", "from": "/nope", "path": "/b"}])) # ============================================================================ # `test' IS THE ONLY OPERATION THAT CHANGES NOTHING AND CAN STOP THE PATCH. # ============================================================================ # # A passing `test' returns the document untouched; a failing one raises # JsonPatchTestFailed. Both are asserted, and so is the failure's effect on the # document: the operations BEFORE the failing test have already been applied to # the working copy, and because in_place defaults False the caller's original is # nevertheless whole. A caller that relied on the patch being atomic against its # own object is relying on that default and not on any rollback. try: emit("test_pass", jout(apply_patch({"k": 1}, [{"op": "test", "path": "/k", "value": 1}]))) except Exception as exc: # noqa: BLE001 emit("test_pass_error", "%s: %s" % (type(exc).__name__, exc)) caught("test_fail", lambda: apply_patch({"k": 1}, [{"op": "test", "path": "/k", "value": 2}])) caught("test_missing_path", lambda: apply_patch({"k": 1}, [{"op": "test", "path": "/nope", "value": 1}])) # `test' compares by JSON VALUE, and 1 and 1.0 are equal in Python while `true' # and 1 are equal too -- `1 == True' is a Python fact, not a JSON one. The # implementation compares with `!=', so it inherits both. This is a real # divergence from RFC 6902 §4.6, which requires type-aware equality, and it is # recorded here as behaviour rather than asserted as correctness. caught("test_int_vs_float", lambda: apply_patch({"k": 1}, [{"op": "test", "path": "/k", "value": 1.0}])) caught("test_int_vs_bool", lambda: apply_patch({"k": 1}, [{"op": "test", "path": "/k", "value": True}])) TEST_STOP = {"a": 1, "b": 2} caught("test_stops_patch", lambda: apply_patch(TEST_STOP, [{"op": "replace", "path": "/a", "value": 99}, {"op": "test", "path": "/b", "value": 0}, {"op": "replace", "path": "/b", "value": 99}])) emit("test_stops_source_whole", jout(TEST_STOP)) # An operation naming an unknown `op' is InvalidJsonPatch, not a silent skip. caught("unknown_op", lambda: apply_patch({"a": 1}, [{"op": "frobnicate", "path": "/a"}])) caught("no_op_member", lambda: apply_patch({"a": 1}, [{"path": "/a"}])) # ============================================================================ # make_patch: THE INVERSE DIRECTION. # ============================================================================ # # The round trip is the behavioural statement -- the patch between two documents, # applied to the first, must produce the second -- and it is asserted first # because it is the property that has to hold whatever the diff algorithm does. # # The patch ITSELF is then asserted against a literal, which is a deliberately # stricter and more brittle case: a change in the algorithm that still round # trips correctly reds it. That is the point. The diff is the part of this # package with freedom in it, and a suite that only checked the round trip would # absorb a rewrite silently and report nothing about it. DIFF_SRC = {"foo": "bar", "numbers": [1, 3, 4, 8], "gone": True} DIFF_DST = {"foo": "bar", "numbers": [1, 4, 7], "added": "new"} try: _p = make_patch(DIFF_SRC, DIFF_DST) emit("made_patch_type", type(_p).__name__) # Operation ORDER is preserved; only the keys inside each operation are # sorted, because a patch is a sequence and sorting it would change it. emit("made_patch", json.dumps(list(_p), sort_keys=True, separators=(",", ":"))) emit("made_patch_len", len(list(_p))) emit("made_roundtrip", jout(_p.apply(DIFF_SRC))) emit("made_roundtrip_equal", "yes" if _p.apply(DIFF_SRC) == DIFF_DST else "no") emit("made_src_untouched", jout(DIFF_SRC)) # A patch between a document and ITSELF is empty, and an empty JsonPatch is # falsy -- which is the documented way a caller asks "did anything change?". _empty = make_patch(DIFF_SRC, copy.deepcopy(DIFF_SRC)) emit("made_empty", json.dumps(list(_empty), separators=(",", ":"))) emit("made_empty_falsy", "yes" if not _empty else "no") emit("made_nonempty_truthy", "yes" if _p else "no") except Exception as exc: # noqa: BLE001 emit("made_error", "%s: %s" % (type(exc).__name__, exc)) # The patch as a STRING and back, which is the form it travels in. from_string # then to_string must round trip to a patch that compares EQUAL to the original # -- JsonPatch defines __eq__ over the operation list, so this is a statement # about the operations and not about the serialisation's whitespace. try: _s = _p.to_string() emit("patch_to_string_parses", "yes" if json.loads(_s) == list(_p) else "no") emit("patch_from_string_equal", "yes" if JsonPatch.from_string(_s) == _p else "no") except Exception as exc: # noqa: BLE001 emit("patch_string_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # in_place: THE DEFAULT LEAVES THE CALLER'S DOCUMENT ALONE. # ============================================================================ # # This is the one behaviour a caller is most likely to assume backwards, because # jsonpointer's own set_pointer defaults the OTHER way (inplace=True). Both # directions are asserted on FRESH documents so that neither can be explained by # the other. INPLACE_DOC = {"k": 1} try: _r = apply_patch(INPLACE_DOC, [{"op": "replace", "path": "/k", "value": 2}]) emit("default_returns", jout(_r)) emit("default_original", jout(INPLACE_DOC)) emit("default_returns_same", "yes" if _r is INPLACE_DOC else "no") except Exception as exc: # noqa: BLE001 emit("default_error", "%s: %s" % (type(exc).__name__, exc)) INPLACE_DOC2 = {"k": 1} try: _r2 = apply_patch(INPLACE_DOC2, [{"op": "replace", "path": "/k", "value": 2}], in_place=True) emit("inplace_returns", jout(_r2)) emit("inplace_original", jout(INPLACE_DOC2)) emit("inplace_returns_same", "yes" if _r2 is INPLACE_DOC2 else "no") except Exception as exc: # noqa: BLE001 emit("inplace_error", "%s: %s" % (type(exc).__name__, exc)) emit("probe_complete", "yes")