aid-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aid_sdk/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ AID SDK - Python client for the AID Service Platform.
3
+
4
+ Usage::
5
+
6
+ from aid_sdk import JobClient
7
+
8
+ with JobClient({"base_url": "http://localhost:8080"}) as client:
9
+ job = client.submit_job(
10
+ solver_type="forming",
11
+ files={"input": "file_001"},
12
+ params={
13
+ "material": {"materialId": "DC04", "thickness": 0.8},
14
+ "processType": "drawing",
15
+ "processParams": {"binderForce": 200, "frictionCoeff": 0.15, "punchStroke": 120},
16
+ },
17
+ )
18
+
19
+ job.on("progress", lambda data: print(f"Progress: {data['progress']}%"))
20
+ job.on("job_complete", lambda data: print(f"Done: {data}"))
21
+
22
+ job.wait_for_completion()
23
+ """
24
+
25
+ from aid_sdk.client import JobClient
26
+ from aid_sdk.errors import (
27
+ AIDError,
28
+ FileUploadException,
29
+ InvalidTaskStateException,
30
+ SolverExecutionException,
31
+ TaskNotFoundException,
32
+ UnauthorizedException,
33
+ )
34
+ from aid_sdk.events import EventEmitter
35
+ from aid_sdk.file_helper import FileHelper
36
+ from aid_sdk.job import Job
37
+ from aid_sdk.models import FileIds, IterationInfo, JobStatus, Message, TaskHistory
38
+ from aid_sdk.poller import MessagePoller
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ __all__ = [
43
+ # Core
44
+ "JobClient",
45
+ "Job",
46
+ # Events & Polling
47
+ "EventEmitter",
48
+ "MessagePoller",
49
+ # File operations
50
+ "FileHelper",
51
+ # Models
52
+ "JobStatus",
53
+ "TaskHistory",
54
+ "IterationInfo",
55
+ "Message",
56
+ "FileIds",
57
+ # Errors
58
+ "AIDError",
59
+ "TaskNotFoundException",
60
+ "InvalidTaskStateException",
61
+ "UnauthorizedException",
62
+ "FileUploadException",
63
+ "SolverExecutionException",
64
+ # Meta
65
+ "__version__",
66
+ ]
aid_sdk/client.py ADDED
@@ -0,0 +1,166 @@
1
+ """
2
+ AID SDK JobClient.
3
+
4
+ Main entry point for interacting with the AID service platform.
5
+ """
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ from typing import Any, Dict, List, Optional, Union
10
+
11
+ from aid_sdk.file_helper import FileHelper
12
+ from aid_sdk.http_client import HttpClient
13
+ from aid_sdk.job import Job
14
+ from aid_sdk.models import FileIds
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Default configuration values
19
+ _DEFAULT_BASE_URL = "http://localhost:8080"
20
+ _DEFAULT_API_KEY = "dev-skip-auth-local"
21
+ _DEFAULT_TIMEOUT = 30 # seconds
22
+ _DEFAULT_POLL_INTERVAL = 2.0 # seconds
23
+
24
+
25
+ class JobClient:
26
+ """AID service client for submitting and managing simulation jobs.
27
+
28
+ Supports the context-manager protocol for automatic resource cleanup::
29
+
30
+ with JobClient(config) as client:
31
+ job = client.submit_job("forming", files, params)
32
+ job.wait_for_completion()
33
+
34
+ Args:
35
+ config: Optional configuration dict with keys:
36
+ - ``base_url`` (str): AID service base URL.
37
+ - ``api_key`` (str): API key for authentication.
38
+ - ``timeout`` (int): HTTP request timeout in seconds.
39
+ - ``poll_interval`` (float): Message poll interval in seconds.
40
+ """
41
+
42
+ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
43
+ cfg = config or {}
44
+
45
+ self._base_url: str = cfg.get(
46
+ "base_url",
47
+ os.environ.get("SOLVER_API_URL", _DEFAULT_BASE_URL),
48
+ )
49
+ self._api_key: str = cfg.get(
50
+ "api_key",
51
+ os.environ.get("SOLVER_API_KEY", _DEFAULT_API_KEY),
52
+ )
53
+ self._timeout: int = int(cfg.get("timeout", _DEFAULT_TIMEOUT))
54
+ self._poll_interval: float = float(cfg.get("poll_interval", _DEFAULT_POLL_INTERVAL))
55
+
56
+ self._http = HttpClient(
57
+ base_url=self._base_url,
58
+ api_key=self._api_key,
59
+ timeout=self._timeout,
60
+ )
61
+ self._file_helper = FileHelper(self._http)
62
+ self._jobs: List[Job] = []
63
+
64
+ # ------------------------------------------------------------------
65
+ # Public API
66
+ # ------------------------------------------------------------------
67
+
68
+ def submit_job(
69
+ self,
70
+ solver_type: str,
71
+ files: Union[List[str], Dict[str, str], FileIds, None] = None,
72
+ params: Optional[Dict[str, Any]] = None,
73
+ job_name: Optional[str] = None,
74
+ ) -> Job:
75
+ """Synchronously submit a new simulation job.
76
+
77
+ Args:
78
+ solver_type: Solver type (forming / thermal / springback).
79
+ files: Input file IDs (list or grouped dict). Local paths are NOT
80
+ automatically uploaded; use ``FileHelper`` or pass pre-uploaded IDs.
81
+ params: Solver parameters.
82
+ job_name: Optional human-readable job name.
83
+
84
+ Returns:
85
+ A :class:`Job` instance with polling started.
86
+ """
87
+ body: Dict[str, Any] = {
88
+ "solverType": solver_type,
89
+ "params": params or {},
90
+ }
91
+ if files is not None:
92
+ body["files"] = files
93
+ if job_name is not None:
94
+ body["jobName"] = job_name
95
+
96
+ resp = self._http.post("/api/v1/submit", body)
97
+ data = resp.get("data", resp)
98
+
99
+ job = Job(
100
+ task_id=data.get("taskId", data.get("task_id", "")),
101
+ http_client=self._http,
102
+ file_helper=self._file_helper,
103
+ poll_interval=self._poll_interval,
104
+ status=data.get("status", "ACTIVE"),
105
+ current_iteration=data.get(
106
+ "currentIteration", data.get("current_iteration", 1)
107
+ ),
108
+ )
109
+
110
+ # Auto-start polling
111
+ job.start_polling()
112
+ self._jobs.append(job)
113
+
114
+ logger.info(
115
+ "job_submitted: task_id=%s solver_type=%s", job.task_id, solver_type
116
+ )
117
+ return job
118
+
119
+ async def submit_job_async(
120
+ self,
121
+ solver_type: str,
122
+ files: Union[List[str], Dict[str, str], FileIds, None] = None,
123
+ params: Optional[Dict[str, Any]] = None,
124
+ job_name: Optional[str] = None,
125
+ ) -> Job:
126
+ """Asynchronously submit a new simulation job.
127
+
128
+ Runs the synchronous submit in an executor to avoid blocking the event loop.
129
+
130
+ Args:
131
+ solver_type: Solver type.
132
+ files: Input file IDs.
133
+ params: Solver parameters.
134
+ job_name: Optional job name.
135
+
136
+ Returns:
137
+ A :class:`Job` instance.
138
+ """
139
+ loop = asyncio.get_event_loop()
140
+ return await loop.run_in_executor(
141
+ None,
142
+ lambda: self.submit_job(solver_type, files, params, job_name),
143
+ )
144
+
145
+ @property
146
+ def file_helper(self) -> FileHelper:
147
+ """Access the file helper for direct upload/download operations."""
148
+ return self._file_helper
149
+
150
+ # ------------------------------------------------------------------
151
+ # Context manager
152
+ # ------------------------------------------------------------------
153
+
154
+ def __enter__(self) -> "JobClient":
155
+ return self
156
+
157
+ def __exit__(self, *args: Any) -> None:
158
+ self.close()
159
+
160
+ def close(self) -> None:
161
+ """Stop all job pollers and close the HTTP session."""
162
+ for job in self._jobs:
163
+ job.stop_polling()
164
+ self._jobs.clear()
165
+ self._http.close()
166
+ logger.info("job_client_closed")
File without changes
aid_sdk/errors.py ADDED
@@ -0,0 +1,91 @@
1
+ """
2
+ AID SDK Exception Classes.
3
+
4
+ Defines structured error types mapping to AID service error codes.
5
+ """
6
+ from typing import Optional
7
+
8
+
9
+ class AIDError(Exception):
10
+ """Base exception for all AID SDK errors.
11
+
12
+ Attributes:
13
+ code: AID error code (e.g. TASK_3000, AUTH_6000).
14
+ message: Human-readable error description.
15
+ http_status: HTTP status code from the server response, if applicable.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ code: str,
21
+ message: str,
22
+ http_status: Optional[int] = None,
23
+ ) -> None:
24
+ self.code = code
25
+ self.message = message
26
+ self.http_status = http_status
27
+ super().__init__(f"[{code}] {message}")
28
+
29
+ def __repr__(self) -> str:
30
+ return (
31
+ f"{self.__class__.__name__}(code={self.code!r}, "
32
+ f"message={self.message!r}, http_status={self.http_status!r})"
33
+ )
34
+
35
+
36
+ class TaskNotFoundException(AIDError):
37
+ """Raised when the requested task does not exist (TASK_3000 / HTTP 404)."""
38
+
39
+ def __init__(self, message: str = "任务不存在", http_status: int = 404) -> None:
40
+ super().__init__(code="TASK_3000", message=message, http_status=http_status)
41
+
42
+
43
+ class InvalidTaskStateException(AIDError):
44
+ """Raised when an operation is invalid for the current task state.
45
+
46
+ Covers TASK_3001 (not in continuable state) and TASK_3002 (already completed).
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ code: str = "TASK_3001",
52
+ message: str = "任务未处于可操作状态",
53
+ http_status: int = 400,
54
+ ) -> None:
55
+ super().__init__(code=code, message=message, http_status=http_status)
56
+
57
+
58
+ class UnauthorizedException(AIDError):
59
+ """Raised on authentication / authorization failure (AUTH_6000 / HTTP 401)."""
60
+
61
+ def __init__(
62
+ self,
63
+ code: str = "AUTH_6000",
64
+ message: str = "未授权访问",
65
+ http_status: int = 401,
66
+ ) -> None:
67
+ super().__init__(code=code, message=message, http_status=http_status)
68
+
69
+
70
+ class FileUploadException(AIDError):
71
+ """Raised when a file upload operation fails (FILE_7001 / HTTP 500)."""
72
+
73
+ def __init__(
74
+ self,
75
+ message: str = "文件上传失败",
76
+ code: str = "FILE_7001",
77
+ http_status: Optional[int] = 500,
78
+ ) -> None:
79
+ super().__init__(code=code, message=message, http_status=http_status)
80
+
81
+
82
+ class SolverExecutionException(AIDError):
83
+ """Raised when the solver encounters an execution error (SOLVER_2000 / HTTP 500)."""
84
+
85
+ def __init__(
86
+ self,
87
+ message: str = "求解器执行失败",
88
+ code: str = "SOLVER_2000",
89
+ http_status: Optional[int] = 500,
90
+ ) -> None:
91
+ super().__init__(code=code, message=message, http_status=http_status)
aid_sdk/events.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ AID SDK EventEmitter.
3
+
4
+ Simple publish-subscribe event system for job message callbacks.
5
+ """
6
+ import logging
7
+ import threading
8
+ from typing import Any, Callable, Dict, List
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Valid event names
13
+ VALID_EVENTS = frozenset({
14
+ "progress",
15
+ "log",
16
+ "iteration_complete",
17
+ "iteration_error",
18
+ "job_complete",
19
+ })
20
+
21
+
22
+ class EventEmitter:
23
+ """Thread-safe event emitter supporting on/off/emit operations."""
24
+
25
+ def __init__(self) -> None:
26
+ self._listeners: Dict[str, List[Callable[..., Any]]] = {}
27
+ self._lock = threading.Lock()
28
+
29
+ def on(self, event: str, callback: Callable[..., Any]) -> None:
30
+ """Register a callback for the given event.
31
+
32
+ Args:
33
+ event: Event name (progress, log, iteration_complete, iteration_error, job_complete).
34
+ callback: Function to invoke when the event fires.
35
+ """
36
+ with self._lock:
37
+ self._listeners.setdefault(event, []).append(callback)
38
+
39
+ def off(self, event: str, callback: Callable[..., Any]) -> None:
40
+ """Remove a previously registered callback.
41
+
42
+ Args:
43
+ event: Event name.
44
+ callback: The exact callable that was registered.
45
+ """
46
+ with self._lock:
47
+ listeners = self._listeners.get(event, [])
48
+ try:
49
+ listeners.remove(callback)
50
+ except ValueError:
51
+ pass
52
+
53
+ def emit(self, event: str, data: Any = None) -> None:
54
+ """Fire an event, invoking all registered listeners with the given data.
55
+
56
+ Exceptions raised by listeners are logged and swallowed to prevent
57
+ one bad callback from breaking the polling loop.
58
+
59
+ Args:
60
+ event: Event name.
61
+ data: Payload passed to each listener.
62
+ """
63
+ with self._lock:
64
+ listeners = list(self._listeners.get(event, []))
65
+
66
+ for cb in listeners:
67
+ try:
68
+ cb(data)
69
+ except Exception as exc:
70
+ logger.error(
71
+ "event_callback_error: event=%s callback=%s error=%s",
72
+ event, cb, exc,
73
+ )
74
+
75
+ def remove_all_listeners(self, event: str = None) -> None:
76
+ """Remove all listeners, optionally for a specific event only.
77
+
78
+ Args:
79
+ event: If provided, only clear listeners for this event; otherwise clear all.
80
+ """
81
+ with self._lock:
82
+ if event is not None:
83
+ self._listeners.pop(event, None)
84
+ else:
85
+ self._listeners.clear()
aid_sdk/file_helper.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ AID SDK File Helper.
3
+
4
+ Handles file upload and download through OSS presigned URLs obtained from the AID service.
5
+ """
6
+ import logging
7
+ import os
8
+ import uuid
9
+ from typing import Any, Dict, List, Optional, Union
10
+
11
+ import requests
12
+
13
+ from aid_sdk.errors import FileUploadException
14
+ from aid_sdk.http_client import HttpClient
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class FileHelper:
20
+ """Utility for uploading input files and downloading result files via OSS."""
21
+
22
+ def __init__(self, http_client: HttpClient) -> None:
23
+ self._http = http_client
24
+
25
+ # ------------------------------------------------------------------
26
+ # Upload
27
+ # ------------------------------------------------------------------
28
+
29
+ def get_upload_url(self, file_key: str, expires: int = 3600) -> dict:
30
+ """Request a presigned upload URL from the AID service.
31
+
32
+ Args:
33
+ file_key: The OSS object key for the file.
34
+ expires: URL validity in seconds (default 3600).
35
+
36
+ Returns:
37
+ Dict with ``upload_url``, ``file_key``, ``expires_at``.
38
+ """
39
+ resp = self._http.post("/api/v1/files/upload_url", {
40
+ "file_key": file_key,
41
+ "expires": expires,
42
+ })
43
+ return resp.get("data", resp)
44
+
45
+ def upload_file(self, local_path: str, file_key: Optional[str] = None, expires: int = 3600) -> str:
46
+ """Upload a local file to OSS using a presigned URL.
47
+
48
+ Args:
49
+ local_path: Path to the local file.
50
+ file_key: OSS object key. If not provided, derived from the filename with a UUID prefix.
51
+ expires: URL validity in seconds.
52
+
53
+ Returns:
54
+ The ``file_key`` of the uploaded file.
55
+
56
+ Raises:
57
+ FileUploadException: On any upload failure.
58
+ """
59
+ if not os.path.isfile(local_path):
60
+ raise FileUploadException(message=f"文件不存在: {local_path}")
61
+
62
+ if file_key is None:
63
+ filename = os.path.basename(local_path)
64
+ file_key = f"uploads/{uuid.uuid4().hex[:8]}/{filename}"
65
+
66
+ try:
67
+ info = self.get_upload_url(file_key, expires)
68
+ except Exception as exc:
69
+ raise FileUploadException(message=f"获取上传 URL 失败: {exc}") from exc
70
+
71
+ upload_url = info.get("upload_url", "")
72
+ if not upload_url:
73
+ raise FileUploadException(message="服务端未返回有效的 upload_url")
74
+
75
+ try:
76
+ with open(local_path, "rb") as fh:
77
+ put_resp = requests.put(upload_url, data=fh, timeout=300)
78
+ if put_resp.status_code not in (200, 201, 204):
79
+ raise FileUploadException(
80
+ message=f"OSS 上传失败: HTTP {put_resp.status_code}"
81
+ )
82
+ except requests.RequestException as exc:
83
+ raise FileUploadException(message=f"OSS 上传网络错误: {exc}") from exc
84
+
85
+ logger.info("file_uploaded: local=%s file_key=%s", local_path, file_key)
86
+ return file_key
87
+
88
+ def upload_files(
89
+ self,
90
+ files: Union[List[str], Dict[str, str]],
91
+ expires: int = 3600,
92
+ ) -> Union[List[str], Dict[str, str]]:
93
+ """Batch upload multiple files.
94
+
95
+ Args:
96
+ files:
97
+ - A list of local paths → returns a list of file_keys.
98
+ - A dict mapping logical names to local paths → returns a dict mapping names to file_keys.
99
+ expires: URL validity in seconds.
100
+
101
+ Returns:
102
+ Matching structure of file_keys.
103
+ """
104
+ if isinstance(files, dict):
105
+ result: Dict[str, str] = {}
106
+ for name, path in files.items():
107
+ result[name] = self.upload_file(path, expires=expires)
108
+ return result
109
+ else:
110
+ return [self.upload_file(p, expires=expires) for p in files]
111
+
112
+ # ------------------------------------------------------------------
113
+ # Download
114
+ # ------------------------------------------------------------------
115
+
116
+ def get_download_url(self, file_key: str, expires: int = 3600) -> dict:
117
+ """Request a presigned download URL from the AID service.
118
+
119
+ Args:
120
+ file_key: The OSS object key.
121
+ expires: URL validity in seconds.
122
+
123
+ Returns:
124
+ Dict with ``download_url``, ``expires_at``.
125
+ """
126
+ resp = self._http.post("/api/v1/files/download_url", {
127
+ "file_key": file_key,
128
+ "expires": expires,
129
+ })
130
+ return resp.get("data", resp)
131
+
132
+ def download_file(
133
+ self,
134
+ file_key: str,
135
+ dest_dir: str,
136
+ filename: Optional[str] = None,
137
+ expires: int = 3600,
138
+ ) -> str:
139
+ """Download a file from OSS to a local directory.
140
+
141
+ Args:
142
+ file_key: OSS object key.
143
+ dest_dir: Local directory to save the file.
144
+ filename: Local filename override; defaults to the last segment of ``file_key``.
145
+ expires: URL validity in seconds.
146
+
147
+ Returns:
148
+ Full path to the downloaded local file.
149
+ """
150
+ info = self.get_download_url(file_key, expires)
151
+ download_url = info.get("download_url", "")
152
+ if not download_url:
153
+ raise FileUploadException(
154
+ code="FILE_7002", message="服务端未返回有效的 download_url"
155
+ )
156
+
157
+ if filename is None:
158
+ filename = file_key.rsplit("/", 1)[-1] or "downloaded_file"
159
+
160
+ os.makedirs(dest_dir, exist_ok=True)
161
+ local_path = os.path.join(dest_dir, filename)
162
+
163
+ try:
164
+ with requests.get(download_url, stream=True, timeout=300) as r:
165
+ r.raise_for_status()
166
+ with open(local_path, "wb") as fh:
167
+ for chunk in r.iter_content(chunk_size=8192):
168
+ fh.write(chunk)
169
+ except requests.RequestException as exc:
170
+ raise FileUploadException(
171
+ code="FILE_7002", message=f"OSS 下载失败: {exc}"
172
+ ) from exc
173
+
174
+ logger.info("file_downloaded: file_key=%s local=%s", file_key, local_path)
175
+ return local_path
aid_sdk/http_client.py ADDED
@@ -0,0 +1,157 @@
1
+ """
2
+ AID SDK HTTP Client.
3
+
4
+ Encapsulates HTTP requests with retry logic and unified error handling.
5
+ """
6
+ import logging
7
+ import time
8
+ from typing import Any, Dict, Optional
9
+
10
+ import requests
11
+
12
+ from aid_sdk.errors import (
13
+ AIDError,
14
+ InvalidTaskStateException,
15
+ TaskNotFoundException,
16
+ UnauthorizedException,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Default retry configuration
22
+ DEFAULT_MAX_RETRIES = 3
23
+ DEFAULT_RETRY_BASE_DELAY = 1.0 # seconds
24
+
25
+
26
+ def _map_error(status_code: int, detail: dict) -> AIDError:
27
+ """Map an HTTP error response to the appropriate SDK exception."""
28
+ code = detail.get("code", "SYSTEM_9000") if isinstance(detail, dict) else "SYSTEM_9000"
29
+ message = detail.get("message", str(detail)) if isinstance(detail, dict) else str(detail)
30
+
31
+ if code == "TASK_3000" or (status_code == 404 and code.startswith("TASK")):
32
+ return TaskNotFoundException(message=message, http_status=status_code)
33
+
34
+ if code in ("TASK_3001", "TASK_3002", "TASK_3003"):
35
+ return InvalidTaskStateException(code=code, message=message, http_status=status_code)
36
+
37
+ if status_code == 401 or code.startswith("AUTH_6"):
38
+ return UnauthorizedException(code=code, message=message, http_status=status_code)
39
+
40
+ return AIDError(code=code, message=message, http_status=status_code)
41
+
42
+
43
+ class HttpClient:
44
+ """HTTP client wrapper with retry logic and AID-specific error handling."""
45
+
46
+ def __init__(
47
+ self,
48
+ base_url: str,
49
+ api_key: str,
50
+ timeout: int = 30,
51
+ max_retries: int = DEFAULT_MAX_RETRIES,
52
+ ) -> None:
53
+ self._base_url = base_url.rstrip("/")
54
+ self._timeout = timeout
55
+ self._max_retries = max_retries
56
+
57
+ self._session = requests.Session()
58
+ self._session.headers.update({
59
+ "Content-Type": "application/json",
60
+ "Accept": "application/json",
61
+ })
62
+ if api_key:
63
+ self._session.headers["Authorization"] = f"Bearer {api_key}"
64
+
65
+ # ------------------------------------------------------------------
66
+ # Public API
67
+ # ------------------------------------------------------------------
68
+
69
+ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> dict:
70
+ """Send a GET request and return the parsed response body."""
71
+ return self._request("GET", path, params=params)
72
+
73
+ def post(self, path: str, json_body: Optional[dict] = None) -> dict:
74
+ """Send a POST request and return the parsed response body."""
75
+ return self._request("POST", path, json_body=json_body)
76
+
77
+ def delete(self, path: str) -> dict:
78
+ """Send a DELETE request and return the parsed response body."""
79
+ return self._request("DELETE", path)
80
+
81
+ def close(self) -> None:
82
+ """Close the underlying HTTP session."""
83
+ self._session.close()
84
+
85
+ # ------------------------------------------------------------------
86
+ # Internal
87
+ # ------------------------------------------------------------------
88
+
89
+ def _request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ params: Optional[Dict[str, Any]] = None,
94
+ json_body: Optional[dict] = None,
95
+ ) -> dict:
96
+ """Execute an HTTP request with exponential-backoff retry on 5xx errors."""
97
+ url = f"{self._base_url}{path}"
98
+ last_error: Optional[Exception] = None
99
+
100
+ for attempt in range(self._max_retries):
101
+ try:
102
+ resp = self._session.request(
103
+ method,
104
+ url,
105
+ params=params,
106
+ json=json_body,
107
+ timeout=self._timeout,
108
+ )
109
+
110
+ if resp.status_code >= 500:
111
+ # Server error → retry with exponential backoff
112
+ last_error = Exception(
113
+ f"HTTP {resp.status_code}: {resp.text[:200]}"
114
+ )
115
+ logger.warning(
116
+ "http_retry: attempt=%d/%d status=%d url=%s",
117
+ attempt + 1, self._max_retries, resp.status_code, url,
118
+ )
119
+ else:
120
+ return self._handle_response(resp)
121
+
122
+ except requests.ConnectionError as exc:
123
+ last_error = exc
124
+ logger.warning(
125
+ "http_retry: attempt=%d/%d error=%s url=%s",
126
+ attempt + 1, self._max_retries, exc, url,
127
+ )
128
+ except requests.Timeout as exc:
129
+ last_error = exc
130
+ logger.warning(
131
+ "http_retry: attempt=%d/%d timeout url=%s",
132
+ attempt + 1, self._max_retries, url,
133
+ )
134
+
135
+ # Exponential backoff before next attempt
136
+ if attempt < self._max_retries - 1:
137
+ delay = DEFAULT_RETRY_BASE_DELAY * (2 ** attempt)
138
+ time.sleep(delay)
139
+
140
+ # All retries exhausted
141
+ raise AIDError(
142
+ code="SYSTEM_9001",
143
+ message=f"请求失败,已重试 {self._max_retries} 次: {last_error}",
144
+ )
145
+
146
+ def _handle_response(self, resp: requests.Response) -> dict:
147
+ """Parse response JSON; raise SDK exceptions on 4xx errors."""
148
+ try:
149
+ body = resp.json()
150
+ except ValueError:
151
+ body = {}
152
+
153
+ if resp.status_code >= 400:
154
+ detail = body.get("detail", body)
155
+ raise _map_error(resp.status_code, detail)
156
+
157
+ return body
aid_sdk/job.py ADDED
@@ -0,0 +1,235 @@
1
+ """
2
+ AID SDK Job.
3
+
4
+ Represents a submitted simulation job with event-driven messaging.
5
+ """
6
+ import logging
7
+ from typing import Any, Callable, Dict, List, Optional, Union
8
+
9
+ from aid_sdk.events import EventEmitter
10
+ from aid_sdk.file_helper import FileHelper
11
+ from aid_sdk.http_client import HttpClient
12
+ from aid_sdk.models import FileIds, JobStatus, TaskHistory
13
+ from aid_sdk.poller import MessagePoller
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class Job:
19
+ """Represents a running or completed AID simulation job.
20
+
21
+ A Job instance is returned by ``JobClient.submit_job``. It provides methods
22
+ for continuing, completing, and cancelling the job, as well as event-driven
23
+ message polling.
24
+
25
+ Attributes:
26
+ task_id: Unique task identifier assigned by the server.
27
+ status: Current lifecycle status.
28
+ current_iteration: The latest iteration number.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ task_id: str,
34
+ http_client: HttpClient,
35
+ file_helper: FileHelper,
36
+ poll_interval: float = 2.0,
37
+ status: str = "ACTIVE",
38
+ current_iteration: int = 1,
39
+ ) -> None:
40
+ self.task_id: str = task_id
41
+ self.status: str = status
42
+ self.current_iteration: int = current_iteration
43
+
44
+ self._http = http_client
45
+ self._file_helper = file_helper
46
+ self._poll_interval = poll_interval
47
+
48
+ self._emitter = EventEmitter()
49
+ self._poller: Optional[MessagePoller] = None
50
+
51
+ # ------------------------------------------------------------------
52
+ # Task operations
53
+ # ------------------------------------------------------------------
54
+
55
+ def continue_job(
56
+ self,
57
+ files: Union[List[str], Dict[str, str], FileIds, None] = None,
58
+ params: Optional[Dict[str, Any]] = None,
59
+ ) -> "Job":
60
+ """Synchronously continue this job with a new iteration.
61
+
62
+ Args:
63
+ files: New input file IDs (list or grouped dict). If local paths are
64
+ provided they will be uploaded to OSS first.
65
+ params: New solver parameters for this iteration.
66
+
67
+ Returns:
68
+ This Job instance (updated in place).
69
+ """
70
+ body: Dict[str, Any] = {
71
+ "taskId": self.task_id,
72
+ "params": params or {},
73
+ }
74
+ if files is not None:
75
+ body["files"] = files
76
+
77
+ resp = self._http.post("/api/v1/continue", body)
78
+ data = resp.get("data", resp)
79
+
80
+ self.status = data.get("status", self.status)
81
+ self.current_iteration = data.get(
82
+ "currentIteration", data.get("current_iteration", self.current_iteration + 1)
83
+ )
84
+
85
+ # Ensure polling is running for the new iteration
86
+ if self._poller is not None and not self._poller.is_running:
87
+ self._poller.start()
88
+
89
+ return self
90
+
91
+ def complete_job(self) -> bool:
92
+ """Mark this job as completed.
93
+
94
+ Returns:
95
+ True on success.
96
+ """
97
+ resp = self._http.post(f"/api/v1/tasks/{self.task_id}/complete")
98
+ data = resp.get("data", resp)
99
+ self.status = data.get("status", "COMPLETED")
100
+ self.stop_polling()
101
+ return True
102
+
103
+ def cancel(self) -> bool:
104
+ """Cancel this job.
105
+
106
+ Returns:
107
+ True on success.
108
+ """
109
+ resp = self._http.delete(f"/api/v1/tasks/{self.task_id}/cancel")
110
+ data = resp.get("data", resp)
111
+ self.status = data.get("status", "CANCELLED")
112
+ self.stop_polling()
113
+ return True
114
+
115
+ # ------------------------------------------------------------------
116
+ # Queries
117
+ # ------------------------------------------------------------------
118
+
119
+ def get_status(self) -> dict:
120
+ """Query the current task status from the server.
121
+
122
+ Returns:
123
+ Raw status dict from the API.
124
+ """
125
+ resp = self._http.get(f"/api/v1/tasks/{self.task_id}/status")
126
+ data = resp.get("data", resp)
127
+ # Sync local state
128
+ self.status = data.get("status", self.status)
129
+ self.current_iteration = data.get(
130
+ "currentIteration", data.get("current_iteration", self.current_iteration)
131
+ )
132
+ return data
133
+
134
+ def get_history(self) -> dict:
135
+ """Fetch the full task history including all iterations.
136
+
137
+ Returns:
138
+ Raw history dict from the API.
139
+ """
140
+ resp = self._http.get(f"/api/v1/tasks/{self.task_id}/history")
141
+ return resp.get("data", resp)
142
+
143
+ def get_result_url(
144
+ self,
145
+ iteration: Optional[int] = None,
146
+ file_key: Optional[str] = None,
147
+ ) -> Union[str, dict]:
148
+ """Get result file download URL(s).
149
+
150
+ Args:
151
+ iteration: Iteration number. Defaults to the latest.
152
+ file_key: Specific file key. If omitted, returns all files.
153
+
154
+ Returns:
155
+ A single URL string if ``file_key`` is given, otherwise a dict
156
+ mapping file keys to URLs.
157
+ """
158
+ params: Dict[str, Any] = {}
159
+ if iteration is not None:
160
+ params["iteration"] = iteration
161
+ if file_key is not None:
162
+ params["fileKey"] = file_key
163
+
164
+ resp = self._http.get(
165
+ f"/api/v1/tasks/{self.task_id}/result",
166
+ params=params if params else None,
167
+ )
168
+ return resp.get("data", resp)
169
+
170
+ # ------------------------------------------------------------------
171
+ # Event system
172
+ # ------------------------------------------------------------------
173
+
174
+ def on(self, event: str, callback: Callable[..., Any]) -> None:
175
+ """Register an event callback.
176
+
177
+ Supported events: ``progress``, ``log``, ``iteration_complete``,
178
+ ``iteration_error``, ``job_complete``.
179
+
180
+ Args:
181
+ event: Event name.
182
+ callback: Function invoked with the event payload dict.
183
+ """
184
+ self._emitter.on(event, callback)
185
+
186
+ def off(self, event: str, callback: Callable[..., Any]) -> None:
187
+ """Remove a previously registered event callback.
188
+
189
+ Args:
190
+ event: Event name.
191
+ callback: The exact callable to remove.
192
+ """
193
+ self._emitter.off(event, callback)
194
+
195
+ # ------------------------------------------------------------------
196
+ # Polling
197
+ # ------------------------------------------------------------------
198
+
199
+ def start_polling(self) -> None:
200
+ """Start the background message poller (daemon thread)."""
201
+ if self._poller is None:
202
+ self._poller = MessagePoller(
203
+ http_client=self._http,
204
+ task_id=self.task_id,
205
+ emitter=self._emitter,
206
+ poll_interval=self._poll_interval,
207
+ )
208
+ self._poller.start()
209
+
210
+ def stop_polling(self) -> None:
211
+ """Stop the background message poller."""
212
+ if self._poller is not None:
213
+ self._poller.stop()
214
+
215
+ def wait_for_completion(self, timeout: Optional[float] = None) -> dict:
216
+ """Block until the job completes or the timeout expires.
217
+
218
+ This is a convenience method that starts polling (if not already running)
219
+ and waits for the ``job_complete`` event.
220
+
221
+ Args:
222
+ timeout: Maximum seconds to wait. ``None`` means wait indefinitely.
223
+
224
+ Returns:
225
+ The completion payload dict, or an empty dict on timeout.
226
+ """
227
+ if self._poller is None:
228
+ self.start_polling()
229
+ elif not self._poller.is_running:
230
+ self.start_polling()
231
+
232
+ completed = self._poller.completed_event.wait(timeout=timeout)
233
+ if completed and self._poller.completion_data:
234
+ return self._poller.completion_data
235
+ return {}
aid_sdk/models.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ AID SDK Data Models.
3
+
4
+ Lightweight dataclass definitions for SDK request/response structures.
5
+ """
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+
10
+ # Type alias for file identifiers: list of IDs or grouped dict
11
+ FileIds = Union[List[str], Dict[str, Any]]
12
+
13
+
14
+ @dataclass
15
+ class JobStatus:
16
+ """Snapshot of a job's current state."""
17
+ task_id: str
18
+ status: str # ACTIVE | RUNNING | WAITING_USER | FAILED | COMPLETED | CANCELLED
19
+ current_iteration: int = 0
20
+ total_iterations: int = 0
21
+ progress: int = 0
22
+ stage: str = ""
23
+ created_at: Optional[int] = None
24
+ updated_at: Optional[int] = None
25
+
26
+ @classmethod
27
+ def from_response(cls, data: dict) -> "JobStatus":
28
+ """Build from API response dict (camelCase keys)."""
29
+ return cls(
30
+ task_id=data.get("taskId", data.get("task_id", "")),
31
+ status=data.get("status", "ACTIVE"),
32
+ current_iteration=data.get("currentIteration", data.get("current_iteration", 0)),
33
+ total_iterations=data.get("totalIterations", data.get("total_iterations", 0)),
34
+ progress=data.get("progress", 0),
35
+ stage=data.get("stage", ""),
36
+ created_at=data.get("createdAt", data.get("created_at")),
37
+ updated_at=data.get("updatedAt", data.get("updated_at")),
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class IterationInfo:
43
+ """Per-iteration detail from task history."""
44
+ iteration_num: int
45
+ process_type: str = ""
46
+ status: str = "PENDING"
47
+ input_file_ids: FileIds = field(default_factory=dict)
48
+ result_file_ids: FileIds = field(default_factory=dict)
49
+ params: Dict[str, Any] = field(default_factory=dict)
50
+ progress: int = 0
51
+ stage: str = ""
52
+ started_at: Optional[int] = None
53
+ finished_at: Optional[int] = None
54
+
55
+
56
+ @dataclass
57
+ class TaskHistory:
58
+ """Full task history including all iterations."""
59
+ task_id: str
60
+ status: str
61
+ total_iterations: int
62
+ iterations: List[IterationInfo] = field(default_factory=list)
63
+
64
+ @classmethod
65
+ def from_response(cls, data: dict) -> "TaskHistory":
66
+ """Build from API response dict."""
67
+ iterations = []
68
+ for it in data.get("iterations", []):
69
+ iterations.append(
70
+ IterationInfo(
71
+ iteration_num=it.get("iterationNum", it.get("iteration_num", 0)),
72
+ process_type=it.get("processType", it.get("process_type", "")),
73
+ status=it.get("status", "PENDING"),
74
+ input_file_ids=it.get("inputFileIds", it.get("input_file_ids", {})),
75
+ result_file_ids=it.get("resultFileIds", it.get("result_file_ids", {})),
76
+ params=it.get("params", {}),
77
+ progress=it.get("progress", 0),
78
+ stage=it.get("stage", ""),
79
+ started_at=it.get("startedAt", it.get("started_at")),
80
+ finished_at=it.get("finishedAt", it.get("finished_at")),
81
+ )
82
+ )
83
+ return cls(
84
+ task_id=data.get("taskId", data.get("task_id", "")),
85
+ status=data.get("status", "ACTIVE"),
86
+ total_iterations=data.get("totalIterations", data.get("total_iterations", 0)),
87
+ iterations=iterations,
88
+ )
89
+
90
+
91
+ @dataclass
92
+ class Message:
93
+ """A single message from the polling stream."""
94
+ seq: int
95
+ type: str # progress | log | status
96
+ data: Dict[str, Any]
97
+ task_id: str = ""
98
+ timestamp: Optional[int] = None
99
+
100
+ @classmethod
101
+ def from_response(cls, raw: dict) -> "Message":
102
+ """Build from polling response dict."""
103
+ return cls(
104
+ seq=raw.get("seq", 0),
105
+ type=raw.get("type", ""),
106
+ data=raw.get("data", {}),
107
+ task_id=raw.get("taskId", raw.get("task_id", "")),
108
+ timestamp=raw.get("timestamp"),
109
+ )
aid_sdk/poller.py ADDED
@@ -0,0 +1,183 @@
1
+ """
2
+ AID SDK Message Poller.
3
+
4
+ Background daemon thread that periodically fetches messages from the AID service
5
+ and dispatches them through the EventEmitter.
6
+ """
7
+ import logging
8
+ import threading
9
+ import time
10
+ from typing import Any, Callable, Optional
11
+
12
+ from aid_sdk.events import EventEmitter
13
+ from aid_sdk.http_client import HttpClient
14
+ from aid_sdk.models import Message
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class MessagePoller:
20
+ """Background daemon thread that polls ``GET /api/v1/tasks/{task_id}/messages``
21
+ and dispatches events.
22
+
23
+ Message deduplication is based on the ``seq`` field.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ http_client: HttpClient,
29
+ task_id: str,
30
+ emitter: EventEmitter,
31
+ poll_interval: float = 2.0,
32
+ ) -> None:
33
+ self._http = http_client
34
+ self._task_id = task_id
35
+ self._emitter = emitter
36
+ self._poll_interval = poll_interval
37
+
38
+ self._last_seq: int = 0
39
+ self._running = False
40
+ self._thread: Optional[threading.Thread] = None
41
+
42
+ # Completion signal for wait_for_completion
43
+ self._completed = threading.Event()
44
+ self._completion_data: Optional[dict] = None
45
+
46
+ # ------------------------------------------------------------------
47
+ # Public API
48
+ # ------------------------------------------------------------------
49
+
50
+ @property
51
+ def is_running(self) -> bool:
52
+ """Whether the poller thread is currently active."""
53
+ return self._running
54
+
55
+ @property
56
+ def completed_event(self) -> threading.Event:
57
+ """Threading event that is set when a job_complete message is received."""
58
+ return self._completed
59
+
60
+ @property
61
+ def completion_data(self) -> Optional[dict]:
62
+ """The payload of the job_complete message, once available."""
63
+ return self._completion_data
64
+
65
+ def start(self) -> None:
66
+ """Start the background polling daemon thread."""
67
+ if self._running:
68
+ return
69
+ self._running = True
70
+ self._thread = threading.Thread(
71
+ target=self._poll_loop,
72
+ name=f"aid-poller-{self._task_id}",
73
+ daemon=True,
74
+ )
75
+ self._thread.start()
76
+ logger.info("poller_started: task_id=%s interval=%.1fs", self._task_id, self._poll_interval)
77
+
78
+ def stop(self) -> None:
79
+ """Signal the poller to stop and wait for the thread to exit."""
80
+ self._running = False
81
+ if self._thread is not None:
82
+ self._thread.join(timeout=self._poll_interval * 2)
83
+ self._thread = None
84
+ logger.info("poller_stopped: task_id=%s", self._task_id)
85
+
86
+ # ------------------------------------------------------------------
87
+ # Internal
88
+ # ------------------------------------------------------------------
89
+
90
+ def _poll_loop(self) -> None:
91
+ """Main polling loop executed in the daemon thread."""
92
+ while self._running:
93
+ try:
94
+ self._fetch_and_dispatch()
95
+ except Exception as exc:
96
+ logger.warning(
97
+ "poller_error: task_id=%s error=%s", self._task_id, exc
98
+ )
99
+
100
+ # Sleep in small increments so stop() is responsive
101
+ elapsed = 0.0
102
+ while elapsed < self._poll_interval and self._running:
103
+ time.sleep(min(0.5, self._poll_interval - elapsed))
104
+ elapsed += 0.5
105
+
106
+ def _fetch_and_dispatch(self) -> None:
107
+ """Fetch new messages and emit corresponding events."""
108
+ try:
109
+ resp = self._http.get(
110
+ f"/api/v1/tasks/{self._task_id}/messages",
111
+ params={"since": self._last_seq, "limit": 100},
112
+ )
113
+ except Exception:
114
+ # Messages endpoint may not be available yet; degrade silently
115
+ return
116
+
117
+ data = resp.get("data", resp)
118
+ messages_raw = data if isinstance(data, list) else data.get("messages", [])
119
+
120
+ for raw in messages_raw:
121
+ msg = Message.from_response(raw)
122
+
123
+ # Dedup: skip messages we've already processed
124
+ if msg.seq <= self._last_seq:
125
+ continue
126
+ self._last_seq = msg.seq
127
+
128
+ self._dispatch_message(msg)
129
+
130
+ def _dispatch_message(self, msg: Message) -> None:
131
+ """Map a message to an event and emit it."""
132
+ if msg.type == "progress":
133
+ self._emitter.emit("progress", {
134
+ "iteration_num": msg.data.get("iterationNum", msg.data.get("iteration_num")),
135
+ "progress": msg.data.get("progress", 0),
136
+ "stage": msg.data.get("stage", ""),
137
+ "step": msg.data.get("step"),
138
+ "total_steps": msg.data.get("totalSteps", msg.data.get("total_steps")),
139
+ })
140
+
141
+ elif msg.type == "log":
142
+ self._emitter.emit("log", {
143
+ "iteration_num": msg.data.get("iterationNum", msg.data.get("iteration_num")),
144
+ "level": msg.data.get("level", "INFO"),
145
+ "message": msg.data.get("message", ""),
146
+ "timestamp": msg.timestamp,
147
+ })
148
+
149
+ elif msg.type == "status":
150
+ status = msg.data.get("status", "")
151
+ if status == "SUCCESS":
152
+ self._emitter.emit("iteration_complete", {
153
+ "iteration_num": msg.data.get("iterationNum", msg.data.get("iteration_num")),
154
+ "result_file_ids": msg.data.get("resultFileIds", msg.data.get("result_file_ids", {})),
155
+ "total_time": msg.data.get("totalTime", msg.data.get("total_time")),
156
+ "summary": msg.data.get("summary"),
157
+ })
158
+ elif status == "FAILED":
159
+ self._emitter.emit("iteration_error", {
160
+ "iteration_num": msg.data.get("iterationNum", msg.data.get("iteration_num")),
161
+ "code": msg.data.get("errorCode", msg.data.get("error_code", "")),
162
+ "message": msg.data.get("errorMsg", msg.data.get("error_msg", "")),
163
+ "details": msg.data.get("details"),
164
+ })
165
+ elif status == "COMPLETED":
166
+ payload = {
167
+ "task_id": self._task_id,
168
+ "total_iterations": msg.data.get("totalIterations", msg.data.get("total_iterations")),
169
+ }
170
+ self._emitter.emit("job_complete", payload)
171
+ self._completion_data = payload
172
+ self._completed.set()
173
+ self._running = False
174
+
175
+ elif msg.type == "job_complete":
176
+ payload = {
177
+ "task_id": self._task_id,
178
+ "total_iterations": msg.data.get("totalIterations", msg.data.get("total_iterations")),
179
+ }
180
+ self._emitter.emit("job_complete", payload)
181
+ self._completion_data = payload
182
+ self._completed.set()
183
+ self._running = False
aid_sdk/sls_poller.py ADDED
File without changes
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: aid-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for AID Service Platform - CAE Stamping Simulation Task Management
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: requests>=2.28.0
7
+ Requires-Dist: typing-extensions>=4.0.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.0; extra == "dev"
10
+ Requires-Dist: pytest-mock>=3.0; extra == "dev"
11
+ Dynamic: requires-python
@@ -0,0 +1,15 @@
1
+ aid_sdk/__init__.py,sha256=hVg9kU0YWtZzbMzrw9ByhmqTPhKradeFd_HQxE5J_Yc,1740
2
+ aid_sdk/client.py,sha256=hA95qWFhbhC5FvaDkd4c6C4xzS9ZhIoR6OVHr7hELLQ,5513
3
+ aid_sdk/config_loader.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ aid_sdk/errors.py,sha256=5nZujHNHPPJ4HIebQZAnhuI12VdTxnVuldI017tiPUQ,2836
5
+ aid_sdk/events.py,sha256=jlAVu23LuO2GQoIDIw3v_QteJ5-oQMXKW4C_bkp7cho,2662
6
+ aid_sdk/file_helper.py,sha256=W53w3o1ySmsqGX_0gSTSLm_EaRbJZlG0eIjMtmUu7ho,6285
7
+ aid_sdk/http_client.py,sha256=i9XPWb_ySSyXPub4LYMr2kdRkZ7JfcNgxPkilulVbMY,5605
8
+ aid_sdk/job.py,sha256=C3FUX5XxB0adkigtmKOC7sVgJc1bQTY4ehF6OxhljAE,7893
9
+ aid_sdk/models.py,sha256=vkczPO7NgVHklJcrzaV571brY49fAFrCDrntGIn4UbE,3960
10
+ aid_sdk/poller.py,sha256=wE2DgftH0NnrBIm003m6VhOcBACkDhMSEBHsRjhKCLE,7075
11
+ aid_sdk/sls_poller.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ aid_sdk-0.1.0.dist-info/METADATA,sha256=ifU_AovmpHabTfGSOGk-sev0e-UFO4qz40up4WoKx1A,380
13
+ aid_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ aid_sdk-0.1.0.dist-info/top_level.txt,sha256=8mBla07brDr9b7R1BSZmfGSvo0tpd3owIsfr7ImmB3s,8
15
+ aid_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ aid_sdk