taskforge-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskforge_sdk-0.1.0/.gitignore +41 -0
- taskforge_sdk-0.1.0/.gitkeep +0 -0
- taskforge_sdk-0.1.0/PKG-INFO +218 -0
- taskforge_sdk-0.1.0/README.md +204 -0
- taskforge_sdk-0.1.0/examples/quickstart.py +46 -0
- taskforge_sdk-0.1.0/pyproject.toml +31 -0
- taskforge_sdk-0.1.0/taskforge/.gitkeep +0 -0
- taskforge_sdk-0.1.0/taskforge/__init__.py +63 -0
- taskforge_sdk-0.1.0/taskforge/client.py +276 -0
- taskforge_sdk-0.1.0/taskforge/exceptions.py +42 -0
- taskforge_sdk-0.1.0/taskforge/registry.py +55 -0
- taskforge_sdk-0.1.0/taskforge/types.py +114 -0
- taskforge_sdk-0.1.0/tests/.gitkeep +0 -0
- taskforge_sdk-0.1.0/tests/__init__.py +0 -0
- taskforge_sdk-0.1.0/tests/test_client.py +340 -0
- taskforge_sdk-0.1.0/tests/test_registry.py +53 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
htmlcov/
|
|
11
|
+
.coverage
|
|
12
|
+
|
|
13
|
+
# Node / Next
|
|
14
|
+
node_modules/
|
|
15
|
+
.next/
|
|
16
|
+
out/
|
|
17
|
+
dist/
|
|
18
|
+
*.tsbuildinfo
|
|
19
|
+
|
|
20
|
+
# Env — never commit secrets
|
|
21
|
+
.env
|
|
22
|
+
.env.*
|
|
23
|
+
!.env.example
|
|
24
|
+
|
|
25
|
+
# IDE / OS
|
|
26
|
+
.vscode/
|
|
27
|
+
.idea/
|
|
28
|
+
*.swp
|
|
29
|
+
.DS_Store
|
|
30
|
+
|
|
31
|
+
# Logs
|
|
32
|
+
*.log
|
|
33
|
+
logs/
|
|
34
|
+
|
|
35
|
+
# Local data volumes
|
|
36
|
+
postgres-data/
|
|
37
|
+
redis-data/
|
|
38
|
+
# SDK local dev
|
|
39
|
+
sdk/.venv/
|
|
40
|
+
sdk/*.egg-info/
|
|
41
|
+
sdk/**/__pycache__/
|
|
File without changes
|
|
@@ -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,204 @@
|
|
|
1
|
+
|
|
2
|
+
# TaskForge SDK
|
|
3
|
+
|
|
4
|
+
Python client for the TaskForge distributed job engine.
|
|
5
|
+
|
|
6
|
+
Typed async HTTP client plus a task registry for worker handlers.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
From a local checkout (the package isn't published to PyPI):
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd sdk
|
|
14
|
+
pip install -e ".[dev]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10+ (the SDK is pure Python — no compiled dependencies).
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
from taskforge import TaskForgeClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
async with TaskForgeClient(
|
|
28
|
+
base_url="http://localhost:8000",
|
|
29
|
+
api_key="dev-api-key-change-me",
|
|
30
|
+
) as client:
|
|
31
|
+
# Submit a job
|
|
32
|
+
job = await client.enqueue(
|
|
33
|
+
task_name="send_welcome_email",
|
|
34
|
+
queue="emails",
|
|
35
|
+
payload={"recipient": "user@example.com"},
|
|
36
|
+
priority="high",
|
|
37
|
+
max_attempts=5,
|
|
38
|
+
)
|
|
39
|
+
print(f"submitted: {job.id} ({job.status})")
|
|
40
|
+
|
|
41
|
+
# Poll until terminal
|
|
42
|
+
final = await client.wait_for(job.id, timeout=60)
|
|
43
|
+
print(f"final status: {final.status}")
|
|
44
|
+
if final.result:
|
|
45
|
+
print(f"result: {final.result}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Expected output:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
submitted: <uuid> (queued)
|
|
55
|
+
final status: succeeded
|
|
56
|
+
result: {'echoed': {...}}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Task registry
|
|
60
|
+
|
|
61
|
+
Register handlers on the worker side with the decorator. Three forms:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from taskforge import TaskRegistry
|
|
65
|
+
|
|
66
|
+
registry = TaskRegistry()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Form 1: bare decorator, uses fn.__name__
|
|
70
|
+
@registry.task
|
|
71
|
+
async def send_welcome_email(payload: dict) -> dict:
|
|
72
|
+
recipient = payload["recipient"]
|
|
73
|
+
return {"recipient": recipient, "sent": True}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Form 2: empty parens, same behavior
|
|
77
|
+
@registry.task()
|
|
78
|
+
async def noop(payload: dict) -> dict:
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Form 3: explicit name
|
|
83
|
+
@registry.task(name="custom.name")
|
|
84
|
+
async def any_function(payload: dict) -> dict:
|
|
85
|
+
return {"ok": True}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
## Error hierarchy
|
|
91
|
+
|
|
92
|
+
All exceptions inherit from `TaskForgeError`:
|
|
93
|
+
|
|
94
|
+
| Class | When |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `TaskForgeConnectionError` | Network unreachable (httpx raised) |
|
|
97
|
+
| `APIError` | Any 4xx/5xx from the server |
|
|
98
|
+
| `NotFoundError` | 404 (job/queue/dead-letter missing) |
|
|
99
|
+
| `ConflictError` | 409 (idempotency mismatch, invalid transition) |
|
|
100
|
+
| `ValidationError` | 422 (payload validation) |
|
|
101
|
+
| `UnauthorizedError` | 401 (bad API key) |
|
|
102
|
+
|
|
103
|
+
`APIError` carries `.status_code`, `.code`, `.message`, `.request_id`. FastAPI validation errors (`422`) are flattened — the `.message` joins all per-field `msg` strings with `; `.
|
|
104
|
+
|
|
105
|
+
## Client methods
|
|
106
|
+
|
|
107
|
+
| Method | Purpose |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `enqueue(task_name, payload, *, queue, priority, retry_policy, max_attempts, ...)` | Submit a job |
|
|
110
|
+
| `get(job_id)` | Full job detail with attempt history |
|
|
111
|
+
| `list_jobs(*, status, queue, task_name, priority, limit, cursor)` | Filtered list, one page |
|
|
112
|
+
| `iter_jobs(**filters)` | Async iterator over all pages |
|
|
113
|
+
| `cancel(job_id)` | Cancel queued/scheduled/retry_scheduled |
|
|
114
|
+
| `wait_for(job_id, *, poll_interval, timeout)` | Poll until terminal state |
|
|
115
|
+
| `queues()` | List queues |
|
|
116
|
+
| `health_live()`, `health_ready()` | Liveness / readiness booleans |
|
|
117
|
+
|
|
118
|
+
### `enqueue` — retry policy
|
|
119
|
+
|
|
120
|
+
Three ways to specify, from most to least explicit:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# 1. Full RetryPolicy object
|
|
124
|
+
from taskforge import RetryPolicy
|
|
125
|
+
await client.enqueue(
|
|
126
|
+
task_name="x", payload={},
|
|
127
|
+
retry_policy=RetryPolicy(max_attempts=3, initial_delay_seconds=5),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# 2. Shorthand kwargs (unset fields use server defaults)
|
|
131
|
+
await client.enqueue(task_name="x", payload={}, max_attempts=3)
|
|
132
|
+
|
|
133
|
+
# 3. Omit entirely (server default: 5 attempts, 2s initial delay, jitter)
|
|
134
|
+
await client.enqueue(task_name="x", payload={})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `wait_for` — polling helper
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
final = await client.wait_for(job_id, poll_interval=1.0, timeout=300)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
### `iter_jobs` — pagination
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
async for job in client.iter_jobs(status="dead_lettered", limit=25):
|
|
149
|
+
print(job.id, job.task_name)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Yields across all cursor pages. `limit` controls page size, not total.
|
|
153
|
+
|
|
154
|
+
## Configuration
|
|
155
|
+
|
|
156
|
+
Two environment variables are picked up automatically by `TaskForgeClient.from_env()`:
|
|
157
|
+
|
|
158
|
+
| Variable | Default | Purpose |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `TASKFORGE_URL` | `http://localhost:8000` | API base URL |
|
|
161
|
+
| `TASKFORGE_API_KEY` | (none — required) | API key for auth |
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from taskforge import TaskForgeClient
|
|
165
|
+
client = TaskForgeClient.from_env()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Or pass explicitly:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
client = TaskForgeClient(base_url="http://prod:8000", api_key="tf_...")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Testing
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
cd sdk
|
|
178
|
+
pytest -q
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
20 tests, no live backend required:
|
|
182
|
+
- 16 client tests via `httpx.MockTransport` — verify request bodies, query params, error mapping, `wait_for` polling, connection errors
|
|
183
|
+
- 4 registry tests — bare decorator, named decorator, empty parens, duplicate detection
|
|
184
|
+
|
|
185
|
+
## What's not here yet
|
|
186
|
+
|
|
187
|
+
Deliberate scope limits for v0.1.0:
|
|
188
|
+
|
|
189
|
+
- **Sync client** — async only. Wrap in `asyncio.run()` for scripts.
|
|
190
|
+
- **Batch enqueue** — one HTTP call per job. Batch endpoint would need a server-side change first.
|
|
191
|
+
- **Client-side retries** — the SDK does not retry on transient 5xx. The *server* retries the *job*; the *request* is caller's responsibility.
|
|
192
|
+
- **Dead-letter replay** — server endpoint (`POST /dead-letters/{id}/replay`) doesn't exist yet. Once added, a `client.replay(...)` method follows.
|
|
193
|
+
- **`POST /jobs/{id}/retry`** — server endpoint doesn't exist yet.
|
|
194
|
+
- **CLI** — no `taskforge submit ...` binary. Would be a thin wrapper around the async client using `typer` or `click`.
|
|
195
|
+
- **PyPI publication** — `pip install taskforge-sdk` doesn't work. Install from a checkout with `pip install -e ./sdk`.
|
|
196
|
+
|
|
197
|
+
See the main repo's README for the full roadmap.
|
|
198
|
+
|
|
199
|
+
## Links
|
|
200
|
+
|
|
201
|
+
- Main project: https://github.com/Olufemooshegs/taskforge
|
|
202
|
+
- API docs: http://localhost:8000/docs (when running locally)
|
|
203
|
+
- Architecture: [`../docs/architecture.md`](../docs/architecture.md)
|
|
204
|
+
- Reliability: [`../docs/reliability.md`](../docs/reliability.md)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""End-to-end SDK demo against a running TaskForge instance.
|
|
2
|
+
|
|
3
|
+
export TASKFORGE_API_KEY=dev-api-key-change-me
|
|
4
|
+
python examples/quickstart.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from taskforge import TaskForgeClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def main() -> None:
|
|
14
|
+
api_key = os.environ.get("TASKFORGE_API_KEY", "dev-api-key-change-me")
|
|
15
|
+
base_url = os.environ.get("TASKFORGE_URL", "http://localhost:8000")
|
|
16
|
+
|
|
17
|
+
async with TaskForgeClient(base_url=base_url, api_key=api_key) as client:
|
|
18
|
+
print(f"Connected. Live: {await client.health_live()}")
|
|
19
|
+
print(f"Ready: {await client.health_ready()}")
|
|
20
|
+
|
|
21
|
+
print("\nQueues:")
|
|
22
|
+
for q in await client.queues():
|
|
23
|
+
print(f" {q.name}: {q.max_concurrency} concurrency, active={q.is_active}")
|
|
24
|
+
|
|
25
|
+
print("\nSubmitting 3 jobs...")
|
|
26
|
+
ids = []
|
|
27
|
+
for i in range(3):
|
|
28
|
+
resp = await client.enqueue(
|
|
29
|
+
task_name="echo",
|
|
30
|
+
payload={"i": i, "msg": f"hello-{i}"},
|
|
31
|
+
queue="default",
|
|
32
|
+
priority="normal",
|
|
33
|
+
)
|
|
34
|
+
ids.append(resp.id)
|
|
35
|
+
print(f" {resp.id} status={resp.status}")
|
|
36
|
+
|
|
37
|
+
print("\nWaiting for completion...")
|
|
38
|
+
for job_id in ids:
|
|
39
|
+
final = await client.wait_for(job_id, poll_interval=0.3, timeout=30)
|
|
40
|
+
print(f" {final.id}: {final.status} in {final.attempts} attempt(s)")
|
|
41
|
+
if final.result:
|
|
42
|
+
print(f" result: {final.result}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "taskforge-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for the TaskForge distributed job engine"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"pydantic>=2.7",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = [
|
|
15
|
+
"pytest>=8.0",
|
|
16
|
+
"pytest-asyncio>=0.24",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/Olufemooshegs/taskforge"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["taskforge"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -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
|
+
]
|