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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ .mypy_cache/
15
+ *.so
@@ -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}