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 +66 -0
- aid_sdk/client.py +166 -0
- aid_sdk/config_loader.py +0 -0
- aid_sdk/errors.py +91 -0
- aid_sdk/events.py +85 -0
- aid_sdk/file_helper.py +175 -0
- aid_sdk/http_client.py +157 -0
- aid_sdk/job.py +235 -0
- aid_sdk/models.py +109 -0
- aid_sdk/poller.py +183 -0
- aid_sdk/sls_poller.py +0 -0
- aid_sdk-0.1.0.dist-info/METADATA +11 -0
- aid_sdk-0.1.0.dist-info/RECORD +15 -0
- aid_sdk-0.1.0.dist-info/WHEEL +5 -0
- aid_sdk-0.1.0.dist-info/top_level.txt +1 -0
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")
|
aid_sdk/config_loader.py
ADDED
|
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 @@
|
|
|
1
|
+
aid_sdk
|