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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workly
3
- Version: 0.1.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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workly"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python SDK for Workly, the open-source HTTP task queue"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workly"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python SDK for Workly, the open-source HTTP task queue"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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, newest first, fetching pages as needed."""
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, newest first, fetching pages as needed."""
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 AlertChannelType, HTTPMethod, RunState, TaskState
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