diff --git a/README.md b/README.md index 1e774c8a..83343b8e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-20) — JSONPath Querying](#whats-new-2026-06-20--jsonpath-querying) - [What's new (2026-06-20) — Multi-Channel Webhook Notifications](#whats-new-2026-06-20--multi-channel-webhook-notifications) - [What's new (2026-06-20) — Outbound CloudEvents Emitter](#whats-new-2026-06-20--outbound-cloudevents-emitter) - [What's new (2026-06-20) — Environment-Scoped Typed Asset Store](#whats-new-2026-06-20--environment-scoped-typed-asset-store) @@ -103,6 +104,12 @@ --- +## What's new (2026-06-20) — JSONPath Querying + +Query API/DB JSON with wildcards, recursion, filters. Full reference: [`docs/source/Eng/doc/new_features/v51_features_doc.rst`](docs/source/Eng/doc/new_features/v51_features_doc.rst). + +- **`json_query` / `json_query_one` / `json_extract`** (`AC_json_query` / `AC_json_extract`, `ac_*`): the executor's path walker only split on `.` and indexed — this adds a JSONPath subset (`$`, `.key`, `[n]`/`[-n]`, `*`/`[*]`, `..` recursive descent, `[?(@.k op v)]` filters) over parsed JSON, so array-bearing API/DB responses are easy to extract from. `json_extract` runs a `{key: path}` mapping into a flat dict. Pure-stdlib `re`; the path engine `AC_http_to_var` and DB-row flows were missing. + ## What's new (2026-06-20) — Multi-Channel Webhook Notifications Alert Teams/Discord/Slack/webhook. Full reference: [`docs/source/Eng/doc/new_features/v50_features_doc.rst`](docs/source/Eng/doc/new_features/v50_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index d50cd73a..ccb5bce5 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-20) — JSONPath 查询](#本次更新-2026-06-20--jsonpath-查询) - [本次更新 (2026-06-20) — 多通道 Webhook 通知](#本次更新-2026-06-20--多通道-webhook-通知) - [本次更新 (2026-06-20) — 对外 CloudEvents 发送器](#本次更新-2026-06-20--对外-cloudevents-发送器) - [本次更新 (2026-06-20) — 环境范围的带类型资产存储](#本次更新-2026-06-20--环境范围的带类型资产存储) @@ -102,6 +103,12 @@ --- +## 本次更新 (2026-06-20) — JSONPath 查询 + +以通配符、递归、过滤查询 API/DB JSON。完整参考:[`docs/source/Zh/doc/new_features/v51_features_doc.rst`](../docs/source/Zh/doc/new_features/v51_features_doc.rst)。 + +- **`json_query` / `json_query_one` / `json_extract`**(`AC_json_query` / `AC_json_extract`、`ac_*`):执行器的路径遍历只会以 `.` 切分并索引 —— 本功能在已解析 JSON 上加入 JSONPath 子集(`$`、`.key`、`[n]`/`[-n]`、`*`/`[*]`、`..` 递归下降、`[?(@.k op v)]` 过滤),让含数组的 API/DB 响应易于提取。`json_extract` 以 `{key: path}` 映射提取成扁平 dict。纯标准库 `re`;这是 `AC_http_to_var` 与 DB-row 流程所缺的路径引擎。 + ## 本次更新 (2026-06-20) — 多通道 Webhook 通知 通知 Teams/Discord/Slack/webhook。完整参考:[`docs/source/Zh/doc/new_features/v50_features_doc.rst`](../docs/source/Zh/doc/new_features/v50_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 4fb83359..24d5d168 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-20) — JSONPath 查詢](#本次更新-2026-06-20--jsonpath-查詢) - [本次更新 (2026-06-20) — 多通道 Webhook 通知](#本次更新-2026-06-20--多通道-webhook-通知) - [本次更新 (2026-06-20) — 對外 CloudEvents 發送器](#本次更新-2026-06-20--對外-cloudevents-發送器) - [本次更新 (2026-06-20) — 環境範圍的具型別資產儲存](#本次更新-2026-06-20--環境範圍的具型別資產儲存) @@ -102,6 +103,12 @@ --- +## 本次更新 (2026-06-20) — JSONPath 查詢 + +以萬用字元、遞迴、過濾查詢 API/DB JSON。完整參考:[`docs/source/Zh/doc/new_features/v51_features_doc.rst`](../docs/source/Zh/doc/new_features/v51_features_doc.rst)。 + +- **`json_query` / `json_query_one` / `json_extract`**(`AC_json_query` / `AC_json_extract`、`ac_*`):執行器的路徑走訪只會以 `.` 切分並索引 —— 本功能在已解析 JSON 上加入 JSONPath 子集(`$`、`.key`、`[n]`/`[-n]`、`*`/`[*]`、`..` 遞迴下降、`[?(@.k op v)]` 過濾),讓含陣列的 API/DB 回應易於擷取。`json_extract` 以 `{key: path}` 對應擷取成扁平 dict。純標準函式庫 `re`;這是 `AC_http_to_var` 與 DB-row 流程所缺的路徑引擎。 + ## 本次更新 (2026-06-20) — 多通道 Webhook 通知 通知 Teams/Discord/Slack/webhook。完整參考:[`docs/source/Zh/doc/new_features/v50_features_doc.rst`](../docs/source/Zh/doc/new_features/v50_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v51_features_doc.rst b/docs/source/Eng/doc/new_features/v51_features_doc.rst new file mode 100644 index 00000000..7d8bfa45 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v51_features_doc.rst @@ -0,0 +1,54 @@ +JSONPath Querying +================= + +The executor's built-in path walker only splits on ``.`` and indexes — it can't +do wildcards, recursive descent, or filters, so API/DB responses with arrays are +awkward to extract from. ``json_query`` adds a focused JSONPath subset over +already-parsed JSON: + +================ =================================================== +Syntax Meaning +================ =================================================== +``$`` root (optional prefix) +``.name`` member access +``[n]`` ``[-n]`` list index (negative from the end) +``*`` ``[*]`` wildcard (all members / elements) +``..`` recursive descent +``[?(@.k op v)]`` filter array elements (``op`` ∈ ``== != < <= > >=``) +================ =================================================== + +Pure standard library (``re``); imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import json_query, json_query_one, json_extract + + json_query(data, "$.store.books[*].title") # every title + json_query(data, "$.store.books[?(@.price > 8)].title") # filtered + json_query(data, "$..price") # recursive descent + + json_query_one(data, "$.user.name", default="?") # first match or default + json_extract(data, {"name": "$.user.name", # mapping -> flat dict + "first_tag": "$.tags[0]"}) + +``json_query`` returns **all** matches (a list); ``json_query_one`` returns the +first (or a default); ``json_extract`` runs a ``{key: path}`` mapping into a flat +dict (first match per path). This is the path engine the existing +``AC_http_to_var`` / API / DB-row flows were missing. + +Executor commands +----------------- + +================================ =================================================== +Command Effect +================================ =================================================== +``AC_json_query`` ``{matches}`` — all values matching a JSONPath. +``AC_json_extract`` ``{result}`` — a ``{key: path}`` mapping extracted. +================================ =================================================== + +``data`` (and ``mapping``) accept a JSON object or a JSON string (so the visual +builder works). The same operations are exposed as MCP tools (``ac_json_query`` / +``ac_json_extract``) and as Script Builder commands under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index af0b5473..137f68a6 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -73,6 +73,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v48_features_doc doc/new_features/v49_features_doc doc/new_features/v50_features_doc + doc/new_features/v51_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/v51_features_doc.rst b/docs/source/Zh/doc/new_features/v51_features_doc.rst new file mode 100644 index 00000000..67e2b1c2 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v51_features_doc.rst @@ -0,0 +1,51 @@ +JSONPath 查詢 +============= + +執行器內建的路徑走訪只會以 ``.`` 切分並索引 —— 無法做萬用字元、遞迴下降或過濾,因此含 +陣列的 API/DB 回應很難擷取。``json_query`` 在已解析的 JSON 上加入聚焦的 JSONPath 子集: + +================ =================================================== +語法 意義 +================ =================================================== +``$`` 根(可省略前綴) +``.name`` 成員存取 +``[n]`` ``[-n]`` 串列索引(負數由尾端起算) +``*`` ``[*]`` 萬用字元(所有成員 / 元素) +``..`` 遞迴下降 +``[?(@.k op v)]`` 過濾陣列元素(``op`` ∈ ``== != < <= > >=``) +================ =================================================== + +純標準函式庫(``re``);不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import json_query, json_query_one, json_extract + + json_query(data, "$.store.books[*].title") # 每個 title + json_query(data, "$.store.books[?(@.price > 8)].title") # 過濾 + json_query(data, "$..price") # 遞迴下降 + + json_query_one(data, "$.user.name", default="?") # 第一個或預設 + json_extract(data, {"name": "$.user.name", # 對應 -> 扁平 dict + "first_tag": "$.tags[0]"}) + +``json_query`` 回傳**所有**符合項(清單);``json_query_one`` 回傳第一個(或預設); +``json_extract`` 以 ``{key: path}`` 對應擷取成扁平 dict(每路徑取第一個符合項)。這正是 +既有 ``AC_http_to_var`` / API / DB-row 流程所缺的路徑引擎。 + +執行器指令 +---------- + +================================ =================================================== +指令 效果 +================================ =================================================== +``AC_json_query`` ``{matches}`` —— 符合 JSONPath 的所有值。 +``AC_json_extract`` ``{result}`` —— 擷取 ``{key: path}`` 對應。 +================================ =================================================== + +``data``(與 ``mapping``)接受 JSON 物件或 JSON 字串(因此視覺化建構器可用)。相同操作亦 +提供為 MCP 工具(``ac_json_query`` / ``ac_json_extract``),以及 Script Builder 中 **Data** +分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 9fad47fd..6bed0219 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -73,6 +73,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v48_features_doc doc/new_features/v49_features_doc doc/new_features/v50_features_doc + doc/new_features/v51_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 67e9cc9e..c9030ce4 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -276,6 +276,10 @@ from je_auto_control.utils.notify_channels import ( WebhookChannel, WebhookResult, notify_webhook, set_default_poster, ) +# JSONPath-style querying over parsed JSON +from je_auto_control.utils.jsonpath import ( + json_extract, json_query, json_query_one, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -738,6 +742,7 @@ def start_autocontrol_gui(*args, **kwargs): "Asset", "AssetStore", "AssetValue", "active_environment", "EventEmitter", "post_cloudevent", "to_cloudevent", "WebhookChannel", "WebhookResult", "notify_webhook", "set_default_poster", + "json_extract", "json_query", "json_query_one", # 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 ee671695..df004e8e 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1127,6 +1127,24 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Send a Slack/Discord/Teams/raw webhook notification.", )) + specs.append(CommandSpec( + "AC_json_query", "Data", "JSONPath: Query", + fields=( + FieldSpec("data", FieldType.STRING, + placeholder='{"a": [1, 2]}'), + FieldSpec("path", FieldType.STRING, placeholder="$.a[*]"), + ), + description="Query parsed JSON with a JSONPath subset (all matches).", + )) + specs.append(CommandSpec( + "AC_json_extract", "Data", "JSONPath: Extract Mapping", + fields=( + FieldSpec("data", FieldType.STRING), + FieldSpec("mapping", FieldType.STRING, + placeholder='{"name": "$.user.name"}'), + ), + description="Extract a {key: jsonpath} mapping into a flat object.", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 19b7f34a..6a006ad5 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3296,6 +3296,26 @@ def _notify_webhook(url: str, text: str, transport: str = "raw", "transport": outcome.transport} +def _json_query(data: Any, path: str) -> Dict[str, Any]: + """Adapter: return all JSONPath matches in data (JSON string or object).""" + import json + from je_auto_control.utils.jsonpath import json_query + if isinstance(data, str): + data = json.loads(data) + return {"matches": json_query(data, path)} + + +def _json_extract(data: Any, mapping: Any) -> Dict[str, Any]: + """Adapter: extract a {key: path} mapping from data into a flat dict.""" + import json + from je_auto_control.utils.jsonpath import json_extract + if isinstance(data, str): + data = json.loads(data) + if isinstance(mapping, str): + mapping = json.loads(mapping) + return {"result": json_extract(data, mapping)} + + class Executor: """ Executor @@ -3576,6 +3596,8 @@ def __init__(self): "AC_list_assets": _list_assets, "AC_emit_event": _emit_event, "AC_notify_webhook": _notify_webhook, + "AC_json_query": _json_query, + "AC_json_extract": _json_extract, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/jsonpath/__init__.py b/je_auto_control/utils/jsonpath/__init__.py new file mode 100644 index 00000000..fe5f5824 --- /dev/null +++ b/je_auto_control/utils/jsonpath/__init__.py @@ -0,0 +1,6 @@ +"""Minimal JSONPath-style querying over parsed JSON data.""" +from je_auto_control.utils.jsonpath.jsonpath import ( + json_extract, json_query, json_query_one, +) + +__all__ = ["json_extract", "json_query", "json_query_one"] diff --git a/je_auto_control/utils/jsonpath/jsonpath.py b/je_auto_control/utils/jsonpath/jsonpath.py new file mode 100644 index 00000000..a8bcfae3 --- /dev/null +++ b/je_auto_control/utils/jsonpath/jsonpath.py @@ -0,0 +1,173 @@ +"""A small JSONPath-style query engine over already-parsed JSON. + +The executor's built-in path walker only splits on ``.`` and indexes — it can't +do wildcards, recursive descent, or filters, so API/DB responses with arrays are +awkward to extract from. This adds a focused JSONPath subset: + +* ``$`` root (optional prefix) +* ``.name`` / ``name`` member access +* ``[n]`` / ``[-n]`` list index (negative from the end) +* ``*`` / ``[*]`` wildcard (all members / all elements) +* ``..`` recursive descent +* ``[?(@.k op v)]`` filter array elements (``op`` ∈ == != < <= > >=) + +Pure standard library (``re``); imports no ``PySide6``. +""" +import re +from typing import Any, Dict, List, Mapping, Tuple + +_COMPARATORS = { + "==": lambda a, b: a == b, "!=": lambda a, b: a != b, + "<": lambda a, b: a < b, "<=": lambda a, b: a <= b, + ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, +} + +# A small, linear filter pattern (no nested quantifiers -> no backtracking). +_FILTER_RE = re.compile(r"@\.(\w+)\s*(==|!=|<=|>=|<|>)\s*(.+)") + + +def _parse_value(raw: str) -> Any: + raw = raw.strip() + if raw[:1] in "'\"" and raw[-1:] in "'\"": + return raw[1:-1] + for caster in (int, float): + try: + return caster(raw) + except ValueError: + continue + return {"true": True, "false": False, "null": None}.get(raw, raw) + + +def _parse_bracket(inner: str) -> Tuple[str, Any]: + """Turn the text inside ``[...]`` into a token.""" + inner = inner.strip() + if inner == "*": + return ("wild", None) + if inner.startswith("?"): + body = inner[1:].strip().lstrip("(").rstrip(")").strip() + match = _FILTER_RE.match(body) + if match: + return ("filter", (match.group(1), match.group(2), + _parse_value(match.group(3)))) + return ("wild", None) + if inner[:1] in "'\"" and inner[-1:] in "'\"": + return ("key", inner[1:-1]) + try: + return ("index", int(inner)) + except ValueError: + return ("key", inner) + + +def _read_bare_key(path: str, start: int) -> Tuple[str, int]: + end = start + while end < len(path) and (path[end].isalnum() or path[end] == "_"): + end += 1 + return path[start:end], end + + +def _tokenize(path: str) -> List[Tuple[str, Any]]: + """Tokenize a JSONPath with a linear scan (no backtracking regex).""" + path = path.strip() + if path.startswith("$"): + path = path[1:] + tokens: List[Tuple[str, Any]] = [] + index, length = 0, len(path) + while index < length: + char = path[index] + if path.startswith("..", index): + tokens.append(("recurse", None)) + index += 2 + elif char == ".": + index += 1 # skip dot; key/* read next pass + elif char == "*": + tokens.append(("wild", None)) + index += 1 + elif char == "[": + close = path.find("]", index) + if close == -1: + break + tokens.append(_parse_bracket(path[index + 1:close])) + index = close + 1 + else: + name, index = _read_bare_key(path, index) + if name: + tokens.append(("key", name)) + else: + index += 1 # skip an unrecognized char + return tokens + + +def _descendants(node: Any) -> List[Any]: + found = [node] + if isinstance(node, dict): + for value in node.values(): + found.extend(_descendants(value)) + elif isinstance(node, list): + for item in node: + found.extend(_descendants(item)) + return found + + +def _match_filter(node: Any, spec: Tuple[str, str, Any]) -> bool: + field, op, value = spec + if not isinstance(node, dict) or field not in node: + return False + try: + return _COMPARATORS[op](node[field], value) + except TypeError: + return False + + +def _on_key(node: Any, arg: Any) -> List[Any]: + return [node[arg]] if isinstance(node, dict) and arg in node else [] + + +def _on_index(node: Any, arg: Any) -> List[Any]: + if isinstance(node, list) and -len(node) <= arg < len(node): + return [node[arg]] + return [] + + +def _on_wild(node: Any, _arg: Any) -> List[Any]: + if isinstance(node, dict): + return list(node.values()) + return list(node) if isinstance(node, list) else [] + + +def _on_filter(node: Any, arg: Any) -> List[Any]: + elements = node if isinstance(node, list) else [node] + return [item for item in elements if _match_filter(item, arg)] + + +_STEP_HANDLERS = { + "key": _on_key, "index": _on_index, "wild": _on_wild, + "recurse": lambda node, _arg: _descendants(node), "filter": _on_filter, +} + + +def _step(nodes: List[Any], token: Tuple[str, Any]) -> List[Any]: + kind, arg = token + handler = _STEP_HANDLERS[kind] + result: List[Any] = [] + for node in nodes: + result.extend(handler(node, arg)) + return result + + +def json_query(data: Any, path: str) -> List[Any]: + """Return every value in ``data`` matching the JSONPath ``path``.""" + nodes: List[Any] = [data] + for token in _tokenize(path): + nodes = _step(nodes, token) + return nodes + + +def json_query_one(data: Any, path: str, default: Any = None) -> Any: + """Return the first match for ``path``, or ``default`` if none.""" + matches = json_query(data, path) + return matches[0] if matches else default + + +def json_extract(data: Any, mapping: Mapping[str, str]) -> Dict[str, Any]: + """Return ``{key: json_query_one(data, path)}`` for each ``key: path``.""" + return {key: json_query_one(data, path) for key, path in mapping.items()} diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 604ef48b..8e6a77ad 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3200,6 +3200,32 @@ def notify_channel_tools() -> List[MCPTool]: ] +def jsonpath_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_json_query", + description=("Query parsed JSON with a JSONPath subset ($, .key, " + "[n]/[-n], * / [*], .. recursive, [?(@.k op v)] " + "filter). Returns {matches} (all matches)."), + input_schema=schema( + {"data": {"type": "object"}, "path": {"type": "string"}}, + ["data", "path"]), + handler=h.json_query, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_json_extract", + description=("Extract a {key: jsonpath} 'mapping' from 'data' into a " + "flat object (first match per path). Returns {result}."), + input_schema=schema( + {"data": {"type": "object"}, "mapping": {"type": "object"}}, + ["data", "mapping"]), + handler=h.json_extract, + annotations=READ_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -4263,6 +4289,7 @@ def media_assert_tools() -> List[MCPTool]: video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, locale_tools, voice_tools, coordinate_space_tools, loop_guard_tools, process_mining_tools, asset_tools, events_tools, notify_channel_tools, + jsonpath_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 01af8b10..4f06fa5d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1546,6 +1546,16 @@ def notify_webhook(url, text, transport="raw", title=None): "transport": outcome.transport} +def json_query(data, path): + from je_auto_control.utils.jsonpath import json_query as _q + return {"matches": _q(data, path)} + + +def json_extract(data, mapping): + from je_auto_control.utils.jsonpath import json_extract as _x + return {"result": _x(data, mapping)} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/test/unit_test/headless/test_jsonpath_batch.py b/test/unit_test/headless/test_jsonpath_batch.py new file mode 100644 index 00000000..21d90714 --- /dev/null +++ b/test/unit_test/headless/test_jsonpath_batch.py @@ -0,0 +1,96 @@ +"""Headless tests for the JSONPath subset engine. Pure stdlib, no Qt imports.""" +import json + +import je_auto_control as ac +from je_auto_control.utils.jsonpath import ( + json_extract, json_query, json_query_one) + +DATA = { + "store": { + "books": [ + {"title": "A", "price": 5}, + {"title": "B", "price": 12}, + {"title": "C", "price": 9}, + ], + "name": "Main", + }, + "tags": ["x", "y", "z"], +} + + +def test_dotted_and_index(): + assert json_query(DATA, "$.store.name") == ["Main"] + assert json_query(DATA, "$.store.books[0].title") == ["A"] + assert json_query(DATA, "store.books[-1].title") == ["C"] + + +def test_wildcard(): + assert json_query(DATA, "$.store.books[*].title") == ["A", "B", "C"] + assert json_query(DATA, "$.tags[*]") == ["x", "y", "z"] + + +def test_filter(): + assert json_query(DATA, "$.store.books[?(@.price > 8)].title") == ["B", "C"] + assert json_query(DATA, "$.store.books[?(@.title == 'A')].price") == [5] + + +def test_recursive_descent(): + assert json_query(DATA, "$..price") == [5, 12, 9] + assert json_query(DATA, "$..title") == ["A", "B", "C"] + + +def test_query_one_and_default(): + assert json_query_one(DATA, "$.store.books[1].title") == "B" + assert json_query_one(DATA, "$.missing.path", "DEF") == "DEF" + + +def test_extract_mapping(): + out = json_extract(DATA, {"first_book": "$.store.books[0].title", + "store_name": "store.name", + "missing": "$.nope"}) + assert out == {"first_book": "A", "store_name": "Main", "missing": None} + + +def test_quoted_key(): + data = {"a.b": {"c": 1}} + assert json_query(data, "$['a.b'].c") == [1] + + +def test_no_match_returns_empty(): + assert json_query(DATA, "$.store.books[99].title") == [] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_json_query", + {"data": json.dumps(DATA), "path": "$.store.books[?(@.price>8)].title"}, + ]]) + matches = next(v for v in rec.values() if isinstance(v, dict))["matches"] + assert matches == ["B", "C"] + + rec2 = ac.execute_action([[ + "AC_json_extract", + {"data": json.dumps(DATA), "mapping": json.dumps({"n": "store.name"})}, + ]]) + result = next(v for v in rec2.values() if isinstance(v, dict))["result"] + assert result == {"n": "Main"} + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_json_query", "AC_json_extract"} <= 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_json_query", "ac_json_extract"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_json_query", "AC_json_extract"} <= cmds + + +def test_facade_exports(): + for attr in ("json_query", "json_query_one", "json_extract"): + assert hasattr(ac, attr) + assert attr in ac.__all__