continuum-task-server-sdk 0.0.9__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/.gitignore +15 -0
- continuum_task_server_sdk-0.0.9/PKG-INFO +215 -0
- continuum_task_server_sdk-0.0.9/README.md +187 -0
- continuum_task_server_sdk-0.0.9/continuum_task_server/__init__.py +36 -0
- continuum_task_server_sdk-0.0.9/continuum_task_server/_http.py +142 -0
- continuum_task_server_sdk-0.0.9/continuum_task_server/client.py +349 -0
- continuum_task_server_sdk-0.0.9/continuum_task_server/exceptions.py +69 -0
- continuum_task_server_sdk-0.0.9/continuum_task_server/models.py +113 -0
- continuum_task_server_sdk-0.0.9/continuum_task_server/server.py +364 -0
- continuum_task_server_sdk-0.0.9/pyproject.toml +63 -0
|
@@ -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,187 @@
|
|
|
1
|
+
# Continuum Task Server SDK for Python
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
- **`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).
|
|
6
|
+
- **`ContinuumClient`** — thin pythonic client over the management + queue REST APIs (task types, task items, versions, queue, content store).
|
|
7
|
+
|
|
8
|
+
Requires Python 3.10+.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Tagged releases (`v*`) are published to [PyPI](https://pypi.org/project/continuum-task-server-sdk/):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install continuum-task-server-sdk
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Dev and PR builds are still attached to private GitHub Releases. Install those with a token:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install \
|
|
22
|
+
"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"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`GITHUB_TOKEN` must be a PAT with repo read access.
|
|
26
|
+
|
|
27
|
+
## Quickstart: build a task server in 20 lines
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import os
|
|
31
|
+
from continuum_task_server import TaskServer
|
|
32
|
+
|
|
33
|
+
server = TaskServer(
|
|
34
|
+
base_url=os.environ["CONTINUUM_URL"],
|
|
35
|
+
api_key=os.environ["CONTINUUM_API_KEY"],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@server.task("echo")
|
|
39
|
+
def echo(item):
|
|
40
|
+
return {"echoed": item.input_data_json}
|
|
41
|
+
|
|
42
|
+
@server.task("greet")
|
|
43
|
+
def greet(item):
|
|
44
|
+
name = (item.input_data_json or {}).get("name", "world")
|
|
45
|
+
return {"message": f"hello, {name}"}
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
server.run()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
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
|
+
|
|
53
|
+
- **Handler returns** a value → queue item marked `ENDED`, return value JSON-encoded as `outputData` (default `auto_complete=True`).
|
|
54
|
+
- **Handler raises** → queue item marked `KILLED`, error info written to `outputData`.
|
|
55
|
+
|
|
56
|
+
Press `Ctrl+C` (or send `SIGTERM`) and the server stops polling and waits up to `shutdown_timeout` seconds for in-flight handlers to finish.
|
|
57
|
+
|
|
58
|
+
### Deferred completion (`auto_complete=False`)
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
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).
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
@server.task("wait-for-mail", auto_complete=False)
|
|
66
|
+
def wait_for_mail(item):
|
|
67
|
+
db.insert_outstanding(queue_item_id=str(item.id), payload=item.input_data_json)
|
|
68
|
+
# Returns without ENDED — no background heartbeat from TaskServer
|
|
69
|
+
|
|
70
|
+
# Elsewhere: each time you poll your DB for outstanding work (including after restart):
|
|
71
|
+
for row in db.outstanding_rows():
|
|
72
|
+
server.client.queue.heartbeat(row.queue_item_id)
|
|
73
|
+
if mail_arrived(row):
|
|
74
|
+
server.complete_queue_item(row.queue_item_id, output_data={"received": True})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker key and call `client.queue.heartbeat` / `client.queue.update_status` the same way.
|
|
78
|
+
|
|
79
|
+
### TaskServer options
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
TaskServer(
|
|
83
|
+
base_url="http://localhost:8080",
|
|
84
|
+
api_key="...",
|
|
85
|
+
max_workers=4, # thread pool size across all tasks
|
|
86
|
+
poll_interval=1.0, # initial poll delay (backs off when idle)
|
|
87
|
+
max_poll_interval=5.0, # max idle poll delay
|
|
88
|
+
heartbeat_interval=15.0, # how often to call /heartbeat per running task
|
|
89
|
+
shutdown_timeout=30.0, # how long to wait for handlers during shutdown
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Per-task concurrency limit:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
@server.task("docker-run", concurrency=2)
|
|
97
|
+
def run_docker(item):
|
|
98
|
+
...
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Handler signature: `def handler(item: QueueItem) -> dict | list | str | None`. Inside, you have:
|
|
102
|
+
|
|
103
|
+
- `item.input_data` — raw JSON string from the queue item (or `None`).
|
|
104
|
+
- `item.input_data_json` — parsed value (`dict` / `list` / `str` / `None`).
|
|
105
|
+
- `server.client` — full `ContinuumClient` if you need to chain management calls, fetch content, enqueue child tasks, etc.
|
|
106
|
+
|
|
107
|
+
## Using `ContinuumClient` directly
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from continuum_task_server import ContinuumClient, TaskStatus
|
|
111
|
+
|
|
112
|
+
with ContinuumClient(base_url="http://localhost:8080", api_key="...") as client:
|
|
113
|
+
# Task types
|
|
114
|
+
types = client.task_types.list()
|
|
115
|
+
echo_type = client.task_types.get_by_name("echo")
|
|
116
|
+
|
|
117
|
+
# Task items + versions
|
|
118
|
+
item = client.task_items.get_by_name("my-task")
|
|
119
|
+
versions = client.task_items.versions.list(item.id)
|
|
120
|
+
|
|
121
|
+
# Enqueue work
|
|
122
|
+
queued = client.queue.add(task_name="echo", input_data={"hello": "world"})
|
|
123
|
+
|
|
124
|
+
# Worker-side primitives (normally handled by TaskServer)
|
|
125
|
+
claimed = client.queue.claim("echo")
|
|
126
|
+
if claimed is not None:
|
|
127
|
+
client.queue.heartbeat(claimed.id)
|
|
128
|
+
client.queue.update_status(claimed.id, TaskStatus.ENDED, output_data={"ok": True})
|
|
129
|
+
|
|
130
|
+
# Content store
|
|
131
|
+
content = client.content_store.get_by_url("db://...")
|
|
132
|
+
if content is not None:
|
|
133
|
+
print(content.as_text())
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`input_data` / `output_data` accept `dict` / `list` / `str` / `None`; non-string values are JSON-encoded for you.
|
|
137
|
+
|
|
138
|
+
### Errors
|
|
139
|
+
|
|
140
|
+
All API failures raise `ContinuumError` or a specific subclass: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ServerError`. Each carries `status_code` and `body`.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from continuum_task_server import ContinuumClient, NotFoundError
|
|
144
|
+
|
|
145
|
+
with ContinuumClient(...) as client:
|
|
146
|
+
try:
|
|
147
|
+
client.task_types.get_by_name("does-not-exist")
|
|
148
|
+
except NotFoundError as e:
|
|
149
|
+
print(e.status_code, e.body)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Endpoints covered
|
|
153
|
+
|
|
154
|
+
| Group | Method | Path |
|
|
155
|
+
| --- | --- | --- |
|
|
156
|
+
| Task Types | `GET`/`POST` | `/api/management/task-types[/{id}\|/by-name]` |
|
|
157
|
+
| Task Items | `GET`/`POST` | `/api/management/task-items[/{id}\|/by-name\|/{id}/publish]` |
|
|
158
|
+
| Task Item Versions | `GET`/`POST`/`PATCH` | `/api/management/task-items/{id}/versions[...]` |
|
|
159
|
+
| Queue (management) | `GET`/`POST` | `/api/management/queue-items[/{id}]` |
|
|
160
|
+
| Queue (worker) | `POST` | `/api/queue/claim`, `/api/queue/queue-items/{id}/heartbeat`, `/api/queue/queue-items/{id}/status` |
|
|
161
|
+
| Queue content | `GET` | `/api/queue/queue-items/{id}/content` |
|
|
162
|
+
| Content store | `GET` | `/api/management/content-store[/{id}\|?url=db://...]` |
|
|
163
|
+
|
|
164
|
+
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).
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
python -m venv .venv && source .venv/bin/activate
|
|
170
|
+
pip install -e ".[dev]"
|
|
171
|
+
|
|
172
|
+
ruff check .
|
|
173
|
+
ruff format --check .
|
|
174
|
+
pytest
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Tests use [`respx`](https://lundberg.github.io/respx/) to mock the httpx transport — no live server required.
|
|
178
|
+
|
|
179
|
+
## Versioning
|
|
180
|
+
|
|
181
|
+
- **Tag `v1.2.3`** → release wheel `1.2.3` attached to a `v1.2.3` GitHub Release.
|
|
182
|
+
- **Push to `main`** → prerelease wheel `0.1.0.dev{run}+{shortsha}` attached to a `dev-{shortsha}` Release.
|
|
183
|
+
- **Pull request** → prerelease wheel `0.1.0b{pr}.{run}` attached to a `pr-{pr}` Release; install command posted as a PR comment.
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT.
|
|
@@ -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}
|