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 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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any