from __future__ import annotations import asyncio import json from typing import Any, Dict, Optional from urllib import error, parse, request from app.config import software_config class PluginSubmitError(RuntimeError): pass class PluginHttpClient: """HTTP client for submitting tasks to CAD plugins.""" def __init__(self, config_provider=software_config): self._config_provider = config_provider async def submit_task(self, software_id: str, task_type: str, payload: Dict[str, Any]) -> Dict[str, Any]: plugin_config = self._config_provider.get_plugin_config(software_id) if not plugin_config: raise PluginSubmitError(f"Plugin config for software '{software_id}' not found") base_url = plugin_config.get("base_url") submit_path = plugin_config.get("submit_path", "/api/plugin/tasks") timeout = int(plugin_config.get("request_timeout_sec", 10)) task_config = self._config_provider.get_plugin_task_config(software_id, task_type) or {} submit_path = task_config.get("path", submit_path) body_mode = task_config.get("body_mode", "passthrough") if not base_url: raise PluginSubmitError(f"Plugin base_url for software '{software_id}' is missing") url = parse.urljoin(base_url.rstrip("/") + "/", submit_path.lstrip("/")) request_payload = self._build_payload(payload=payload, body_mode=body_mode) try: return await asyncio.to_thread(self._post_json, url, request_payload, timeout) except PluginSubmitError: raise except Exception as exc: raise PluginSubmitError(str(exc)) from exc @staticmethod def _build_payload(payload: Dict[str, Any], body_mode: str) -> Dict[str, Any]: task_params = payload.get("task_params", {}) if isinstance(payload.get("task_params"), dict) else {} model_path = payload.get("model_path") callback = { "execution_id": payload.get("execution_id"), "batch_id": payload.get("batch_id"), "item_id": payload.get("item_id"), "callback_url": payload.get("callback_url"), "attempt": payload.get("attempt"), } if body_mode == "file_path": return {"filePath": model_path, **task_params, **callback} if body_mode == "task_params_only": return {**task_params, **callback} if body_mode == "project_open": return {**task_params, **callback} if body_mode == "creo_close_model": return { "software_type": "creo", "force_close": task_params.get("force_close") is True, } if body_mode == "empty": return {} if body_mode == "passthrough": return payload # Unknown modes fallback to passthrough for compatibility. return payload @staticmethod def _post_json(url: str, payload: Dict[str, Any], timeout: int) -> Dict[str, Any]: raw = json.dumps(payload).encode("utf-8") req = request.Request( url=url, data=raw, headers={"Content-Type": "application/json"}, method="POST", ) try: with request.urlopen(req, timeout=timeout) as resp: body = resp.read().decode("utf-8") if resp.length != 0 else "" if not body: return {} try: return json.loads(body) except json.JSONDecodeError: return {"raw": body} except error.HTTPError as exc: body = exc.read().decode("utf-8", errors="ignore") if exc.fp else "" raise PluginSubmitError(f"Plugin HTTP {exc.code}: {body}") from exc except error.URLError as exc: raise PluginSubmitError(f"Plugin request failed: {exc.reason}") from exc