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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any