taskforge-sdk 0.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.
- taskforge/.gitkeep +0 -0
- taskforge/__init__.py +63 -0
- taskforge/client.py +276 -0
- taskforge/exceptions.py +42 -0
- taskforge/registry.py +55 -0
- taskforge/types.py +114 -0
- taskforge_sdk-0.1.0.dist-info/METADATA +218 -0
- taskforge_sdk-0.1.0.dist-info/RECORD +9 -0
- taskforge_sdk-0.1.0.dist-info/WHEEL +4 -0
taskforge/.gitkeep
ADDED
|
File without changes
|
taskforge/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""TaskForge Python SDK.
|
|
2
|
+
|
|
3
|
+
Client for the TaskForge HTTP API and task registry for worker handlers.
|
|
4
|
+
|
|
5
|
+
Quickstart:
|
|
6
|
+
from taskforge import TaskForgeClient
|
|
7
|
+
|
|
8
|
+
async with TaskForgeClient(api_key="...") as client:
|
|
9
|
+
job = await client.enqueue(
|
|
10
|
+
task_name="send_welcome_email",
|
|
11
|
+
queue="emails",
|
|
12
|
+
payload={"recipient": "user@example.com"},
|
|
13
|
+
)
|
|
14
|
+
final = await client.wait_for(job.id)
|
|
15
|
+
print(final.status)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from taskforge.client import TaskForgeClient
|
|
19
|
+
from taskforge.exceptions import (
|
|
20
|
+
APIError,
|
|
21
|
+
ConflictError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
TaskForgeConnectionError,
|
|
24
|
+
TaskForgeError,
|
|
25
|
+
UnauthorizedError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
from taskforge.registry import TaskRegistry
|
|
29
|
+
from taskforge.types import (
|
|
30
|
+
Attempt,
|
|
31
|
+
Job,
|
|
32
|
+
JobDetail,
|
|
33
|
+
JobStatus,
|
|
34
|
+
Page,
|
|
35
|
+
Priority,
|
|
36
|
+
Queue,
|
|
37
|
+
RetryPolicy,
|
|
38
|
+
SubmitResponse,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"__version__",
|
|
45
|
+
"TaskForgeClient",
|
|
46
|
+
"TaskRegistry",
|
|
47
|
+
"Attempt",
|
|
48
|
+
"Job",
|
|
49
|
+
"JobDetail",
|
|
50
|
+
"JobStatus",
|
|
51
|
+
"Page",
|
|
52
|
+
"Priority",
|
|
53
|
+
"Queue",
|
|
54
|
+
"RetryPolicy",
|
|
55
|
+
"SubmitResponse",
|
|
56
|
+
"TaskForgeError",
|
|
57
|
+
"TaskForgeConnectionError",
|
|
58
|
+
"APIError",
|
|
59
|
+
"NotFoundError",
|
|
60
|
+
"ConflictError",
|
|
61
|
+
"ValidationError",
|
|
62
|
+
"UnauthorizedError",
|
|
63
|
+
]
|
taskforge/client.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Async client for the TaskForge HTTP API.
|
|
2
|
+
|
|
3
|
+
from taskforge import TaskForgeClient
|
|
4
|
+
|
|
5
|
+
async with TaskForgeClient(api_key="...") as client:
|
|
6
|
+
job = await client.enqueue(
|
|
7
|
+
task_name="send_welcome_email",
|
|
8
|
+
queue="emails",
|
|
9
|
+
payload={"recipient": "user@example.com"},
|
|
10
|
+
)
|
|
11
|
+
print(job.id)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any, AsyncIterator
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from taskforge.exceptions import (
|
|
21
|
+
APIError,
|
|
22
|
+
ConflictError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
TaskForgeConnectionError,
|
|
25
|
+
UnauthorizedError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
from taskforge.types import (
|
|
29
|
+
Job,
|
|
30
|
+
JobDetail,
|
|
31
|
+
JobStatus,
|
|
32
|
+
Page,
|
|
33
|
+
Priority,
|
|
34
|
+
Queue,
|
|
35
|
+
RetryPolicy,
|
|
36
|
+
SubmitResponse,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
40
|
+
DEFAULT_TIMEOUT = 30.0
|
|
41
|
+
|
|
42
|
+
_STATUS_TO_ERROR = {
|
|
43
|
+
401: UnauthorizedError,
|
|
44
|
+
404: NotFoundError,
|
|
45
|
+
409: ConflictError,
|
|
46
|
+
422: ValidationError,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _error_message(body: dict[str, Any]) -> str:
|
|
51
|
+
"""Extract a readable message from a FastAPI error body."""
|
|
52
|
+
detail = body.get("detail") or body.get("message")
|
|
53
|
+
if isinstance(detail, list):
|
|
54
|
+
parts = []
|
|
55
|
+
for item in detail:
|
|
56
|
+
if isinstance(item, dict):
|
|
57
|
+
parts.append(str(item.get("msg") or item))
|
|
58
|
+
else:
|
|
59
|
+
parts.append(str(item))
|
|
60
|
+
return "; ".join(parts) if parts else "unknown validation error"
|
|
61
|
+
return str(detail) if detail else f"unknown error"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TaskForgeClient:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
68
|
+
api_key: str | None = None,
|
|
69
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
70
|
+
*,
|
|
71
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
api_key = api_key or os.environ.get("TASKFORGE_API_KEY")
|
|
74
|
+
if not api_key:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
"api_key is required - pass api_key=... or set TASKFORGE_API_KEY"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self._client = httpx.AsyncClient(
|
|
80
|
+
base_url=base_url.rstrip("/") + "/api/v1",
|
|
81
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
transport=transport,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_env(cls) -> "TaskForgeClient":
|
|
88
|
+
"""Construct from TASKFORGE_URL and TASKFORGE_API_KEY env vars."""
|
|
89
|
+
return cls(
|
|
90
|
+
base_url=os.environ.get("TASKFORGE_URL", DEFAULT_BASE_URL),
|
|
91
|
+
api_key=os.environ.get("TASKFORGE_API_KEY", ""),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def __aenter__(self) -> "TaskForgeClient":
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
98
|
+
await self.aclose()
|
|
99
|
+
|
|
100
|
+
async def aclose(self) -> None:
|
|
101
|
+
await self._client.aclose()
|
|
102
|
+
|
|
103
|
+
# -- HTTP helper --------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
106
|
+
try:
|
|
107
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
108
|
+
except httpx.HTTPError as exc:
|
|
109
|
+
raise TaskForgeConnectionError(str(exc)) from exc
|
|
110
|
+
|
|
111
|
+
if resp.is_success:
|
|
112
|
+
if resp.status_code == 204 or not resp.content:
|
|
113
|
+
return None
|
|
114
|
+
return resp.json()
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
body = resp.json()
|
|
118
|
+
except Exception:
|
|
119
|
+
body = {"detail": resp.text or f"HTTP {resp.status_code}"}
|
|
120
|
+
|
|
121
|
+
error_cls = _STATUS_TO_ERROR.get(resp.status_code, APIError)
|
|
122
|
+
raise error_cls(
|
|
123
|
+
status_code=resp.status_code,
|
|
124
|
+
code=body.get("code"),
|
|
125
|
+
message=_error_message(body),
|
|
126
|
+
request_id=body.get("request_id") or resp.headers.get("X-Request-ID"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# -- Jobs ---------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def enqueue(
|
|
132
|
+
self,
|
|
133
|
+
task_name: str,
|
|
134
|
+
payload: dict[str, Any],
|
|
135
|
+
*,
|
|
136
|
+
queue: str = "default",
|
|
137
|
+
priority: Priority = "normal",
|
|
138
|
+
scheduled_at: datetime | str | None = None,
|
|
139
|
+
retry_policy: RetryPolicy | None = None,
|
|
140
|
+
max_attempts: int | None = None,
|
|
141
|
+
initial_delay_seconds: int | None = None,
|
|
142
|
+
max_delay_seconds: int | None = None,
|
|
143
|
+
timeout_seconds: int | None = None,
|
|
144
|
+
idempotency_key: str | None = None,
|
|
145
|
+
) -> SubmitResponse:
|
|
146
|
+
"""Submit a job. Returns the server's 202 Accepted response."""
|
|
147
|
+
if retry_policy is None:
|
|
148
|
+
overrides: dict[str, Any] = {}
|
|
149
|
+
if max_attempts is not None:
|
|
150
|
+
overrides["max_attempts"] = max_attempts
|
|
151
|
+
if initial_delay_seconds is not None:
|
|
152
|
+
overrides["initial_delay_seconds"] = initial_delay_seconds
|
|
153
|
+
if max_delay_seconds is not None:
|
|
154
|
+
overrides["max_delay_seconds"] = max_delay_seconds
|
|
155
|
+
if overrides:
|
|
156
|
+
retry_policy = RetryPolicy(**overrides)
|
|
157
|
+
|
|
158
|
+
body: dict[str, Any] = {
|
|
159
|
+
"queue": queue,
|
|
160
|
+
"task_name": task_name,
|
|
161
|
+
"payload": payload,
|
|
162
|
+
"priority": priority,
|
|
163
|
+
}
|
|
164
|
+
if scheduled_at is not None:
|
|
165
|
+
body["scheduled_at"] = (
|
|
166
|
+
scheduled_at.isoformat()
|
|
167
|
+
if hasattr(scheduled_at, "isoformat")
|
|
168
|
+
else scheduled_at
|
|
169
|
+
)
|
|
170
|
+
if retry_policy is not None:
|
|
171
|
+
body["retry_policy"] = retry_policy.model_dump()
|
|
172
|
+
if timeout_seconds is not None:
|
|
173
|
+
body["timeout_seconds"] = timeout_seconds
|
|
174
|
+
if idempotency_key is not None:
|
|
175
|
+
body["idempotency_key"] = idempotency_key
|
|
176
|
+
|
|
177
|
+
data = await self._request("POST", "/jobs", json=body)
|
|
178
|
+
return SubmitResponse.model_validate(data)
|
|
179
|
+
|
|
180
|
+
async def get(self, job_id: str) -> JobDetail:
|
|
181
|
+
"""Fetch the full job record including payload, result, and attempts."""
|
|
182
|
+
data = await self._request("GET", f"/jobs/{job_id}")
|
|
183
|
+
return JobDetail.model_validate(data)
|
|
184
|
+
|
|
185
|
+
async def list_jobs(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
status: JobStatus | list[JobStatus] | None = None,
|
|
189
|
+
queue: str | None = None,
|
|
190
|
+
task_name: str | None = None,
|
|
191
|
+
priority: int | None = None,
|
|
192
|
+
limit: int = 25,
|
|
193
|
+
cursor: str | None = None,
|
|
194
|
+
) -> Page[Job]:
|
|
195
|
+
"""List jobs with filters. Returns one page."""
|
|
196
|
+
params: list[tuple[str, str]] = [("limit", str(limit))]
|
|
197
|
+
if status is not None:
|
|
198
|
+
statuses = [status] if isinstance(status, str) else status
|
|
199
|
+
params.extend(("status", s) for s in statuses)
|
|
200
|
+
if queue:
|
|
201
|
+
params.append(("queue", queue))
|
|
202
|
+
if task_name:
|
|
203
|
+
params.append(("task_name", task_name))
|
|
204
|
+
if priority is not None:
|
|
205
|
+
params.append(("priority", str(priority)))
|
|
206
|
+
if cursor:
|
|
207
|
+
params.append(("cursor", cursor))
|
|
208
|
+
|
|
209
|
+
data = await self._request("GET", "/jobs", params=params)
|
|
210
|
+
return Page[Job](
|
|
211
|
+
items=[Job.model_validate(item) for item in data["items"]],
|
|
212
|
+
next_cursor=data.get("next_cursor"),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def iter_jobs(self, **filters: Any) -> AsyncIterator[Job]:
|
|
216
|
+
"""Iterate over all pages of matching jobs."""
|
|
217
|
+
cursor: str | None = None
|
|
218
|
+
while True:
|
|
219
|
+
page = await self.list_jobs(cursor=cursor, **filters)
|
|
220
|
+
for item in page.items:
|
|
221
|
+
yield item
|
|
222
|
+
if not page.next_cursor:
|
|
223
|
+
break
|
|
224
|
+
cursor = page.next_cursor
|
|
225
|
+
|
|
226
|
+
async def cancel(self, job_id: str) -> JobDetail:
|
|
227
|
+
"""Cancel a queued/scheduled/retry_scheduled job."""
|
|
228
|
+
data = await self._request("POST", f"/jobs/{job_id}/cancel")
|
|
229
|
+
return JobDetail.model_validate(data)
|
|
230
|
+
|
|
231
|
+
async def wait_for(
|
|
232
|
+
self,
|
|
233
|
+
job_id: str,
|
|
234
|
+
*,
|
|
235
|
+
poll_interval: float = 1.0,
|
|
236
|
+
timeout: float = 300.0,
|
|
237
|
+
) -> JobDetail:
|
|
238
|
+
"""Poll until the job reaches a terminal state."""
|
|
239
|
+
import asyncio
|
|
240
|
+
|
|
241
|
+
terminal = {"succeeded", "failed", "dead_lettered", "cancelled"}
|
|
242
|
+
elapsed = 0.0
|
|
243
|
+
while elapsed < timeout:
|
|
244
|
+
job = await self.get(job_id)
|
|
245
|
+
if job.status in terminal:
|
|
246
|
+
return job
|
|
247
|
+
await asyncio.sleep(poll_interval)
|
|
248
|
+
elapsed += poll_interval
|
|
249
|
+
raise TimeoutError(
|
|
250
|
+
f"job {job_id} did not reach terminal state within {timeout}s"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# -- Queues -------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def queues(self) -> list[Queue]:
|
|
256
|
+
"""List all queues configured for the tenant."""
|
|
257
|
+
data = await self._request("GET", "/queues")
|
|
258
|
+
return [Queue.model_validate(item) for item in data["items"]]
|
|
259
|
+
|
|
260
|
+
# -- Health -------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
async def health_live(self) -> bool:
|
|
263
|
+
"""True if the API process responds."""
|
|
264
|
+
try:
|
|
265
|
+
data = await self._request("GET", "/health/live")
|
|
266
|
+
return bool(data and data.get("status") == "ok")
|
|
267
|
+
except TaskForgeConnectionError:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
async def health_ready(self) -> bool:
|
|
271
|
+
"""True if the API and its dependencies are ready."""
|
|
272
|
+
try:
|
|
273
|
+
data = await self._request("GET", "/health/ready")
|
|
274
|
+
return bool(data and data.get("status") == "ok")
|
|
275
|
+
except TaskForgeConnectionError:
|
|
276
|
+
return False
|
taskforge/exceptions.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Typed exceptions raised by TaskForgeClient."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TaskForgeError(Exception):
|
|
5
|
+
"""Base class for all SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TaskForgeConnectionError(TaskForgeError):
|
|
9
|
+
"""Network layer failed - the API was unreachable."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(TaskForgeError):
|
|
13
|
+
"""Server returned a non-2xx response."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
status_code: int,
|
|
18
|
+
code: str | None,
|
|
19
|
+
message: str,
|
|
20
|
+
request_id: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.code = code
|
|
24
|
+
self.message = message
|
|
25
|
+
self.request_id = request_id
|
|
26
|
+
super().__init__(f"[{status_code}] {code or 'error'}: {message}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotFoundError(APIError):
|
|
30
|
+
"""404 - resource does not exist."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConflictError(APIError):
|
|
34
|
+
"""409 - state conflict (idempotency mismatch, invalid transition, ...)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationError(APIError):
|
|
38
|
+
"""422 - request payload failed validation."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnauthorizedError(APIError):
|
|
42
|
+
"""401 - missing or invalid API key."""
|
taskforge/registry.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Explicit task registry for worker handlers.
|
|
2
|
+
|
|
3
|
+
Framework-agnostic: holds a dict of name -> coroutine function.
|
|
4
|
+
|
|
5
|
+
Supports three decorator forms:
|
|
6
|
+
@registry.task # bare — uses fn.__name__
|
|
7
|
+
@registry.task() # empty parens — same
|
|
8
|
+
@registry.task(name="custom.name") # explicit name
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any, Union
|
|
13
|
+
|
|
14
|
+
HandlerFn = Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskRegistry:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._handlers: dict[str, HandlerFn] = {}
|
|
20
|
+
|
|
21
|
+
def task(self, name: Union[str, HandlerFn, None] = None):
|
|
22
|
+
"""Decorator. Accepts bare, empty-paren, or named usage.
|
|
23
|
+
|
|
24
|
+
Bare usage detection: if `name` is callable, `@registry.task` was
|
|
25
|
+
used without parens — the function itself arrives as `name`.
|
|
26
|
+
"""
|
|
27
|
+
# Bare usage: @registry.task → 'name' is the function itself
|
|
28
|
+
if callable(name):
|
|
29
|
+
fn = name
|
|
30
|
+
self._register(fn.__name__, fn)
|
|
31
|
+
return fn
|
|
32
|
+
|
|
33
|
+
# Paren usage: @registry.task() or @registry.task(name="...")
|
|
34
|
+
def decorator(fn: HandlerFn) -> HandlerFn:
|
|
35
|
+
self._register(name or fn.__name__, fn)
|
|
36
|
+
return fn
|
|
37
|
+
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
def _register(self, task_name: str, fn: HandlerFn) -> None:
|
|
41
|
+
if task_name in self._handlers:
|
|
42
|
+
raise ValueError(f"task {task_name!r} is already registered")
|
|
43
|
+
self._handlers[task_name] = fn
|
|
44
|
+
|
|
45
|
+
def get(self, name: str) -> HandlerFn | None:
|
|
46
|
+
return self._handlers.get(name)
|
|
47
|
+
|
|
48
|
+
def names(self) -> list[str]:
|
|
49
|
+
return sorted(self._handlers.keys())
|
|
50
|
+
|
|
51
|
+
def __contains__(self, name: object) -> bool:
|
|
52
|
+
return isinstance(name, str) and name in self._handlers
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
return len(self._handlers)
|
taskforge/types.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Typed models mirroring the TaskForge HTTP API."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
Priority = Literal["low", "normal", "high"]
|
|
10
|
+
|
|
11
|
+
JobStatus = Literal[
|
|
12
|
+
"scheduled",
|
|
13
|
+
"queued",
|
|
14
|
+
"running",
|
|
15
|
+
"retry_scheduled",
|
|
16
|
+
"succeeded",
|
|
17
|
+
"failed",
|
|
18
|
+
"dead_lettered",
|
|
19
|
+
"cancelled",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RetryPolicy(BaseModel):
|
|
24
|
+
"""Per-job retry policy. Defaults match the server."""
|
|
25
|
+
|
|
26
|
+
max_attempts: int = Field(default=5, ge=1, le=50)
|
|
27
|
+
initial_delay_seconds: int = Field(default=2, ge=1, le=3600)
|
|
28
|
+
max_delay_seconds: int = Field(default=300, ge=1, le=86400)
|
|
29
|
+
backoff_multiplier: float = Field(default=2.0, ge=1.0, le=10.0)
|
|
30
|
+
jitter: bool = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SubmitResponse(BaseModel):
|
|
34
|
+
"""Response from POST /jobs (202 Accepted)."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="ignore")
|
|
37
|
+
|
|
38
|
+
job_id: UUID
|
|
39
|
+
status: JobStatus
|
|
40
|
+
queue: str
|
|
41
|
+
task_name: str
|
|
42
|
+
created_at: datetime
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def id(self) -> UUID:
|
|
46
|
+
"""Alias for job_id, for ergonomic access."""
|
|
47
|
+
return self.job_id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Job(BaseModel):
|
|
51
|
+
"""Job summary as returned by GET /jobs."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="ignore")
|
|
54
|
+
|
|
55
|
+
id: UUID
|
|
56
|
+
queue: str
|
|
57
|
+
task_name: str
|
|
58
|
+
status: JobStatus
|
|
59
|
+
priority: int
|
|
60
|
+
attempts: int
|
|
61
|
+
max_attempts: int
|
|
62
|
+
scheduled_at: datetime
|
|
63
|
+
started_at: datetime | None = None
|
|
64
|
+
completed_at: datetime | None = None
|
|
65
|
+
created_at: datetime
|
|
66
|
+
updated_at: datetime
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Attempt(BaseModel):
|
|
70
|
+
"""One execution attempt of a job."""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(extra="ignore")
|
|
73
|
+
|
|
74
|
+
id: UUID
|
|
75
|
+
attempt_number: int
|
|
76
|
+
status: str
|
|
77
|
+
started_at: datetime
|
|
78
|
+
finished_at: datetime | None = None
|
|
79
|
+
duration_ms: int | None = None
|
|
80
|
+
error_type: str | None = None
|
|
81
|
+
error_message: str | None = None
|
|
82
|
+
error_traceback: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class JobDetail(Job):
|
|
86
|
+
"""Full job record with payload, result, and attempt history."""
|
|
87
|
+
|
|
88
|
+
payload: dict[str, Any]
|
|
89
|
+
result: dict[str, Any] | None = None
|
|
90
|
+
last_error: str | None = None
|
|
91
|
+
timeout_seconds: int
|
|
92
|
+
attempt_records: list[Attempt] = Field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Queue(BaseModel):
|
|
96
|
+
"""Queue configuration."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="ignore")
|
|
99
|
+
|
|
100
|
+
id: UUID
|
|
101
|
+
name: str
|
|
102
|
+
max_concurrency: int
|
|
103
|
+
is_active: bool
|
|
104
|
+
created_at: datetime
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
T = TypeVar("T")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Page(BaseModel, Generic[T]):
|
|
111
|
+
"""Cursor-paginated list of items."""
|
|
112
|
+
|
|
113
|
+
items: list[T]
|
|
114
|
+
next_cursor: str | None = None
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: taskforge-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the TaskForge distributed job engine
|
|
5
|
+
Project-URL: Homepage, https://github.com/Olufemooshegs/taskforge
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: pydantic>=2.7
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# TaskForge SDK
|
|
17
|
+
|
|
18
|
+
Python client for the TaskForge distributed job engine.
|
|
19
|
+
|
|
20
|
+
Typed async HTTP client plus a task registry for worker handlers.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
From a local checkout (the package isn't published to PyPI):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd sdk
|
|
28
|
+
pip install -e ".[dev]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Python 3.10+ (the SDK is pure Python — no compiled dependencies).
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from taskforge import TaskForgeClient
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with TaskForgeClient(
|
|
42
|
+
base_url="http://localhost:8000",
|
|
43
|
+
api_key="dev-api-key-change-me",
|
|
44
|
+
) as client:
|
|
45
|
+
# Submit a job
|
|
46
|
+
job = await client.enqueue(
|
|
47
|
+
task_name="send_welcome_email",
|
|
48
|
+
queue="emails",
|
|
49
|
+
payload={"recipient": "user@example.com"},
|
|
50
|
+
priority="high",
|
|
51
|
+
max_attempts=5,
|
|
52
|
+
)
|
|
53
|
+
print(f"submitted: {job.id} ({job.status})")
|
|
54
|
+
|
|
55
|
+
# Poll until terminal
|
|
56
|
+
final = await client.wait_for(job.id, timeout=60)
|
|
57
|
+
print(f"final status: {final.status}")
|
|
58
|
+
if final.result:
|
|
59
|
+
print(f"result: {final.result}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Expected output:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
submitted: <uuid> (queued)
|
|
69
|
+
final status: succeeded
|
|
70
|
+
result: {'echoed': {...}}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Task registry
|
|
74
|
+
|
|
75
|
+
Register handlers on the worker side with the decorator. Three forms:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from taskforge import TaskRegistry
|
|
79
|
+
|
|
80
|
+
registry = TaskRegistry()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Form 1: bare decorator, uses fn.__name__
|
|
84
|
+
@registry.task
|
|
85
|
+
async def send_welcome_email(payload: dict) -> dict:
|
|
86
|
+
recipient = payload["recipient"]
|
|
87
|
+
return {"recipient": recipient, "sent": True}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Form 2: empty parens, same behavior
|
|
91
|
+
@registry.task()
|
|
92
|
+
async def noop(payload: dict) -> dict:
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Form 3: explicit name
|
|
97
|
+
@registry.task(name="custom.name")
|
|
98
|
+
async def any_function(payload: dict) -> dict:
|
|
99
|
+
return {"ok": True}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The registry is framework-agnostic — it holds a dict of name → coroutine function. Feeding it into a worker loop lives in the TaskForge backend's worker process, not in this SDK.
|
|
103
|
+
|
|
104
|
+
## Error hierarchy
|
|
105
|
+
|
|
106
|
+
All exceptions inherit from `TaskForgeError`:
|
|
107
|
+
|
|
108
|
+
| Class | When |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `TaskForgeConnectionError` | Network unreachable (httpx raised) |
|
|
111
|
+
| `APIError` | Any 4xx/5xx from the server |
|
|
112
|
+
| `NotFoundError` | 404 (job/queue/dead-letter missing) |
|
|
113
|
+
| `ConflictError` | 409 (idempotency mismatch, invalid transition) |
|
|
114
|
+
| `ValidationError` | 422 (payload validation) |
|
|
115
|
+
| `UnauthorizedError` | 401 (bad API key) |
|
|
116
|
+
|
|
117
|
+
`APIError` carries `.status_code`, `.code`, `.message`, `.request_id`. FastAPI validation errors (`422`) are flattened — the `.message` joins all per-field `msg` strings with `; `.
|
|
118
|
+
|
|
119
|
+
## Client methods
|
|
120
|
+
|
|
121
|
+
| Method | Purpose |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `enqueue(task_name, payload, *, queue, priority, retry_policy, max_attempts, ...)` | Submit a job |
|
|
124
|
+
| `get(job_id)` | Full job detail with attempt history |
|
|
125
|
+
| `list_jobs(*, status, queue, task_name, priority, limit, cursor)` | Filtered list, one page |
|
|
126
|
+
| `iter_jobs(**filters)` | Async iterator over all pages |
|
|
127
|
+
| `cancel(job_id)` | Cancel queued/scheduled/retry_scheduled |
|
|
128
|
+
| `wait_for(job_id, *, poll_interval, timeout)` | Poll until terminal state |
|
|
129
|
+
| `queues()` | List queues |
|
|
130
|
+
| `health_live()`, `health_ready()` | Liveness / readiness booleans |
|
|
131
|
+
|
|
132
|
+
### `enqueue` — retry policy
|
|
133
|
+
|
|
134
|
+
Three ways to specify, from most to least explicit:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# 1. Full RetryPolicy object
|
|
138
|
+
from taskforge import RetryPolicy
|
|
139
|
+
await client.enqueue(
|
|
140
|
+
task_name="x", payload={},
|
|
141
|
+
retry_policy=RetryPolicy(max_attempts=3, initial_delay_seconds=5),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# 2. Shorthand kwargs (unset fields use server defaults)
|
|
145
|
+
await client.enqueue(task_name="x", payload={}, max_attempts=3)
|
|
146
|
+
|
|
147
|
+
# 3. Omit entirely (server default: 5 attempts, 2s initial delay, jitter)
|
|
148
|
+
await client.enqueue(task_name="x", payload={})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### `wait_for` — polling helper
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
final = await client.wait_for(job_id, poll_interval=1.0, timeout=300)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Poll interval in seconds, timeout in seconds. Returns the `JobDetail` once `status` is one of `succeeded`, `failed`, `dead_lettered`, or `cancelled`. Raises `TimeoutError` if the job doesn't reach a terminal state in time.
|
|
158
|
+
|
|
159
|
+
### `iter_jobs` — pagination
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
async for job in client.iter_jobs(status="dead_lettered", limit=25):
|
|
163
|
+
print(job.id, job.task_name)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Yields across all cursor pages. `limit` controls page size, not total.
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
Two environment variables are picked up automatically by `TaskForgeClient.from_env()`:
|
|
171
|
+
|
|
172
|
+
| Variable | Default | Purpose |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `TASKFORGE_URL` | `http://localhost:8000` | API base URL |
|
|
175
|
+
| `TASKFORGE_API_KEY` | (none — required) | API key for auth |
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from taskforge import TaskForgeClient
|
|
179
|
+
client = TaskForgeClient.from_env()
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Or pass explicitly:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
client = TaskForgeClient(base_url="http://prod:8000", api_key="tf_...")
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Testing
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
cd sdk
|
|
192
|
+
pytest -q
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
20 tests, no live backend required:
|
|
196
|
+
- 16 client tests via `httpx.MockTransport` — verify request bodies, query params, error mapping, `wait_for` polling, connection errors
|
|
197
|
+
- 4 registry tests — bare decorator, named decorator, empty parens, duplicate detection
|
|
198
|
+
|
|
199
|
+
## What's not here yet
|
|
200
|
+
|
|
201
|
+
Deliberate scope limits for v0.1.0:
|
|
202
|
+
|
|
203
|
+
- **Sync client** — async only. Wrap in `asyncio.run()` for scripts.
|
|
204
|
+
- **Batch enqueue** — one HTTP call per job. Batch endpoint would need a server-side change first.
|
|
205
|
+
- **Client-side retries** — the SDK does not retry on transient 5xx. The *server* retries the *job*; the *request* is caller's responsibility.
|
|
206
|
+
- **Dead-letter replay** — server endpoint (`POST /dead-letters/{id}/replay`) doesn't exist yet. Once added, a `client.replay(...)` method follows.
|
|
207
|
+
- **`POST /jobs/{id}/retry`** — server endpoint doesn't exist yet.
|
|
208
|
+
- **CLI** — no `taskforge submit ...` binary. Would be a thin wrapper around the async client using `typer` or `click`.
|
|
209
|
+
- **PyPI publication** — `pip install taskforge-sdk` doesn't work. Install from a checkout with `pip install -e ./sdk`.
|
|
210
|
+
|
|
211
|
+
See the main repo's README for the full roadmap.
|
|
212
|
+
|
|
213
|
+
## Links
|
|
214
|
+
|
|
215
|
+
- Main project: https://github.com/Olufemooshegs/taskforge
|
|
216
|
+
- API docs: http://localhost:8000/docs (when running locally)
|
|
217
|
+
- Architecture: [`../docs/architecture.md`](../docs/architecture.md)
|
|
218
|
+
- Reliability: [`../docs/reliability.md`](../docs/reliability.md)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
taskforge/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
taskforge/__init__.py,sha256=Ke_2u1ADIcv7dYcJpFCzf0mRRKWsvFuUOKrB_NAGAIA,1294
|
|
3
|
+
taskforge/client.py,sha256=cXewty5Vx2FzDs-taGzlawy0SJVrgjZXBnBglZ_trbg,9228
|
|
4
|
+
taskforge/exceptions.py,sha256=58c0YQN1i9Eo8516fzAebbUWSPBJhuhE3jetu2dpJgY,1035
|
|
5
|
+
taskforge/registry.py,sha256=9HYBvRRz-RSbR6mVArh_9xS_OXS5Te6kvdirv3cprVQ,1859
|
|
6
|
+
taskforge/types.py,sha256=M70eGAY0zWMi3AONc1j-jHIBp6CDbBxCx94zN-9MDSU,2562
|
|
7
|
+
taskforge_sdk-0.1.0.dist-info/METADATA,sha256=zTekEsDodsuZkpIsrn3DqABCdvjys_fLWCfP0xY9kwk,6697
|
|
8
|
+
taskforge_sdk-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
taskforge_sdk-0.1.0.dist-info/RECORD,,
|