diff --git a/README.md b/README.md index e76baad1..cf5bc739 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — JSON Pointer, Patch & Merge Patch](#whats-new-2026-06-21--json-pointer-patch--merge-patch) - [What's new (2026-06-21) — Client-Side Rate Limiting](#whats-new-2026-06-21--client-side-rate-limiting) - [What's new (2026-06-21) — JSON Web Tokens (JWT)](#whats-new-2026-06-21--json-web-tokens-jwt) - [What's new (2026-06-21) — License Policy Gate](#whats-new-2026-06-21--license-policy-gate) @@ -115,6 +116,12 @@ --- +## What's new (2026-06-21) — JSON Pointer, Patch & Merge Patch + +Address, diff and patch JSON. Full reference: [`docs/source/Eng/doc/new_features/v63_features_doc.rst`](docs/source/Eng/doc/new_features/v63_features_doc.rst). + +- **`resolve_pointer` / `make_patch` / `apply_patch` / `merge_patch` / `make_merge_patch`** (`AC_resolve_pointer`, `AC_apply_json_patch`, `AC_make_json_patch`, `AC_merge_patch`): `jsonpath` is read-only and `approval` compares whole artifacts — nothing could address one location, compute a structured delta, or apply a partial update. This adds the three IETF primitives — JSON Pointer (RFC 6901), JSON Patch (RFC 6902, all six ops, **atomic** apply), and JSON Merge Patch (RFC 7386, `null` deletes) — for config-drift detection, partial updates, HTTP PATCH bodies, and golden-master deltas. Pure-stdlib `json`+`copy`, validated against the RFC test vectors. + ## What's new (2026-06-21) — Client-Side Rate Limiting Stay under API quotas. Full reference: [`docs/source/Eng/doc/new_features/v62_features_doc.rst`](docs/source/Eng/doc/new_features/v62_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 915d5d6b..f1ca0c03 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — JSON Pointer、Patch 与 Merge Patch](#本次更新-2026-06-21--json-pointerpatch-与-merge-patch) - [本次更新 (2026-06-21) — 客户端速率限制](#本次更新-2026-06-21--客户端速率限制) - [本次更新 (2026-06-21) — JSON Web Token(JWT)](#本次更新-2026-06-21--json-web-tokenjwt) - [本次更新 (2026-06-21) — 许可证策略闸门](#本次更新-2026-06-21--许可证策略闸门) @@ -114,6 +115,12 @@ --- +## 本次更新 (2026-06-21) — JSON Pointer、Patch 与 Merge Patch + +定址、取差异并修补 JSON。完整参考:[`docs/source/Zh/doc/new_features/v63_features_doc.rst`](../docs/source/Zh/doc/new_features/v63_features_doc.rst)。 + +- **`resolve_pointer` / `make_patch` / `apply_patch` / `merge_patch` / `make_merge_patch`**(`AC_resolve_pointer`、`AC_apply_json_patch`、`AC_make_json_patch`、`AC_merge_patch`):`jsonpath` 是只读的,`approval` 比较整份产物 —— 没有任何东西能定址单一位置、计算结构化差异或套用部分更新。本功能补上三个 IETF 原语 —— JSON Pointer(RFC 6901)、JSON Patch(RFC 6902,全六种操作,**原子**套用)、JSON Merge Patch(RFC 7386,`null` 删除)—— 适用于配置漂移检测、部分更新、HTTP PATCH 内容与 golden-master 差异。纯标准库 `json`+`copy`,以 RFC 测试向量验证。 + ## 本次更新 (2026-06-21) — 客户端速率限制 守在 API 配额之内。完整参考:[`docs/source/Zh/doc/new_features/v62_features_doc.rst`](../docs/source/Zh/doc/new_features/v62_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 8584f233..0f66374c 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — JSON Pointer、Patch 與 Merge Patch](#本次更新-2026-06-21--json-pointerpatch-與-merge-patch) - [本次更新 (2026-06-21) — 用戶端速率限制](#本次更新-2026-06-21--用戶端速率限制) - [本次更新 (2026-06-21) — JSON Web Token(JWT)](#本次更新-2026-06-21--json-web-tokenjwt) - [本次更新 (2026-06-21) — 授權政策閘門](#本次更新-2026-06-21--授權政策閘門) @@ -114,6 +115,12 @@ --- +## 本次更新 (2026-06-21) — JSON Pointer、Patch 與 Merge Patch + +定址、取差異並修補 JSON。完整參考:[`docs/source/Zh/doc/new_features/v63_features_doc.rst`](../docs/source/Zh/doc/new_features/v63_features_doc.rst)。 + +- **`resolve_pointer` / `make_patch` / `apply_patch` / `merge_patch` / `make_merge_patch`**(`AC_resolve_pointer`、`AC_apply_json_patch`、`AC_make_json_patch`、`AC_merge_patch`):`jsonpath` 是唯讀的,`approval` 比較整份產物 —— 沒有任何東西能定址單一位置、計算結構化差異或套用部分更新。本功能補上三個 IETF 原語 —— JSON Pointer(RFC 6901)、JSON Patch(RFC 6902,全六種操作,**原子**套用)、JSON Merge Patch(RFC 7386,`null` 刪除)—— 適用於設定漂移偵測、部分更新、HTTP PATCH 內容與 golden-master 差異。純標準函式庫 `json`+`copy`,以 RFC 測試向量驗證。 + ## 本次更新 (2026-06-21) — 用戶端速率限制 守在 API 配額之內。完整參考:[`docs/source/Zh/doc/new_features/v62_features_doc.rst`](../docs/source/Zh/doc/new_features/v62_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v63_features_doc.rst b/docs/source/Eng/doc/new_features/v63_features_doc.rst new file mode 100644 index 00000000..79058377 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v63_features_doc.rst @@ -0,0 +1,53 @@ +JSON Pointer, Patch & Merge Patch +================================= + +``jsonpath`` queries are read-only and ``approval`` compares whole artifacts by +equality, but nothing could address a single location, compute a structured +*delta*, or apply a partial update to a JSON document. This adds the three IETF +primitives that fill that gap: + +* **RFC 6901 JSON Pointer** — address one location (``/a/b/0``). +* **RFC 6902 JSON Patch** — an ordered op list + (add/remove/replace/move/copy/test); plus ``make_patch`` to diff two docs. +* **RFC 7386 JSON Merge Patch** — a recursive merge where ``null`` deletes. + +Useful for config-drift detection, partial updates in flows, HTTP PATCH bodies, +and reporting golden-master deltas. Pure standard library (``json`` + ``copy``); +fully deterministic; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + resolve_pointer, make_patch, apply_patch, merge_patch, + make_merge_patch, set_pointer, remove_pointer) + + doc = {"user": {"name": "Jo", "tags": ["a", "b"]}} + + resolve_pointer(doc, "/user/tags/0") # "a" + + patch = make_patch(doc, {"user": {"name": "Joe", "tags": ["a"]}}) + # [{"op": "replace", "path": "/user/name", "value": "Joe"}, + # {"op": "remove", "path": "/user/tags/1"}] + apply_patch(doc, patch) # the updated document + + merge_patch({"a": 1, "b": 2}, {"b": None, "c": 3}) # {"a": 1, "c": 3} + +``apply_patch`` is **atomic** — it applies to a copy and only returns on full +success, so a failing ``test`` op leaves the original untouched. The six ops +follow RFC 6902 exactly (``add`` inserts into arrays, ``test`` does deep +equality keeping ``true`` distinct from ``1``, ``move`` rejects moving a value +into its own child). ``set_pointer`` / ``remove_pointer`` are pure +single-location convenience helpers. ``merge_patch`` follows RFC 7386 (a +``null`` value deletes the key; a non-object patch replaces wholesale). + +Executor commands +----------------- + +``AC_resolve_pointer`` (``{value}``), ``AC_apply_json_patch`` (``{result}``), +``AC_make_json_patch`` (``{patch}``) and ``AC_merge_patch`` (``{result}``) take +their JSON inputs as objects or JSON strings. Each is also exposed as an MCP +tool (``ac_resolve_pointer`` / ``ac_apply_json_patch`` / ``ac_make_json_patch`` +/ ``ac_merge_patch``) and as a Script Builder command under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index c430ce88..cc695a6f 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -85,6 +85,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v60_features_doc doc/new_features/v61_features_doc doc/new_features/v62_features_doc + doc/new_features/v63_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v63_features_doc.rst b/docs/source/Zh/doc/new_features/v63_features_doc.rst new file mode 100644 index 00000000..970b4966 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v63_features_doc.rst @@ -0,0 +1,47 @@ +JSON Pointer、Patch 與 Merge Patch +================================== + +``jsonpath`` 查詢是唯讀的,而 ``approval`` 以相等性比較整份產物,但沒有任何東西能定址單一位置、 +計算結構化*差異*,或對 JSON 文件套用部分更新。本功能補上填補此缺口的三個 IETF 原語: + +* **RFC 6901 JSON Pointer** —— 定址單一位置(``/a/b/0``)。 +* **RFC 6902 JSON Patch** —— 有序的操作清單(add/remove/replace/move/copy/test);另含 + ``make_patch`` 以對兩份文件取差異。 +* **RFC 7386 JSON Merge Patch** —— 遞迴合併,其中 ``null`` 代表刪除。 + +適用於設定漂移偵測、流程中的部分更新、HTTP PATCH 內容,以及回報 golden-master 差異。純標準 +函式庫(``json`` + ``copy``);完全具決定性;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + resolve_pointer, make_patch, apply_patch, merge_patch, + make_merge_patch, set_pointer, remove_pointer) + + doc = {"user": {"name": "Jo", "tags": ["a", "b"]}} + + resolve_pointer(doc, "/user/tags/0") # "a" + + patch = make_patch(doc, {"user": {"name": "Joe", "tags": ["a"]}}) + # [{"op": "replace", "path": "/user/name", "value": "Joe"}, + # {"op": "remove", "path": "/user/tags/1"}] + apply_patch(doc, patch) # 更新後的文件 + + merge_patch({"a": 1, "b": 2}, {"b": None, "c": 3}) # {"a": 1, "c": 3} + +``apply_patch`` 是**原子的** —— 它套用在副本上,只有完全成功才回傳,因此失敗的 ``test`` 操作 +會讓原始文件保持不變。六個操作完全遵循 RFC 6902(``add`` 會插入陣列、``test`` 做深度相等且讓 +``true`` 與 ``1`` 相異、``move`` 拒絕把值移入自身子節點)。``set_pointer`` / ``remove_pointer`` +是純粹的單一位置便利函式。``merge_patch`` 遵循 RFC 7386(``null`` 值刪除該鍵;非物件的 patch +整體取代)。 + +執行器命令 +---------- + +``AC_resolve_pointer``(``{value}``)、``AC_apply_json_patch``(``{result}``)、 +``AC_make_json_patch``(``{patch}``)與 ``AC_merge_patch``(``{result}``)接受物件或 JSON 字串 +作為輸入。每個亦以 MCP 工具(``ac_resolve_pointer`` / ``ac_apply_json_patch`` / +``ac_make_json_patch`` / ``ac_merge_patch``)以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 144e57e5..1500da46 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -85,6 +85,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v60_features_doc doc/new_features/v61_features_doc doc/new_features/v62_features_doc + doc/new_features/v63_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 0fdc4f00..d0024b38 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -324,6 +324,11 @@ from je_auto_control.utils.rate_limit import ( SlidingWindowLimiter, TokenBucket, throttle, ) +# JSON Pointer / Patch / Merge Patch (RFC 6901 / 6902 / 7386) +from je_auto_control.utils.json_patch import ( + PatchError, PatchTestFailed, apply_patch, make_merge_patch, make_patch, + merge_patch, remove_pointer, resolve_pointer, set_pointer, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -803,6 +808,9 @@ def start_autocontrol_gui(*args, **kwargs): "ClaimsPolicy", "ExpiredTokenError", "InvalidSignatureError", "JwtError", "decode_jwt", "encode_jwt", "SlidingWindowLimiter", "TokenBucket", "throttle", + "PatchError", "PatchTestFailed", "apply_patch", "make_merge_patch", + "make_patch", "merge_patch", "remove_pointer", "resolve_pointer", + "set_pointer", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 83715253..4b814ba9 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1155,6 +1155,40 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Validate JSON against a JSON Schema; returns {ok, errors}.", )) + specs.append(CommandSpec( + "AC_resolve_pointer", "Data", "JSON Pointer: Resolve", + fields=( + FieldSpec("doc", FieldType.STRING, placeholder='{"a": {"b": [1, 2]}}'), + FieldSpec("pointer", FieldType.STRING, placeholder="/a/b/0"), + ), + description="Resolve an RFC 6901 JSON Pointer; returns {value}.", + )) + specs.append(CommandSpec( + "AC_apply_json_patch", "Data", "JSON Patch: Apply", + fields=( + FieldSpec("doc", FieldType.STRING, placeholder='{"a": 1}'), + FieldSpec("patch", FieldType.STRING, + placeholder='[{"op": "add", "path": "/b", "value": 2}]'), + ), + description="Apply an RFC 6902 JSON Patch; returns {result}.", + )) + specs.append(CommandSpec( + "AC_make_json_patch", "Data", "JSON Patch: Diff", + fields=( + FieldSpec("old", FieldType.STRING, placeholder='{"a": 1}'), + FieldSpec("new", FieldType.STRING, placeholder='{"a": 2}'), + ), + description="Compute an RFC 6902 patch from old to new; returns {patch}.", + )) + specs.append(CommandSpec( + "AC_merge_patch", "Data", "JSON Merge Patch: Apply", + fields=( + FieldSpec("doc", FieldType.STRING, placeholder='{"a": 1, "b": 2}'), + FieldSpec("patch", FieldType.STRING, + placeholder='{"b": null, "c": 3}'), + ), + description="Apply an RFC 7386 merge patch (null deletes); returns {result}.", + )) specs.append(CommandSpec( "AC_scan_vulns", "Security", "Scan Dependencies for Vulnerabilities", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index a923beea..e50249b1 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2928,6 +2928,48 @@ def _rate_limit(name: str, rate: float = 1.0, capacity: float = 1.0, "wait": round(bucket.time_until_available(float(n)), 4)} +def _resolve_pointer(doc: Any, pointer: str) -> Dict[str, Any]: + """Adapter: resolve a JSON Pointer in doc (a dict/list or JSON string).""" + import json + from je_auto_control.utils.json_patch import resolve_pointer + if isinstance(doc, str): + doc = json.loads(doc) + return {"value": resolve_pointer(doc, pointer)} + + +def _apply_json_patch(doc: Any, patch: Any) -> Dict[str, Any]: + """Adapter: apply an RFC 6902 JSON Patch (each a list/object or JSON str).""" + import json + from je_auto_control.utils.json_patch import apply_patch + if isinstance(doc, str): + doc = json.loads(doc) + if isinstance(patch, str): + patch = json.loads(patch) + return {"result": apply_patch(doc, patch)} + + +def _make_json_patch(old: Any, new: Any) -> Dict[str, Any]: + """Adapter: compute an RFC 6902 patch turning old into new.""" + import json + from je_auto_control.utils.json_patch import make_patch + if isinstance(old, str): + old = json.loads(old) + if isinstance(new, str): + new = json.loads(new) + return {"patch": make_patch(old, new)} + + +def _merge_patch(doc: Any, patch: Any) -> Dict[str, Any]: + """Adapter: apply an RFC 7386 JSON Merge Patch (null deletes).""" + import json + from je_auto_control.utils.json_patch import merge_patch + if isinstance(doc, str): + doc = json.loads(doc) + if isinstance(patch, str): + patch = json.loads(patch) + return {"result": merge_patch(doc, patch)} + + def _jwt_encode(claims: Any, key: str, alg: str = "HS256") -> Dict[str, Any]: """Adapter: sign a compact JWT from claims (a dict or JSON string).""" import json @@ -3739,6 +3781,10 @@ def __init__(self): "AC_jwt_encode": _jwt_encode, "AC_jwt_decode": _jwt_decode, "AC_rate_limit": _rate_limit, + "AC_resolve_pointer": _resolve_pointer, + "AC_apply_json_patch": _apply_json_patch, + "AC_make_json_patch": _make_json_patch, + "AC_merge_patch": _merge_patch, "AC_generate_sop": _generate_sop, "AC_tween_drag": _tween_drag, "AC_list_plugins": _list_plugins, diff --git a/je_auto_control/utils/json_patch/__init__.py b/je_auto_control/utils/json_patch/__init__.py new file mode 100644 index 00000000..4871b549 --- /dev/null +++ b/je_auto_control/utils/json_patch/__init__.py @@ -0,0 +1,11 @@ +"""JSON Pointer (6901), JSON Patch (6902) and Merge Patch (7386) helpers.""" +from je_auto_control.utils.json_patch.json_patch import ( + PatchError, PatchTestFailed, apply_patch, escape_token, make_merge_patch, + make_patch, merge_patch, remove_pointer, resolve_pointer, set_pointer, +) + +__all__ = [ + "PatchError", "PatchTestFailed", "apply_patch", "escape_token", + "make_merge_patch", "make_patch", "merge_patch", "remove_pointer", + "resolve_pointer", "set_pointer", +] diff --git a/je_auto_control/utils/json_patch/json_patch.py b/je_auto_control/utils/json_patch/json_patch.py new file mode 100644 index 00000000..2398a69e --- /dev/null +++ b/je_auto_control/utils/json_patch/json_patch.py @@ -0,0 +1,301 @@ +"""Standards-based JSON Pointer / Patch / Merge Patch over parsed JSON. + +``jsonpath`` queries (read-only) and ``approval`` compares whole artifacts by +equality, but nothing could address a single location, compute a structured +delta, or apply a partial update to a JSON document. This adds the three IETF +primitives that fill that gap: + +* **RFC 6901 JSON Pointer** — address one location (``/a/b/0``). +* **RFC 6902 JSON Patch** — an ordered op list (add/remove/replace/move/copy/ + test); plus ``make_patch`` to diff two documents. +* **RFC 7386 JSON Merge Patch** — a recursive merge where ``null`` deletes. + +Useful for config-drift detection, partial updates in flows, HTTP PATCH bodies, +and reporting golden-master deltas. Pure standard library (``json`` + ``copy``); +fully deterministic; imports no ``PySide6``. +""" +import copy +from typing import Any, Dict, List + +from je_auto_control.utils.exception.exceptions import AutoControlException + +_MISSING = object() + + +class PatchError(AutoControlException): + """A JSON Patch could not be applied.""" + + +class PatchTestFailed(PatchError): + """A JSON Patch ``test`` operation did not match.""" + + +# --- RFC 6901 JSON Pointer ------------------------------------------------- + +def _unescape(ref: str) -> str: + return ref.replace("~1", "/").replace("~0", "~") + + +def escape_token(ref: str) -> str: + """Escape a single reference token for use in a JSON Pointer.""" + return ref.replace("~", "~0").replace("/", "~1") + + +def _split_pointer(pointer: str) -> List[str]: + if pointer == "": + return [] + if not pointer.startswith("/"): + raise PatchError(f"invalid JSON Pointer {pointer!r}") + refs: List[str] = [] + for ref in pointer.split("/")[1:]: + refs.append(_unescape(ref)) + return refs + + +def _array_index(ref: str, length: int, *, allow_end: bool = False) -> int: + if ref == "-": + if allow_end: + return length + raise PatchError("array index '-' is not valid here") + if not ref.isdigit() or (len(ref) > 1 and ref[0] == "0"): + raise PatchError(f"invalid array index {ref!r}") + index = int(ref) + if index >= length: + raise PatchError(f"array index {index} out of range") + return index + + +def _child(node: Any, ref: str) -> Any: + if isinstance(node, dict): + if ref in node: + return node[ref] + raise PatchError(f"object has no key {ref!r}") + if isinstance(node, list): + return node[_array_index(ref, len(node))] + raise PatchError(f"cannot descend into {type(node).__name__}") + + +def _walk(node: Any, refs: List[str]) -> Any: + for ref in refs: + node = _child(node, ref) + return node + + +def resolve_pointer(doc: Any, pointer: str, default: Any = _MISSING) -> Any: + """Return the value ``pointer`` addresses in ``doc`` (RFC 6901).""" + try: + return _walk(doc, _split_pointer(pointer)) + except PatchError: + if default is not _MISSING: + return default + raise + + +def set_pointer(doc: Any, pointer: str, value: Any) -> Any: + """Return a copy of ``doc`` with ``pointer`` set to ``value``.""" + if pointer == "": + return copy.deepcopy(value) + refs = _split_pointer(pointer) + result = copy.deepcopy(doc) + parent = _walk(result, refs[:-1]) + _assign(parent, refs[-1], value, insert=False) + return result + + +def remove_pointer(doc: Any, pointer: str) -> Any: + """Return a copy of ``doc`` with the value at ``pointer`` removed.""" + if pointer == "": + raise PatchError("cannot remove the whole document") + refs = _split_pointer(pointer) + result = copy.deepcopy(doc) + _delete(_walk(result, refs[:-1]), refs[-1]) + return result + + +def _assign(parent: Any, ref: str, value: Any, *, insert: bool) -> None: + if isinstance(parent, dict): + parent[ref] = value + elif isinstance(parent, list): + index = _array_index(ref, len(parent), allow_end=True) + if insert: + parent.insert(index, value) + elif index == len(parent): + parent.append(value) + else: + parent[index] = value + else: + raise PatchError(f"cannot set into {type(parent).__name__}") + + +def _delete(parent: Any, ref: str) -> None: + if isinstance(parent, dict): + if ref not in parent: + raise PatchError(f"object has no key {ref!r}") + del parent[ref] + elif isinstance(parent, list): + del parent[_array_index(ref, len(parent))] + else: + raise PatchError(f"cannot remove from {type(parent).__name__}") + + +# --- equality (bool stays distinct from int) ------------------------------- + +def _dict_equal(left: Dict, right: Dict) -> bool: + return left.keys() == right.keys() and all( + _json_equal(left[key], right[key]) for key in left) + + +def _list_equal(left: List, right: List) -> bool: + return len(left) == len(right) and all( + _json_equal(a, b) for a, b in zip(left, right)) + + +def _json_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return left is right + if isinstance(left, dict) and isinstance(right, dict): + return _dict_equal(left, right) + if isinstance(left, list) and isinstance(right, list): + return _list_equal(left, right) + return left == right + + +# --- RFC 6902 JSON Patch --------------------------------------------------- + +def _op_add(doc: Any, op: Dict[str, Any]) -> Any: + refs = _split_pointer(op["path"]) + if not refs: + return copy.deepcopy(op["value"]) + _assign(_walk(doc, refs[:-1]), refs[-1], op["value"], insert=True) + return doc + + +def _op_remove(doc: Any, op: Dict[str, Any]) -> Any: + refs = _split_pointer(op["path"]) + if not refs: + raise PatchError("cannot remove the whole document") + _delete(_walk(doc, refs[:-1]), refs[-1]) + return doc + + +def _op_replace(doc: Any, op: Dict[str, Any]) -> Any: + refs = _split_pointer(op["path"]) + if not refs: + return copy.deepcopy(op["value"]) + parent = _walk(doc, refs[:-1]) + _child(parent, refs[-1]) # must exist + _assign(parent, refs[-1], op["value"], insert=False) + return doc + + +def _is_prefix(prefix: str, pointer: str) -> bool: + head = _split_pointer(prefix) + full = _split_pointer(pointer) + return len(head) < len(full) and full[:len(head)] == head + + +def _op_move(doc: Any, op: Dict[str, Any]) -> Any: + source, dest = op["from"], op["path"] + if source == dest: + return doc + if _is_prefix(source, dest): + raise PatchError("cannot move a value into its own child") + value = resolve_pointer(doc, source) + doc = _op_remove(doc, {"path": source}) + return _op_add(doc, {"path": dest, "value": value}) + + +def _op_copy(doc: Any, op: Dict[str, Any]) -> Any: + value = copy.deepcopy(resolve_pointer(doc, op["from"])) + return _op_add(doc, {"path": op["path"], "value": value}) + + +def _op_test(doc: Any, op: Dict[str, Any]) -> Any: + actual = resolve_pointer(doc, op["path"], default=_MISSING) + if actual is _MISSING or not _json_equal(actual, op["value"]): + raise PatchTestFailed(f"test failed at {op['path']!r}") + return doc + + +_OPS = { + "add": _op_add, "remove": _op_remove, "replace": _op_replace, + "move": _op_move, "copy": _op_copy, "test": _op_test, +} + + +def apply_patch(doc: Any, patch: List[Dict[str, Any]]) -> Any: + """Apply an RFC 6902 patch to ``doc`` atomically; return the new document.""" + result = copy.deepcopy(doc) + for op in patch: + handler = _OPS.get(op.get("op")) + if handler is None: + raise PatchError(f"unknown patch op {op.get('op')!r}") + result = handler(result, op) + return result + + +def _diff_dict(old: Dict, new: Dict, path: str) -> List[Dict[str, Any]]: + ops: List[Dict[str, Any]] = [] + for key in old: + if key not in new: + ops.append({"op": "remove", "path": f"{path}/{escape_token(key)}"}) + for key, value in new.items(): + child = f"{path}/{escape_token(key)}" + if key not in old: + ops.append({"op": "add", "path": child, "value": value}) + else: + ops.extend(make_patch(old[key], value, child)) + return ops + + +def _diff_list(old: List, new: List, path: str) -> List[Dict[str, Any]]: + ops: List[Dict[str, Any]] = [] + for index in range(min(len(old), len(new))): + ops.extend(make_patch(old[index], new[index], f"{path}/{index}")) + for index in range(len(old) - 1, len(new) - 1, -1): + ops.append({"op": "remove", "path": f"{path}/{index}"}) + for index in range(len(old), len(new)): + ops.append({"op": "add", "path": f"{path}/-", "value": new[index]}) + return ops + + +def make_patch(old: Any, new: Any, path: str = "") -> List[Dict[str, Any]]: + """Return an RFC 6902 patch that turns ``old`` into ``new``.""" + if _json_equal(old, new): + return [] + if isinstance(old, dict) and isinstance(new, dict): + return _diff_dict(old, new, path) + if isinstance(old, list) and isinstance(new, list): + return _diff_list(old, new, path) + return [{"op": "replace", "path": path, "value": new}] + + +# --- RFC 7386 JSON Merge Patch --------------------------------------------- + +def merge_patch(doc: Any, patch: Any) -> Any: + """Apply an RFC 7386 merge patch (``null`` deletes a key).""" + if not isinstance(patch, dict): + return copy.deepcopy(patch) + result = copy.deepcopy(doc) if isinstance(doc, dict) else {} + for key, value in patch.items(): + if value is None: + result.pop(key, None) + else: + result[key] = merge_patch(result.get(key), value) + return result + + +def make_merge_patch(old: Any, new: Any) -> Any: + """Return an RFC 7386 merge patch turning ``old`` into ``new``.""" + if not (isinstance(old, dict) and isinstance(new, dict)): + return copy.deepcopy(new) + patch: Dict[str, Any] = {} + for key in old: + if key not in new: + patch[key] = None + for key, value in new.items(): + if key not in old: + patch[key] = copy.deepcopy(value) + elif not _json_equal(old[key], value): + patch[key] = make_merge_patch(old[key], value) + return patch diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index edd2452c..a2ebb790 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,51 @@ def rate_limit_tools() -> List[MCPTool]: ] +def json_patch_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_resolve_pointer", + description=("Resolve an RFC 6901 JSON Pointer ('pointer' like " + "'/a/b/0') in 'doc'. Returns {value}."), + input_schema=schema( + {"doc": {"type": "object"}, "pointer": {"type": "string"}}, + ["doc", "pointer"]), + handler=h.resolve_pointer, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_apply_json_patch", + description=("Apply an RFC 6902 JSON Patch 'patch' (add/remove/" + "replace/move/copy/test) to 'doc'. Returns {result}."), + input_schema=schema( + {"doc": {"type": "object"}, "patch": {"type": "array"}}, + ["doc", "patch"]), + handler=h.apply_json_patch, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_make_json_patch", + description=("Compute an RFC 6902 JSON Patch turning 'old' into " + "'new'. Returns {patch}."), + input_schema=schema( + {"old": {"type": "object"}, "new": {"type": "object"}}, + ["old", "new"]), + handler=h.make_json_patch, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_merge_patch", + description=("Apply an RFC 7386 JSON Merge Patch 'patch' to 'doc' " + "(null deletes a key). Returns {result}."), + input_schema=schema( + {"doc": {"type": "object"}, "patch": {"type": "object"}}, + ["doc", "patch"]), + handler=h.merge_patch, + annotations=READ_ONLY, + ), + ] + + def saga_tools() -> List[MCPTool]: return [ MCPTool( @@ -4538,8 +4583,8 @@ def media_assert_tools() -> List[MCPTool]: locale_tools, voice_tools, coordinate_space_tools, loop_guard_tools, process_mining_tools, asset_tools, events_tools, notify_channel_tools, jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_tools, - license_policy_tools, jwt_tools, rate_limit_tools, saga_tools, - decision_table_tools, locator_repair_tools, + license_policy_tools, jwt_tools, rate_limit_tools, json_patch_tools, + saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index aef8502a..d05a3768 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1611,6 +1611,26 @@ def rate_limit(name, rate=1.0, capacity=1.0, n=1.0): "wait": round(bucket.time_until_available(float(n)), 4)} +def resolve_pointer(doc, pointer): + from je_auto_control.utils.json_patch import resolve_pointer as _resolve + return {"value": _resolve(doc, pointer)} + + +def apply_json_patch(doc, patch): + from je_auto_control.utils.json_patch import apply_patch + return {"result": apply_patch(doc, patch)} + + +def make_json_patch(old, new): + from je_auto_control.utils.json_patch import make_patch + return {"patch": make_patch(old, new)} + + +def merge_patch(doc, patch): + from je_auto_control.utils.json_patch import merge_patch as _merge + return {"result": _merge(doc, patch)} + + def run_saga(steps): from je_auto_control.utils.saga import run_saga as _run result = _run(steps) diff --git a/test/unit_test/headless/test_json_patch_batch.py b/test/unit_test/headless/test_json_patch_batch.py new file mode 100644 index 00000000..f1b1fb06 --- /dev/null +++ b/test/unit_test/headless/test_json_patch_batch.py @@ -0,0 +1,145 @@ +"""Headless tests for JSON Pointer / Patch / Merge Patch. Pure stdlib, no Qt.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.json_patch import ( + PatchError, PatchTestFailed, apply_patch, make_merge_patch, make_patch, + merge_patch, remove_pointer, resolve_pointer, set_pointer) + + +def test_pointer_resolution_rfc6901(): + doc = {"foo": ["bar", "baz"], "a/b": 1, "m~n": 2} + assert resolve_pointer(doc, "/foo/0") == "bar" + assert resolve_pointer(doc, "") is doc + assert resolve_pointer(doc, "/a~1b") == 1 # ~1 -> / + assert resolve_pointer(doc, "/m~0n") == 2 # ~0 -> ~ + assert resolve_pointer(doc, "/nope", "default") == "default" + with pytest.raises(PatchError): + resolve_pointer(doc, "/nope") + + +def test_patch_add_remove_replace(): + assert apply_patch({"foo": "bar"}, + [{"op": "add", "path": "/baz", "value": "qux"}]) == \ + {"foo": "bar", "baz": "qux"} + assert apply_patch({"foo": ["a", "b"]}, + [{"op": "add", "path": "/foo/1", "value": "X"}]) == \ + {"foo": ["a", "X", "b"]} # array insert (shift) + assert apply_patch({"a": 1, "b": 2}, + [{"op": "remove", "path": "/b"}]) == {"a": 1} + assert apply_patch({"a": 1}, + [{"op": "replace", "path": "/a", "value": 9}]) == {"a": 9} + + +def test_patch_move_and_copy(): + assert apply_patch({"a": {"x": 1}, "b": {}}, + [{"op": "move", "from": "/a/x", "path": "/b/y"}]) == \ + {"a": {}, "b": {"y": 1}} + assert apply_patch({"a": 5, "b": {}}, + [{"op": "copy", "from": "/a", "path": "/b/c"}]) == \ + {"a": 5, "b": {"c": 5}} + + +def test_patch_move_into_own_child_rejected(): + with pytest.raises(PatchError): + apply_patch({"a": {"b": 1}}, + [{"op": "move", "from": "/a", "path": "/a/b"}]) + + +def test_patch_test_op(): + out = apply_patch({"a": 1}, [{"op": "test", "path": "/a", "value": 1}, + {"op": "replace", "path": "/a", "value": 2}]) + assert out == {"a": 2} + with pytest.raises(PatchTestFailed): + apply_patch({"a": 1}, [{"op": "test", "path": "/a", "value": 2}]) + # bool and int stay distinct + with pytest.raises(PatchTestFailed): + apply_patch({"a": True}, [{"op": "test", "path": "/a", "value": 1}]) + + +def test_patch_is_atomic(): + original = {"a": 1} + with pytest.raises(PatchTestFailed): + apply_patch(original, [{"op": "add", "path": "/b", "value": 2}, + {"op": "test", "path": "/a", "value": 9}]) + assert original == {"a": 1} # untouched on failure + + +def test_unknown_op_raises(): + with pytest.raises(PatchError): + apply_patch({}, [{"op": "frobnicate", "path": "/a"}]) + + +def test_make_patch_round_trips(): + old = {"name": "Jo", "tags": ["x", "y"], "age": 30, "nested": {"k": 1}} + new = {"name": "Joe", "tags": ["x", "z", "w"], "nested": {"k": 2}, + "extra": True} + patch = make_patch(old, new) + assert apply_patch(old, patch) == new + assert make_patch(old, old) == [] + + +def test_merge_patch_rfc7386(): + assert merge_patch({"a": 1, "b": 2}, {"b": None, "c": 3}) == {"a": 1, "c": 3} + assert merge_patch({"a": {"x": 1, "y": 2}}, {"a": {"y": None, "z": 3}}) == \ + {"a": {"x": 1, "z": 3}} + assert merge_patch({"a": [1, 2]}, {"a": "str"}) == {"a": "str"} + + +def test_make_merge_patch_round_trips(): + old = {"a": 1, "b": 2, "c": 3} + new = {"a": 1, "b": 9} + patch = make_merge_patch(old, new) + assert patch == {"b": 9, "c": None} + assert merge_patch(old, patch) == new + + +def test_set_and_remove_pointer_are_pure(): + doc = {"a": {"b": 1}} + assert set_pointer(doc, "/a/b", 9) == {"a": {"b": 9}} + assert doc == {"a": {"b": 1}} # original unchanged + assert remove_pointer({"a": 1, "b": 2}, "/b") == {"a": 1} + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_apply_json_patch", + {"doc": json.dumps({"a": 1}), + "patch": json.dumps([{"op": "add", "path": "/b", "value": 2}])}, + ]]) + result = next(v for v in rec.values() if isinstance(v, dict))["result"] + assert result == {"a": 1, "b": 2} + + rec2 = ac.execute_action([[ + "AC_merge_patch", + {"doc": json.dumps({"a": 1, "b": 2}), + "patch": json.dumps({"b": None})}, + ]]) + result2 = next(v for v in rec2.values() if isinstance(v, dict))["result"] + assert result2 == {"a": 1} + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_resolve_pointer", "AC_apply_json_patch", "AC_make_json_patch", + "AC_merge_patch"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_resolve_pointer", "ac_apply_json_patch", "ac_make_json_patch", + "ac_merge_patch"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_resolve_pointer", "AC_apply_json_patch", "AC_make_json_patch", + "AC_merge_patch"} <= cmds + + +def test_facade_exports(): + for attr in ("resolve_pointer", "apply_patch", "make_patch", "merge_patch", + "make_merge_patch", "set_pointer", "remove_pointer", + "PatchError", "PatchTestFailed"): + assert hasattr(ac, attr) + assert attr in ac.__all__