workly 0.1.0__tar.gz → 0.2.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.
- {workly-0.1.0 → workly-0.2.0}/PKG-INFO +28 -1
- {workly-0.1.0 → workly-0.2.0}/README.md +27 -0
- {workly-0.1.0 → workly-0.2.0}/pyproject.toml +1 -1
- {workly-0.1.0 → workly-0.2.0}/pyproject.toml.orig +1 -1
- {workly-0.1.0 → workly-0.2.0}/src/workly/__init__.py +18 -2
- {workly-0.1.0 → workly-0.2.0}/src/workly/_async_client.py +97 -2
- {workly-0.1.0 → workly-0.2.0}/src/workly/_client.py +96 -2
- {workly-0.1.0 → workly-0.2.0}/src/workly/_models.py +78 -0
- {workly-0.1.0 → workly-0.2.0}/src/workly/_requests.py +69 -1
- {workly-0.1.0 → workly-0.2.0}/LICENSE +0 -0
- {workly-0.1.0 → workly-0.2.0}/src/workly/_errors.py +0 -0
- {workly-0.1.0 → workly-0.2.0}/src/workly/_signing.py +0 -0
- {workly-0.1.0 → workly-0.2.0}/src/workly/_transport.py +0 -0
- {workly-0.1.0 → workly-0.2.0}/src/workly/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Workly, the open-source HTTP task queue
|
|
5
5
|
Keywords: task queue,background jobs,webhooks,http
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -182,6 +182,33 @@ client.schedules.delete("nightly-report") # with all its runs
|
|
|
182
182
|
|
|
183
183
|
Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled-For` headers and the same signature, so `verify_signature` works unchanged.
|
|
184
184
|
|
|
185
|
+
## Task groups
|
|
186
|
+
|
|
187
|
+
A group tracks a set of tasks and enqueues a callback task once it's closed and they've all finished: a `POST` of the group's status and counts. See [Task groups](https://docs.workly.run/groups/).
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
group = client.groups.create(
|
|
191
|
+
name="daily-stock-prices",
|
|
192
|
+
idempotency_key="stock-prices-2026-10-19", # creating it again returns this group
|
|
193
|
+
on_complete={"queue": "jobs", "url": "https://api.example.com/jobs/refresh-views"},
|
|
194
|
+
)
|
|
195
|
+
for company in companies:
|
|
196
|
+
client.tasks.enqueue(
|
|
197
|
+
"stock-prices",
|
|
198
|
+
url="https://api.example.com/jobs/update-stock-price",
|
|
199
|
+
payload={"symbol": company.symbol},
|
|
200
|
+
group_id=group.id,
|
|
201
|
+
)
|
|
202
|
+
client.groups.close(group.id) # no more tasks; it completes once they've finished
|
|
203
|
+
|
|
204
|
+
group = client.groups.get(group.id)
|
|
205
|
+
print(group.status, group.total, group.succeeded, group.failed, group.remaining)
|
|
206
|
+
for task in client.tasks.list(group_id=group.id, state="failed"):
|
|
207
|
+
print(task.id, task.last_error)
|
|
208
|
+
for group in client.groups.list(name="daily-stock-prices", status="failed"):
|
|
209
|
+
print(group.id, group.finished_at)
|
|
210
|
+
```
|
|
211
|
+
|
|
185
212
|
## Async
|
|
186
213
|
|
|
187
214
|
`AsyncWorkly` has the same methods, as coroutines:
|
|
@@ -162,6 +162,33 @@ client.schedules.delete("nightly-report") # with all its runs
|
|
|
162
162
|
|
|
163
163
|
Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled-For` headers and the same signature, so `verify_signature` works unchanged.
|
|
164
164
|
|
|
165
|
+
## Task groups
|
|
166
|
+
|
|
167
|
+
A group tracks a set of tasks and enqueues a callback task once it's closed and they've all finished: a `POST` of the group's status and counts. See [Task groups](https://docs.workly.run/groups/).
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
group = client.groups.create(
|
|
171
|
+
name="daily-stock-prices",
|
|
172
|
+
idempotency_key="stock-prices-2026-10-19", # creating it again returns this group
|
|
173
|
+
on_complete={"queue": "jobs", "url": "https://api.example.com/jobs/refresh-views"},
|
|
174
|
+
)
|
|
175
|
+
for company in companies:
|
|
176
|
+
client.tasks.enqueue(
|
|
177
|
+
"stock-prices",
|
|
178
|
+
url="https://api.example.com/jobs/update-stock-price",
|
|
179
|
+
payload={"symbol": company.symbol},
|
|
180
|
+
group_id=group.id,
|
|
181
|
+
)
|
|
182
|
+
client.groups.close(group.id) # no more tasks; it completes once they've finished
|
|
183
|
+
|
|
184
|
+
group = client.groups.get(group.id)
|
|
185
|
+
print(group.status, group.total, group.succeeded, group.failed, group.remaining)
|
|
186
|
+
for task in client.tasks.list(group_id=group.id, state="failed"):
|
|
187
|
+
print(task.id, task.last_error)
|
|
188
|
+
for group in client.groups.list(name="daily-stock-prices", status="failed"):
|
|
189
|
+
print(group.id, group.finished_at)
|
|
190
|
+
```
|
|
191
|
+
|
|
165
192
|
## Async
|
|
166
193
|
|
|
167
194
|
`AsyncWorkly` has the same methods, as coroutines:
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from ._async_client import (
|
|
4
4
|
AsyncAlerts,
|
|
5
|
+
AsyncGroups,
|
|
5
6
|
AsyncQueues,
|
|
6
7
|
AsyncRuns,
|
|
7
8
|
AsyncSchedules,
|
|
8
9
|
AsyncTasks,
|
|
9
10
|
AsyncWorkly,
|
|
10
11
|
)
|
|
11
|
-
from ._client import Alerts, Queues, Runs, Schedules, Tasks, Workly
|
|
12
|
+
from ._client import Alerts, Groups, Queues, Runs, Schedules, Tasks, Workly
|
|
12
13
|
from ._errors import (
|
|
13
14
|
APIConnectionError,
|
|
14
15
|
APIError,
|
|
@@ -35,6 +36,10 @@ from ._models import (
|
|
|
35
36
|
Delivery,
|
|
36
37
|
EnqueueResult,
|
|
37
38
|
FailureGroup,
|
|
39
|
+
Group,
|
|
40
|
+
GroupCallback,
|
|
41
|
+
GroupPage,
|
|
42
|
+
GroupStatus,
|
|
38
43
|
HTTPMethod,
|
|
39
44
|
LastRun,
|
|
40
45
|
MissedPolicy,
|
|
@@ -48,11 +53,13 @@ from ._models import (
|
|
|
48
53
|
RunTrigger,
|
|
49
54
|
Schedule,
|
|
50
55
|
SkipReason,
|
|
56
|
+
SortOrder,
|
|
51
57
|
Task,
|
|
52
58
|
TaskPage,
|
|
59
|
+
TaskSort,
|
|
53
60
|
TaskState,
|
|
54
61
|
)
|
|
55
|
-
from ._requests import TaskSpec, __version__
|
|
62
|
+
from ._requests import GroupCallbackSpec, TaskSpec, __version__
|
|
56
63
|
from ._signing import sign, verify_signature
|
|
57
64
|
|
|
58
65
|
__all__ = [
|
|
@@ -66,6 +73,7 @@ __all__ = [
|
|
|
66
73
|
"AlertChannelType",
|
|
67
74
|
"Alerts",
|
|
68
75
|
"AsyncAlerts",
|
|
76
|
+
"AsyncGroups",
|
|
69
77
|
"AsyncQueues",
|
|
70
78
|
"AsyncRuns",
|
|
71
79
|
"AsyncSchedules",
|
|
@@ -80,6 +88,12 @@ __all__ = [
|
|
|
80
88
|
"Delivery",
|
|
81
89
|
"EnqueueResult",
|
|
82
90
|
"FailureGroup",
|
|
91
|
+
"Group",
|
|
92
|
+
"GroupCallback",
|
|
93
|
+
"GroupCallbackSpec",
|
|
94
|
+
"GroupPage",
|
|
95
|
+
"GroupStatus",
|
|
96
|
+
"Groups",
|
|
83
97
|
"HTTPMethod",
|
|
84
98
|
"LastRun",
|
|
85
99
|
"MissedPolicy",
|
|
@@ -101,8 +115,10 @@ __all__ = [
|
|
|
101
115
|
"Schedules",
|
|
102
116
|
"SignatureVerificationError",
|
|
103
117
|
"SkipReason",
|
|
118
|
+
"SortOrder",
|
|
104
119
|
"Task",
|
|
105
120
|
"TaskPage",
|
|
121
|
+
"TaskSort",
|
|
106
122
|
"TaskSpec",
|
|
107
123
|
"TaskState",
|
|
108
124
|
"Tasks",
|
|
@@ -21,6 +21,9 @@ from ._models import (
|
|
|
21
21
|
Delivery,
|
|
22
22
|
EnqueueResult,
|
|
23
23
|
FailureGroup,
|
|
24
|
+
Group,
|
|
25
|
+
GroupPage,
|
|
26
|
+
GroupStatus,
|
|
24
27
|
HTTPMethod,
|
|
25
28
|
MissedPolicy,
|
|
26
29
|
OverlapPolicy,
|
|
@@ -30,8 +33,10 @@ from ._models import (
|
|
|
30
33
|
RunPage,
|
|
31
34
|
RunState,
|
|
32
35
|
Schedule,
|
|
36
|
+
SortOrder,
|
|
33
37
|
Task,
|
|
34
38
|
TaskPage,
|
|
39
|
+
TaskSort,
|
|
35
40
|
TaskState,
|
|
36
41
|
)
|
|
37
42
|
|
|
@@ -60,6 +65,7 @@ class AsyncWorkly:
|
|
|
60
65
|
self._max_retries = max_retries
|
|
61
66
|
self.queues = AsyncQueues(self)
|
|
62
67
|
self.tasks = AsyncTasks(self)
|
|
68
|
+
self.groups = AsyncGroups(self)
|
|
63
69
|
self.schedules = AsyncSchedules(self)
|
|
64
70
|
self.runs = AsyncRuns(self)
|
|
65
71
|
self.alerts = AsyncAlerts(self)
|
|
@@ -208,6 +214,7 @@ class AsyncTasks:
|
|
|
208
214
|
idempotency_key: str | None = None,
|
|
209
215
|
max_attempts: int | None = None,
|
|
210
216
|
timeout_seconds: float | None = None,
|
|
217
|
+
group_id: str | None = None,
|
|
211
218
|
traceparent: str | None = None,
|
|
212
219
|
) -> Task:
|
|
213
220
|
"""Enqueue a task to deliver to `url`.
|
|
@@ -215,7 +222,8 @@ class AsyncTasks:
|
|
|
215
222
|
`payload` is sent as JSON; `body` as raw text (use one). The queue is
|
|
216
223
|
created with default settings if it doesn't exist. If
|
|
217
224
|
`idempotency_key` matches a task already in the queue, that task is
|
|
218
|
-
returned and nothing is enqueued.
|
|
225
|
+
returned and nothing is enqueued. With `group_id`, the task joins
|
|
226
|
+
that group, which must be open.
|
|
219
227
|
"""
|
|
220
228
|
spec = {
|
|
221
229
|
"url": url,
|
|
@@ -229,6 +237,7 @@ class AsyncTasks:
|
|
|
229
237
|
"idempotency_key": idempotency_key,
|
|
230
238
|
"max_attempts": max_attempts,
|
|
231
239
|
"timeout_seconds": timeout_seconds,
|
|
240
|
+
"group_id": group_id,
|
|
232
241
|
}
|
|
233
242
|
return Task._from_json(await self._client._send(r.enqueue(queue, spec, traceparent)))
|
|
234
243
|
|
|
@@ -270,22 +279,33 @@ class AsyncTasks:
|
|
|
270
279
|
*,
|
|
271
280
|
queue: str | None = None,
|
|
272
281
|
state: TaskState | None = None,
|
|
282
|
+
group_id: str | None = None,
|
|
273
283
|
idempotency_key: str | None = None,
|
|
274
284
|
last_error: str | None = None,
|
|
275
285
|
created_after: datetime | None = None,
|
|
276
286
|
created_before: datetime | None = None,
|
|
287
|
+
sort: TaskSort | None = None,
|
|
288
|
+
order: SortOrder | None = None,
|
|
277
289
|
page_size: int | None = None,
|
|
278
290
|
) -> AsyncIterator[Task]:
|
|
279
|
-
"""Tasks matching every filter given,
|
|
291
|
+
"""Tasks matching every filter given, fetching pages as needed.
|
|
292
|
+
|
|
293
|
+
Newest first, unless `sort` and `order` say otherwise: `order` defaults
|
|
294
|
+
to "asc" (soonest first) for `sort="run_at"` and to "desc" for the rest.
|
|
295
|
+
List pending tasks by `run_at` to see them in the order they run.
|
|
296
|
+
"""
|
|
280
297
|
cursor: str | None = None
|
|
281
298
|
while True:
|
|
282
299
|
page = await self.list_page(
|
|
283
300
|
queue=queue,
|
|
284
301
|
state=state,
|
|
302
|
+
group_id=group_id,
|
|
285
303
|
idempotency_key=idempotency_key,
|
|
286
304
|
last_error=last_error,
|
|
287
305
|
created_after=created_after,
|
|
288
306
|
created_before=created_before,
|
|
307
|
+
sort=sort,
|
|
308
|
+
order=order,
|
|
289
309
|
cursor=cursor,
|
|
290
310
|
limit=page_size,
|
|
291
311
|
)
|
|
@@ -300,10 +320,13 @@ class AsyncTasks:
|
|
|
300
320
|
*,
|
|
301
321
|
queue: str | None = None,
|
|
302
322
|
state: TaskState | None = None,
|
|
323
|
+
group_id: str | None = None,
|
|
303
324
|
idempotency_key: str | None = None,
|
|
304
325
|
last_error: str | None = None,
|
|
305
326
|
created_after: datetime | None = None,
|
|
306
327
|
created_before: datetime | None = None,
|
|
328
|
+
sort: TaskSort | None = None,
|
|
329
|
+
order: SortOrder | None = None,
|
|
307
330
|
cursor: str | None = None,
|
|
308
331
|
limit: int | None = None,
|
|
309
332
|
) -> TaskPage:
|
|
@@ -311,10 +334,13 @@ class AsyncTasks:
|
|
|
311
334
|
req = r.search_tasks(
|
|
312
335
|
queue=queue,
|
|
313
336
|
state=state,
|
|
337
|
+
group_id=group_id,
|
|
314
338
|
idempotency_key=idempotency_key,
|
|
315
339
|
last_error=last_error,
|
|
316
340
|
created_after=created_after,
|
|
317
341
|
created_before=created_before,
|
|
342
|
+
sort=sort,
|
|
343
|
+
order=order,
|
|
318
344
|
cursor=cursor,
|
|
319
345
|
limit=limit,
|
|
320
346
|
)
|
|
@@ -328,6 +354,75 @@ class AsyncTasks:
|
|
|
328
354
|
return Activity._from_json(await self._client._send(r.get_activity(range)))
|
|
329
355
|
|
|
330
356
|
|
|
357
|
+
class AsyncGroups:
|
|
358
|
+
"""Task groups: a set of tasks, and a callback task once all have finished.
|
|
359
|
+
|
|
360
|
+
Create a group, enqueue tasks with its `group_id`, then close it. Once it
|
|
361
|
+
is closed and every task has succeeded, failed or been cancelled, it
|
|
362
|
+
completes and enqueues its `on_complete` callback, once.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
def __init__(self, client: AsyncWorkly) -> None:
|
|
366
|
+
self._client = client
|
|
367
|
+
|
|
368
|
+
async def create(
|
|
369
|
+
self,
|
|
370
|
+
*,
|
|
371
|
+
name: str | None = None,
|
|
372
|
+
idempotency_key: str | None = None,
|
|
373
|
+
on_complete: r.GroupCallbackSpec | None = None,
|
|
374
|
+
) -> Group:
|
|
375
|
+
"""Create an open group.
|
|
376
|
+
|
|
377
|
+
`on_complete` is the task to enqueue when the group completes, e.g.
|
|
378
|
+
`{"queue": "jobs", "url": "https://api.example.com/done"}`; it is a
|
|
379
|
+
POST of the group's summary as JSON. If `idempotency_key` matches an
|
|
380
|
+
existing group, that group is returned and nothing is created.
|
|
381
|
+
"""
|
|
382
|
+
req = r.create_group(name, idempotency_key, on_complete)
|
|
383
|
+
return Group._from_json(await self._client._send(req))
|
|
384
|
+
|
|
385
|
+
async def get(self, group_id: str) -> Group:
|
|
386
|
+
return Group._from_json(await self._client._send(r.get_group(group_id)))
|
|
387
|
+
|
|
388
|
+
async def close(self, group_id: str) -> Group:
|
|
389
|
+
"""Stop the group accepting tasks, so it completes once they have finished.
|
|
390
|
+
|
|
391
|
+
A group whose tasks have all finished completes at once. Closing a
|
|
392
|
+
closed group does nothing.
|
|
393
|
+
"""
|
|
394
|
+
return Group._from_json(await self._client._send(r.close_group(group_id)))
|
|
395
|
+
|
|
396
|
+
async def list(
|
|
397
|
+
self,
|
|
398
|
+
*,
|
|
399
|
+
status: GroupStatus | None = None,
|
|
400
|
+
name: str | None = None,
|
|
401
|
+
page_size: int | None = None,
|
|
402
|
+
) -> AsyncIterator[Group]:
|
|
403
|
+
"""Groups, newest first, fetching pages as needed."""
|
|
404
|
+
cursor: str | None = None
|
|
405
|
+
while True:
|
|
406
|
+
page = await self.list_page(status=status, name=name, cursor=cursor, limit=page_size)
|
|
407
|
+
for group in page.groups:
|
|
408
|
+
yield group
|
|
409
|
+
if page.next_cursor is None:
|
|
410
|
+
return
|
|
411
|
+
cursor = page.next_cursor
|
|
412
|
+
|
|
413
|
+
async def list_page(
|
|
414
|
+
self,
|
|
415
|
+
*,
|
|
416
|
+
status: GroupStatus | None = None,
|
|
417
|
+
name: str | None = None,
|
|
418
|
+
cursor: str | None = None,
|
|
419
|
+
limit: int | None = None,
|
|
420
|
+
) -> GroupPage:
|
|
421
|
+
"""One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
422
|
+
req = r.list_groups(status=status, name=name, cursor=cursor, limit=limit)
|
|
423
|
+
return GroupPage._from_json(await self._client._send(req))
|
|
424
|
+
|
|
425
|
+
|
|
331
426
|
class AsyncSchedules:
|
|
332
427
|
"""Cron schedules that call an endpoint. Separate from queues: each call is a run."""
|
|
333
428
|
|
|
@@ -21,6 +21,9 @@ from ._models import (
|
|
|
21
21
|
Delivery,
|
|
22
22
|
EnqueueResult,
|
|
23
23
|
FailureGroup,
|
|
24
|
+
Group,
|
|
25
|
+
GroupPage,
|
|
26
|
+
GroupStatus,
|
|
24
27
|
HTTPMethod,
|
|
25
28
|
MissedPolicy,
|
|
26
29
|
OverlapPolicy,
|
|
@@ -30,8 +33,10 @@ from ._models import (
|
|
|
30
33
|
RunPage,
|
|
31
34
|
RunState,
|
|
32
35
|
Schedule,
|
|
36
|
+
SortOrder,
|
|
33
37
|
Task,
|
|
34
38
|
TaskPage,
|
|
39
|
+
TaskSort,
|
|
35
40
|
TaskState,
|
|
36
41
|
)
|
|
37
42
|
|
|
@@ -60,6 +65,7 @@ class Workly:
|
|
|
60
65
|
self._max_retries = max_retries
|
|
61
66
|
self.queues = Queues(self)
|
|
62
67
|
self.tasks = Tasks(self)
|
|
68
|
+
self.groups = Groups(self)
|
|
63
69
|
self.schedules = Schedules(self)
|
|
64
70
|
self.runs = Runs(self)
|
|
65
71
|
self.alerts = Alerts(self)
|
|
@@ -208,6 +214,7 @@ class Tasks:
|
|
|
208
214
|
idempotency_key: str | None = None,
|
|
209
215
|
max_attempts: int | None = None,
|
|
210
216
|
timeout_seconds: float | None = None,
|
|
217
|
+
group_id: str | None = None,
|
|
211
218
|
traceparent: str | None = None,
|
|
212
219
|
) -> Task:
|
|
213
220
|
"""Enqueue a task to deliver to `url`.
|
|
@@ -215,7 +222,8 @@ class Tasks:
|
|
|
215
222
|
`payload` is sent as JSON; `body` as raw text (use one). The queue is
|
|
216
223
|
created with default settings if it doesn't exist. If
|
|
217
224
|
`idempotency_key` matches a task already in the queue, that task is
|
|
218
|
-
returned and nothing is enqueued.
|
|
225
|
+
returned and nothing is enqueued. With `group_id`, the task joins
|
|
226
|
+
that group, which must be open.
|
|
219
227
|
"""
|
|
220
228
|
spec = {
|
|
221
229
|
"url": url,
|
|
@@ -229,6 +237,7 @@ class Tasks:
|
|
|
229
237
|
"idempotency_key": idempotency_key,
|
|
230
238
|
"max_attempts": max_attempts,
|
|
231
239
|
"timeout_seconds": timeout_seconds,
|
|
240
|
+
"group_id": group_id,
|
|
232
241
|
}
|
|
233
242
|
return Task._from_json(self._client._send(r.enqueue(queue, spec, traceparent)))
|
|
234
243
|
|
|
@@ -270,22 +279,33 @@ class Tasks:
|
|
|
270
279
|
*,
|
|
271
280
|
queue: str | None = None,
|
|
272
281
|
state: TaskState | None = None,
|
|
282
|
+
group_id: str | None = None,
|
|
273
283
|
idempotency_key: str | None = None,
|
|
274
284
|
last_error: str | None = None,
|
|
275
285
|
created_after: datetime | None = None,
|
|
276
286
|
created_before: datetime | None = None,
|
|
287
|
+
sort: TaskSort | None = None,
|
|
288
|
+
order: SortOrder | None = None,
|
|
277
289
|
page_size: int | None = None,
|
|
278
290
|
) -> Iterator[Task]:
|
|
279
|
-
"""Tasks matching every filter given,
|
|
291
|
+
"""Tasks matching every filter given, fetching pages as needed.
|
|
292
|
+
|
|
293
|
+
Newest first, unless `sort` and `order` say otherwise: `order` defaults
|
|
294
|
+
to "asc" (soonest first) for `sort="run_at"` and to "desc" for the rest.
|
|
295
|
+
List pending tasks by `run_at` to see them in the order they run.
|
|
296
|
+
"""
|
|
280
297
|
cursor: str | None = None
|
|
281
298
|
while True:
|
|
282
299
|
page = self.list_page(
|
|
283
300
|
queue=queue,
|
|
284
301
|
state=state,
|
|
302
|
+
group_id=group_id,
|
|
285
303
|
idempotency_key=idempotency_key,
|
|
286
304
|
last_error=last_error,
|
|
287
305
|
created_after=created_after,
|
|
288
306
|
created_before=created_before,
|
|
307
|
+
sort=sort,
|
|
308
|
+
order=order,
|
|
289
309
|
cursor=cursor,
|
|
290
310
|
limit=page_size,
|
|
291
311
|
)
|
|
@@ -299,10 +319,13 @@ class Tasks:
|
|
|
299
319
|
*,
|
|
300
320
|
queue: str | None = None,
|
|
301
321
|
state: TaskState | None = None,
|
|
322
|
+
group_id: str | None = None,
|
|
302
323
|
idempotency_key: str | None = None,
|
|
303
324
|
last_error: str | None = None,
|
|
304
325
|
created_after: datetime | None = None,
|
|
305
326
|
created_before: datetime | None = None,
|
|
327
|
+
sort: TaskSort | None = None,
|
|
328
|
+
order: SortOrder | None = None,
|
|
306
329
|
cursor: str | None = None,
|
|
307
330
|
limit: int | None = None,
|
|
308
331
|
) -> TaskPage:
|
|
@@ -310,10 +333,13 @@ class Tasks:
|
|
|
310
333
|
req = r.search_tasks(
|
|
311
334
|
queue=queue,
|
|
312
335
|
state=state,
|
|
336
|
+
group_id=group_id,
|
|
313
337
|
idempotency_key=idempotency_key,
|
|
314
338
|
last_error=last_error,
|
|
315
339
|
created_after=created_after,
|
|
316
340
|
created_before=created_before,
|
|
341
|
+
sort=sort,
|
|
342
|
+
order=order,
|
|
317
343
|
cursor=cursor,
|
|
318
344
|
limit=limit,
|
|
319
345
|
)
|
|
@@ -327,6 +353,74 @@ class Tasks:
|
|
|
327
353
|
return Activity._from_json(self._client._send(r.get_activity(range)))
|
|
328
354
|
|
|
329
355
|
|
|
356
|
+
class Groups:
|
|
357
|
+
"""Task groups: a set of tasks, and a callback task once all have finished.
|
|
358
|
+
|
|
359
|
+
Create a group, enqueue tasks with its `group_id`, then close it. Once it
|
|
360
|
+
is closed and every task has succeeded, failed or been cancelled, it
|
|
361
|
+
completes and enqueues its `on_complete` callback, once.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def __init__(self, client: Workly) -> None:
|
|
365
|
+
self._client = client
|
|
366
|
+
|
|
367
|
+
def create(
|
|
368
|
+
self,
|
|
369
|
+
*,
|
|
370
|
+
name: str | None = None,
|
|
371
|
+
idempotency_key: str | None = None,
|
|
372
|
+
on_complete: r.GroupCallbackSpec | None = None,
|
|
373
|
+
) -> Group:
|
|
374
|
+
"""Create an open group.
|
|
375
|
+
|
|
376
|
+
`on_complete` is the task to enqueue when the group completes, e.g.
|
|
377
|
+
`{"queue": "jobs", "url": "https://api.example.com/done"}`; it is a
|
|
378
|
+
POST of the group's summary as JSON. If `idempotency_key` matches an
|
|
379
|
+
existing group, that group is returned and nothing is created.
|
|
380
|
+
"""
|
|
381
|
+
req = r.create_group(name, idempotency_key, on_complete)
|
|
382
|
+
return Group._from_json(self._client._send(req))
|
|
383
|
+
|
|
384
|
+
def get(self, group_id: str) -> Group:
|
|
385
|
+
return Group._from_json(self._client._send(r.get_group(group_id)))
|
|
386
|
+
|
|
387
|
+
def close(self, group_id: str) -> Group:
|
|
388
|
+
"""Stop the group accepting tasks, so it completes once they have finished.
|
|
389
|
+
|
|
390
|
+
A group whose tasks have all finished completes at once. Closing a
|
|
391
|
+
closed group does nothing.
|
|
392
|
+
"""
|
|
393
|
+
return Group._from_json(self._client._send(r.close_group(group_id)))
|
|
394
|
+
|
|
395
|
+
def list(
|
|
396
|
+
self,
|
|
397
|
+
*,
|
|
398
|
+
status: GroupStatus | None = None,
|
|
399
|
+
name: str | None = None,
|
|
400
|
+
page_size: int | None = None,
|
|
401
|
+
) -> Iterator[Group]:
|
|
402
|
+
"""Groups, newest first, fetching pages as needed."""
|
|
403
|
+
cursor: str | None = None
|
|
404
|
+
while True:
|
|
405
|
+
page = self.list_page(status=status, name=name, cursor=cursor, limit=page_size)
|
|
406
|
+
yield from page.groups
|
|
407
|
+
if page.next_cursor is None:
|
|
408
|
+
return
|
|
409
|
+
cursor = page.next_cursor
|
|
410
|
+
|
|
411
|
+
def list_page(
|
|
412
|
+
self,
|
|
413
|
+
*,
|
|
414
|
+
status: GroupStatus | None = None,
|
|
415
|
+
name: str | None = None,
|
|
416
|
+
cursor: str | None = None,
|
|
417
|
+
limit: int | None = None,
|
|
418
|
+
) -> GroupPage:
|
|
419
|
+
"""One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
420
|
+
req = r.list_groups(status=status, name=name, cursor=cursor, limit=limit)
|
|
421
|
+
return GroupPage._from_json(self._client._send(req))
|
|
422
|
+
|
|
423
|
+
|
|
330
424
|
class Schedules:
|
|
331
425
|
"""Cron schedules that call an endpoint. Separate from queues: each call is a run."""
|
|
332
426
|
|
|
@@ -10,6 +10,9 @@ from datetime import datetime
|
|
|
10
10
|
from typing import Any, Literal
|
|
11
11
|
|
|
12
12
|
TaskState = Literal["pending", "running", "succeeded", "failed", "cancelled"]
|
|
13
|
+
TaskSort = Literal["created_at", "run_at", "last_attempt_at", "attempt_count"]
|
|
14
|
+
"""What tasks are listed by. `run_at` is when a task runs next: pending tasks run in that order."""
|
|
15
|
+
SortOrder = Literal["asc", "desc"]
|
|
13
16
|
HTTPMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
14
17
|
AttemptOutcome = Literal["success", "http_error", "timeout", "network_error", "lost"]
|
|
15
18
|
RunState = Literal["pending", "running", "succeeded", "failed", "cancelled", "skipped"]
|
|
@@ -17,6 +20,8 @@ RunTrigger = Literal["schedule", "manual"]
|
|
|
17
20
|
SkipReason = Literal["overlap", "missed"]
|
|
18
21
|
OverlapPolicy = Literal["skip", "allow"]
|
|
19
22
|
MissedPolicy = Literal["skip", "catch_up"]
|
|
23
|
+
GroupStatus = Literal["open", "running", "succeeded", "failed"]
|
|
24
|
+
"""`open` accepts tasks; `running` is closed with tasks unfinished; the other two are final."""
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
def _time(value: str) -> datetime:
|
|
@@ -218,6 +223,8 @@ class Task:
|
|
|
218
223
|
"""When the latest attempt finished."""
|
|
219
224
|
retried_at: datetime | None
|
|
220
225
|
"""When the task was last retried by hand."""
|
|
226
|
+
group_id: str | None = None
|
|
227
|
+
"""The group the task belongs to, if any."""
|
|
221
228
|
|
|
222
229
|
@classmethod
|
|
223
230
|
def _from_json(cls, d: dict[str, Any]) -> Task:
|
|
@@ -243,6 +250,7 @@ class Task:
|
|
|
243
250
|
finished_at=_opt_time(d.get("finished_at")),
|
|
244
251
|
last_attempt_at=_opt_time(d.get("last_attempt_at")),
|
|
245
252
|
retried_at=_opt_time(d.get("retried_at")),
|
|
253
|
+
group_id=d.get("group_id"),
|
|
246
254
|
)
|
|
247
255
|
|
|
248
256
|
|
|
@@ -500,3 +508,73 @@ class AlertChannelList:
|
|
|
500
508
|
channels=[AlertChannel._from_json(c) for c in d["channels"]],
|
|
501
509
|
email_enabled=d["email_enabled"],
|
|
502
510
|
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@dataclass(frozen=True, slots=True)
|
|
514
|
+
class GroupCallback:
|
|
515
|
+
"""The task a group enqueues when it completes: a POST of the group's summary as JSON."""
|
|
516
|
+
|
|
517
|
+
queue: str
|
|
518
|
+
url: str
|
|
519
|
+
headers: dict[str, str]
|
|
520
|
+
|
|
521
|
+
@classmethod
|
|
522
|
+
def _from_json(cls, d: dict[str, Any]) -> GroupCallback:
|
|
523
|
+
return cls(queue=d["queue"], url=d["url"], headers=d.get("headers") or {})
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass(frozen=True, slots=True)
|
|
527
|
+
class Group:
|
|
528
|
+
"""A set of tasks tracked together, with a callback once all have finished."""
|
|
529
|
+
|
|
530
|
+
id: str
|
|
531
|
+
name: str | None
|
|
532
|
+
status: GroupStatus
|
|
533
|
+
idempotency_key: str | None
|
|
534
|
+
total: int
|
|
535
|
+
succeeded: int
|
|
536
|
+
failed: int
|
|
537
|
+
cancelled: int
|
|
538
|
+
remaining: int
|
|
539
|
+
"""Tasks still pending or running."""
|
|
540
|
+
on_complete: GroupCallback | None
|
|
541
|
+
callback_task_id: str | None
|
|
542
|
+
"""The callback task, once the group has completed."""
|
|
543
|
+
created_at: datetime
|
|
544
|
+
closed_at: datetime | None
|
|
545
|
+
finished_at: datetime | None
|
|
546
|
+
"""When the group completed."""
|
|
547
|
+
|
|
548
|
+
@classmethod
|
|
549
|
+
def _from_json(cls, d: dict[str, Any]) -> Group:
|
|
550
|
+
cb = d.get("on_complete")
|
|
551
|
+
return cls(
|
|
552
|
+
id=d["id"],
|
|
553
|
+
name=d.get("name"),
|
|
554
|
+
status=d["status"],
|
|
555
|
+
idempotency_key=d.get("idempotency_key"),
|
|
556
|
+
total=d["total"],
|
|
557
|
+
succeeded=d["succeeded"],
|
|
558
|
+
failed=d["failed"],
|
|
559
|
+
cancelled=d["cancelled"],
|
|
560
|
+
remaining=d["remaining"],
|
|
561
|
+
on_complete=None if cb is None else GroupCallback._from_json(cb),
|
|
562
|
+
callback_task_id=d.get("callback_task_id"),
|
|
563
|
+
created_at=_time(d["created_at"]),
|
|
564
|
+
closed_at=_opt_time(d.get("closed_at")),
|
|
565
|
+
finished_at=_opt_time(d.get("finished_at")),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@dataclass(frozen=True, slots=True)
|
|
570
|
+
class GroupPage:
|
|
571
|
+
groups: list[Group]
|
|
572
|
+
next_cursor: str | None
|
|
573
|
+
"""Pass as `cursor` to get the next page. None on the last page."""
|
|
574
|
+
|
|
575
|
+
@classmethod
|
|
576
|
+
def _from_json(cls, d: dict[str, Any]) -> GroupPage:
|
|
577
|
+
return cls(
|
|
578
|
+
groups=[Group._from_json(g) for g in d["groups"]],
|
|
579
|
+
next_cursor=d.get("next_cursor"),
|
|
580
|
+
)
|
|
@@ -14,7 +14,15 @@ from importlib.metadata import PackageNotFoundError, version
|
|
|
14
14
|
from typing import Any, Required, TypedDict
|
|
15
15
|
from urllib.parse import quote
|
|
16
16
|
|
|
17
|
-
from ._models import
|
|
17
|
+
from ._models import (
|
|
18
|
+
AlertChannelType,
|
|
19
|
+
GroupStatus,
|
|
20
|
+
HTTPMethod,
|
|
21
|
+
RunState,
|
|
22
|
+
SortOrder,
|
|
23
|
+
TaskSort,
|
|
24
|
+
TaskState,
|
|
25
|
+
)
|
|
18
26
|
|
|
19
27
|
DEFAULT_URL = "http://localhost:7337"
|
|
20
28
|
|
|
@@ -64,6 +72,15 @@ class TaskSpec(TypedDict, total=False):
|
|
|
64
72
|
idempotency_key: str
|
|
65
73
|
max_attempts: int
|
|
66
74
|
timeout_seconds: float
|
|
75
|
+
group_id: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GroupCallbackSpec(TypedDict, total=False):
|
|
79
|
+
"""The `on_complete` callback of `groups.create`: a task to enqueue when the group completes."""
|
|
80
|
+
|
|
81
|
+
queue: Required[str]
|
|
82
|
+
url: Required[str]
|
|
83
|
+
headers: Mapping[str, str]
|
|
67
84
|
|
|
68
85
|
|
|
69
86
|
def task_body(spec: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -178,10 +195,13 @@ def search_tasks(
|
|
|
178
195
|
*,
|
|
179
196
|
queue: str | None,
|
|
180
197
|
state: TaskState | None,
|
|
198
|
+
group_id: str | None,
|
|
181
199
|
idempotency_key: str | None,
|
|
182
200
|
last_error: str | None,
|
|
183
201
|
created_after: datetime | None,
|
|
184
202
|
created_before: datetime | None,
|
|
203
|
+
sort: TaskSort | None,
|
|
204
|
+
order: SortOrder | None,
|
|
185
205
|
cursor: str | None,
|
|
186
206
|
limit: int | None,
|
|
187
207
|
) -> Request:
|
|
@@ -190,6 +210,8 @@ def search_tasks(
|
|
|
190
210
|
params["queue"] = queue
|
|
191
211
|
if state is not None:
|
|
192
212
|
params["state"] = state
|
|
213
|
+
if group_id is not None:
|
|
214
|
+
params["group_id"] = group_id
|
|
193
215
|
if idempotency_key is not None:
|
|
194
216
|
params["idempotency_key"] = idempotency_key
|
|
195
217
|
if last_error is not None:
|
|
@@ -198,6 +220,10 @@ def search_tasks(
|
|
|
198
220
|
params["created_after"] = _timestamp(created_after, "created_after")
|
|
199
221
|
if created_before is not None:
|
|
200
222
|
params["created_before"] = _timestamp(created_before, "created_before")
|
|
223
|
+
if sort is not None:
|
|
224
|
+
params["sort"] = sort
|
|
225
|
+
if order is not None:
|
|
226
|
+
params["order"] = order
|
|
201
227
|
if cursor is not None:
|
|
202
228
|
params["cursor"] = cursor
|
|
203
229
|
if limit is not None:
|
|
@@ -226,6 +252,48 @@ def retry_task(task_id: str) -> Request:
|
|
|
226
252
|
return Request("POST", f"/v1/tasks/{_q(task_id)}/retry")
|
|
227
253
|
|
|
228
254
|
|
|
255
|
+
# Groups
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def create_group(
|
|
259
|
+
name: str | None, idempotency_key: str | None, on_complete: GroupCallbackSpec | None
|
|
260
|
+
) -> Request:
|
|
261
|
+
body: dict[str, Any] = {}
|
|
262
|
+
if name is not None:
|
|
263
|
+
body["name"] = name
|
|
264
|
+
if idempotency_key is not None:
|
|
265
|
+
body["idempotency_key"] = idempotency_key
|
|
266
|
+
if on_complete is not None:
|
|
267
|
+
callback: dict[str, Any] = {"queue": on_complete["queue"], "url": on_complete["url"]}
|
|
268
|
+
if "headers" in on_complete:
|
|
269
|
+
callback["headers"] = dict(on_complete["headers"])
|
|
270
|
+
body["on_complete"] = callback
|
|
271
|
+
return Request("POST", "/v1/groups", json=body, idempotent=idempotency_key is not None)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_group(group_id: str) -> Request:
|
|
275
|
+
return Request("GET", f"/v1/groups/{_q(group_id)}", idempotent=True)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def list_groups(
|
|
279
|
+
*, status: GroupStatus | None, name: str | None, cursor: str | None, limit: int | None
|
|
280
|
+
) -> Request:
|
|
281
|
+
params: dict[str, str] = {}
|
|
282
|
+
if status is not None:
|
|
283
|
+
params["status"] = status
|
|
284
|
+
if name is not None:
|
|
285
|
+
params["name"] = name
|
|
286
|
+
if cursor is not None:
|
|
287
|
+
params["cursor"] = cursor
|
|
288
|
+
if limit is not None:
|
|
289
|
+
params["limit"] = str(limit)
|
|
290
|
+
return Request("GET", "/v1/groups", params=params, idempotent=True)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def close_group(group_id: str) -> Request:
|
|
294
|
+
return Request("POST", f"/v1/groups/{_q(group_id)}/close", idempotent=True)
|
|
295
|
+
|
|
296
|
+
|
|
229
297
|
# Schedules and runs
|
|
230
298
|
|
|
231
299
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|