continuum-task-server-sdk 0.0.9__tar.gz → 0.1.0__tar.gz
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_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/PKG-INFO +54 -11
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/README.md +51 -9
- continuum_task_server_sdk-0.1.0/continuum_task_server/__init__.py +74 -0
- continuum_task_server_sdk-0.1.0/continuum_task_server/_stomp.py +137 -0
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/continuum_task_server/client.py +1 -1
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/continuum_task_server/exceptions.py +27 -2
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/continuum_task_server/models.py +84 -0
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/continuum_task_server/server.py +151 -8
- continuum_task_server_sdk-0.1.0/continuum_task_server/websocket.py +848 -0
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/pyproject.toml +2 -1
- continuum_task_server_sdk-0.0.9/continuum_task_server/__init__.py +0 -36
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/.gitignore +0 -0
- {continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/continuum_task_server/_http.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: continuum-task-server-sdk
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Python SDK for the Continuum Task Server
|
|
5
5
|
Project-URL: Homepage, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python
|
|
6
6
|
Project-URL: Issues, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python/issues
|
|
@@ -19,6 +19,7 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Requires-Dist: httpx<1.0,>=0.27
|
|
21
21
|
Requires-Dist: pydantic<3.0,>=2.6
|
|
22
|
+
Requires-Dist: websocket-client<2.0,>=1.8
|
|
22
23
|
Provides-Extra: dev
|
|
23
24
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
24
25
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -30,8 +31,9 @@ Description-Content-Type: text/markdown
|
|
|
30
31
|
|
|
31
32
|
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
|
|
|
33
|
-
- **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown.
|
|
34
|
+
- **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown. Defaults to HTTP polling; set ``transport="websocket"`` (or ``CONTINUUM_TRANSPORT=websocket`` in the example) to claim from STOMP ``work.available`` hints instead.
|
|
34
35
|
- **`ContinuumClient`** — thin pythonic client over the management + queue REST APIs (task types, task items, versions, queue, content store).
|
|
36
|
+
- **`ContinuumWebSocketClient`** — synchronous STOMP-over-WebSocket client for the worker hot path (enqueue, claim, heartbeat, status, ``subscribeWork``, durable ``subscribeWait`` / ``ackEvent``).
|
|
35
37
|
|
|
36
38
|
Requires Python 3.10+.
|
|
37
39
|
|
|
@@ -63,19 +65,24 @@ server = TaskServer(
|
|
|
63
65
|
api_key=os.environ["CONTINUUM_API_KEY"],
|
|
64
66
|
)
|
|
65
67
|
|
|
68
|
+
|
|
66
69
|
@server.task("echo")
|
|
67
70
|
def echo(item):
|
|
68
71
|
return {"echoed": item.input_data_json}
|
|
69
72
|
|
|
73
|
+
|
|
70
74
|
@server.task("greet")
|
|
71
75
|
def greet(item):
|
|
72
76
|
name = (item.input_data_json or {}).get("name", "world")
|
|
73
77
|
return {"message": f"hello, {name}"}
|
|
74
78
|
|
|
79
|
+
|
|
75
80
|
if __name__ == "__main__":
|
|
76
81
|
server.run()
|
|
77
82
|
```
|
|
78
83
|
|
|
84
|
+
Set `CONTINUUM_TRANSPORT=websocket` (and enable Queue API WebSockets) to replace polling with STOMP wake-ups. HTTP polling remains the default.
|
|
85
|
+
|
|
79
86
|
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
87
|
|
|
81
88
|
- **Handler returns** a value → queue item marked `ENDED`, return value JSON-encoded as `outputData` (default `auto_complete=True`).
|
|
@@ -95,6 +102,7 @@ def wait_for_mail(item):
|
|
|
95
102
|
db.insert_outstanding(queue_item_id=str(item.id), payload=item.input_data_json)
|
|
96
103
|
# Returns without ENDED — no background heartbeat from TaskServer
|
|
97
104
|
|
|
105
|
+
|
|
98
106
|
# Elsewhere: each time you poll your DB for outstanding work (including after restart):
|
|
99
107
|
for row in db.outstanding_rows():
|
|
100
108
|
server.client.queue.heartbeat(row.queue_item_id)
|
|
@@ -110,11 +118,12 @@ Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker
|
|
|
110
118
|
TaskServer(
|
|
111
119
|
base_url="http://localhost:8080",
|
|
112
120
|
api_key="...",
|
|
113
|
-
max_workers=4,
|
|
114
|
-
poll_interval=1.0,
|
|
115
|
-
max_poll_interval=5.0,
|
|
121
|
+
max_workers=4, # thread pool size across all tasks
|
|
122
|
+
poll_interval=1.0, # initial poll delay (backs off when idle)
|
|
123
|
+
max_poll_interval=5.0, # max idle poll delay
|
|
116
124
|
heartbeat_interval=15.0, # how often to call /heartbeat per running task
|
|
117
|
-
shutdown_timeout=30.0,
|
|
125
|
+
shutdown_timeout=30.0, # how long to wait for handlers during shutdown
|
|
126
|
+
transport="http", # "websocket" uses STOMP; httpx transports still accepted
|
|
118
127
|
)
|
|
119
128
|
```
|
|
120
129
|
|
|
@@ -122,8 +131,7 @@ Per-task concurrency limit:
|
|
|
122
131
|
|
|
123
132
|
```python
|
|
124
133
|
@server.task("docker-run", concurrency=2)
|
|
125
|
-
def run_docker(item):
|
|
126
|
-
...
|
|
134
|
+
def run_docker(item): ...
|
|
127
135
|
```
|
|
128
136
|
|
|
129
137
|
Handler signature: `def handler(item: QueueItem) -> dict | list | str | None`. Inside, you have:
|
|
@@ -163,9 +171,44 @@ with ContinuumClient(base_url="http://localhost:8080", api_key="...") as client:
|
|
|
163
171
|
|
|
164
172
|
`input_data` / `output_data` accept `dict` / `list` / `str` / `None`; non-string values are JSON-encoded for you.
|
|
165
173
|
|
|
174
|
+
## WebSocket / STOMP
|
|
175
|
+
|
|
176
|
+
The Queue API worker socket is native STOMP at `/ws/queue` (API-key handshake, no SockJS/JWT). `http://` base URLs become `ws://`; `https://` becomes `wss://`.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from continuum_task_server import ContinuumWebSocketClient, TaskStatus, WaitMode
|
|
180
|
+
|
|
181
|
+
with ContinuumWebSocketClient(base_url, api_key) as queue:
|
|
182
|
+
queue.subscribe_work("echo")
|
|
183
|
+
hint = queue.next_event(timeout=30) # work.available is a hint, not a reservation
|
|
184
|
+
item = queue.claim("echo") # None if another worker won
|
|
185
|
+
queue.heartbeat(item.id)
|
|
186
|
+
wait = queue.subscribe_wait(
|
|
187
|
+
"run-42",
|
|
188
|
+
mode=WaitMode.EACH,
|
|
189
|
+
queue_item_ids=[item.id],
|
|
190
|
+
)
|
|
191
|
+
# Or create the child and wait in one transaction:
|
|
192
|
+
# created = queue.enqueue_and_subscribe_wait(
|
|
193
|
+
# task_name="echo", subscriber_id="run-42", mode=WaitMode.EACH, input_data={"hello": "world"},
|
|
194
|
+
# )
|
|
195
|
+
queue.update_status(item.id, TaskStatus.ENDED, output_data={"ok": True})
|
|
196
|
+
completed = queue.next_event(timeout=30) # queue.completed; dedupe by event_id
|
|
197
|
+
queue.ack_event("run-42", completed.event_id)
|
|
198
|
+
queue.unsubscribe_work("echo")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- **TaskServer WebSocket mode** uses this client for subscribe / claim / heartbeat / status. There is no HTTP fallback poll; recovery is reconnect + resubscribe. The API also checks PostgreSQL at subscribe time and periodically for connected sessions.
|
|
202
|
+
- **Reconnect** restores `/user/queue/replies` and `/user/queue/events`, every `subscribeWork` task name, and every durable `subscriberId` via bind-only `subscribeWait` (replays unacked completions from PostgreSQL).
|
|
203
|
+
- **Acks are explicit.** Do not assume the SDK acked a delivery. Completions are at-least-once; ignore duplicate `eventId`s.
|
|
204
|
+
- **Claim and enqueue are not retried** if the socket drops after the frame is written. The reply may have been lost; check queue state instead of sending the same command again. Heartbeat and status wait for reconnect and retry.
|
|
205
|
+
- **Durable waits** use a client-chosen `subscriberId` scoped to the API-key owner. `EACH` delivers per terminal item; `ALL` delivers one summary. Bind with only `subscriberId` after reconnect.
|
|
206
|
+
|
|
207
|
+
See `examples/websocket_bindings.py` and `examples/wait_subscriber.py`.
|
|
208
|
+
|
|
166
209
|
### Errors
|
|
167
210
|
|
|
168
|
-
All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`. Each carries `status_code` and `body
|
|
211
|
+
All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `ServerError`, plus WebSocket `TransportError`, `ProtocolError`, `CommandTimeoutError`, and `AmbiguousCommandError`. Each carries `status_code` and `body` when they come from the API.
|
|
169
212
|
|
|
170
213
|
```python
|
|
171
214
|
from continuum_task_server import ContinuumClient, NotFoundError
|
|
@@ -202,7 +245,7 @@ ruff format --check .
|
|
|
202
245
|
pytest
|
|
203
246
|
```
|
|
204
247
|
|
|
205
|
-
Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport — no live server required.
|
|
248
|
+
Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport and an in-memory WebSocket for STOMP tests — no live server required.
|
|
206
249
|
|
|
207
250
|
## Versioning
|
|
208
251
|
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
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.
|
|
4
4
|
|
|
5
|
-
- **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown.
|
|
5
|
+
- **`TaskServer`** — decorator-based worker loop: claim, heartbeat **while the handler runs**, status updates, backoff, graceful shutdown. Defaults to HTTP polling; set ``transport="websocket"`` (or ``CONTINUUM_TRANSPORT=websocket`` in the example) to claim from STOMP ``work.available`` hints instead.
|
|
6
6
|
- **`ContinuumClient`** — thin pythonic client over the management + queue REST APIs (task types, task items, versions, queue, content store).
|
|
7
|
+
- **`ContinuumWebSocketClient`** — synchronous STOMP-over-WebSocket client for the worker hot path (enqueue, claim, heartbeat, status, ``subscribeWork``, durable ``subscribeWait`` / ``ackEvent``).
|
|
7
8
|
|
|
8
9
|
Requires Python 3.10+.
|
|
9
10
|
|
|
@@ -35,19 +36,24 @@ server = TaskServer(
|
|
|
35
36
|
api_key=os.environ["CONTINUUM_API_KEY"],
|
|
36
37
|
)
|
|
37
38
|
|
|
39
|
+
|
|
38
40
|
@server.task("echo")
|
|
39
41
|
def echo(item):
|
|
40
42
|
return {"echoed": item.input_data_json}
|
|
41
43
|
|
|
44
|
+
|
|
42
45
|
@server.task("greet")
|
|
43
46
|
def greet(item):
|
|
44
47
|
name = (item.input_data_json or {}).get("name", "world")
|
|
45
48
|
return {"message": f"hello, {name}"}
|
|
46
49
|
|
|
50
|
+
|
|
47
51
|
if __name__ == "__main__":
|
|
48
52
|
server.run()
|
|
49
53
|
```
|
|
50
54
|
|
|
55
|
+
Set `CONTINUUM_TRANSPORT=websocket` (and enable Queue API WebSockets) to replace polling with STOMP wake-ups. HTTP polling remains the default.
|
|
56
|
+
|
|
51
57
|
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:
|
|
52
58
|
|
|
53
59
|
- **Handler returns** a value → queue item marked `ENDED`, return value JSON-encoded as `outputData` (default `auto_complete=True`).
|
|
@@ -67,6 +73,7 @@ def wait_for_mail(item):
|
|
|
67
73
|
db.insert_outstanding(queue_item_id=str(item.id), payload=item.input_data_json)
|
|
68
74
|
# Returns without ENDED — no background heartbeat from TaskServer
|
|
69
75
|
|
|
76
|
+
|
|
70
77
|
# Elsewhere: each time you poll your DB for outstanding work (including after restart):
|
|
71
78
|
for row in db.outstanding_rows():
|
|
72
79
|
server.client.queue.heartbeat(row.queue_item_id)
|
|
@@ -82,11 +89,12 @@ Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker
|
|
|
82
89
|
TaskServer(
|
|
83
90
|
base_url="http://localhost:8080",
|
|
84
91
|
api_key="...",
|
|
85
|
-
max_workers=4,
|
|
86
|
-
poll_interval=1.0,
|
|
87
|
-
max_poll_interval=5.0,
|
|
92
|
+
max_workers=4, # thread pool size across all tasks
|
|
93
|
+
poll_interval=1.0, # initial poll delay (backs off when idle)
|
|
94
|
+
max_poll_interval=5.0, # max idle poll delay
|
|
88
95
|
heartbeat_interval=15.0, # how often to call /heartbeat per running task
|
|
89
|
-
shutdown_timeout=30.0,
|
|
96
|
+
shutdown_timeout=30.0, # how long to wait for handlers during shutdown
|
|
97
|
+
transport="http", # "websocket" uses STOMP; httpx transports still accepted
|
|
90
98
|
)
|
|
91
99
|
```
|
|
92
100
|
|
|
@@ -94,8 +102,7 @@ Per-task concurrency limit:
|
|
|
94
102
|
|
|
95
103
|
```python
|
|
96
104
|
@server.task("docker-run", concurrency=2)
|
|
97
|
-
def run_docker(item):
|
|
98
|
-
...
|
|
105
|
+
def run_docker(item): ...
|
|
99
106
|
```
|
|
100
107
|
|
|
101
108
|
Handler signature: `def handler(item: QueueItem) -> dict | list | str | None`. Inside, you have:
|
|
@@ -135,9 +142,44 @@ with ContinuumClient(base_url="http://localhost:8080", api_key="...") as client:
|
|
|
135
142
|
|
|
136
143
|
`input_data` / `output_data` accept `dict` / `list` / `str` / `None`; non-string values are JSON-encoded for you.
|
|
137
144
|
|
|
145
|
+
## WebSocket / STOMP
|
|
146
|
+
|
|
147
|
+
The Queue API worker socket is native STOMP at `/ws/queue` (API-key handshake, no SockJS/JWT). `http://` base URLs become `ws://`; `https://` becomes `wss://`.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from continuum_task_server import ContinuumWebSocketClient, TaskStatus, WaitMode
|
|
151
|
+
|
|
152
|
+
with ContinuumWebSocketClient(base_url, api_key) as queue:
|
|
153
|
+
queue.subscribe_work("echo")
|
|
154
|
+
hint = queue.next_event(timeout=30) # work.available is a hint, not a reservation
|
|
155
|
+
item = queue.claim("echo") # None if another worker won
|
|
156
|
+
queue.heartbeat(item.id)
|
|
157
|
+
wait = queue.subscribe_wait(
|
|
158
|
+
"run-42",
|
|
159
|
+
mode=WaitMode.EACH,
|
|
160
|
+
queue_item_ids=[item.id],
|
|
161
|
+
)
|
|
162
|
+
# Or create the child and wait in one transaction:
|
|
163
|
+
# created = queue.enqueue_and_subscribe_wait(
|
|
164
|
+
# task_name="echo", subscriber_id="run-42", mode=WaitMode.EACH, input_data={"hello": "world"},
|
|
165
|
+
# )
|
|
166
|
+
queue.update_status(item.id, TaskStatus.ENDED, output_data={"ok": True})
|
|
167
|
+
completed = queue.next_event(timeout=30) # queue.completed; dedupe by event_id
|
|
168
|
+
queue.ack_event("run-42", completed.event_id)
|
|
169
|
+
queue.unsubscribe_work("echo")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **TaskServer WebSocket mode** uses this client for subscribe / claim / heartbeat / status. There is no HTTP fallback poll; recovery is reconnect + resubscribe. The API also checks PostgreSQL at subscribe time and periodically for connected sessions.
|
|
173
|
+
- **Reconnect** restores `/user/queue/replies` and `/user/queue/events`, every `subscribeWork` task name, and every durable `subscriberId` via bind-only `subscribeWait` (replays unacked completions from PostgreSQL).
|
|
174
|
+
- **Acks are explicit.** Do not assume the SDK acked a delivery. Completions are at-least-once; ignore duplicate `eventId`s.
|
|
175
|
+
- **Claim and enqueue are not retried** if the socket drops after the frame is written. The reply may have been lost; check queue state instead of sending the same command again. Heartbeat and status wait for reconnect and retry.
|
|
176
|
+
- **Durable waits** use a client-chosen `subscriberId` scoped to the API-key owner. `EACH` delivers per terminal item; `ALL` delivers one summary. Bind with only `subscriberId` after reconnect.
|
|
177
|
+
|
|
178
|
+
See `examples/websocket_bindings.py` and `examples/wait_subscriber.py`.
|
|
179
|
+
|
|
138
180
|
### Errors
|
|
139
181
|
|
|
140
|
-
All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`. Each carries `status_code` and `body
|
|
182
|
+
All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `ServerError`, plus WebSocket `TransportError`, `ProtocolError`, `CommandTimeoutError`, and `AmbiguousCommandError`. Each carries `status_code` and `body` when they come from the API.
|
|
141
183
|
|
|
142
184
|
```python
|
|
143
185
|
from continuum_task_server import ContinuumClient, NotFoundError
|
|
@@ -174,7 +216,7 @@ ruff format --check .
|
|
|
174
216
|
pytest
|
|
175
217
|
```
|
|
176
218
|
|
|
177
|
-
Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport — no live server required.
|
|
219
|
+
Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport and an in-memory WebSocket for STOMP tests — no live server required.
|
|
178
220
|
|
|
179
221
|
## Versioning
|
|
180
222
|
|
|
@@ -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,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)
|
{continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/continuum_task_server/client.py
RENAMED
|
@@ -145,7 +145,7 @@ class TaskItemVersionsApi:
|
|
|
145
145
|
}
|
|
146
146
|
)
|
|
147
147
|
return TaskItemVersion.model_validate(
|
|
148
|
-
self._http.
|
|
148
|
+
self._http.post_json(f"{self._PATH}/versions/{version_id}", body)
|
|
149
149
|
)
|
|
150
150
|
|
|
151
151
|
def deactivate(self, task_item_id: UUID | str, version_id: UUID | str) -> TaskItemVersion:
|
|
@@ -25,7 +25,7 @@ class ContinuumError(Exception):
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class BadRequestError(ContinuumError):
|
|
28
|
-
"""HTTP 400."""
|
|
28
|
+
"""HTTP / command 400."""
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class UnauthorizedError(ContinuumError):
|
|
@@ -44,21 +44,46 @@ class ConflictError(ContinuumError):
|
|
|
44
44
|
"""HTTP 409."""
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
class RateLimitError(ContinuumError):
|
|
48
|
+
"""HTTP / command 429."""
|
|
49
|
+
|
|
50
|
+
|
|
47
51
|
class ServerError(ContinuumError):
|
|
48
52
|
"""HTTP 5xx."""
|
|
49
53
|
|
|
50
54
|
|
|
55
|
+
class TransportError(ContinuumError):
|
|
56
|
+
"""WebSocket / STOMP connection failed or dropped."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProtocolError(ContinuumError):
|
|
60
|
+
"""Malformed STOMP frame or unexpected protocol response."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CommandTimeoutError(ContinuumError):
|
|
64
|
+
"""Timed out waiting for a correlated command reply."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AmbiguousCommandError(TransportError):
|
|
68
|
+
"""A non-idempotent command was sent but its reply was lost.
|
|
69
|
+
|
|
70
|
+
The server may or may not have applied the mutation (claim / enqueue).
|
|
71
|
+
Do not retry with the same intent without checking queue state.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
51
75
|
_STATUS_TO_EXC: dict[int, type[ContinuumError]] = {
|
|
52
76
|
400: BadRequestError,
|
|
53
77
|
401: UnauthorizedError,
|
|
54
78
|
403: ForbiddenError,
|
|
55
79
|
404: NotFoundError,
|
|
56
80
|
409: ConflictError,
|
|
81
|
+
429: RateLimitError,
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
|
|
60
85
|
def error_for_status(status_code: int, body: str) -> ContinuumError:
|
|
61
|
-
"""Build the appropriate exception for a non-2xx HTTP
|
|
86
|
+
"""Build the appropriate exception for a non-2xx HTTP or command status."""
|
|
62
87
|
exc_cls = _STATUS_TO_EXC.get(status_code)
|
|
63
88
|
if exc_cls is None:
|
|
64
89
|
if 500 <= status_code < 600:
|
{continuum_task_server_sdk-0.0.9 → continuum_task_server_sdk-0.1.0}/continuum_task_server/models.py
RENAMED
|
@@ -111,3 +111,87 @@ class Content(_Base):
|
|
|
111
111
|
|
|
112
112
|
def as_text(self, encoding: str = "utf-8") -> str:
|
|
113
113
|
return self.data.decode(encoding)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TransportMode(str, Enum):
|
|
117
|
+
"""How ``TaskServer`` obtains work."""
|
|
118
|
+
|
|
119
|
+
HTTP = "http"
|
|
120
|
+
WEBSOCKET = "websocket"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class WaitMode(str, Enum):
|
|
124
|
+
"""Durable completion-wait mode."""
|
|
125
|
+
|
|
126
|
+
EACH = "EACH"
|
|
127
|
+
ALL = "ALL"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class QueueEventType(str, Enum):
|
|
131
|
+
"""Wire ``eventType`` values on ``/user/queue/events``."""
|
|
132
|
+
|
|
133
|
+
WORK_AVAILABLE = "work.available"
|
|
134
|
+
COMPLETED = "queue.completed"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class WaitTargetStatus(_Base):
|
|
138
|
+
"""Per-item outcome on an ALL-mode completion summary."""
|
|
139
|
+
|
|
140
|
+
queue_item_id: UUID = Field(alias="queueItemId")
|
|
141
|
+
status: TaskStatus | str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class QueueEvent(_Base):
|
|
145
|
+
"""Compact ``work.available`` hint or durable ``queue.completed`` delivery."""
|
|
146
|
+
|
|
147
|
+
event_id: UUID = Field(alias="eventId")
|
|
148
|
+
event_version: int = Field(default=1, alias="eventVersion")
|
|
149
|
+
event_type: QueueEventType = Field(alias="eventType")
|
|
150
|
+
occurred_at: datetime | None = Field(default=None, alias="occurredAt")
|
|
151
|
+
queue_item_id: UUID | None = Field(default=None, alias="queueItemId")
|
|
152
|
+
organization_id: UUID | None = Field(default=None, alias="organizationId")
|
|
153
|
+
task_type_id: UUID | None = Field(default=None, alias="taskTypeId")
|
|
154
|
+
task_name: str | None = Field(default=None, alias="taskName")
|
|
155
|
+
status: TaskStatus | None = None
|
|
156
|
+
subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
|
|
157
|
+
correlation: str | None = None
|
|
158
|
+
wait_mode: WaitMode | None = Field(default=None, alias="waitMode")
|
|
159
|
+
targets: list[WaitTargetStatus] | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class WorkSubscriptionResult(_Base):
|
|
163
|
+
"""Result of ``subscribeWork`` / ``unsubscribeWork``."""
|
|
164
|
+
|
|
165
|
+
task_name: str | None = Field(default=None, alias="taskName")
|
|
166
|
+
subscribed: bool | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class WaitSubscriptionResult(_Base):
|
|
170
|
+
"""Result of ``subscribeWait`` / bind-only replay."""
|
|
171
|
+
|
|
172
|
+
subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
|
|
173
|
+
subscriber_id: str | None = Field(default=None, alias="subscriberId")
|
|
174
|
+
wait_mode: WaitMode | None = Field(default=None, alias="waitMode")
|
|
175
|
+
pending_count: int = Field(default=0, alias="pendingCount")
|
|
176
|
+
closed: bool = False
|
|
177
|
+
subscribed: bool | None = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class EventAckResult(_Base):
|
|
181
|
+
"""Result of ``ackEvent``."""
|
|
182
|
+
|
|
183
|
+
subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
|
|
184
|
+
closed: bool = False
|
|
185
|
+
acked: bool = True
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class EnqueueAndSubscribeWaitResult(_Base):
|
|
189
|
+
"""Combined result of ``enqueueAndSubscribeWait``."""
|
|
190
|
+
|
|
191
|
+
item: QueueItem
|
|
192
|
+
subscription_id: UUID | None = Field(default=None, alias="subscriptionId")
|
|
193
|
+
subscriber_id: str | None = Field(default=None, alias="subscriberId")
|
|
194
|
+
wait_mode: WaitMode | None = Field(default=None, alias="waitMode")
|
|
195
|
+
pending_count: int = Field(default=0, alias="pendingCount")
|
|
196
|
+
closed: bool = False
|
|
197
|
+
subscribed: bool | None = None
|