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.
@@ -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
+ ]