From e8b415b3dea27c396798fd9d38f8e75830e5998a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 22 Jun 2026 02:27:17 +0800 Subject: [PATCH 1/2] Add multipart/form-data build and parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http_request sent only JSON or raw bodies — there was no file upload, and the stdlib cgi module that once parsed multipart was removed in Python 3.13. Add build_multipart (text fields + files, injectable boundary for a byte-stable body) and parse_multipart. Wired through facade, executor (AC_build_multipart / AC_parse_multipart, base64 body), MCP, and the Script Builder with a headless test batch and EN/Zh docs. --- README.md | 7 + README/README_zh-CN.md | 7 + README/README_zh-TW.md | 7 + .../Eng/doc/new_features/v89_features_doc.rst | 42 ++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v89_features_doc.rst | 38 ++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 22 ++++ .../utils/executor/action_executor.py | 25 ++++ .../utils/mcp_server/tools/_factories.py | 31 ++++- .../utils/mcp_server/tools/_handlers.py | 10 ++ je_auto_control/utils/multipart/__init__.py | 8 ++ je_auto_control/utils/multipart/multipart.py | 123 ++++++++++++++++++ .../headless/test_multipart_batch.py | 88 +++++++++++++ 15 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v89_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v89_features_doc.rst create mode 100644 je_auto_control/utils/multipart/__init__.py create mode 100644 je_auto_control/utils/multipart/multipart.py create mode 100644 test/unit_test/headless/test_multipart_batch.py diff --git a/README.md b/README.md index 17f18681..d8c4b6ba 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [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) - [What's new (2026-06-22) — Referential Integrity Checks](#whats-new-2026-06-22--referential-integrity-checks) @@ -141,6 +142,12 @@ --- +## 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). + +- **`build_multipart` / `parse_multipart` / `MultipartFile`** (`AC_build_multipart`, `AC_parse_multipart`): `http_request` sent only JSON/raw — there was no file upload, and stdlib `cgi` (which parsed multipart) was removed in 3.13. This assembles a `multipart/form-data` body from text fields and files with an injectable boundary (byte-stable), and parses one back into `{fields, files}`. Pure-stdlib, deterministic. + ## What's new (2026-06-22) — Secret Redaction for Config & Logs Mask secrets before logging or exporting. Full reference: [`docs/source/Eng/doc/new_features/v88_features_doc.rst`](docs/source/Eng/doc/new_features/v88_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 4c004c10..fc4da596 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (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-标头与分页) - [本次更新 (2026-06-22) — 参照完整性检查](#本次更新-2026-06-22--参照完整性检查) @@ -140,6 +141,12 @@ --- +## 本次更新 (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)。 + +- **`build_multipart` / `parse_multipart` / `MultipartFile`**(`AC_build_multipart`、`AC_parse_multipart`):`http_request` 只发 JSON/原始 —— 没有文件上传,且解析 multipart 的标准库 `cgi` 已在 3.13 移除。本功能以可注入的 boundary(字节稳定)从文本字段与文件组装 `multipart/form-data` 内文,并能解析回 `{fields, files}`。纯标准库、确定。 + ## 本次更新 (2026-06-22) — 配置与日志的机密脱敏 在记录或导出前脱敏机密。完整参考:[`docs/source/Zh/doc/new_features/v88_features_doc.rst`](../docs/source/Zh/doc/new_features/v88_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c299ea2d..b0e28a80 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (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-標頭與分頁) - [本次更新 (2026-06-22) — 參照完整性檢查](#本次更新-2026-06-22--參照完整性檢查) @@ -140,6 +141,12 @@ --- +## 本次更新 (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)。 + +- **`build_multipart` / `parse_multipart` / `MultipartFile`**(`AC_build_multipart`、`AC_parse_multipart`):`http_request` 只送 JSON/原始 —— 沒有檔案上傳,且解析 multipart 的標準函式庫 `cgi` 已在 3.13 移除。本功能以可注入的 boundary(位元組穩定)從文字欄位與檔案組裝 `multipart/form-data` 內文,並能解析回 `{fields, files}`。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-22) — 設定與日誌的機密遮蔽 在記錄或匯出前遮蔽機密。完整參考:[`docs/source/Zh/doc/new_features/v88_features_doc.rst`](../docs/source/Zh/doc/new_features/v88_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v89_features_doc.rst b/docs/source/Eng/doc/new_features/v89_features_doc.rst new file mode 100644 index 00000000..c8cd2f71 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v89_features_doc.rst @@ -0,0 +1,42 @@ +multipart/form-data Build & Parse +================================= + +``http_request`` sends only JSON or raw bodies — there was no file upload, and +the stdlib ``cgi`` module (which once parsed multipart) was removed in Python +3.13. This assembles a ``multipart/form-data`` body from text fields and files +with a deterministic boundary, and parses one back. + +Pure standard library (``re`` / ``secrets``); imports no ``PySide6``. The +boundary is injectable, so a built body is byte-stable and CI-testable. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import build_multipart, parse_multipart, MultipartFile + + content_type, body = build_multipart( + fields={"title": "Q3 report"}, + files=[MultipartFile("file", "report.csv", csv_text, "text/csv")], + ) + http_request(url, method="POST", + headers={"Content-Type": content_type}, data=body) + + parsed = parse_multipart(content_type, body) # {"fields": {...}, "files": [...]} + +``build_multipart`` accepts ``fields`` (a dict or ``(name, value)`` list) and +``files`` (``MultipartFile`` instances or ``{name, filename, content, +content_type?}`` dicts), returning ``(content_type, body_bytes)``. Pass an +explicit ``boundary`` for a byte-stable body, or call ``new_boundary`` for a +fresh token. ``parse_multipart`` reads a body back into ``{fields, files}`` (each +file as ``{name, filename, content_type, content}``). + +Executor commands +----------------- + +``AC_build_multipart`` returns ``{content_type, body_base64}`` for ``fields`` / +``files`` (and an optional ``boundary``); ``AC_parse_multipart`` takes a +``content_type`` and ``body_base64`` and returns ``{fields, files}``. Both are +exposed as MCP tools (``ac_build_multipart`` / ``ac_parse_multipart``) and as +Script Builder commands under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 525ca9ab..3642009c 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -111,6 +111,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v86_features_doc doc/new_features/v87_features_doc doc/new_features/v88_features_doc + doc/new_features/v89_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/v89_features_doc.rst b/docs/source/Zh/doc/new_features/v89_features_doc.rst new file mode 100644 index 00000000..6324bebf --- /dev/null +++ b/docs/source/Zh/doc/new_features/v89_features_doc.rst @@ -0,0 +1,38 @@ +multipart/form-data 建立與解析 +============================= + +``http_request`` 只送出 JSON 或原始內文 —— 沒有檔案上傳,而曾經能解析 multipart 的標準函式庫 ``cgi`` +模組已在 Python 3.13 移除。本功能以決定性的 boundary 從文字欄位與檔案組裝 ``multipart/form-data`` +內文,並能將其解析回來。 + +純標準函式庫(``re`` / ``secrets``);不匯入 ``PySide6``。boundary 可注入,因此建立出的內文位元組穩定、 +可於 CI 測試。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import build_multipart, parse_multipart, MultipartFile + + content_type, body = build_multipart( + fields={"title": "Q3 report"}, + files=[MultipartFile("file", "report.csv", csv_text, "text/csv")], + ) + http_request(url, method="POST", + headers={"Content-Type": content_type}, data=body) + + parsed = parse_multipart(content_type, body) # {"fields": {...}, "files": [...]} + +``build_multipart`` 接受 ``fields``(dict 或 ``(name, value)`` 清單)與 ``files``(``MultipartFile`` +實例或 ``{name, filename, content, content_type?}`` dict),回傳 ``(content_type, body_bytes)``。傳入明確 +的 ``boundary`` 可得位元組穩定的內文,或呼叫 ``new_boundary`` 取得新 token。``parse_multipart`` 把內文讀回 +``{fields, files}``(每個檔案為 ``{name, filename, content_type, content}``)。 + +執行器命令 +---------- + +``AC_build_multipart`` 對 ``fields`` / ``files``(以及選用 ``boundary``)回傳 ``{content_type, +body_base64}``;``AC_parse_multipart`` 接受 ``content_type`` 與 ``body_base64`` 回傳 ``{fields, files}``。 +兩者皆以 MCP 工具(``ac_build_multipart`` / ``ac_parse_multipart``)以及 Script Builder 中 **Data** 分類下 +的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index dd3a58c1..12d4302d 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -111,6 +111,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v86_features_doc doc/new_features/v87_features_doc doc/new_features/v88_features_doc + doc/new_features/v89_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 27e8110f..5971a6da 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -420,6 +420,10 @@ from je_auto_control.utils.link_header import ( Link, links_by_rel, next_url, paginate, parse_link_header, ) +# multipart/form-data building + parsing (file upload bodies) +from je_auto_control.utils.multipart import ( + MultipartFile, build_multipart, new_boundary, parse_multipart, +) # W3C Trace Context propagation (traceparent / tracestate) from je_auto_control.utils.trace_context import ( SpanContext, TraceContextError, child_context, extract_context, @@ -943,6 +947,7 @@ def start_autocontrol_gui(*args, **kwargs): "raise_for_problem", "SSEEvent", "SSEParser", "parse_event_stream", "Link", "links_by_rel", "next_url", "paginate", "parse_link_header", + "MultipartFile", "build_multipart", "new_boundary", "parse_multipart", "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 6252377c..42697df8 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1744,6 +1744,28 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Return the rel=next URL from a Link header.", )) + specs.append(CommandSpec( + "AC_build_multipart", "Data", "Multipart: Build Body", + fields=( + FieldSpec("fields", FieldType.STRING, optional=True, + placeholder='{"name": "report", "tag": "v1"}'), + FieldSpec("files", FieldType.STRING, optional=True, + placeholder='[{"name": "f", "filename": "a.txt", ' + '"content": "hi"}]'), + FieldSpec("boundary", FieldType.STRING, optional=True), + ), + description="Build a multipart/form-data body (base64) for upload.", + )) + specs.append(CommandSpec( + "AC_parse_multipart", "Data", "Multipart: Parse Body", + fields=( + FieldSpec("content_type", FieldType.STRING, + placeholder="multipart/form-data; boundary=..."), + FieldSpec("body_base64", FieldType.STRING, + placeholder=""), + ), + description="Parse a base64 multipart body into fields and files.", + )) 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 87274355..34517602 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3030,6 +3030,29 @@ def _redact_secret_text(text: str, mask: str = "***") -> Dict[str, Any]: return {"text": redact_secret_text(text, mask=mask)} +def _build_multipart(fields: Any = None, files: Any = None, + boundary: Any = None) -> Dict[str, Any]: + """Adapter: build a multipart/form-data body (base64-encoded).""" + import base64 + import json + from je_auto_control.utils.multipart import build_multipart + if isinstance(fields, str): + fields = json.loads(fields) + if isinstance(files, str): + files = json.loads(files) + content_type, body = build_multipart(fields, files, boundary=boundary) + return {"content_type": content_type, + "body_base64": base64.b64encode(body).decode("ascii")} + + +def _parse_multipart(content_type: str, body_base64: str) -> Dict[str, Any]: + """Adapter: parse a base64-encoded multipart body into {fields, files}.""" + import base64 + from je_auto_control.utils.multipart import parse_multipart + body = base64.b64decode(body_base64) + return parse_multipart(content_type, body) + + def _parse_link_header(value: str) -> Dict[str, Any]: """Adapter: parse an RFC 8288 Link header into {links}.""" from je_auto_control.utils.link_header import parse_link_header @@ -4305,6 +4328,8 @@ def __init__(self): "AC_redact_secret_text": _redact_secret_text, "AC_parse_link_header": _parse_link_header, "AC_next_url": _next_url, + "AC_build_multipart": _build_multipart, + "AC_parse_multipart": _parse_multipart, "AC_profile_rows": _profile_rows, "AC_infer_schema": _infer_schema, "AC_parse_problem": _parse_problem, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 093ae0da..2ba51ef1 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,35 @@ def rate_limit_tools() -> List[MCPTool]: ] +def multipart_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_build_multipart", + description=("Build a multipart/form-data body from 'fields' " + "(object/list) and 'files' (each {name, filename, " + "content, content_type?}); optional 'boundary'. Returns " + "{content_type, body_base64}."), + input_schema=schema( + {"fields": {"type": "object"}, "files": {"type": "array"}, + "boundary": {"type": "string"}}, + []), + handler=h.build_multipart, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_parse_multipart", + description=("Parse a base64 multipart body ('body_base64') with its " + "'content_type' into {fields, files}."), + input_schema=schema( + {"content_type": {"type": "string"}, + "body_base64": {"type": "string"}}, + ["content_type", "body_base64"]), + handler=h.parse_multipart, + annotations=READ_ONLY, + ), + ] + + def link_header_tools() -> List[MCPTool]: return [ MCPTool( @@ -5216,7 +5245,7 @@ def media_assert_tools() -> List[MCPTool]: config_redaction_tools, 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, + dataset_diff_tools, referential_tools, link_header_tools, multipart_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 5f1add80..38c5ae15 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1756,6 +1756,16 @@ def next_url(value): return _next_url(value) +def build_multipart(fields=None, files=None, boundary=None): + from je_auto_control.utils.executor.action_executor import _build_multipart + return _build_multipart(fields, files, boundary) + + +def parse_multipart(content_type, body_base64): + from je_auto_control.utils.executor.action_executor import _parse_multipart + return _parse_multipart(content_type, body_base64) + + def redact_config(obj, mask="***"): from je_auto_control.utils.executor.action_executor import _redact_config return _redact_config(obj, mask) diff --git a/je_auto_control/utils/multipart/__init__.py b/je_auto_control/utils/multipart/__init__.py new file mode 100644 index 00000000..31d100f1 --- /dev/null +++ b/je_auto_control/utils/multipart/__init__.py @@ -0,0 +1,8 @@ +"""multipart/form-data building and parsing for AutoControl.""" +from je_auto_control.utils.multipart.multipart import ( + MultipartFile, build_multipart, new_boundary, parse_multipart, +) + +__all__ = [ + "MultipartFile", "build_multipart", "new_boundary", "parse_multipart", +] diff --git a/je_auto_control/utils/multipart/multipart.py b/je_auto_control/utils/multipart/multipart.py new file mode 100644 index 00000000..a0ee9ec5 --- /dev/null +++ b/je_auto_control/utils/multipart/multipart.py @@ -0,0 +1,123 @@ +"""Build and parse ``multipart/form-data`` bodies (file upload support). + +``http_request`` sends only JSON or raw bodies — there was no file upload, and +the stdlib ``cgi`` module (which once parsed multipart) was removed in Python +3.13. This assembles a ``multipart/form-data`` body from text fields and files +with a deterministic boundary, and parses one back. + +Pure standard library (``re`` / ``secrets``); imports no ``PySide6``. The +boundary is injectable, so a built body is byte-stable and CI-testable. +""" +import re +import secrets +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union + +Fields = Union[Mapping[str, str], Sequence[Tuple[str, str]], None] +_DISP_RE = re.compile(r'(\w+)="([^"]*)"') + + +@dataclass +class MultipartFile: + """One file part of a multipart body.""" + + name: str + filename: str + content: Union[str, bytes] + content_type: str = "application/octet-stream" + + +def _to_bytes(value: Union[str, bytes]) -> bytes: + return value if isinstance(value, bytes) else str(value).encode("utf-8") + + +def new_boundary() -> str: + """Return a fresh multipart boundary token.""" + return "----AutoControlBoundary" + secrets.token_hex(16) + + +def _iter_fields(fields: Fields) -> List[Tuple[str, str]]: + if fields is None: + return [] + if isinstance(fields, Mapping): + return list(fields.items()) + return list(fields) + + +def _field_part(boundary: str, name: str, value: str) -> bytes: + head = (f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n') + return head.encode("utf-8") + _to_bytes(value) + b"\r\n" + + +def _as_file(spec: Union[MultipartFile, Mapping[str, Any]]) -> MultipartFile: + if isinstance(spec, MultipartFile): + return spec + return MultipartFile(name=spec["name"], filename=spec["filename"], + content=spec["content"], + content_type=spec.get("content_type", + "application/octet-stream")) + + +def _file_part(boundary: str, spec: MultipartFile) -> bytes: + head = (f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{spec.name}"; ' + f'filename="{spec.filename}"\r\n' + f"Content-Type: {spec.content_type}\r\n\r\n") + return head.encode("utf-8") + _to_bytes(spec.content) + b"\r\n" + + +def build_multipart(fields: Fields = None, + files: Optional[Sequence[Any]] = None, *, + boundary: Optional[str] = None) -> Tuple[str, bytes]: + """Build a ``multipart/form-data`` body; return ``(content_type, body)``.""" + boundary = boundary or new_boundary() + parts = [_field_part(boundary, name, value) + for name, value in _iter_fields(fields)] + parts.extend(_file_part(boundary, _as_file(spec)) for spec in (files or [])) + body = b"".join(parts) + f"--{boundary}--\r\n".encode("utf-8") + return f"multipart/form-data; boundary={boundary}", body + + +def _boundary_of(content_type: str) -> Optional[str]: + match = re.search(r"boundary=([^;]+)", content_type or "") + return match.group(1).strip().strip('"') if match else None + + +def _disp_params(header_block: bytes) -> Dict[str, str]: + headers: Dict[str, str] = {} + for line in header_block.decode("utf-8", "replace").split("\r\n"): + key, sep, value = line.partition(":") + if sep: + headers[key.strip().lower()] = value.strip() + return headers + + +def _assign_part(headers: Mapping[str, str], content: bytes, + fields: Dict[str, str], files: List[Dict[str, Any]]) -> None: + disposition = headers.get("content-disposition", "") + params = dict(_DISP_RE.findall(disposition)) + name = params.get("name", "") + if "filename" in params: + files.append({"name": name, "filename": params["filename"], + "content_type": headers.get("content-type", ""), + "content": content.decode("utf-8", "replace")}) + else: + fields[name] = content.decode("utf-8", "replace") + + +def parse_multipart(content_type: str, body: bytes) -> Dict[str, Any]: + """Parse a ``multipart/form-data`` body into ``{fields, files}``.""" + boundary = _boundary_of(content_type) + if not boundary: + raise ValueError("content_type has no multipart boundary") + fields: Dict[str, str] = {} + files: List[Dict[str, Any]] = [] + for chunk in body.split(b"--" + boundary.encode("utf-8")): + trimmed = chunk.strip(b"\r\n") + if not trimmed or trimmed == b"--": + continue + head, sep, content = trimmed.partition(b"\r\n\r\n") + if sep: + _assign_part(_disp_params(head), content, fields, files) + return {"fields": fields, "files": files} diff --git a/test/unit_test/headless/test_multipart_batch.py b/test/unit_test/headless/test_multipart_batch.py new file mode 100644 index 00000000..0027cecd --- /dev/null +++ b/test/unit_test/headless/test_multipart_batch.py @@ -0,0 +1,88 @@ +"""Headless tests for multipart/form-data build + parse. Pure stdlib, no Qt.""" +import base64 +import json + +import je_auto_control as ac +from je_auto_control.utils.multipart import ( + MultipartFile, build_multipart, new_boundary, parse_multipart, +) + +_BOUNDARY = "TESTBOUNDARY123" + + +def test_build_is_byte_stable_with_fixed_boundary(): + ct, body = build_multipart({"title": "report"}, boundary=_BOUNDARY) + assert ct == f"multipart/form-data; boundary={_BOUNDARY}" + assert body == build_multipart({"title": "report"}, boundary=_BOUNDARY)[1] + assert b'Content-Disposition: form-data; name="title"' in body + assert b"report" in body + assert body.endswith(f"--{_BOUNDARY}--\r\n".encode()) + + +def test_build_includes_file_part(): + files = [MultipartFile("upload", "a.txt", "hello", "text/plain")] + _, body = build_multipart(files=files, boundary=_BOUNDARY) + assert b'filename="a.txt"' in body + assert b"Content-Type: text/plain" in body + assert b"hello" in body + + +def test_round_trip_fields_and_files(): + files = [{"name": "f", "filename": "data.csv", "content": "a,b\n1,2"}] + ct, body = build_multipart({"title": "Q3"}, files, boundary=_BOUNDARY) + parsed = parse_multipart(ct, body) + assert parsed["fields"] == {"title": "Q3"} + assert parsed["files"][0]["filename"] == "data.csv" + assert parsed["files"][0]["content"] == "a,b\n1,2" + + +def test_field_list_form(): + _, body = build_multipart([("k", "1"), ("k", "2")], boundary=_BOUNDARY) + assert body.count(b'name="k"') == 2 + + +def test_new_boundary_is_unique(): + assert new_boundary() != new_boundary() + + +def test_parse_requires_boundary(): + try: + parse_multipart("application/json", b"{}") + raised = False + except ValueError: + raised = True + assert raised + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_build_multipart", + {"fields": json.dumps({"title": "x"}), "boundary": _BOUNDARY}]]) + out = next(v for v in rec.values() if isinstance(v, dict)) + body = base64.b64decode(out["body_base64"]) + assert b'name="title"' in body + rec2 = ac.execute_action([[ + "AC_parse_multipart", + {"content_type": out["content_type"], + "body_base64": out["body_base64"]}]]) + parsed = next(v for v in rec2.values() if isinstance(v, dict)) + assert parsed["fields"] == {"title": "x"} + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_build_multipart", "AC_parse_multipart"} <= 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_build_multipart", "ac_parse_multipart"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_build_multipart", "AC_parse_multipart"} <= specs + + +def test_facade_exports(): + for attr in ("MultipartFile", "build_multipart", "parse_multipart", + "new_boundary"): + assert hasattr(ac, attr) and attr in ac.__all__ From 374976b15a9d5d60cfada1a424c221e5c84e485e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 22 Jun 2026 02:33:27 +0800 Subject: [PATCH 2/2] Replace multipart disposition regex with non-regex parser (Sonar S5852) --- je_auto_control/utils/multipart/multipart.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/je_auto_control/utils/multipart/multipart.py b/je_auto_control/utils/multipart/multipart.py index a0ee9ec5..d98999e0 100644 --- a/je_auto_control/utils/multipart/multipart.py +++ b/je_auto_control/utils/multipart/multipart.py @@ -14,7 +14,6 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union Fields = Union[Mapping[str, str], Sequence[Tuple[str, str]], None] -_DISP_RE = re.compile(r'(\w+)="([^"]*)"') @dataclass @@ -93,10 +92,19 @@ def _disp_params(header_block: bytes) -> Dict[str, str]: return headers +def _disposition_params(disposition: str) -> Dict[str, str]: + params: Dict[str, str] = {} + for segment in disposition.split(";"): + key, sep, value = segment.partition("=") + value = value.strip() + if sep and len(value) >= 2 and value[0] == '"' and value[-1] == '"': + params[key.strip()] = value[1:-1] + return params + + def _assign_part(headers: Mapping[str, str], content: bytes, fields: Dict[str, str], files: List[Dict[str, Any]]) -> None: - disposition = headers.get("content-disposition", "") - params = dict(_DISP_RE.findall(disposition)) + params = _disposition_params(headers.get("content-disposition", "")) name = params.get("name", "") if "filename" in params: files.append({"name": name, "filename": params["filename"],