continuum-task-server-sdk 0.0.9__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.
- continuum_task_server/__init__.py +36 -0
- continuum_task_server/_http.py +142 -0
- continuum_task_server/client.py +349 -0
- continuum_task_server/exceptions.py +69 -0
- continuum_task_server/models.py +113 -0
- continuum_task_server/server.py +364 -0
- continuum_task_server_sdk-0.0.9.dist-info/METADATA +215 -0
- continuum_task_server_sdk-0.0.9.dist-info/RECORD +9 -0
- continuum_task_server_sdk-0.0.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Continuum Task Server SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import ContinuumClient
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
ContinuumError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
ServerError,
|
|
13
|
+
UnauthorizedError,
|
|
14
|
+
)
|
|
15
|
+
from .models import Content, QueueItem, TaskItem, TaskItemVersion, TaskStatus, TaskType
|
|
16
|
+
from .server import TaskServer
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BadRequestError",
|
|
20
|
+
"ConflictError",
|
|
21
|
+
"Content",
|
|
22
|
+
"ContinuumClient",
|
|
23
|
+
"ContinuumError",
|
|
24
|
+
"ForbiddenError",
|
|
25
|
+
"NotFoundError",
|
|
26
|
+
"QueueItem",
|
|
27
|
+
"ServerError",
|
|
28
|
+
"TaskItem",
|
|
29
|
+
"TaskItemVersion",
|
|
30
|
+
"TaskServer",
|
|
31
|
+
"TaskStatus",
|
|
32
|
+
"TaskType",
|
|
33
|
+
"UnauthorizedError",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Internal HTTP wrapper. Handles Api-Key auth, JSON (de)serialization, and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import ContinuumError, error_for_status
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("continuum_task_server")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_base_url(url: str) -> str:
|
|
17
|
+
return url[:-1] if url.endswith("/") else url
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpClient:
|
|
21
|
+
"""Thin httpx wrapper. Not part of the public API."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str,
|
|
26
|
+
api_key: str,
|
|
27
|
+
*,
|
|
28
|
+
connect_timeout: float = 10.0,
|
|
29
|
+
request_timeout: float = 30.0,
|
|
30
|
+
transport: httpx.BaseTransport | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if not base_url:
|
|
33
|
+
raise ValueError("base_url must not be empty")
|
|
34
|
+
if not api_key:
|
|
35
|
+
raise ValueError("api_key must not be empty")
|
|
36
|
+
self.base_url = _normalize_base_url(base_url)
|
|
37
|
+
self.api_key = api_key
|
|
38
|
+
timeout = httpx.Timeout(request_timeout, connect=connect_timeout)
|
|
39
|
+
self._client = httpx.Client(
|
|
40
|
+
base_url=self.base_url,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
headers={"Api-Key": api_key},
|
|
43
|
+
transport=transport,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> HttpClient:
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *exc: object) -> None:
|
|
53
|
+
self.close()
|
|
54
|
+
|
|
55
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
56
|
+
if response.status_code >= 400:
|
|
57
|
+
body = response.text
|
|
58
|
+
logger.error(
|
|
59
|
+
"HTTP %s on %s %s: %s",
|
|
60
|
+
response.status_code,
|
|
61
|
+
response.request.method,
|
|
62
|
+
response.request.url,
|
|
63
|
+
body,
|
|
64
|
+
)
|
|
65
|
+
raise error_for_status(response.status_code, body)
|
|
66
|
+
|
|
67
|
+
def get_json(self, path: str) -> Any:
|
|
68
|
+
try:
|
|
69
|
+
response = self._client.get(path, headers={"Content-Type": "application/json"})
|
|
70
|
+
except httpx.HTTPError as e:
|
|
71
|
+
raise ContinuumError(f"Request failed: {e}") from e
|
|
72
|
+
self._raise_for_status(response)
|
|
73
|
+
if response.status_code == 204 or not response.content:
|
|
74
|
+
return None
|
|
75
|
+
return response.json()
|
|
76
|
+
|
|
77
|
+
def get_bytes(self, path: str) -> tuple[bytes, str] | None:
|
|
78
|
+
"""Returns (data, content_type) tuple, or None on 204."""
|
|
79
|
+
try:
|
|
80
|
+
response = self._client.get(path)
|
|
81
|
+
except httpx.HTTPError as e:
|
|
82
|
+
raise ContinuumError(f"Request failed: {e}") from e
|
|
83
|
+
if response.status_code == 204:
|
|
84
|
+
return None
|
|
85
|
+
self._raise_for_status(response)
|
|
86
|
+
mime = response.headers.get("Content-Type", "application/octet-stream")
|
|
87
|
+
return response.content, mime
|
|
88
|
+
|
|
89
|
+
def post_json(self, path: str, body: Any) -> Any:
|
|
90
|
+
payload = json.dumps(body, default=_json_default) if body is not None else "{}"
|
|
91
|
+
try:
|
|
92
|
+
response = self._client.post(
|
|
93
|
+
path,
|
|
94
|
+
content=payload,
|
|
95
|
+
headers={"Content-Type": "application/json"},
|
|
96
|
+
)
|
|
97
|
+
except httpx.HTTPError as e:
|
|
98
|
+
raise ContinuumError(f"Request failed: {e}") from e
|
|
99
|
+
self._raise_for_status(response)
|
|
100
|
+
if response.status_code == 204 or not response.content:
|
|
101
|
+
return None
|
|
102
|
+
return response.json()
|
|
103
|
+
|
|
104
|
+
def post_optional(self, path: str, body: Any) -> Any | None:
|
|
105
|
+
"""POST that may return 204 No Content (e.g. claim)."""
|
|
106
|
+
return self.post_json(path, body)
|
|
107
|
+
|
|
108
|
+
def patch_json(self, path: str, body: Any) -> Any:
|
|
109
|
+
payload = json.dumps(body, default=_json_default) if body is not None else "{}"
|
|
110
|
+
try:
|
|
111
|
+
response = self._client.request(
|
|
112
|
+
"PATCH",
|
|
113
|
+
path,
|
|
114
|
+
content=payload,
|
|
115
|
+
headers={"Content-Type": "application/json"},
|
|
116
|
+
)
|
|
117
|
+
except httpx.HTTPError as e:
|
|
118
|
+
raise ContinuumError(f"Request failed: {e}") from e
|
|
119
|
+
self._raise_for_status(response)
|
|
120
|
+
if response.status_code == 204 or not response.content:
|
|
121
|
+
return None
|
|
122
|
+
return response.json()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _json_default(obj: Any) -> Any:
|
|
126
|
+
"""Fallback JSON encoder for UUIDs, datetimes, enums."""
|
|
127
|
+
import datetime as _dt
|
|
128
|
+
import enum as _enum
|
|
129
|
+
import uuid as _uuid
|
|
130
|
+
|
|
131
|
+
if isinstance(obj, _uuid.UUID):
|
|
132
|
+
return str(obj)
|
|
133
|
+
if isinstance(obj, _dt.datetime):
|
|
134
|
+
return obj.isoformat()
|
|
135
|
+
if isinstance(obj, _enum.Enum):
|
|
136
|
+
return obj.value
|
|
137
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def drop_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
141
|
+
"""Strip None values from a dict (mirrors @JsonInclude.NON_NULL)."""
|
|
142
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""ContinuumClient - pythonic mirror of the Java SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._http import HttpClient, drop_none
|
|
14
|
+
from .models import Content, QueueItem, TaskItem, TaskItemVersion, TaskStatus, TaskType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _encode_input(data: Any) -> str | None:
|
|
18
|
+
"""Accept dict/list/str/None and return a JSON string (or None)."""
|
|
19
|
+
if data is None:
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(data, str):
|
|
22
|
+
return data
|
|
23
|
+
return json.dumps(data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskTypesApi:
|
|
27
|
+
_PATH = "/api/management/task-types"
|
|
28
|
+
|
|
29
|
+
def __init__(self, http: HttpClient) -> None:
|
|
30
|
+
self._http = http
|
|
31
|
+
|
|
32
|
+
def list(self) -> list[TaskType]:
|
|
33
|
+
data = self._http.get_json(self._PATH) or []
|
|
34
|
+
return [TaskType.model_validate(item) for item in data]
|
|
35
|
+
|
|
36
|
+
def get_by_id(self, task_type_id: UUID | str) -> TaskType:
|
|
37
|
+
return TaskType.model_validate(self._http.get_json(f"{self._PATH}/{task_type_id}"))
|
|
38
|
+
|
|
39
|
+
def get_by_name(self, name: str) -> TaskType:
|
|
40
|
+
return TaskType.model_validate(
|
|
41
|
+
self._http.get_json(f"{self._PATH}/by-name?name={quote(name)}")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def save(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
id: UUID | str | None = None,
|
|
48
|
+
name: str | None = None,
|
|
49
|
+
description: str | None = None,
|
|
50
|
+
active_flag: bool | None = None,
|
|
51
|
+
organization_id: UUID | str | None = None,
|
|
52
|
+
is_core: bool | None = None,
|
|
53
|
+
max_duration_sec: int | None = None,
|
|
54
|
+
heartbeat_timeout_sec: int | None = None,
|
|
55
|
+
) -> TaskType:
|
|
56
|
+
"""Create (no id) or update (with id) a task type."""
|
|
57
|
+
body = drop_none(
|
|
58
|
+
{
|
|
59
|
+
"id": str(id) if id else None,
|
|
60
|
+
"name": name,
|
|
61
|
+
"description": description,
|
|
62
|
+
"activeFlag": active_flag,
|
|
63
|
+
"organizationId": str(organization_id) if organization_id else None,
|
|
64
|
+
"isCore": is_core,
|
|
65
|
+
"maxDurationSec": max_duration_sec,
|
|
66
|
+
"heartbeatTimeoutSec": heartbeat_timeout_sec,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
return TaskType.model_validate(self._http.post_json(self._PATH, body))
|
|
70
|
+
|
|
71
|
+
def create(
|
|
72
|
+
self,
|
|
73
|
+
name: str,
|
|
74
|
+
*,
|
|
75
|
+
organization_id: UUID | str | None = None,
|
|
76
|
+
is_core: bool = False,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> TaskType:
|
|
79
|
+
return self.save(
|
|
80
|
+
name=name,
|
|
81
|
+
organization_id=organization_id,
|
|
82
|
+
is_core=is_core,
|
|
83
|
+
**kwargs,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TaskItemVersionsApi:
|
|
88
|
+
_PATH = "/api/management/task-items"
|
|
89
|
+
|
|
90
|
+
def __init__(self, http: HttpClient) -> None:
|
|
91
|
+
self._http = http
|
|
92
|
+
|
|
93
|
+
def list(self, task_item_id: UUID | str) -> list[TaskItemVersion]:
|
|
94
|
+
data = self._http.get_json(f"{self._PATH}/{task_item_id}/versions") or []
|
|
95
|
+
return [TaskItemVersion.model_validate(item) for item in data]
|
|
96
|
+
|
|
97
|
+
def get_by_id(self, version_id: UUID | str) -> TaskItemVersion:
|
|
98
|
+
return TaskItemVersion.model_validate(
|
|
99
|
+
self._http.get_json(f"{self._PATH}/versions/{version_id}")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def create(
|
|
103
|
+
self,
|
|
104
|
+
task_item_id: UUID | str,
|
|
105
|
+
*,
|
|
106
|
+
main_path: str,
|
|
107
|
+
item_definition: str | dict | list | None = None,
|
|
108
|
+
content: bytes | None = None,
|
|
109
|
+
mime_type: str | None = None,
|
|
110
|
+
) -> TaskItemVersion:
|
|
111
|
+
body: dict[str, Any] = {"mainPath": main_path}
|
|
112
|
+
if item_definition is not None:
|
|
113
|
+
body["itemDefinition"] = (
|
|
114
|
+
item_definition if isinstance(item_definition, str) else json.dumps(item_definition)
|
|
115
|
+
)
|
|
116
|
+
if content is not None:
|
|
117
|
+
body["contentBytes"] = base64.b64encode(content).decode("ascii")
|
|
118
|
+
if mime_type:
|
|
119
|
+
body["mimeType"] = mime_type
|
|
120
|
+
elif mime_type:
|
|
121
|
+
body["mimeType"] = mime_type
|
|
122
|
+
|
|
123
|
+
return TaskItemVersion.model_validate(
|
|
124
|
+
self._http.post_json(f"{self._PATH}/{task_item_id}/versions", body)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def update(
|
|
128
|
+
self,
|
|
129
|
+
task_item_id: UUID | str,
|
|
130
|
+
version_id: UUID | str,
|
|
131
|
+
*,
|
|
132
|
+
main_path: str | None = None,
|
|
133
|
+
item_definition: str | dict | list | None = None,
|
|
134
|
+
active_flag: bool | None = None,
|
|
135
|
+
) -> TaskItemVersion:
|
|
136
|
+
body = drop_none(
|
|
137
|
+
{
|
|
138
|
+
"mainPath": main_path,
|
|
139
|
+
"itemDefinition": (
|
|
140
|
+
item_definition
|
|
141
|
+
if item_definition is None or isinstance(item_definition, str)
|
|
142
|
+
else json.dumps(item_definition)
|
|
143
|
+
),
|
|
144
|
+
"activeFlag": active_flag,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return TaskItemVersion.model_validate(
|
|
148
|
+
self._http.patch_json(f"{self._PATH}/{task_item_id}/versions/{version_id}", body)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def deactivate(self, task_item_id: UUID | str, version_id: UUID | str) -> TaskItemVersion:
|
|
152
|
+
return self.update(task_item_id, version_id, active_flag=False)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TaskItemsApi:
|
|
156
|
+
_PATH = "/api/management/task-items"
|
|
157
|
+
|
|
158
|
+
def __init__(self, http: HttpClient) -> None:
|
|
159
|
+
self._http = http
|
|
160
|
+
self.versions = TaskItemVersionsApi(http)
|
|
161
|
+
|
|
162
|
+
def list(self) -> list[TaskItem]:
|
|
163
|
+
data = self._http.get_json(self._PATH) or []
|
|
164
|
+
return [TaskItem.model_validate(item) for item in data]
|
|
165
|
+
|
|
166
|
+
def get_by_id(self, task_item_id: UUID | str) -> TaskItem:
|
|
167
|
+
return TaskItem.model_validate(self._http.get_json(f"{self._PATH}/{task_item_id}"))
|
|
168
|
+
|
|
169
|
+
def get_by_name(self, name: str) -> TaskItem:
|
|
170
|
+
return TaskItem.model_validate(
|
|
171
|
+
self._http.get_json(f"{self._PATH}/by-name?name={quote(name)}")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def create(
|
|
175
|
+
self,
|
|
176
|
+
name: str,
|
|
177
|
+
*,
|
|
178
|
+
organization_id: UUID | str | None = None,
|
|
179
|
+
task_type_id: UUID | str | None = None,
|
|
180
|
+
is_core: bool = False,
|
|
181
|
+
) -> TaskItem:
|
|
182
|
+
body = drop_none(
|
|
183
|
+
{
|
|
184
|
+
"name": name,
|
|
185
|
+
"isCore": is_core,
|
|
186
|
+
"organizationId": str(organization_id) if organization_id else None,
|
|
187
|
+
"taskTypeId": str(task_type_id) if task_type_id else None,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
return TaskItem.model_validate(self._http.post_json(self._PATH, body))
|
|
191
|
+
|
|
192
|
+
def update(
|
|
193
|
+
self,
|
|
194
|
+
task_item_id: UUID | str,
|
|
195
|
+
*,
|
|
196
|
+
name: str | None = None,
|
|
197
|
+
active_flag: bool | None = None,
|
|
198
|
+
task_type_id: UUID | str | None = None,
|
|
199
|
+
) -> TaskItem:
|
|
200
|
+
body = drop_none(
|
|
201
|
+
{
|
|
202
|
+
"name": name,
|
|
203
|
+
"activeFlag": active_flag,
|
|
204
|
+
"taskTypeId": str(task_type_id) if task_type_id else None,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
return TaskItem.model_validate(self._http.post_json(f"{self._PATH}/{task_item_id}", body))
|
|
208
|
+
|
|
209
|
+
def publish_version(self, task_item_id: UUID | str, version_id: UUID | str) -> TaskItem:
|
|
210
|
+
return TaskItem.model_validate(
|
|
211
|
+
self._http.post_json(
|
|
212
|
+
f"{self._PATH}/{task_item_id}/publish",
|
|
213
|
+
{"versionId": str(version_id)},
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class QueueApi:
|
|
219
|
+
_MGMT = "/api/management/queue-items"
|
|
220
|
+
_QUEUE = "/api/queue"
|
|
221
|
+
|
|
222
|
+
def __init__(self, http: HttpClient) -> None:
|
|
223
|
+
self._http = http
|
|
224
|
+
|
|
225
|
+
def add(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
task_name: str | None = None,
|
|
229
|
+
task_item_name: str | None = None,
|
|
230
|
+
input_data: Any = None,
|
|
231
|
+
priority: int | None = None,
|
|
232
|
+
parent_id: UUID | str | None = None,
|
|
233
|
+
) -> QueueItem:
|
|
234
|
+
"""Add a queue item. At least one of task_name or task_item_name is required."""
|
|
235
|
+
if task_name is None and task_item_name is None:
|
|
236
|
+
raise ValueError("at least one of task_name or task_item_name is required")
|
|
237
|
+
body = drop_none(
|
|
238
|
+
{
|
|
239
|
+
"parent_id": str(parent_id) if parent_id else None,
|
|
240
|
+
"taskName": task_name,
|
|
241
|
+
"taskItemName": task_item_name,
|
|
242
|
+
"priority": priority,
|
|
243
|
+
"inputData": _encode_input(input_data),
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
return QueueItem.model_validate(self._http.post_json(self._MGMT, body))
|
|
247
|
+
|
|
248
|
+
def get_by_id(self, queue_item_id: UUID | str) -> QueueItem:
|
|
249
|
+
return QueueItem.model_validate(self._http.get_json(f"{self._MGMT}/{queue_item_id}"))
|
|
250
|
+
|
|
251
|
+
def get_content(self, queue_item_id: UUID | str) -> Content | None:
|
|
252
|
+
result = self._http.get_bytes(f"{self._QUEUE}/queue-items/{queue_item_id}/content")
|
|
253
|
+
if result is None:
|
|
254
|
+
return None
|
|
255
|
+
data, mime = result
|
|
256
|
+
return Content(data=data, mime_type=mime)
|
|
257
|
+
|
|
258
|
+
def claim(self, task_name: str) -> QueueItem | None:
|
|
259
|
+
"""Claim one OPEN queue item for the given task name. Returns None if none available."""
|
|
260
|
+
result = self._http.post_optional(f"{self._QUEUE}/claim", {"taskName": task_name})
|
|
261
|
+
if result is None:
|
|
262
|
+
return None
|
|
263
|
+
return QueueItem.model_validate(result)
|
|
264
|
+
|
|
265
|
+
def heartbeat(self, queue_item_id: UUID | str) -> QueueItem:
|
|
266
|
+
return QueueItem.model_validate(
|
|
267
|
+
self._http.post_json(f"{self._QUEUE}/queue-items/{queue_item_id}/heartbeat", {})
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def update_status(
|
|
271
|
+
self,
|
|
272
|
+
queue_item_id: UUID | str,
|
|
273
|
+
status: TaskStatus,
|
|
274
|
+
*,
|
|
275
|
+
output_data: Any = None,
|
|
276
|
+
) -> QueueItem:
|
|
277
|
+
# Server expects `status` on this endpoint (not `taskStatus` on queue item JSON).
|
|
278
|
+
body: dict[str, Any] = {"status": status.value}
|
|
279
|
+
encoded = _encode_input(output_data)
|
|
280
|
+
if encoded is not None:
|
|
281
|
+
body["outputData"] = encoded
|
|
282
|
+
return QueueItem.model_validate(
|
|
283
|
+
self._http.post_json(f"{self._QUEUE}/queue-items/{queue_item_id}/status", body)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class ContentStoreApi:
|
|
288
|
+
_PATH = "/api/management/content-store"
|
|
289
|
+
|
|
290
|
+
def __init__(self, http: HttpClient) -> None:
|
|
291
|
+
self._http = http
|
|
292
|
+
|
|
293
|
+
def get_by_id(self, content_id: UUID | str) -> Content | None:
|
|
294
|
+
result = self._http.get_bytes(f"{self._PATH}/{content_id}")
|
|
295
|
+
if result is None:
|
|
296
|
+
return None
|
|
297
|
+
data, mime = result
|
|
298
|
+
return Content(data=data, mime_type=mime)
|
|
299
|
+
|
|
300
|
+
def get_by_url(self, content_url: str) -> Content | None:
|
|
301
|
+
result = self._http.get_bytes(f"{self._PATH}?url={quote(content_url, safe='')}")
|
|
302
|
+
if result is None:
|
|
303
|
+
return None
|
|
304
|
+
data, mime = result
|
|
305
|
+
return Content(data=data, mime_type=mime)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class ContinuumClient:
|
|
309
|
+
"""Client for the Continuum Task Server.
|
|
310
|
+
|
|
311
|
+
Example:
|
|
312
|
+
>>> client = ContinuumClient(base_url="http://localhost:8080", api_key="...")
|
|
313
|
+
>>> task_types = client.task_types.list()
|
|
314
|
+
>>> item = client.queue.claim("echo")
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
def __init__(
|
|
318
|
+
self,
|
|
319
|
+
base_url: str,
|
|
320
|
+
api_key: str,
|
|
321
|
+
*,
|
|
322
|
+
connect_timeout: float = 10.0,
|
|
323
|
+
request_timeout: float = 30.0,
|
|
324
|
+
transport: httpx.BaseTransport | None = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
self._http = HttpClient(
|
|
327
|
+
base_url=base_url,
|
|
328
|
+
api_key=api_key,
|
|
329
|
+
connect_timeout=connect_timeout,
|
|
330
|
+
request_timeout=request_timeout,
|
|
331
|
+
transport=transport,
|
|
332
|
+
)
|
|
333
|
+
self.task_types = TaskTypesApi(self._http)
|
|
334
|
+
self.task_items = TaskItemsApi(self._http)
|
|
335
|
+
self.queue = QueueApi(self._http)
|
|
336
|
+
self.content_store = ContentStoreApi(self._http)
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def base_url(self) -> str:
|
|
340
|
+
return self._http.base_url
|
|
341
|
+
|
|
342
|
+
def close(self) -> None:
|
|
343
|
+
self._http.close()
|
|
344
|
+
|
|
345
|
+
def __enter__(self) -> ContinuumClient:
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def __exit__(self, *exc: object) -> None:
|
|
349
|
+
self.close()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Exception types raised by the Continuum SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContinuumError(Exception):
|
|
7
|
+
"""Base exception for all Continuum SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status_code: int | None = None,
|
|
14
|
+
body: str | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.body = body
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
base = super().__str__()
|
|
22
|
+
if self.status_code is not None:
|
|
23
|
+
return f"[{self.status_code}] {base}"
|
|
24
|
+
return base
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BadRequestError(ContinuumError):
|
|
28
|
+
"""HTTP 400."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnauthorizedError(ContinuumError):
|
|
32
|
+
"""HTTP 401 - check API key type (Organization vs Worker)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ForbiddenError(ContinuumError):
|
|
36
|
+
"""HTTP 403."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(ContinuumError):
|
|
40
|
+
"""HTTP 404."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConflictError(ContinuumError):
|
|
44
|
+
"""HTTP 409."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ServerError(ContinuumError):
|
|
48
|
+
"""HTTP 5xx."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_STATUS_TO_EXC: dict[int, type[ContinuumError]] = {
|
|
52
|
+
400: BadRequestError,
|
|
53
|
+
401: UnauthorizedError,
|
|
54
|
+
403: ForbiddenError,
|
|
55
|
+
404: NotFoundError,
|
|
56
|
+
409: ConflictError,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def error_for_status(status_code: int, body: str) -> ContinuumError:
|
|
61
|
+
"""Build the appropriate exception for a non-2xx HTTP response."""
|
|
62
|
+
exc_cls = _STATUS_TO_EXC.get(status_code)
|
|
63
|
+
if exc_cls is None:
|
|
64
|
+
if 500 <= status_code < 600:
|
|
65
|
+
exc_cls = ServerError
|
|
66
|
+
else:
|
|
67
|
+
exc_cls = ContinuumError
|
|
68
|
+
message = body if body else f"Request failed with status {status_code}"
|
|
69
|
+
return exc_cls(message, status_code=status_code, body=body)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Pydantic models mirroring the Continuum Task Server JSON shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskStatus(str, Enum):
|
|
15
|
+
"""Status of a queue item in the task processing lifecycle."""
|
|
16
|
+
|
|
17
|
+
OPEN = "OPEN"
|
|
18
|
+
CLAIMED = "CLAIMED"
|
|
19
|
+
STARTED = "STARTED"
|
|
20
|
+
TIMEDOUT = "TIMEDOUT"
|
|
21
|
+
CANCELLED = "CANCELLED"
|
|
22
|
+
KILLED = "KILLED"
|
|
23
|
+
ENDED = "ENDED"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Base(BaseModel):
|
|
27
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskType(_Base):
|
|
31
|
+
id: UUID | None = None
|
|
32
|
+
name: str | None = None
|
|
33
|
+
description: str | None = None
|
|
34
|
+
active_flag: bool = Field(default=False, alias="activeFlag")
|
|
35
|
+
organization_id: UUID | None = Field(default=None, alias="organizationId")
|
|
36
|
+
is_global: bool = Field(default=False, alias="isGlobal")
|
|
37
|
+
max_duration_sec: int | None = Field(default=None, alias="maxDurationSec")
|
|
38
|
+
heartbeat_timeout_sec: int | None = Field(default=None, alias="heartbeatTimeoutSec")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaskItem(_Base):
|
|
42
|
+
id: UUID | None = None
|
|
43
|
+
name: str | None = None
|
|
44
|
+
organization_id: UUID | None = Field(default=None, alias="organizationId")
|
|
45
|
+
is_global: bool = Field(default=False, alias="isGlobal")
|
|
46
|
+
active_flag: bool = Field(default=False, alias="activeFlag")
|
|
47
|
+
published_version: int | None = Field(default=None, alias="publishedVersion")
|
|
48
|
+
task_type_id: UUID | None = Field(default=None, alias="taskTypeId")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TaskItemVersion(_Base):
|
|
52
|
+
id: UUID | None = None
|
|
53
|
+
task_item_id: UUID | None = Field(default=None, alias="taskItemId")
|
|
54
|
+
version_number: int = Field(default=0, alias="versionNumber")
|
|
55
|
+
content_url: str | None = Field(default=None, alias="contentUrl")
|
|
56
|
+
main_path: str | None = Field(default=None, alias="mainPath")
|
|
57
|
+
item_definition: str | None = Field(default=None, alias="itemDefinition")
|
|
58
|
+
active_flag: bool = Field(default=False, alias="activeFlag")
|
|
59
|
+
created_by: UUID | None = Field(default=None, alias="createdBy")
|
|
60
|
+
created_dtm: datetime | None = Field(default=None, alias="createdDtm")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def item_definition_json(self) -> Any:
|
|
64
|
+
if not self.item_definition:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(self.item_definition)
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
return self.item_definition
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class QueueItem(_Base):
|
|
73
|
+
id: UUID
|
|
74
|
+
depth: int = 0
|
|
75
|
+
task_type_id: UUID | None = Field(default=None, alias="taskTypeId")
|
|
76
|
+
task_item_version_id: UUID | None = Field(default=None, alias="taskItemVersionId")
|
|
77
|
+
priority: int = 0
|
|
78
|
+
task_status: TaskStatus | None = Field(default=None, alias="taskStatus")
|
|
79
|
+
organization_id: UUID | None = Field(default=None, alias="organizationId")
|
|
80
|
+
created_dtm: datetime | None = Field(default=None, alias="createdDtm")
|
|
81
|
+
created_by: UUID | None = Field(default=None, alias="createdBy")
|
|
82
|
+
input_data: str | None = Field(default=None, alias="inputData")
|
|
83
|
+
output_data: str | None = Field(default=None, alias="outputData")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def input_data_json(self) -> Any:
|
|
87
|
+
"""Parse `input_data` as JSON. Returns None if input_data is None/empty,
|
|
88
|
+
or the raw string if it's not valid JSON."""
|
|
89
|
+
if not self.input_data:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return json.loads(self.input_data)
|
|
93
|
+
except (ValueError, TypeError):
|
|
94
|
+
return self.input_data
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def output_data_json(self) -> Any:
|
|
98
|
+
if not self.output_data:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(self.output_data)
|
|
102
|
+
except (ValueError, TypeError):
|
|
103
|
+
return self.output_data
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Content(_Base):
|
|
107
|
+
"""Raw content returned from a content-store or queue-item content endpoint."""
|
|
108
|
+
|
|
109
|
+
data: bytes
|
|
110
|
+
mime_type: str = "application/octet-stream"
|
|
111
|
+
|
|
112
|
+
def as_text(self, encoding: str = "utf-8") -> str:
|
|
113
|
+
return self.data.decode(encoding)
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""TaskServer - worker-loop abstraction for handling Continuum queue items.
|
|
2
|
+
|
|
3
|
+
Register handlers per task-type name with ``@server.task("name")`` and call
|
|
4
|
+
``server.run()``. The server polls for OPEN items, claims them, heartbeats while
|
|
5
|
+
the handler runs, then reports ENDED (return value) or KILLED (exception).
|
|
6
|
+
|
|
7
|
+
Use ``@server.task(..., auto_complete=False)`` when the handler records work
|
|
8
|
+
elsewhere and you will call ``complete_queue_item`` / ``fail_queue_item`` later.
|
|
9
|
+
The SDK does **not** heartbeat after the handler returns; you must call
|
|
10
|
+
``client.queue.heartbeat`` yourself (for example on each pass of your DB poll) so
|
|
11
|
+
the claim stays alive across process restarts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import signal
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import traceback
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from .client import ContinuumClient
|
|
30
|
+
from .exceptions import ContinuumError
|
|
31
|
+
from .models import QueueItem, TaskStatus
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("continuum_task_server")
|
|
34
|
+
|
|
35
|
+
Handler = Callable[[QueueItem], Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Registration:
|
|
40
|
+
name: str
|
|
41
|
+
handler: Handler
|
|
42
|
+
concurrency: int
|
|
43
|
+
auto_complete: bool
|
|
44
|
+
semaphore: threading.Semaphore = field(init=False)
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
self.semaphore = threading.Semaphore(self.concurrency)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TaskServer:
|
|
51
|
+
"""Worker-first task server.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> server = TaskServer(base_url="http://localhost:8080", api_key="...")
|
|
55
|
+
>>> @server.task("echo")
|
|
56
|
+
... def echo(item):
|
|
57
|
+
... return {"echoed": item.input_data_json}
|
|
58
|
+
>>> server.run() # blocks until SIGINT/SIGTERM
|
|
59
|
+
|
|
60
|
+
With ``auto_complete=True`` (default), handler return values become ``outputData``
|
|
61
|
+
and the task is marked ENDED. Raising any exception marks the task KILLED.
|
|
62
|
+
|
|
63
|
+
With ``auto_complete=False``, the handler returns without ENDED; you are
|
|
64
|
+
responsible for heartbeats until you call ``complete_queue_item`` or
|
|
65
|
+
``fail_queue_item`` (see README). Heartbeats only run while the handler is
|
|
66
|
+
executing, not after it returns.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
base_url: str,
|
|
72
|
+
api_key: str,
|
|
73
|
+
*,
|
|
74
|
+
max_workers: int = 4,
|
|
75
|
+
poll_interval: float = 1.0,
|
|
76
|
+
max_poll_interval: float = 5.0,
|
|
77
|
+
heartbeat_interval: float = 15.0,
|
|
78
|
+
shutdown_timeout: float = 30.0,
|
|
79
|
+
client: ContinuumClient | None = None,
|
|
80
|
+
connect_timeout: float = 10.0,
|
|
81
|
+
request_timeout: float = 30.0,
|
|
82
|
+
transport: httpx.BaseTransport | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._client = client or ContinuumClient(
|
|
85
|
+
base_url=base_url,
|
|
86
|
+
api_key=api_key,
|
|
87
|
+
connect_timeout=connect_timeout,
|
|
88
|
+
request_timeout=request_timeout,
|
|
89
|
+
transport=transport,
|
|
90
|
+
)
|
|
91
|
+
self._owns_client = client is None
|
|
92
|
+
self._max_workers = max_workers
|
|
93
|
+
self._poll_interval = poll_interval
|
|
94
|
+
self._max_poll_interval = max_poll_interval
|
|
95
|
+
self._heartbeat_interval = heartbeat_interval
|
|
96
|
+
self._shutdown_timeout = shutdown_timeout
|
|
97
|
+
self._handlers: dict[str, _Registration] = {}
|
|
98
|
+
self._stop_event = threading.Event()
|
|
99
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
100
|
+
self._inflight: set[Future[Any]] = set()
|
|
101
|
+
self._inflight_lock = threading.Lock()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def client(self) -> ContinuumClient:
|
|
105
|
+
"""Underlying ContinuumClient. Useful for management operations from handlers."""
|
|
106
|
+
return self._client
|
|
107
|
+
|
|
108
|
+
def task(
|
|
109
|
+
self,
|
|
110
|
+
name: str,
|
|
111
|
+
*,
|
|
112
|
+
concurrency: int = 1,
|
|
113
|
+
auto_complete: bool = True,
|
|
114
|
+
) -> Callable[[Handler], Handler]:
|
|
115
|
+
"""Decorator registering ``handler`` for queue items of task-type ``name``.
|
|
116
|
+
|
|
117
|
+
``concurrency`` is the max number of in-flight items for this task name.
|
|
118
|
+
|
|
119
|
+
If ``auto_complete`` is False, the handler returns without the server sending
|
|
120
|
+
ENDED; call ``complete_queue_item`` or ``fail_queue_item`` when done. You must
|
|
121
|
+
heartbeat the queue item yourself until then (see README).
|
|
122
|
+
"""
|
|
123
|
+
if concurrency < 1:
|
|
124
|
+
raise ValueError("concurrency must be >= 1")
|
|
125
|
+
|
|
126
|
+
def decorator(handler: Handler) -> Handler:
|
|
127
|
+
if name in self._handlers:
|
|
128
|
+
raise ValueError(f"a handler for task {name!r} is already registered")
|
|
129
|
+
self._handlers[name] = _Registration(
|
|
130
|
+
name=name,
|
|
131
|
+
handler=handler,
|
|
132
|
+
concurrency=concurrency,
|
|
133
|
+
auto_complete=auto_complete,
|
|
134
|
+
)
|
|
135
|
+
return handler
|
|
136
|
+
|
|
137
|
+
return decorator
|
|
138
|
+
|
|
139
|
+
def register(
|
|
140
|
+
self,
|
|
141
|
+
name: str,
|
|
142
|
+
handler: Handler,
|
|
143
|
+
*,
|
|
144
|
+
concurrency: int = 1,
|
|
145
|
+
auto_complete: bool = True,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Imperative alternative to ``@task``."""
|
|
148
|
+
self.task(name, concurrency=concurrency, auto_complete=auto_complete)(handler)
|
|
149
|
+
|
|
150
|
+
def complete_queue_item(
|
|
151
|
+
self,
|
|
152
|
+
queue_item_id: uuid.UUID | str,
|
|
153
|
+
*,
|
|
154
|
+
output_data: Any = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Mark a queue item ENDED.
|
|
157
|
+
|
|
158
|
+
Use after ``auto_complete=False`` when work finished successfully. Uses the
|
|
159
|
+
same ``ContinuumClient`` (and API key) as this ``TaskServer``.
|
|
160
|
+
"""
|
|
161
|
+
qid = self._normalize_queue_id(queue_item_id)
|
|
162
|
+
self._safe_update_status_by_id(qid, TaskStatus.ENDED, output=output_data)
|
|
163
|
+
|
|
164
|
+
def fail_queue_item(
|
|
165
|
+
self,
|
|
166
|
+
queue_item_id: uuid.UUID | str,
|
|
167
|
+
*,
|
|
168
|
+
error: BaseException | str | dict[str, Any] | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Mark a queue item KILLED."""
|
|
171
|
+
qid = self._normalize_queue_id(queue_item_id)
|
|
172
|
+
payload: dict[str, Any]
|
|
173
|
+
if isinstance(error, dict):
|
|
174
|
+
payload = dict(error)
|
|
175
|
+
elif isinstance(error, str):
|
|
176
|
+
payload = {"error": error}
|
|
177
|
+
elif isinstance(error, BaseException):
|
|
178
|
+
payload = {
|
|
179
|
+
"error": str(error),
|
|
180
|
+
"type": type(error).__name__,
|
|
181
|
+
"traceback": "".join(
|
|
182
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
183
|
+
),
|
|
184
|
+
}
|
|
185
|
+
else:
|
|
186
|
+
payload = {"error": "failed"}
|
|
187
|
+
self._safe_update_status_by_id(qid, TaskStatus.KILLED, output=payload)
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _normalize_queue_id(queue_item_id: uuid.UUID | str) -> uuid.UUID:
|
|
191
|
+
if isinstance(queue_item_id, uuid.UUID):
|
|
192
|
+
return queue_item_id
|
|
193
|
+
return uuid.UUID(str(queue_item_id))
|
|
194
|
+
|
|
195
|
+
def run(self, *, install_signal_handlers: bool = True) -> None:
|
|
196
|
+
"""Block and poll until ``stop()`` is called or a shutdown signal arrives."""
|
|
197
|
+
if not self._handlers:
|
|
198
|
+
raise RuntimeError("no task handlers registered; use @server.task(...) first")
|
|
199
|
+
|
|
200
|
+
if install_signal_handlers:
|
|
201
|
+
self._install_signal_handlers()
|
|
202
|
+
|
|
203
|
+
self._stop_event.clear()
|
|
204
|
+
self._executor = ThreadPoolExecutor(max_workers=self._max_workers)
|
|
205
|
+
|
|
206
|
+
logger.info(
|
|
207
|
+
"Continuum task server started: tasks=%s, max_workers=%d",
|
|
208
|
+
list(self._handlers),
|
|
209
|
+
self._max_workers,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
self._poll_loop()
|
|
214
|
+
finally:
|
|
215
|
+
self._drain()
|
|
216
|
+
|
|
217
|
+
def stop(self) -> None:
|
|
218
|
+
"""Signal the server to stop polling and drain in-flight handlers."""
|
|
219
|
+
if not self._stop_event.is_set():
|
|
220
|
+
logger.info("Continuum task server shutdown requested")
|
|
221
|
+
self._stop_event.set()
|
|
222
|
+
|
|
223
|
+
def _install_signal_handlers(self) -> None:
|
|
224
|
+
def _handler(signum: int, _frame: object) -> None:
|
|
225
|
+
logger.info("Received signal %d, stopping", signum)
|
|
226
|
+
self.stop()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
signal.signal(signal.SIGINT, _handler)
|
|
230
|
+
signal.signal(signal.SIGTERM, _handler)
|
|
231
|
+
except ValueError:
|
|
232
|
+
logger.debug("signal handlers not installed (not on main thread)")
|
|
233
|
+
|
|
234
|
+
def _poll_loop(self) -> None:
|
|
235
|
+
names = list(self._handlers)
|
|
236
|
+
index = 0
|
|
237
|
+
backoff = self._poll_interval
|
|
238
|
+
|
|
239
|
+
while not self._stop_event.is_set():
|
|
240
|
+
registration = self._handlers[names[index % len(names)]]
|
|
241
|
+
index += 1
|
|
242
|
+
|
|
243
|
+
if not registration.semaphore.acquire(blocking=False):
|
|
244
|
+
if index % len(names) == 0:
|
|
245
|
+
self._stop_event.wait(timeout=self._poll_interval)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
item = self._client.queue.claim(registration.name)
|
|
250
|
+
except ContinuumError as e:
|
|
251
|
+
registration.semaphore.release()
|
|
252
|
+
logger.error("claim(%s) failed: %s", registration.name, e)
|
|
253
|
+
self._stop_event.wait(timeout=min(backoff, self._max_poll_interval))
|
|
254
|
+
backoff = min(backoff * 2, self._max_poll_interval)
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
if item is None:
|
|
258
|
+
registration.semaphore.release()
|
|
259
|
+
if index % len(names) == 0:
|
|
260
|
+
self._stop_event.wait(timeout=backoff)
|
|
261
|
+
backoff = min(backoff * 2, self._max_poll_interval)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
backoff = self._poll_interval
|
|
265
|
+
self._dispatch(registration, item)
|
|
266
|
+
|
|
267
|
+
def _dispatch(self, registration: _Registration, item: QueueItem) -> None:
|
|
268
|
+
assert self._executor is not None
|
|
269
|
+
future = self._executor.submit(self._run_item, registration, item)
|
|
270
|
+
with self._inflight_lock:
|
|
271
|
+
self._inflight.add(future)
|
|
272
|
+
future.add_done_callback(self._on_done)
|
|
273
|
+
|
|
274
|
+
def _on_done(self, future: Future[Any]) -> None:
|
|
275
|
+
with self._inflight_lock:
|
|
276
|
+
self._inflight.discard(future)
|
|
277
|
+
|
|
278
|
+
def _run_item(self, registration: _Registration, item: QueueItem) -> None:
|
|
279
|
+
try:
|
|
280
|
+
logger.info("Claimed queue item %s (task=%s)", item.id, registration.name)
|
|
281
|
+
stop_heartbeat = threading.Event()
|
|
282
|
+
heartbeat_thread = threading.Thread(
|
|
283
|
+
target=self._heartbeat_loop,
|
|
284
|
+
args=(item, stop_heartbeat),
|
|
285
|
+
name=f"hb-{item.id}",
|
|
286
|
+
daemon=True,
|
|
287
|
+
)
|
|
288
|
+
heartbeat_thread.start()
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
self._safe_update_status(item, TaskStatus.STARTED)
|
|
292
|
+
try:
|
|
293
|
+
result = registration.handler(item)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.exception("Handler raised for queue item %s", item.id)
|
|
296
|
+
error_payload = {
|
|
297
|
+
"error": str(e),
|
|
298
|
+
"type": type(e).__name__,
|
|
299
|
+
"traceback": traceback.format_exc(),
|
|
300
|
+
}
|
|
301
|
+
self._safe_update_status(item, TaskStatus.KILLED, output=error_payload)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
if registration.auto_complete:
|
|
305
|
+
self._safe_update_status(item, TaskStatus.ENDED, output=result)
|
|
306
|
+
logger.info("Completed queue item %s", item.id)
|
|
307
|
+
else:
|
|
308
|
+
logger.info(
|
|
309
|
+
"Handler returned for queue item %s without ENDED "
|
|
310
|
+
"(auto_complete=False); caller must heartbeat and complete",
|
|
311
|
+
item.id,
|
|
312
|
+
)
|
|
313
|
+
finally:
|
|
314
|
+
stop_heartbeat.set()
|
|
315
|
+
heartbeat_thread.join(timeout=5.0)
|
|
316
|
+
finally:
|
|
317
|
+
registration.semaphore.release()
|
|
318
|
+
|
|
319
|
+
def _heartbeat_loop(self, item: QueueItem, stop_event: threading.Event) -> None:
|
|
320
|
+
while not stop_event.wait(timeout=self._heartbeat_interval):
|
|
321
|
+
try:
|
|
322
|
+
self._client.queue.heartbeat(item.id)
|
|
323
|
+
except ContinuumError as e:
|
|
324
|
+
logger.warning("heartbeat for %s failed: %s", item.id, e)
|
|
325
|
+
|
|
326
|
+
def _safe_update_status(
|
|
327
|
+
self,
|
|
328
|
+
item: QueueItem,
|
|
329
|
+
status: TaskStatus,
|
|
330
|
+
*,
|
|
331
|
+
output: Any = None,
|
|
332
|
+
) -> None:
|
|
333
|
+
self._safe_update_status_by_id(item.id, status, output=output)
|
|
334
|
+
|
|
335
|
+
def _safe_update_status_by_id(
|
|
336
|
+
self,
|
|
337
|
+
queue_item_id: uuid.UUID,
|
|
338
|
+
status: TaskStatus,
|
|
339
|
+
*,
|
|
340
|
+
output: Any = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
try:
|
|
343
|
+
self._client.queue.update_status(queue_item_id, status, output_data=output)
|
|
344
|
+
except ContinuumError as e:
|
|
345
|
+
logger.error("update_status(%s, %s) failed: %s", queue_item_id, status.value, e)
|
|
346
|
+
|
|
347
|
+
def _drain(self) -> None:
|
|
348
|
+
if self._executor is None:
|
|
349
|
+
return
|
|
350
|
+
logger.info("Draining in-flight tasks (timeout=%.1fs)", self._shutdown_timeout)
|
|
351
|
+
deadline = time.monotonic() + self._shutdown_timeout
|
|
352
|
+
with self._inflight_lock:
|
|
353
|
+
futures = list(self._inflight)
|
|
354
|
+
for future in futures:
|
|
355
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
356
|
+
try:
|
|
357
|
+
future.result(timeout=remaining)
|
|
358
|
+
except Exception:
|
|
359
|
+
logger.debug("drain: handler future raised", exc_info=True)
|
|
360
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
361
|
+
self._executor = None
|
|
362
|
+
if self._owns_client:
|
|
363
|
+
self._client.close()
|
|
364
|
+
logger.info("Continuum task server stopped")
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: continuum-task-server-sdk
|
|
3
|
+
Version: 0.0.9
|
|
4
|
+
Summary: Python SDK for the Continuum Task Server
|
|
5
|
+
Project-URL: Homepage, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python
|
|
6
|
+
Project-URL: Issues, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python/issues
|
|
7
|
+
Author: Continuum
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: continuum,queue,sdk,task,worker
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
21
|
+
Requires-Dist: pydantic<3.0,>=2.6
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Continuum Task Server SDK for Python
|
|
30
|
+
|
|
31
|
+
Python client for the [Continuum](https://github.com/ContinuumWorkflow) task server. Designed so a 20-line script can stand up a worker that claims queue items, runs your code, and reports results.
|
|
32
|
+
|
|
33
|
+
- **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown. Long-running claims (`auto_complete=False`) need **your** heartbeat loop (documented below).
|
|
34
|
+
- **`ContinuumClient`** — thin pythonic client over the management + queue REST APIs (task types, task items, versions, queue, content store).
|
|
35
|
+
|
|
36
|
+
Requires Python 3.10+.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Tagged releases (`v*`) are published to [PyPI](https://pypi.org/project/continuum-task-server-sdk/):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install continuum-task-server-sdk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Dev and PR builds are still attached to private GitHub Releases. Install those with a token:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install \
|
|
50
|
+
"https://${GITHUB_TOKEN}@github.com/ContinuumWorkflow/continuum-task-server-sdk-python/releases/download/v0.1.0/continuum_task_server_sdk-0.1.0-py3-none-any.whl"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`GITHUB_TOKEN` must be a PAT with repo read access.
|
|
54
|
+
|
|
55
|
+
## Quickstart: build a task server in 20 lines
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import os
|
|
59
|
+
from continuum_task_server import TaskServer
|
|
60
|
+
|
|
61
|
+
server = TaskServer(
|
|
62
|
+
base_url=os.environ["CONTINUUM_URL"],
|
|
63
|
+
api_key=os.environ["CONTINUUM_API_KEY"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@server.task("echo")
|
|
67
|
+
def echo(item):
|
|
68
|
+
return {"echoed": item.input_data_json}
|
|
69
|
+
|
|
70
|
+
@server.task("greet")
|
|
71
|
+
def greet(item):
|
|
72
|
+
name = (item.input_data_json or {}).get("name", "world")
|
|
73
|
+
return {"message": f"hello, {name}"}
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
server.run()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Run it. The server polls `/api/queue/claim` for `echo` and `greet`, claims items as they become available, runs your handler in a thread, heartbeats while it runs, and:
|
|
80
|
+
|
|
81
|
+
- **Handler returns** a value → queue item marked `ENDED`, return value JSON-encoded as `outputData` (default `auto_complete=True`).
|
|
82
|
+
- **Handler raises** → queue item marked `KILLED`, error info written to `outputData`.
|
|
83
|
+
|
|
84
|
+
Press `Ctrl+C` (or send `SIGTERM`) and the server stops polling and waits up to `shutdown_timeout` seconds for in-flight handlers to finish.
|
|
85
|
+
|
|
86
|
+
### Deferred completion (`auto_complete=False`)
|
|
87
|
+
|
|
88
|
+
The handler runs under the normal **in-handler** heartbeat (same as any task). When it returns, the server **does not** send `ENDED` and **does not** keep heartbeating: you own the claim until you finish or lose it to timeouts.
|
|
89
|
+
|
|
90
|
+
Typical pattern: persist `item.id` (and anything else you need), then on each pass of **your** poller (cron, loop, worker restart), call **`server.client.queue.heartbeat(queue_item_id)`** so the Continuum claim stays alive, and when the real-world condition is met call **`server.complete_queue_item(...)`** or **`server.fail_queue_item(...)`**. Use the **same worker API key** as the process that claimed the item (often the same `TaskServer` / `ContinuumClient` config loaded from env).
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
@server.task("wait-for-mail", auto_complete=False)
|
|
94
|
+
def wait_for_mail(item):
|
|
95
|
+
db.insert_outstanding(queue_item_id=str(item.id), payload=item.input_data_json)
|
|
96
|
+
# Returns without ENDED — no background heartbeat from TaskServer
|
|
97
|
+
|
|
98
|
+
# Elsewhere: each time you poll your DB for outstanding work (including after restart):
|
|
99
|
+
for row in db.outstanding_rows():
|
|
100
|
+
server.client.queue.heartbeat(row.queue_item_id)
|
|
101
|
+
if mail_arrived(row):
|
|
102
|
+
server.complete_queue_item(row.queue_item_id, output_data={"received": True})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker key and call `client.queue.heartbeat` / `client.queue.update_status` the same way.
|
|
106
|
+
|
|
107
|
+
### TaskServer options
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
TaskServer(
|
|
111
|
+
base_url="http://localhost:8080",
|
|
112
|
+
api_key="...",
|
|
113
|
+
max_workers=4, # thread pool size across all tasks
|
|
114
|
+
poll_interval=1.0, # initial poll delay (backs off when idle)
|
|
115
|
+
max_poll_interval=5.0, # max idle poll delay
|
|
116
|
+
heartbeat_interval=15.0, # how often to call /heartbeat per running task
|
|
117
|
+
shutdown_timeout=30.0, # how long to wait for handlers during shutdown
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Per-task concurrency limit:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@server.task("docker-run", concurrency=2)
|
|
125
|
+
def run_docker(item):
|
|
126
|
+
...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Handler signature: `def handler(item: QueueItem) -> dict | list | str | None`. Inside, you have:
|
|
130
|
+
|
|
131
|
+
- `item.input_data` — raw JSON string from the queue item (or `None`).
|
|
132
|
+
- `item.input_data_json` — parsed value (`dict` / `list` / `str` / `None`).
|
|
133
|
+
- `server.client` — full `ContinuumClient` if you need to chain management calls, fetch content, enqueue child tasks, etc.
|
|
134
|
+
|
|
135
|
+
## Using `ContinuumClient` directly
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from continuum_task_server import ContinuumClient, TaskStatus
|
|
139
|
+
|
|
140
|
+
with ContinuumClient(base_url="http://localhost:8080", api_key="...") as client:
|
|
141
|
+
# Task types
|
|
142
|
+
types = client.task_types.list()
|
|
143
|
+
echo_type = client.task_types.get_by_name("echo")
|
|
144
|
+
|
|
145
|
+
# Task items + versions
|
|
146
|
+
item = client.task_items.get_by_name("my-task")
|
|
147
|
+
versions = client.task_items.versions.list(item.id)
|
|
148
|
+
|
|
149
|
+
# Enqueue work
|
|
150
|
+
queued = client.queue.add(task_name="echo", input_data={"hello": "world"})
|
|
151
|
+
|
|
152
|
+
# Worker-side primitives (normally handled by TaskServer)
|
|
153
|
+
claimed = client.queue.claim("echo")
|
|
154
|
+
if claimed is not None:
|
|
155
|
+
client.queue.heartbeat(claimed.id)
|
|
156
|
+
client.queue.update_status(claimed.id, TaskStatus.ENDED, output_data={"ok": True})
|
|
157
|
+
|
|
158
|
+
# Content store
|
|
159
|
+
content = client.content_store.get_by_url("db://...")
|
|
160
|
+
if content is not None:
|
|
161
|
+
print(content.as_text())
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`input_data` / `output_data` accept `dict` / `list` / `str` / `None`; non-string values are JSON-encoded for you.
|
|
165
|
+
|
|
166
|
+
### Errors
|
|
167
|
+
|
|
168
|
+
All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`. Each carries `status_code` and `body`.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from continuum_task_server import ContinuumClient, NotFoundError
|
|
172
|
+
|
|
173
|
+
with ContinuumClient(...) as client:
|
|
174
|
+
try:
|
|
175
|
+
client.task_types.get_by_name("does-not-exist")
|
|
176
|
+
except NotFoundError as e:
|
|
177
|
+
print(e.status_code, e.body)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Endpoints covered
|
|
181
|
+
|
|
182
|
+
| Group | Method | Path |
|
|
183
|
+
| --- | --- | --- |
|
|
184
|
+
| Task Types | `GET`/`POST` | `/api/management/task-types[/{id}\|/by-name]` |
|
|
185
|
+
| Task Items | `GET`/`POST` | `/api/management/task-items[/{id}\|/by-name\|/{id}/publish]` |
|
|
186
|
+
| Task Item Versions | `GET`/`POST`/`PATCH` | `/api/management/task-items/{id}/versions[...]` |
|
|
187
|
+
| Queue (management) | `GET`/`POST` | `/api/management/queue-items[/{id}]` |
|
|
188
|
+
| Queue (worker) | `POST` | `/api/queue/claim`, `/api/queue/queue-items/{id}/heartbeat`, `/api/queue/queue-items/{id}/status` |
|
|
189
|
+
| Queue content | `GET` | `/api/queue/queue-items/{id}/content` |
|
|
190
|
+
| Content store | `GET` | `/api/management/content-store[/{id}\|?url=db://...]` |
|
|
191
|
+
|
|
192
|
+
All requests send `Api-Key: <your-key>`. `204 No Content` responses are normalized to `None` (e.g. `client.queue.claim()` returns `None` when nothing's available).
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
python -m venv .venv && source .venv/bin/activate
|
|
198
|
+
pip install -e ".[dev]"
|
|
199
|
+
|
|
200
|
+
ruff check .
|
|
201
|
+
ruff format --check .
|
|
202
|
+
pytest
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport — no live server required.
|
|
206
|
+
|
|
207
|
+
## Versioning
|
|
208
|
+
|
|
209
|
+
- **Tag `v1.2.3`** → release wheel `1.2.3` attached to a `v1.2.3` GitHub Release.
|
|
210
|
+
- **Push to `main`** → prerelease wheel `0.1.0.dev{run}+{shortsha}` attached to a `dev-{shortsha}` Release.
|
|
211
|
+
- **Pull request** → prerelease wheel `0.1.0b{pr}.{run}` attached to a `pr-{pr}` Release; install command posted as a PR comment.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
continuum_task_server/__init__.py,sha256=skwstGRatClptyxyDNtwOJbtqEmqs7XJgQ50nrANnwo,740
|
|
2
|
+
continuum_task_server/_http.py,sha256=feeOeqCUAwDASEfH3RS6mtEysHPkmj_6mtNCyGo3J9o,4776
|
|
3
|
+
continuum_task_server/client.py,sha256=AaLlFasKFBlgQn3GDOvnd2SiouxL_lBBMdbzRg1yKVk,11573
|
|
4
|
+
continuum_task_server/exceptions.py,sha256=BFnmKX_qAqFaw-4Fd7qVGFN2bubrFqVeYSYbtCNEuec,1666
|
|
5
|
+
continuum_task_server/models.py,sha256=6JEABBRG_F6vhbEkX02zgI4R7Tt7O0ICZLurnxH-ZXI,4009
|
|
6
|
+
continuum_task_server/server.py,sha256=tfPlVUbsMUX0GpvZbnZN71N1N24buttsGEf2nd1DTBY,13242
|
|
7
|
+
continuum_task_server_sdk-0.0.9.dist-info/METADATA,sha256=1YJ5thdNqJrb1A5-RCObCTFvvDxYM-gm7vkXoqxh2Vs,8762
|
|
8
|
+
continuum_task_server_sdk-0.0.9.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
continuum_task_server_sdk-0.0.9.dist-info/RECORD,,
|