diff --git a/README.md b/README.md index d8c4b6ba..f124a329 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-22) — HTTP Content Negotiation & Decompression](#whats-new-2026-06-22--http-content-negotiation--decompression) - [What's new (2026-06-22) — multipart/form-data Build & Parse](#whats-new-2026-06-22--multipartform-data-build--parse) - [What's new (2026-06-22) — Secret Redaction for Config & Logs](#whats-new-2026-06-22--secret-redaction-for-config--logs) - [What's new (2026-06-22) — RFC 8288 Link Header & Pagination](#whats-new-2026-06-22--rfc-8288-link-header--pagination) @@ -142,6 +143,12 @@ --- +## What's new (2026-06-22) — HTTP Content Negotiation & Decompression + +Build `Accept` headers and decode gzip/deflate. Full reference: [`docs/source/Eng/doc/new_features/v90_features_doc.rst`](docs/source/Eng/doc/new_features/v90_features_doc.rst). + +- **`build_accept` / `build_accept_encoding` / `parse_quality_values` / `decode_body` / `negotiated_call`** (`AC_decode_body`, `AC_parse_quality_values`): `urllib`/`http_request` never set `Accept-Encoding` nor decoded `Content-Encoding`, so compressed bodies arrived raw. This adds `Accept`/`Accept-Encoding` builders, a q-value parser (sorted by quality), and gzip/deflate (incl. raw deflate) decoding. Brotli excluded (not stdlib). Pure-stdlib, deterministic. + ## What's new (2026-06-22) — multipart/form-data Build & Parse Build file-upload bodies. Full reference: [`docs/source/Eng/doc/new_features/v89_features_doc.rst`](docs/source/Eng/doc/new_features/v89_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index fc4da596..19bde038 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-22) — HTTP 内容协商与解压](#本次更新-2026-06-22--http-内容协商与解压) - [本次更新 (2026-06-22) — multipart/form-data 构建与解析](#本次更新-2026-06-22--multipartform-data-构建与解析) - [本次更新 (2026-06-22) — 配置与日志的机密脱敏](#本次更新-2026-06-22--配置与日志的机密脱敏) - [本次更新 (2026-06-22) — RFC 8288 Link 标头与分页](#本次更新-2026-06-22--rfc-8288-link-标头与分页) @@ -141,6 +142,12 @@ --- +## 本次更新 (2026-06-22) — HTTP 内容协商与解压 + +构建 `Accept` 标头并解码 gzip/deflate。完整参考:[`docs/source/Zh/doc/new_features/v90_features_doc.rst`](../docs/source/Zh/doc/new_features/v90_features_doc.rst)。 + +- **`build_accept` / `build_accept_encoding` / `parse_quality_values` / `decode_body` / `negotiated_call`**(`AC_decode_body`、`AC_parse_quality_values`):`urllib`/`http_request` 从不设置 `Accept-Encoding` 也不解码 `Content-Encoding`,压缩内文以原始形式抵达。本功能加入 `Accept`/`Accept-Encoding` 构建器、q-value 解析器(按品质排序),以及 gzip/deflate(含 raw deflate)解码。排除 Brotli(非标准库)。纯标准库、确定。 + ## 本次更新 (2026-06-22) — multipart/form-data 构建与解析 构建文件上传内文。完整参考:[`docs/source/Zh/doc/new_features/v89_features_doc.rst`](../docs/source/Zh/doc/new_features/v89_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index b0e28a80..18c18522 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-22) — HTTP 內容協商與解壓縮](#本次更新-2026-06-22--http-內容協商與解壓縮) - [本次更新 (2026-06-22) — multipart/form-data 建立與解析](#本次更新-2026-06-22--multipartform-data-建立與解析) - [本次更新 (2026-06-22) — 設定與日誌的機密遮蔽](#本次更新-2026-06-22--設定與日誌的機密遮蔽) - [本次更新 (2026-06-22) — RFC 8288 Link 標頭與分頁](#本次更新-2026-06-22--rfc-8288-link-標頭與分頁) @@ -141,6 +142,12 @@ --- +## 本次更新 (2026-06-22) — HTTP 內容協商與解壓縮 + +建立 `Accept` 標頭並解碼 gzip/deflate。完整參考:[`docs/source/Zh/doc/new_features/v90_features_doc.rst`](../docs/source/Zh/doc/new_features/v90_features_doc.rst)。 + +- **`build_accept` / `build_accept_encoding` / `parse_quality_values` / `decode_body` / `negotiated_call`**(`AC_decode_body`、`AC_parse_quality_values`):`urllib`/`http_request` 從不設定 `Accept-Encoding` 也不解碼 `Content-Encoding`,壓縮內文以原始形式抵達。本功能加入 `Accept`/`Accept-Encoding` 建構器、q-value 解析器(依品質排序),以及 gzip/deflate(含 raw deflate)解碼。排除 Brotli(非標準函式庫)。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-22) — multipart/form-data 建立與解析 建立檔案上傳內文。完整參考:[`docs/source/Zh/doc/new_features/v89_features_doc.rst`](../docs/source/Zh/doc/new_features/v89_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v90_features_doc.rst b/docs/source/Eng/doc/new_features/v90_features_doc.rst new file mode 100644 index 00000000..69bd565f --- /dev/null +++ b/docs/source/Eng/doc/new_features/v90_features_doc.rst @@ -0,0 +1,49 @@ +HTTP Content Negotiation & Decompression +======================================== + +``urllib`` / ``http_request`` never sets ``Accept-Encoding`` and never decodes a +``Content-Encoding`` response, so a body from a server that compresses arrives +raw; there was also no quality-value parser. This adds ``Accept`` / +``Accept-Encoding`` builders, a q-value parser, and gzip / deflate decoding. + +Pure standard library (``gzip`` / ``zlib``); imports no ``PySide6``. Brotli is +deliberately excluded (not stdlib). Every function is pure, so it is fully +deterministic in CI. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + build_accept, build_accept_encoding, negotiated_call, + parse_quality_values, decode_body, build_call, + ) + + call = negotiated_call( + build_call(url), + accept=build_accept([("application/json", 1.0), ("text/html", 0.8)]), + accept_encoding=build_accept_encoding(), + ) + # ... perform the call, then: + body = decode_body(response["headers"], raw_bytes) + + ranked = parse_quality_values("text/html;q=0.8, application/json") + # [("application/json", 1.0), ("text/html", 0.8)] + +``build_accept`` turns media types or ``(type, q)`` pairs into an ``Accept`` +header; ``build_accept_encoding`` defaults to ``gzip, deflate``. +``parse_quality_values`` parses an ``Accept`` / ``Accept-Encoding`` header into +``(token, q)`` pairs sorted by quality (ties keep their order). ``decode_body`` +decompresses a response body according to its ``Content-Encoding`` (``gzip`` / +``deflate``, including raw deflate, plus ``identity``), raising ``ValueError`` +for anything unsupported. ``negotiated_call`` adds the negotiation headers to a +``build_call`` dict. + +Executor commands +----------------- + +``AC_decode_body`` decodes a base64 ``body_base64`` per its ``headers`` and +returns ``{body_base64, text}``; ``AC_parse_quality_values`` returns +``{values}``. Both are exposed as MCP tools (``ac_decode_body`` / +``ac_parse_quality_values``) and as Script Builder commands under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 3642009c..5135094a 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -112,6 +112,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v87_features_doc doc/new_features/v88_features_doc doc/new_features/v89_features_doc + doc/new_features/v90_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/v90_features_doc.rst b/docs/source/Zh/doc/new_features/v90_features_doc.rst new file mode 100644 index 00000000..50dbdaef --- /dev/null +++ b/docs/source/Zh/doc/new_features/v90_features_doc.rst @@ -0,0 +1,43 @@ +HTTP 內容協商與解壓縮 +=================== + +``urllib`` / ``http_request`` 從不設定 ``Accept-Encoding``,也從不解碼 ``Content-Encoding`` 回應,因此 +會壓縮的伺服器回傳的內文是原始的;也沒有 quality-value 解析器。本功能加入 ``Accept`` / ``Accept-Encoding`` +建構器、q-value 解析器,以及 gzip / deflate 解碼。 + +純標準函式庫(``gzip`` / ``zlib``);不匯入 ``PySide6``。刻意排除 Brotli(非標準函式庫)。每個函式皆為 +純函式,因此在 CI 中完全具決定性。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + build_accept, build_accept_encoding, negotiated_call, + parse_quality_values, decode_body, build_call, + ) + + call = negotiated_call( + build_call(url), + accept=build_accept([("application/json", 1.0), ("text/html", 0.8)]), + accept_encoding=build_accept_encoding(), + ) + # ... 執行呼叫,然後: + body = decode_body(response["headers"], raw_bytes) + + ranked = parse_quality_values("text/html;q=0.8, application/json") + # [("application/json", 1.0), ("text/html", 0.8)] + +``build_accept`` 把媒體型別或 ``(type, q)`` 配對轉成 ``Accept`` 標頭;``build_accept_encoding`` 預設 +``gzip, deflate``。``parse_quality_values`` 把 ``Accept`` / ``Accept-Encoding`` 標頭解析成依品質排序的 +``(token, q)`` 配對(同分維持順序)。``decode_body`` 依 ``Content-Encoding``(``gzip`` / ``deflate``,含 +raw deflate,以及 ``identity``)解壓回應內文,不支援者拋出 ``ValueError``。``negotiated_call`` 把協商標頭 +加到 ``build_call`` dict。 + +執行器命令 +---------- + +``AC_decode_body`` 依 ``headers`` 解碼 base64 的 ``body_base64`` 回傳 ``{body_base64, text}``; +``AC_parse_quality_values`` 回傳 ``{values}``。兩者皆以 MCP 工具(``ac_decode_body`` / +``ac_parse_quality_values``)以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 12d4302d..11466a96 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -112,6 +112,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v87_features_doc doc/new_features/v88_features_doc doc/new_features/v89_features_doc + doc/new_features/v90_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 5971a6da..c020a5fc 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -424,6 +424,11 @@ from je_auto_control.utils.multipart import ( MultipartFile, build_multipart, new_boundary, parse_multipart, ) +# HTTP content negotiation + gzip/deflate response decoding +from je_auto_control.utils.http_content import ( + build_accept, build_accept_encoding, decode_body, negotiated_call, + parse_quality_values, +) # W3C Trace Context propagation (traceparent / tracestate) from je_auto_control.utils.trace_context import ( SpanContext, TraceContextError, child_context, extract_context, @@ -948,6 +953,8 @@ def start_autocontrol_gui(*args, **kwargs): "SSEEvent", "SSEParser", "parse_event_stream", "Link", "links_by_rel", "next_url", "paginate", "parse_link_header", "MultipartFile", "build_multipart", "new_boundary", "parse_multipart", + "build_accept", "build_accept_encoding", "decode_body", "negotiated_call", + "parse_quality_values", "SpanContext", "TraceContextError", "child_context", "extract_context", "format_traceparent", "format_tracestate", "inject_context", "new_root_context", "new_span_id", "new_trace_id", "parse_traceparent", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 42697df8..9c2b411c 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1766,6 +1766,24 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Parse a base64 multipart body into fields and files.", )) + specs.append(CommandSpec( + "AC_decode_body", "Data", "HTTP Content: Decode Body", + fields=( + FieldSpec("headers", FieldType.STRING, + placeholder='{"Content-Encoding": "gzip"}'), + FieldSpec("body_base64", FieldType.STRING, + placeholder=""), + ), + description="Decode a gzip/deflate response body by Content-Encoding.", + )) + specs.append(CommandSpec( + "AC_parse_quality_values", "Data", "HTTP Content: Quality Values", + fields=( + FieldSpec("header", FieldType.STRING, + placeholder="text/html;q=0.8, application/json"), + ), + description="Parse an Accept/Accept-Encoding header by q-value.", + )) specs.append(CommandSpec( "AC_resolve_config", "Data", "Layered Config: Resolve", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 34517602..12945b89 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3030,6 +3030,28 @@ def _redact_secret_text(text: str, mask: str = "***") -> Dict[str, Any]: return {"text": redact_secret_text(text, mask=mask)} +def _decode_body(headers: Any, body_base64: str) -> Dict[str, Any]: + """Adapter: decode a Content-Encoding (gzip/deflate) base64 body.""" + import base64 + import json + from je_auto_control.utils.http_content import decode_body + if isinstance(headers, str): + headers = json.loads(headers) + decoded = decode_body(headers, base64.b64decode(body_base64)) + try: + text: Any = decoded.decode("utf-8") + except UnicodeDecodeError: + text = None + return {"body_base64": base64.b64encode(decoded).decode("ascii"), + "text": text} + + +def _parse_quality_values(header: str) -> Dict[str, Any]: + """Adapter: parse a quality-value header into {values}.""" + from je_auto_control.utils.http_content import parse_quality_values + return {"values": [list(item) for item in parse_quality_values(header)]} + + def _build_multipart(fields: Any = None, files: Any = None, boundary: Any = None) -> Dict[str, Any]: """Adapter: build a multipart/form-data body (base64-encoded).""" @@ -4330,6 +4352,8 @@ def __init__(self): "AC_next_url": _next_url, "AC_build_multipart": _build_multipart, "AC_parse_multipart": _parse_multipart, + "AC_decode_body": _decode_body, + "AC_parse_quality_values": _parse_quality_values, "AC_profile_rows": _profile_rows, "AC_infer_schema": _infer_schema, "AC_parse_problem": _parse_problem, diff --git a/je_auto_control/utils/http_content/__init__.py b/je_auto_control/utils/http_content/__init__.py new file mode 100644 index 00000000..557895bf --- /dev/null +++ b/je_auto_control/utils/http_content/__init__.py @@ -0,0 +1,10 @@ +"""HTTP content negotiation and response decompression for AutoControl.""" +from je_auto_control.utils.http_content.http_content import ( + build_accept, build_accept_encoding, decode_body, negotiated_call, + parse_quality_values, +) + +__all__ = [ + "build_accept", "build_accept_encoding", "decode_body", "negotiated_call", + "parse_quality_values", +] diff --git a/je_auto_control/utils/http_content/http_content.py b/je_auto_control/utils/http_content/http_content.py new file mode 100644 index 00000000..24608714 --- /dev/null +++ b/je_auto_control/utils/http_content/http_content.py @@ -0,0 +1,93 @@ +"""HTTP content negotiation and transparent response decompression. + +``urllib`` / ``http_request`` never sets ``Accept-Encoding`` and never decodes a +``Content-Encoding`` response, so a body from a server that compresses arrives +raw; there was also no quality-value parser. This adds ``Accept`` / +``Accept-Encoding`` builders, a q-value parser, and gzip / deflate decoding. + +Pure standard library (``gzip`` / ``zlib``); imports no ``PySide6``. Brotli is +deliberately excluded (not stdlib). Every function is pure, so it is fully +deterministic in CI. +""" +import gzip +import zlib +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union + +AcceptEntry = Union[str, Tuple[str, float]] +_DEFAULT_ENCODINGS = ("gzip", "deflate") + + +def build_accept(types: Sequence[AcceptEntry]) -> str: + """Build an ``Accept`` header from media types or ``(type, q)`` pairs.""" + parts: List[str] = [] + for entry in types: + if isinstance(entry, (tuple, list)): + media, quality = entry[0], float(entry[1]) + parts.append(media if quality >= 1.0 else f"{media};q={quality}") + else: + parts.append(entry) + return ", ".join(parts) + + +def build_accept_encoding(encodings: Optional[Sequence[str]] = None) -> str: + """Build an ``Accept-Encoding`` header (default ``gzip, deflate``).""" + return ", ".join(encodings if encodings is not None else _DEFAULT_ENCODINGS) + + +def _quality_of(params: str) -> float: + for param in params.split(";"): + key, sep, value = param.partition("=") + if sep and key.strip() == "q": + try: + return float(value.strip()) + except ValueError: + return 0.0 + return 1.0 + + +def parse_quality_values(header: Optional[str]) -> List[Tuple[str, float]]: + """Parse a quality-value header into ``(token, q)`` sorted by ``q`` desc.""" + items: List[Tuple[str, float]] = [] + for part in (header or "").split(","): + cleaned = part.strip() + if not cleaned: + continue + token, _, params = cleaned.partition(";") + items.append((token.strip(), _quality_of(params))) + items.sort(key=lambda entry: -entry[1]) # stable: ties keep order + return items + + +def _content_encoding(headers: Optional[Mapping[str, Any]]) -> str: + for key, value in (headers or {}).items(): + if str(key).lower() == "content-encoding": + return str(value).strip().lower() + return "" + + +def decode_body(headers: Optional[Mapping[str, Any]], raw: bytes) -> bytes: + """Decode ``raw`` per the response ``Content-Encoding`` header.""" + encoding = _content_encoding(headers) + if encoding in ("", "identity"): + return raw + if encoding == "gzip": + return gzip.decompress(raw) + if encoding == "deflate": + try: + return zlib.decompress(raw) + except zlib.error: + return zlib.decompress(raw, -zlib.MAX_WBITS) # raw deflate stream + raise ValueError(f"unsupported content-encoding: {encoding!r}") + + +def negotiated_call(call: Mapping[str, Any], *, accept: Optional[str] = None, + accept_encoding: str = "gzip, deflate") -> Dict[str, Any]: + """Return a copy of a ``build_call`` dict with negotiation headers set.""" + headers = dict(call.get("headers") or {}) + if accept: + headers["Accept"] = accept + if accept_encoding: + headers["Accept-Encoding"] = accept_encoding + out = dict(call) + out["headers"] = headers + return out diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 2ba51ef1..aebf604a 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,32 @@ def rate_limit_tools() -> List[MCPTool]: ] +def http_content_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_decode_body", + description=("Decode a base64 'body_base64' per its 'headers' " + "Content-Encoding (gzip / deflate / identity). Returns " + "{body_base64, text}."), + input_schema=schema( + {"headers": {"type": "object"}, + "body_base64": {"type": "string"}}, + ["headers", "body_base64"]), + handler=h.decode_body, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_parse_quality_values", + description=("Parse a quality-value 'header' (Accept / " + "Accept-Encoding) into {values}: [token, q] sorted by q " + "descending."), + input_schema=schema({"header": {"type": "string"}}, ["header"]), + handler=h.parse_quality_values, + annotations=READ_ONLY, + ), + ] + + def multipart_tools() -> List[MCPTool]: return [ MCPTool( @@ -5246,6 +5272,7 @@ def media_assert_tools() -> List[MCPTool]: data_profile_tools, http_problem_tools, dotenv_tools, sse_client_tools, layered_config_tools, data_drift_tools, dataset_diff_tools, referential_tools, link_header_tools, multipart_tools, + http_content_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 38c5ae15..e37f8b30 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1766,6 +1766,17 @@ def parse_multipart(content_type, body_base64): return _parse_multipart(content_type, body_base64) +def decode_body(headers, body_base64): + from je_auto_control.utils.executor.action_executor import _decode_body + return _decode_body(headers, body_base64) + + +def parse_quality_values(header): + from je_auto_control.utils.executor.action_executor import ( + _parse_quality_values) + return _parse_quality_values(header) + + def redact_config(obj, mask="***"): from je_auto_control.utils.executor.action_executor import _redact_config return _redact_config(obj, mask) diff --git a/test/unit_test/headless/test_http_content_batch.py b/test/unit_test/headless/test_http_content_batch.py new file mode 100644 index 00000000..cec59832 --- /dev/null +++ b/test/unit_test/headless/test_http_content_batch.py @@ -0,0 +1,93 @@ +"""Headless tests for HTTP content negotiation + decoding. No Qt.""" +import base64 +import gzip +import json +import zlib + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.http_content import ( + build_accept, build_accept_encoding, decode_body, negotiated_call, + parse_quality_values, +) + + +def test_build_accept_with_and_without_quality(): + assert build_accept(["application/json"]) == "application/json" + header = build_accept([("application/json", 1.0), ("text/html", 0.8)]) + assert header == "application/json, text/html;q=0.8" + + +def test_build_accept_encoding_default_and_custom(): + assert build_accept_encoding() == "gzip, deflate" + assert build_accept_encoding(["identity"]) == "identity" + + +def test_parse_quality_values_sorted_by_q(): + parsed = parse_quality_values("text/html;q=0.8, application/json, text/*;q=0.1") + assert parsed[0] == ("application/json", 1.0) + assert parsed[-1] == ("text/*", 0.1) + + +def test_decode_gzip_and_deflate_and_identity(): + payload = b"hello world payload" + assert decode_body({"Content-Encoding": "gzip"}, + gzip.compress(payload)) == payload + assert decode_body({"content-encoding": "deflate"}, + zlib.compress(payload)) == payload + assert decode_body({}, payload) == payload + assert decode_body({"Content-Encoding": "identity"}, payload) == payload + + +def test_decode_raw_deflate_fallback(): + payload = b"raw deflate stream" + compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS) + raw = compressor.compress(payload) + compressor.flush() + assert decode_body({"Content-Encoding": "deflate"}, raw) == payload + + +def test_decode_unsupported_raises(): + with pytest.raises(ValueError): + decode_body({"Content-Encoding": "br"}, b"...") + + +def test_negotiated_call_sets_headers(): + call = {"url": "https://api/x", "headers": {"accept": "x"}} + out = negotiated_call(call, accept="application/json") + assert out["headers"]["Accept"] == "application/json" + assert out["headers"]["Accept-Encoding"] == "gzip, deflate" + assert call["headers"] == {"accept": "x"} # input untouched + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + body = base64.b64encode(gzip.compress(b"data")).decode() + rec = ac.execute_action([[ + "AC_decode_body", + {"headers": json.dumps({"Content-Encoding": "gzip"}), + "body_base64": body}]]) + out = next(v for v in rec.values() if isinstance(v, dict)) + assert out["text"] == "data" + rec2 = ac.execute_action([[ + "AC_parse_quality_values", {"header": "a;q=0.2, b"}]]) + values = next(v for v in rec2.values() if isinstance(v, dict))["values"] + assert values[0] == ["b", 1.0] + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_decode_body", "AC_parse_quality_values"} <= set(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_decode_body", "ac_parse_quality_values"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_decode_body", "AC_parse_quality_values"} <= specs + + +def test_facade_exports(): + for attr in ("build_accept", "build_accept_encoding", "decode_body", + "negotiated_call", "parse_quality_values"): + assert hasattr(ac, attr) and attr in ac.__all__