continuum-task-server-sdk 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- continuum_task_server/__init__.py +74 -0
- continuum_task_server/_http.py +142 -0
- continuum_task_server/_stomp.py +137 -0
- continuum_task_server/client.py +351 -0
- continuum_task_server/exceptions.py +94 -0
- continuum_task_server/models.py +197 -0
- continuum_task_server/server.py +511 -0
- continuum_task_server/websocket.py +852 -0
- continuum_task_server_sdk-1.1.0.dist-info/METADATA +258 -0
- continuum_task_server_sdk-1.1.0.dist-info/RECORD +11 -0
- continuum_task_server_sdk-1.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Continuum Task Server SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import ContinuumClient
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
AmbiguousCommandError,
|
|
8
|
+
BadRequestError,
|
|
9
|
+
CommandTimeoutError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
ContinuumError,
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
ProtocolError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
TransportError,
|
|
18
|
+
UnauthorizedError,
|
|
19
|
+
)
|
|
20
|
+
from .models import (
|
|
21
|
+
Content,
|
|
22
|
+
EnqueueAndSubscribeWaitResult,
|
|
23
|
+
EventAckResult,
|
|
24
|
+
QueueEvent,
|
|
25
|
+
QueueEventType,
|
|
26
|
+
QueueItem,
|
|
27
|
+
TaskItem,
|
|
28
|
+
TaskItemVersion,
|
|
29
|
+
TaskStatus,
|
|
30
|
+
TaskType,
|
|
31
|
+
TransportMode,
|
|
32
|
+
WaitMode,
|
|
33
|
+
WaitSubscriptionResult,
|
|
34
|
+
WaitTargetStatus,
|
|
35
|
+
WorkSubscriptionResult,
|
|
36
|
+
)
|
|
37
|
+
from .server import TaskServer
|
|
38
|
+
from .websocket import ContinuumWebSocketClient, WebSocketOptions
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AmbiguousCommandError",
|
|
42
|
+
"BadRequestError",
|
|
43
|
+
"CommandTimeoutError",
|
|
44
|
+
"ConflictError",
|
|
45
|
+
"Content",
|
|
46
|
+
"ContinuumClient",
|
|
47
|
+
"ContinuumError",
|
|
48
|
+
"ContinuumWebSocketClient",
|
|
49
|
+
"EnqueueAndSubscribeWaitResult",
|
|
50
|
+
"EventAckResult",
|
|
51
|
+
"ForbiddenError",
|
|
52
|
+
"NotFoundError",
|
|
53
|
+
"ProtocolError",
|
|
54
|
+
"QueueEvent",
|
|
55
|
+
"QueueEventType",
|
|
56
|
+
"QueueItem",
|
|
57
|
+
"RateLimitError",
|
|
58
|
+
"ServerError",
|
|
59
|
+
"TaskItem",
|
|
60
|
+
"TaskItemVersion",
|
|
61
|
+
"TaskServer",
|
|
62
|
+
"TaskStatus",
|
|
63
|
+
"TaskType",
|
|
64
|
+
"TransportError",
|
|
65
|
+
"TransportMode",
|
|
66
|
+
"UnauthorizedError",
|
|
67
|
+
"WaitMode",
|
|
68
|
+
"WaitSubscriptionResult",
|
|
69
|
+
"WaitTargetStatus",
|
|
70
|
+
"WebSocketOptions",
|
|
71
|
+
"WorkSubscriptionResult",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
__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,137 @@
|
|
|
1
|
+
"""STOMP 1.2 frame codec. Internal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
NULL = "\x00"
|
|
8
|
+
LF = "\n"
|
|
9
|
+
|
|
10
|
+
_ESCAPE = {
|
|
11
|
+
"\\": "\\\\",
|
|
12
|
+
"\r": "\\r",
|
|
13
|
+
"\n": "\\n",
|
|
14
|
+
":": "\\c",
|
|
15
|
+
}
|
|
16
|
+
_UNESCAPE = {
|
|
17
|
+
"\\\\": "\\",
|
|
18
|
+
"\\r": "\r",
|
|
19
|
+
"\\n": "\n",
|
|
20
|
+
"\\c": ":",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class StompFrame:
|
|
26
|
+
command: str
|
|
27
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
body: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def encode_frame(frame: StompFrame) -> str:
|
|
32
|
+
"""Serialize a STOMP frame including the terminating NUL."""
|
|
33
|
+
lines = [frame.command]
|
|
34
|
+
headers = dict(frame.headers)
|
|
35
|
+
body = frame.body or ""
|
|
36
|
+
if body and "content-length" not in {k.lower() for k in headers}:
|
|
37
|
+
headers["content-length"] = str(len(body.encode("utf-8")))
|
|
38
|
+
for key, value in headers.items():
|
|
39
|
+
lines.append(f"{_escape(key)}:{_escape(value)}")
|
|
40
|
+
return LF.join(lines) + LF + LF + body + NULL
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encode_heartbeat() -> str:
|
|
44
|
+
return LF
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _escape(text: str) -> str:
|
|
48
|
+
out: list[str] = []
|
|
49
|
+
for ch in text:
|
|
50
|
+
out.append(_ESCAPE.get(ch, ch))
|
|
51
|
+
return "".join(out)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _unescape(text: str) -> str:
|
|
55
|
+
out: list[str] = []
|
|
56
|
+
i = 0
|
|
57
|
+
while i < len(text):
|
|
58
|
+
if text[i] == "\\" and i + 1 < len(text):
|
|
59
|
+
pair = text[i : i + 2]
|
|
60
|
+
if pair in _UNESCAPE:
|
|
61
|
+
out.append(_UNESCAPE[pair])
|
|
62
|
+
i += 2
|
|
63
|
+
continue
|
|
64
|
+
out.append(text[i])
|
|
65
|
+
i += 1
|
|
66
|
+
return "".join(out)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class StompDecoder:
|
|
70
|
+
"""Incremental decoder for one or more STOMP frames, including heartbeats."""
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._buffer = ""
|
|
74
|
+
|
|
75
|
+
def feed(self, data: str | bytes) -> list[StompFrame | None]:
|
|
76
|
+
"""Return complete frames. ``None`` entries are heartbeat newlines."""
|
|
77
|
+
if isinstance(data, bytes):
|
|
78
|
+
chunk = data.decode("utf-8")
|
|
79
|
+
else:
|
|
80
|
+
chunk = data
|
|
81
|
+
self._buffer += chunk
|
|
82
|
+
frames: list[StompFrame | None] = []
|
|
83
|
+
while self._buffer:
|
|
84
|
+
if self._buffer[0] in "\r\n":
|
|
85
|
+
if self._buffer.startswith("\r\n"):
|
|
86
|
+
self._buffer = self._buffer[2:]
|
|
87
|
+
else:
|
|
88
|
+
self._buffer = self._buffer[1:]
|
|
89
|
+
frames.append(None)
|
|
90
|
+
continue
|
|
91
|
+
nul = self._buffer.find(NULL)
|
|
92
|
+
if nul < 0:
|
|
93
|
+
break
|
|
94
|
+
raw = self._buffer[:nul]
|
|
95
|
+
self._buffer = self._buffer[nul + 1 :]
|
|
96
|
+
if raw.startswith("\r\n"):
|
|
97
|
+
raw = raw[2:]
|
|
98
|
+
elif raw.startswith("\n"):
|
|
99
|
+
raw = raw[1:]
|
|
100
|
+
if raw == "":
|
|
101
|
+
frames.append(None)
|
|
102
|
+
continue
|
|
103
|
+
frames.append(_parse_frame(raw))
|
|
104
|
+
return frames
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_frame(raw: str) -> StompFrame:
|
|
108
|
+
header_end = raw.find("\n\n")
|
|
109
|
+
if header_end < 0:
|
|
110
|
+
header_block = raw
|
|
111
|
+
body = ""
|
|
112
|
+
else:
|
|
113
|
+
header_block = raw[:header_end]
|
|
114
|
+
body = raw[header_end + 2 :]
|
|
115
|
+
lines = header_block.split("\n")
|
|
116
|
+
command = lines[0].strip()
|
|
117
|
+
headers: dict[str, str] = {}
|
|
118
|
+
content_length: int | None = None
|
|
119
|
+
for line in lines[1:]:
|
|
120
|
+
if not line:
|
|
121
|
+
continue
|
|
122
|
+
idx = line.find(":")
|
|
123
|
+
if idx < 0:
|
|
124
|
+
continue
|
|
125
|
+
key = _unescape(line[:idx])
|
|
126
|
+
value = _unescape(line[idx + 1 :])
|
|
127
|
+
if key.lower() == "content-length":
|
|
128
|
+
try:
|
|
129
|
+
content_length = int(value)
|
|
130
|
+
except ValueError:
|
|
131
|
+
content_length = None
|
|
132
|
+
if key not in headers:
|
|
133
|
+
headers[key] = value
|
|
134
|
+
if content_length is not None:
|
|
135
|
+
encoded = body.encode("utf-8")
|
|
136
|
+
body = encoded[:content_length].decode("utf-8")
|
|
137
|
+
return StompFrame(command=command, headers=headers, body=body)
|
|
@@ -0,0 +1,351 @@
|
|
|
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.post_json(f"{self._PATH}/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
|
+
idempotency_token: str | None = None,
|
|
234
|
+
) -> QueueItem:
|
|
235
|
+
"""Add a queue item. At least one of task_name or task_item_name is required."""
|
|
236
|
+
if task_name is None and task_item_name is None:
|
|
237
|
+
raise ValueError("at least one of task_name or task_item_name is required")
|
|
238
|
+
body = drop_none(
|
|
239
|
+
{
|
|
240
|
+
"parent_id": str(parent_id) if parent_id else None,
|
|
241
|
+
"taskName": task_name,
|
|
242
|
+
"taskItemName": task_item_name,
|
|
243
|
+
"priority": priority,
|
|
244
|
+
"inputData": _encode_input(input_data),
|
|
245
|
+
"idempotencyToken": idempotency_token,
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
return QueueItem.model_validate(self._http.post_json(self._MGMT, body))
|
|
249
|
+
|
|
250
|
+
def get_by_id(self, queue_item_id: UUID | str) -> QueueItem:
|
|
251
|
+
return QueueItem.model_validate(self._http.get_json(f"{self._MGMT}/{queue_item_id}"))
|
|
252
|
+
|
|
253
|
+
def get_content(self, queue_item_id: UUID | str) -> Content | None:
|
|
254
|
+
result = self._http.get_bytes(f"{self._QUEUE}/queue-items/{queue_item_id}/content")
|
|
255
|
+
if result is None:
|
|
256
|
+
return None
|
|
257
|
+
data, mime = result
|
|
258
|
+
return Content(data=data, mime_type=mime)
|
|
259
|
+
|
|
260
|
+
def claim(self, task_name: str) -> QueueItem | None:
|
|
261
|
+
"""Claim one OPEN queue item for the given task name. Returns None if none available."""
|
|
262
|
+
result = self._http.post_optional(f"{self._QUEUE}/claim", {"taskName": task_name})
|
|
263
|
+
if result is None:
|
|
264
|
+
return None
|
|
265
|
+
return QueueItem.model_validate(result)
|
|
266
|
+
|
|
267
|
+
def heartbeat(self, queue_item_id: UUID | str) -> QueueItem:
|
|
268
|
+
return QueueItem.model_validate(
|
|
269
|
+
self._http.post_json(f"{self._QUEUE}/queue-items/{queue_item_id}/heartbeat", {})
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def update_status(
|
|
273
|
+
self,
|
|
274
|
+
queue_item_id: UUID | str,
|
|
275
|
+
status: TaskStatus,
|
|
276
|
+
*,
|
|
277
|
+
output_data: Any = None,
|
|
278
|
+
) -> QueueItem:
|
|
279
|
+
# Server expects `status` on this endpoint (not `taskStatus` on queue item JSON).
|
|
280
|
+
body: dict[str, Any] = {"status": status.value}
|
|
281
|
+
encoded = _encode_input(output_data)
|
|
282
|
+
if encoded is not None:
|
|
283
|
+
body["outputData"] = encoded
|
|
284
|
+
return QueueItem.model_validate(
|
|
285
|
+
self._http.post_json(f"{self._QUEUE}/queue-items/{queue_item_id}/status", body)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ContentStoreApi:
|
|
290
|
+
_PATH = "/api/management/content-store"
|
|
291
|
+
|
|
292
|
+
def __init__(self, http: HttpClient) -> None:
|
|
293
|
+
self._http = http
|
|
294
|
+
|
|
295
|
+
def get_by_id(self, content_id: UUID | str) -> Content | None:
|
|
296
|
+
result = self._http.get_bytes(f"{self._PATH}/{content_id}")
|
|
297
|
+
if result is None:
|
|
298
|
+
return None
|
|
299
|
+
data, mime = result
|
|
300
|
+
return Content(data=data, mime_type=mime)
|
|
301
|
+
|
|
302
|
+
def get_by_url(self, content_url: str) -> Content | None:
|
|
303
|
+
result = self._http.get_bytes(f"{self._PATH}?url={quote(content_url, safe='')}")
|
|
304
|
+
if result is None:
|
|
305
|
+
return None
|
|
306
|
+
data, mime = result
|
|
307
|
+
return Content(data=data, mime_type=mime)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class ContinuumClient:
|
|
311
|
+
"""Client for the Continuum Task Server.
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
>>> client = ContinuumClient(base_url="http://localhost:8080", api_key="...")
|
|
315
|
+
>>> task_types = client.task_types.list()
|
|
316
|
+
>>> item = client.queue.claim("echo")
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self,
|
|
321
|
+
base_url: str,
|
|
322
|
+
api_key: str,
|
|
323
|
+
*,
|
|
324
|
+
connect_timeout: float = 10.0,
|
|
325
|
+
request_timeout: float = 30.0,
|
|
326
|
+
transport: httpx.BaseTransport | None = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
self._http = HttpClient(
|
|
329
|
+
base_url=base_url,
|
|
330
|
+
api_key=api_key,
|
|
331
|
+
connect_timeout=connect_timeout,
|
|
332
|
+
request_timeout=request_timeout,
|
|
333
|
+
transport=transport,
|
|
334
|
+
)
|
|
335
|
+
self.task_types = TaskTypesApi(self._http)
|
|
336
|
+
self.task_items = TaskItemsApi(self._http)
|
|
337
|
+
self.queue = QueueApi(self._http)
|
|
338
|
+
self.content_store = ContentStoreApi(self._http)
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def base_url(self) -> str:
|
|
342
|
+
return self._http.base_url
|
|
343
|
+
|
|
344
|
+
def close(self) -> None:
|
|
345
|
+
self._http.close()
|
|
346
|
+
|
|
347
|
+
def __enter__(self) -> ContinuumClient:
|
|
348
|
+
return self
|
|
349
|
+
|
|
350
|
+
def __exit__(self, *exc: object) -> None:
|
|
351
|
+
self.close()
|