diff --git a/app/config.py b/app/config.py index d811d90..e54283a 100644 --- a/app/config.py +++ b/app/config.py @@ -65,11 +65,16 @@ class SoftwareConfig: def __init__(self, config_path: str): self.config_path = config_path self._config = None + self._config_mtime: Optional[float] = None def load_config(self) -> dict: try: with open(self.config_path, "r", encoding="utf-8") as f: self._config = yaml.safe_load(f) or {} + try: + self._config_mtime = Path(self.config_path).stat().st_mtime + except OSError: + self._config_mtime = None return self._config except FileNotFoundError as exc: raise FileNotFoundError(f"Software config not found: {self.config_path}") from exc @@ -86,6 +91,18 @@ class SoftwareConfig: def _ensure_loaded(self): if self._config is None: self.load_config() + return + + try: + current_mtime = Path(self.config_path).stat().st_mtime + except OSError: + current_mtime = None + + if self._config_mtime is None or current_mtime is None: + return + + if current_mtime > self._config_mtime: + self.load_config() def get_software_list(self) -> List[str]: self._ensure_loaded() diff --git a/app/core/plugin_http_client.py b/app/core/plugin_http_client.py index f3b6077..4743daa 100644 --- a/app/core/plugin_http_client.py +++ b/app/core/plugin_http_client.py @@ -2,6 +2,7 @@ import asyncio import json +import os from typing import Any, Dict, Optional from urllib import error, parse, request @@ -35,6 +36,14 @@ class PluginHttpClient: url = parse.urljoin(base_url.rstrip("/") + "/", submit_path.lstrip("/")) request_payload = self._build_payload(payload=payload, body_mode=body_mode) + if software_id == "creo" and task_type == "open_model": + request_payload.setdefault("software_type", "creo") + # Creo plugin expects snake_case file path keys. + raw_path = request_payload.get("file_path") or request_payload.get("filePath") + if isinstance(raw_path, str) and raw_path.strip(): + request_payload["file_path"] = raw_path + request_payload.setdefault("dirname", os.path.dirname(raw_path)) + request_payload.setdefault("filename", os.path.basename(raw_path)) try: return await asyncio.to_thread(self._post_json, url, request_payload, timeout) @@ -105,7 +114,7 @@ class PluginHttpClient: @staticmethod def _post_json(url: str, payload: Dict[str, Any], timeout: int) -> Dict[str, Any]: - raw = json.dumps(payload).encode("utf-8") + raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = request.Request( url=url, data=raw, diff --git a/configs/software_config.yaml b/configs/software_config.yaml index 52ad76c..79ff76a 100644 --- a/configs/software_config.yaml +++ b/configs/software_config.yaml @@ -14,6 +14,20 @@ file_storage: - .rte plugins: creo: + auto_close_exclude_task_types: + - open_model + - close_model + auto_close_include_task_types: + - shrinkwrap_shell + - creo_shrinkwrap + auto_close_model_after_tasks: true + auto_open_exclude_task_types: + - open_model + - close_model + auto_open_include_task_types: + - shrinkwrap_shell + - creo_shrinkwrap + auto_open_model_before_tasks: true base_url: http://localhost:12345 callback_timeout_sec: 60 callback_token: creo-callback-token @@ -23,8 +37,10 @@ plugins: pre_batch_cleanup_task_params: force_close: true pre_batch_cleanup_task_type: close_model - pre_batch_cleanup_ignore_error_markers: [] - close_model_ignore_error_markers: [] + pre_batch_cleanup_ignore_error_markers: + - No current model loaded + close_model_ignore_error_markers: + - No current model loaded request_timeout_sec: 10 retry_backoff_sec: - 1 @@ -37,6 +53,7 @@ plugins: path: /api/model/close open_model: body_mode: file_path + completion_mode: sync path: /api/model/open shell_analysis: body_mode: task_params_only @@ -46,6 +63,10 @@ plugins: body_mode: task_params_only completion_mode: sync path: /api/creo/shrinkwrap/shell + creo_shrinkwrap: + body_mode: task_params_only + completion_mode: sync + path: /api/creo/shrinkwrap/shell pdms: base_url: http://localhost:9001 callback_timeout_sec: 60 diff --git a/tests/test_plugin_http_client_mapping.py b/tests/test_plugin_http_client_mapping.py index a1fa8e4..a48fbc8 100644 --- a/tests/test_plugin_http_client_mapping.py +++ b/tests/test_plugin_http_client_mapping.py @@ -32,7 +32,10 @@ async def test_plugin_http_client_uses_creo_open_model_endpoint_and_payload(monk assert response["status"] == "success" assert captured["url"].endswith("/api/model/open") - assert captured["payload"]["filePath"].endswith("part.prt") + assert captured["payload"]["file_path"].endswith("part.prt") + assert captured["payload"]["dirname"].endswith("models") + assert captured["payload"]["filename"] == "part.prt" + assert captured["payload"]["software_type"] == "creo" assert captured["payload"]["execution_id"] == "exec-1" @@ -143,3 +146,36 @@ async def test_plugin_http_client_uses_revit_close_model_empty_payload(monkeypat assert response["success"] is True assert captured["url"].endswith("/api/close") assert captured["payload"] == {} + + +@pytest.mark.asyncio +async def test_plugin_http_client_maps_creo_shrinkwrap_alias_to_shell_endpoint(monkeypatch): + client = PluginHttpClient() + captured = {} + + def fake_post_json(url, payload, timeout): + captured["url"] = url + captured["payload"] = payload + captured["timeout"] = timeout + return {"success": True} + + monkeypatch.setattr(PluginHttpClient, "_post_json", staticmethod(fake_post_json)) + + response = await client.submit_task( + "creo", + "creo_shrinkwrap", + { + "model_path": r"C:\\models\\overall_top_design.asm", + "task_params": {"quality": "normal"}, + "execution_id": "exec-creo-shrinkwrap", + "batch_id": "batch-creo-shrinkwrap", + "item_id": "item-creo-shrinkwrap", + "callback_url": "http://localhost/callback", + "attempt": 0, + }, + ) + + assert response["success"] is True + assert captured["url"].endswith("/api/creo/shrinkwrap/shell") + assert captured["payload"]["quality"] == "normal" + assert captured["payload"]["execution_id"] == "exec-creo-shrinkwrap" diff --git a/tests/test_serial_batch_executor.py b/tests/test_serial_batch_executor.py index 7702970..bf9037d 100644 --- a/tests/test_serial_batch_executor.py +++ b/tests/test_serial_batch_executor.py @@ -27,6 +27,12 @@ class FakePluginHttpClient: behavior = payload.get("task_params", {}).get("behavior", "success") attempt = payload.get("attempt", 0) + if task_type == "open_model": + return {"success": True, "code": 200} + + if task_type == "close_model" and behavior == "no_current_model_loaded": + return {"success": False, "error": "No current model loaded", "code": 500} + if task_type == "close_model" and behavior == "no_document_open": return {"success": False, "error": "NO_DOCUMENT_OPEN", "code": 409, "message": "没有打开的文档"} @@ -200,6 +206,78 @@ async def test_pre_batch_cleanup_ignores_no_document_open(): await manager.stop() +@pytest.mark.asyncio +async def test_creo_pre_batch_cleanup_ignores_no_current_model_loaded(): + callback_registry = PluginCallbackRegistry() + fake_client = FakePluginHttpClient(callback_registry) + router = CadTaskRouter({".asm": "creo"}) + + creo_plugin = software_config._config.setdefault("plugins", {}).setdefault("creo", {}) + creo_plugin["pre_batch_cleanup_enabled"] = True + creo_plugin["pre_batch_cleanup_task_type"] = "close_model" + creo_plugin["pre_batch_cleanup_task_params"] = {"behavior": "no_current_model_loaded"} + creo_plugin["pre_batch_cleanup_ignore_error_markers"] = ["No current model loaded"] + creo_plugin["auto_open_model_before_tasks"] = False + creo_plugin["auto_close_model_after_tasks"] = False + creo_plugin.setdefault("tasks", {}).setdefault("close_model", {})["completion_mode"] = "sync" + creo_plugin.setdefault("tasks", {}).setdefault("shrinkwrap_shell", {})["completion_mode"] = "submit_only" + + manager = CadBatchManager(task_router=router, plugin_client=fake_client, callback_registry=callback_registry) + await manager.start() + + request = BatchSubmitRequest( + items=[BatchSubmitItem(model_path="a.asm", task_type="shrinkwrap_shell", task_params={})], + ) + + try: + batch = await manager.create_batch(request, submitter_id="tester") + final_batch = await _wait_batch_terminal(manager, batch.id) + items = await manager.get_batch_items(batch.id) + + assert final_batch.status == BatchStatus.COMPLETED + assert items[0].status.value == "succeeded" + assert len(fake_client.calls) == 2 + assert fake_client.calls[0]["task_type"] == "close_model" + assert fake_client.calls[1]["task_type"] == "shrinkwrap_shell" + finally: + await manager.stop() + + +@pytest.mark.asyncio +async def test_creo_auto_open_runs_before_creo_shrinkwrap(): + callback_registry = PluginCallbackRegistry() + fake_client = FakePluginHttpClient(callback_registry) + router = CadTaskRouter({".asm": "creo"}) + + creo_plugin = software_config._config.setdefault("plugins", {}).setdefault("creo", {}) + creo_plugin["pre_batch_cleanup_enabled"] = False + creo_plugin["auto_open_model_before_tasks"] = True + creo_plugin["auto_open_include_task_types"] = ["creo_shrinkwrap"] + creo_plugin["auto_close_model_after_tasks"] = False + creo_plugin.setdefault("tasks", {}).setdefault("open_model", {})["completion_mode"] = "sync" + creo_plugin.setdefault("tasks", {}).setdefault("creo_shrinkwrap", {})["completion_mode"] = "submit_only" + + manager = CadBatchManager(task_router=router, plugin_client=fake_client, callback_registry=callback_registry) + await manager.start() + + request = BatchSubmitRequest( + items=[BatchSubmitItem(model_path="a.asm", task_type="creo_shrinkwrap", task_params={})], + ) + + try: + batch = await manager.create_batch(request, submitter_id="tester") + final_batch = await _wait_batch_terminal(manager, batch.id) + items = await manager.get_batch_items(batch.id) + + assert final_batch.status == BatchStatus.COMPLETED + assert items[0].status.value == "succeeded" + assert len(fake_client.calls) == 2 + assert fake_client.calls[0]["task_type"] == "open_model" + assert fake_client.calls[1]["task_type"] == "creo_shrinkwrap" + finally: + await manager.stop() + + @pytest.mark.asyncio async def test_auto_close_ignores_no_document_open(): callback_registry = PluginCallbackRegistry()