workly 0.2.0__tar.gz → 0.3.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.2.0
3
+ Version: 0.3.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
@@ -156,7 +156,7 @@ client.queues.delete("emails") # delete the queue and all its tasks
156
156
 
157
157
  ## Schedules
158
158
 
159
- A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://docs.workly.run/schedules/).
159
+ A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://workly.run/docs/schedules/).
160
160
 
161
161
  ```python
162
162
  schedule = client.schedules.put(
@@ -171,7 +171,7 @@ schedule = client.schedules.put(
171
171
  print(schedule.next_runs) # the next five times
172
172
 
173
173
  run = client.schedules.trigger("nightly-report") # run it now
174
- for run in client.schedules.runs("nightly-report", state="failed"):
174
+ for run in client.runs.list(schedule="nightly-report", state="failed"):
175
175
  print(run.id, run.scheduled_for, run.last_error)
176
176
  client.runs.attempts(run.id)
177
177
  client.runs.retry(run.id) # deliver it again, e.g. a failed or skipped run
@@ -184,13 +184,16 @@ Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled
184
184
 
185
185
  ## Task groups
186
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/).
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://workly.run/docs/groups/). Like every callback, it goes to Workly's `_callbacks` queue.
188
188
 
189
189
  ```python
190
+ from workly import Callback
191
+
190
192
  group = client.groups.create(
191
193
  name="daily-stock-prices",
192
194
  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"},
195
+ # or a dict: {"url": "https://api.example.com/jobs/refresh-views"}
196
+ on_complete=Callback(url="https://api.example.com/jobs/refresh-views"),
194
197
  )
195
198
  for company in companies:
196
199
  client.tasks.enqueue(
@@ -209,6 +212,27 @@ for group in client.groups.list(name="daily-stock-prices", status="failed"):
209
212
  print(group.id, group.finished_at)
210
213
  ```
211
214
 
215
+ ## Callbacks
216
+
217
+ Tasks and schedules take the same `on_complete`: each time the task, or a run of the schedule, succeeds, fails or is cancelled, Workly POSTs a summary of it to the URL. See [Callbacks](https://workly.run/docs/callbacks/).
218
+
219
+ ```python
220
+ client.tasks.enqueue(
221
+ "exports",
222
+ url="https://api.example.com/jobs/export",
223
+ payload={"export_id": 42},
224
+ on_complete=Callback(url="https://api.example.com/jobs/export-done"),
225
+ )
226
+ client.schedules.put(
227
+ "nightly-report",
228
+ cron="0 3 * * *",
229
+ url="https://api.example.com/reports/nightly",
230
+ on_complete={"url": "https://api.example.com/reports/nightly-done"},
231
+ )
232
+ ```
233
+
234
+ The summary arrives as the callback task's JSON body: `task_id`, `queue`, `state`, `attempt_count`, `last_status`, `last_error` and `finished_at` for a task; `run_id`, `schedule` and the same outcome fields for a run.
235
+
212
236
  ## Async
213
237
 
214
238
  `AsyncWorkly` has the same methods, as coroutines:
@@ -136,7 +136,7 @@ client.queues.delete("emails") # delete the queue and all its tasks
136
136
 
137
137
  ## Schedules
138
138
 
139
- A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://docs.workly.run/schedules/).
139
+ A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://workly.run/docs/schedules/).
140
140
 
141
141
  ```python
142
142
  schedule = client.schedules.put(
@@ -151,7 +151,7 @@ schedule = client.schedules.put(
151
151
  print(schedule.next_runs) # the next five times
152
152
 
153
153
  run = client.schedules.trigger("nightly-report") # run it now
154
- for run in client.schedules.runs("nightly-report", state="failed"):
154
+ for run in client.runs.list(schedule="nightly-report", state="failed"):
155
155
  print(run.id, run.scheduled_for, run.last_error)
156
156
  client.runs.attempts(run.id)
157
157
  client.runs.retry(run.id) # deliver it again, e.g. a failed or skipped run
@@ -164,13 +164,16 @@ Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled
164
164
 
165
165
  ## Task groups
166
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/).
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://workly.run/docs/groups/). Like every callback, it goes to Workly's `_callbacks` queue.
168
168
 
169
169
  ```python
170
+ from workly import Callback
171
+
170
172
  group = client.groups.create(
171
173
  name="daily-stock-prices",
172
174
  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"},
175
+ # or a dict: {"url": "https://api.example.com/jobs/refresh-views"}
176
+ on_complete=Callback(url="https://api.example.com/jobs/refresh-views"),
174
177
  )
175
178
  for company in companies:
176
179
  client.tasks.enqueue(
@@ -189,6 +192,27 @@ for group in client.groups.list(name="daily-stock-prices", status="failed"):
189
192
  print(group.id, group.finished_at)
190
193
  ```
191
194
 
195
+ ## Callbacks
196
+
197
+ Tasks and schedules take the same `on_complete`: each time the task, or a run of the schedule, succeeds, fails or is cancelled, Workly POSTs a summary of it to the URL. See [Callbacks](https://workly.run/docs/callbacks/).
198
+
199
+ ```python
200
+ client.tasks.enqueue(
201
+ "exports",
202
+ url="https://api.example.com/jobs/export",
203
+ payload={"export_id": 42},
204
+ on_complete=Callback(url="https://api.example.com/jobs/export-done"),
205
+ )
206
+ client.schedules.put(
207
+ "nightly-report",
208
+ cron="0 3 * * *",
209
+ url="https://api.example.com/reports/nightly",
210
+ on_complete={"url": "https://api.example.com/reports/nightly-done"},
211
+ )
212
+ ```
213
+
214
+ The summary arrives as the callback task's JSON body: `task_id`, `queue`, `state`, `attempt_count`, `last_status`, `last_error` and `finished_at` for a task; `run_id`, `schedule` and the same outcome fields for a run.
215
+
192
216
  ## Async
193
217
 
194
218
  `AsyncWorkly` has the same methods, as coroutines:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workly"
3
- version = "0.2.0"
3
+ version = "0.3.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.2.0"
3
+ version = "0.3.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"
@@ -32,6 +32,7 @@ from ._models import (
32
32
  AlertChannelType,
33
33
  Attempt,
34
34
  AttemptOutcome,
35
+ Callback,
35
36
  CronPreview,
36
37
  Delivery,
37
38
  EnqueueResult,
@@ -59,7 +60,7 @@ from ._models import (
59
60
  TaskSort,
60
61
  TaskState,
61
62
  )
62
- from ._requests import GroupCallbackSpec, TaskSpec, __version__
63
+ from ._requests import CallbackDict, GroupCallbackSpec, TaskSpec, __version__
63
64
  from ._signing import sign, verify_signature
64
65
 
65
66
  __all__ = [
@@ -83,6 +84,8 @@ __all__ = [
83
84
  "AttemptOutcome",
84
85
  "AuthenticationError",
85
86
  "BadRequestError",
87
+ "Callback",
88
+ "CallbackDict",
86
89
  "ConflictError",
87
90
  "CronPreview",
88
91
  "Delivery",
@@ -17,6 +17,7 @@ from ._models import (
17
17
  AlertChannelList,
18
18
  AlertChannelType,
19
19
  Attempt,
20
+ Callback,
20
21
  CronPreview,
21
22
  Delivery,
22
23
  EnqueueResult,
@@ -215,6 +216,7 @@ class AsyncTasks:
215
216
  max_attempts: int | None = None,
216
217
  timeout_seconds: float | None = None,
217
218
  group_id: str | None = None,
219
+ on_complete: Callback | r.CallbackDict | None = None,
218
220
  traceparent: str | None = None,
219
221
  ) -> Task:
220
222
  """Enqueue a task to deliver to `url`.
@@ -223,7 +225,8 @@ class AsyncTasks:
223
225
  created with default settings if it doesn't exist. If
224
226
  `idempotency_key` matches a task already in the queue, that task is
225
227
  returned and nothing is enqueued. With `group_id`, the task joins
226
- that group, which must be open.
228
+ that group, which must be open. `on_complete` is called each time the
229
+ task succeeds, fails or is cancelled, with a `TaskSummary`.
227
230
  """
228
231
  spec = {
229
232
  "url": url,
@@ -238,6 +241,7 @@ class AsyncTasks:
238
241
  "max_attempts": max_attempts,
239
242
  "timeout_seconds": timeout_seconds,
240
243
  "group_id": group_id,
244
+ "on_complete": on_complete,
241
245
  }
242
246
  return Task._from_json(await self._client._send(r.enqueue(queue, spec, traceparent)))
243
247
 
@@ -370,13 +374,13 @@ class AsyncGroups:
370
374
  *,
371
375
  name: str | None = None,
372
376
  idempotency_key: str | None = None,
373
- on_complete: r.GroupCallbackSpec | None = None,
377
+ on_complete: Callback | r.CallbackDict | None = None,
374
378
  ) -> Group:
375
379
  """Create an open group.
376
380
 
377
381
  `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
382
+ `Callback(url="https://api.example.com/done")` or the same as a dict;
383
+ it is a POST of the group's summary as JSON. If `idempotency_key` matches an
380
384
  existing group, that group is returned and nothing is created.
381
385
  """
382
386
  req = r.create_group(name, idempotency_key, on_complete)
@@ -457,12 +461,15 @@ class AsyncSchedules:
457
461
  retry_on_4xx: bool | None = None,
458
462
  overlap: OverlapPolicy | None = None,
459
463
  missed: MissedPolicy | None = None,
464
+ on_complete: Callback | r.CallbackDict | None = None,
460
465
  ) -> Schedule:
461
466
  """Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
462
467
 
463
468
  Declarative: the schedule ends up with exactly these settings, and
464
469
  omitted ones are reset to their defaults. It doesn't change whether
465
- the schedule is paused. `next_runs` on the result shows when it runs.
470
+ the schedule is paused. `next_runs` on the result shows when it runs
471
+ (`next_runs_if_resumed` while it is paused). `on_complete` is called
472
+ each time a run succeeds, fails or is cancelled, with a `RunSummary`.
466
473
  """
467
474
  config = dict(
468
475
  cron=cron,
@@ -481,6 +488,7 @@ class AsyncSchedules:
481
488
  retry_on_4xx=retry_on_4xx,
482
489
  overlap=overlap,
483
490
  missed=missed,
491
+ on_complete=on_complete,
484
492
  )
485
493
  return Schedule._from_json(await self._client._send(r.put_schedule(name, config)))
486
494
 
@@ -507,38 +515,44 @@ class AsyncSchedules:
507
515
  """Run the schedule now, even if paused. Doesn't change when it next fires."""
508
516
  return Run._from_json(await self._client._send(r.trigger_schedule(name)))
509
517
 
510
- async def runs(
511
- self, name: str, *, state: RunState | None = None, page_size: int | None = None
518
+
519
+ class AsyncRuns:
520
+ """Runs of schedules: one per firing, with its own attempts."""
521
+
522
+ def __init__(self, client: AsyncWorkly) -> None:
523
+ self._client = client
524
+
525
+ async def list(
526
+ self,
527
+ *,
528
+ schedule: str | None = None,
529
+ state: RunState | None = None,
530
+ page_size: int | None = None,
512
531
  ) -> AsyncIterator[Run]:
513
- """The schedule's runs, newest first, fetching pages as needed."""
532
+ """Runs of every schedule, or of one, newest first, fetching pages as needed."""
514
533
  cursor: str | None = None
515
534
  while True:
516
- page = await self.runs_page(name, state=state, cursor=cursor, limit=page_size)
535
+ page = await self.list_page(
536
+ schedule=schedule, state=state, cursor=cursor, limit=page_size
537
+ )
517
538
  for run in page.runs:
518
539
  yield run
519
540
  if page.next_cursor is None:
520
541
  return
521
542
  cursor = page.next_cursor
522
543
 
523
- async def runs_page(
544
+ async def list_page(
524
545
  self,
525
- name: str,
526
546
  *,
547
+ schedule: str | None = None,
527
548
  state: RunState | None = None,
528
549
  cursor: str | None = None,
529
550
  limit: int | None = None,
530
551
  ) -> RunPage:
531
- """One page of `runs`. Pass the previous page's `next_cursor` as `cursor`."""
532
- req = r.list_runs(name, state=state, cursor=cursor, limit=limit)
552
+ """One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
553
+ req = r.list_runs(schedule=schedule, state=state, cursor=cursor, limit=limit)
533
554
  return RunPage._from_json(await self._client._send(req))
534
555
 
535
-
536
- class AsyncRuns:
537
- """Runs of schedules: one per firing, with its own attempts."""
538
-
539
- def __init__(self, client: AsyncWorkly) -> None:
540
- self._client = client
541
-
542
556
  async def get(self, run_id: str) -> Run:
543
557
  return Run._from_json(await self._client._send(r.get_run(run_id)))
544
558
 
@@ -17,6 +17,7 @@ from ._models import (
17
17
  AlertChannelList,
18
18
  AlertChannelType,
19
19
  Attempt,
20
+ Callback,
20
21
  CronPreview,
21
22
  Delivery,
22
23
  EnqueueResult,
@@ -215,6 +216,7 @@ class Tasks:
215
216
  max_attempts: int | None = None,
216
217
  timeout_seconds: float | None = None,
217
218
  group_id: str | None = None,
219
+ on_complete: Callback | r.CallbackDict | None = None,
218
220
  traceparent: str | None = None,
219
221
  ) -> Task:
220
222
  """Enqueue a task to deliver to `url`.
@@ -223,7 +225,8 @@ class Tasks:
223
225
  created with default settings if it doesn't exist. If
224
226
  `idempotency_key` matches a task already in the queue, that task is
225
227
  returned and nothing is enqueued. With `group_id`, the task joins
226
- that group, which must be open.
228
+ that group, which must be open. `on_complete` is called each time the
229
+ task succeeds, fails or is cancelled, with a `TaskSummary`.
227
230
  """
228
231
  spec = {
229
232
  "url": url,
@@ -238,6 +241,7 @@ class Tasks:
238
241
  "max_attempts": max_attempts,
239
242
  "timeout_seconds": timeout_seconds,
240
243
  "group_id": group_id,
244
+ "on_complete": on_complete,
241
245
  }
242
246
  return Task._from_json(self._client._send(r.enqueue(queue, spec, traceparent)))
243
247
 
@@ -369,13 +373,13 @@ class Groups:
369
373
  *,
370
374
  name: str | None = None,
371
375
  idempotency_key: str | None = None,
372
- on_complete: r.GroupCallbackSpec | None = None,
376
+ on_complete: Callback | r.CallbackDict | None = None,
373
377
  ) -> Group:
374
378
  """Create an open group.
375
379
 
376
380
  `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
381
+ `Callback(url="https://api.example.com/done")` or the same as a dict;
382
+ it is a POST of the group's summary as JSON. If `idempotency_key` matches an
379
383
  existing group, that group is returned and nothing is created.
380
384
  """
381
385
  req = r.create_group(name, idempotency_key, on_complete)
@@ -455,12 +459,15 @@ class Schedules:
455
459
  retry_on_4xx: bool | None = None,
456
460
  overlap: OverlapPolicy | None = None,
457
461
  missed: MissedPolicy | None = None,
462
+ on_complete: Callback | r.CallbackDict | None = None,
458
463
  ) -> Schedule:
459
464
  """Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
460
465
 
461
466
  Declarative: the schedule ends up with exactly these settings, and
462
467
  omitted ones are reset to their defaults. It doesn't change whether
463
- the schedule is paused. `next_runs` on the result shows when it runs.
468
+ the schedule is paused. `next_runs` on the result shows when it runs
469
+ (`next_runs_if_resumed` while it is paused). `on_complete` is called
470
+ each time a run succeeds, fails or is cancelled, with a `RunSummary`.
464
471
  """
465
472
  config = dict(
466
473
  cron=cron,
@@ -479,6 +486,7 @@ class Schedules:
479
486
  retry_on_4xx=retry_on_4xx,
480
487
  overlap=overlap,
481
488
  missed=missed,
489
+ on_complete=on_complete,
482
490
  )
483
491
  return Schedule._from_json(self._client._send(r.put_schedule(name, config)))
484
492
 
@@ -505,37 +513,41 @@ class Schedules:
505
513
  """Run the schedule now, even if paused. Doesn't change when it next fires."""
506
514
  return Run._from_json(self._client._send(r.trigger_schedule(name)))
507
515
 
508
- def runs(
509
- self, name: str, *, state: RunState | None = None, page_size: int | None = None
516
+
517
+ class Runs:
518
+ """Runs of schedules: one per firing, with its own attempts."""
519
+
520
+ def __init__(self, client: Workly) -> None:
521
+ self._client = client
522
+
523
+ def list(
524
+ self,
525
+ *,
526
+ schedule: str | None = None,
527
+ state: RunState | None = None,
528
+ page_size: int | None = None,
510
529
  ) -> Iterator[Run]:
511
- """The schedule's runs, newest first, fetching pages as needed."""
530
+ """Runs of every schedule, or of one, newest first, fetching pages as needed."""
512
531
  cursor: str | None = None
513
532
  while True:
514
- page = self.runs_page(name, state=state, cursor=cursor, limit=page_size)
533
+ page = self.list_page(schedule=schedule, state=state, cursor=cursor, limit=page_size)
515
534
  yield from page.runs
516
535
  if page.next_cursor is None:
517
536
  return
518
537
  cursor = page.next_cursor
519
538
 
520
- def runs_page(
539
+ def list_page(
521
540
  self,
522
- name: str,
523
541
  *,
542
+ schedule: str | None = None,
524
543
  state: RunState | None = None,
525
544
  cursor: str | None = None,
526
545
  limit: int | None = None,
527
546
  ) -> RunPage:
528
- """One page of `runs`. Pass the previous page's `next_cursor` as `cursor`."""
529
- req = r.list_runs(name, state=state, cursor=cursor, limit=limit)
547
+ """One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
548
+ req = r.list_runs(schedule=schedule, state=state, cursor=cursor, limit=limit)
530
549
  return RunPage._from_json(self._client._send(req))
531
550
 
532
-
533
- class Runs:
534
- """Runs of schedules: one per firing, with its own attempts."""
535
-
536
- def __init__(self, client: Workly) -> None:
537
- self._client = client
538
-
539
551
  def get(self, run_id: str) -> Run:
540
552
  return Run._from_json(self._client._send(r.get_run(run_id)))
541
553
 
@@ -5,7 +5,8 @@ Unknown fields are ignored, so newer servers don't break older SDKs.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from dataclasses import dataclass
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
9
10
  from datetime import datetime
10
11
  from typing import Any, Literal
11
12
 
@@ -65,6 +66,10 @@ class QueueActivity:
65
66
  cancelled: int
66
67
  retention_seconds: float | None
67
68
  """How long the server keeps finished tasks; None if forever. Older ones aren't counted."""
69
+ p50_duration_ms: int | None = None
70
+ """Median duration of the latest attempt of the succeeded and failed tasks counted."""
71
+ p95_duration_ms: int | None = None
72
+ """95th percentile of the same durations."""
68
73
 
69
74
  @classmethod
70
75
  def _from_json(cls, d: dict[str, Any]) -> QueueActivity:
@@ -74,6 +79,8 @@ class QueueActivity:
74
79
  failed=d["failed"],
75
80
  cancelled=d["cancelled"],
76
81
  retention_seconds=d.get("retention_seconds"),
82
+ p50_duration_ms=d.get("p50_duration_ms"),
83
+ p95_duration_ms=d.get("p95_duration_ms"),
77
84
  )
78
85
 
79
86
 
@@ -225,9 +232,18 @@ class Task:
225
232
  """When the task was last retried by hand."""
226
233
  group_id: str | None = None
227
234
  """The group the task belongs to, if any."""
235
+ started_at: datetime | None = None
236
+ """When the first delivery attempt started; minus `created_at`, the time spent waiting."""
237
+ last_duration_ms: int | None = None
238
+ """How long the latest finished attempt took."""
239
+ on_complete: Callback | None = None
240
+ """Called each time the task finishes."""
241
+ callback_task_id: str | None = None
242
+ """The callback task enqueued when the task last finished, if it has `on_complete`."""
228
243
 
229
244
  @classmethod
230
245
  def _from_json(cls, d: dict[str, Any]) -> Task:
246
+ cb = d.get("on_complete")
231
247
  return cls(
232
248
  id=d["id"],
233
249
  queue=d["queue"],
@@ -251,6 +267,10 @@ class Task:
251
267
  last_attempt_at=_opt_time(d.get("last_attempt_at")),
252
268
  retried_at=_opt_time(d.get("retried_at")),
253
269
  group_id=d.get("group_id"),
270
+ started_at=_opt_time(d.get("started_at")),
271
+ last_duration_ms=d.get("last_duration_ms"),
272
+ on_complete=None if cb is None else Callback._from_json(cb),
273
+ callback_task_id=d.get("callback_task_id"),
254
274
  )
255
275
 
256
276
 
@@ -375,14 +395,21 @@ class Schedule:
375
395
  next_run_at: datetime | None
376
396
  """When the schedule next fires; None while paused."""
377
397
  next_runs: list[datetime]
378
- """The next five slots (as if resumed now, while paused)."""
398
+ """The next five slots. Empty while paused; see `next_runs_if_resumed`."""
379
399
  last_run: LastRun | None
380
400
  created_at: datetime
381
401
  updated_at: datetime
402
+ paused_at: datetime | None = None
403
+ """When a paused schedule was paused; None while it runs."""
404
+ next_runs_if_resumed: list[datetime] | None = None
405
+ """While paused, the five slots it would run at if resumed now; None while it runs."""
406
+ on_complete: Callback | None = None
407
+ """Called each time a run finishes, except skipped runs."""
382
408
 
383
409
  @classmethod
384
410
  def _from_json(cls, d: dict[str, Any]) -> Schedule:
385
411
  last = d.get("last_run")
412
+ cb = d.get("on_complete")
386
413
  next_runs: list[str] = d.get("next_runs") or []
387
414
  return cls(
388
415
  name=d["name"],
@@ -407,6 +434,13 @@ class Schedule:
407
434
  last_run=None if last is None else LastRun._from_json(last),
408
435
  created_at=_time(d["created_at"]),
409
436
  updated_at=_time(d["updated_at"]),
437
+ paused_at=_opt_time(d.get("paused_at")),
438
+ next_runs_if_resumed=(
439
+ None
440
+ if (resumed := d.get("next_runs_if_resumed")) is None
441
+ else [_time(t) for t in resumed]
442
+ ),
443
+ on_complete=None if cb is None else Callback._from_json(cb),
410
444
  )
411
445
 
412
446
 
@@ -437,6 +471,12 @@ class Run:
437
471
  finished_at: datetime | None
438
472
  last_attempt_at: datetime | None
439
473
  retried_at: datetime | None
474
+ started_at: datetime | None = None
475
+ """When the first delivery attempt started."""
476
+ last_duration_ms: int | None = None
477
+ """How long the latest finished attempt took."""
478
+ callback_task_id: str | None = None
479
+ """The callback task enqueued when the run last finished, if its schedule has `on_complete`."""
440
480
 
441
481
  @classmethod
442
482
  def _from_json(cls, d: dict[str, Any]) -> Run:
@@ -462,6 +502,9 @@ class Run:
462
502
  finished_at=_opt_time(d.get("finished_at")),
463
503
  last_attempt_at=_opt_time(d.get("last_attempt_at")),
464
504
  retried_at=_opt_time(d.get("retried_at")),
505
+ started_at=_opt_time(d.get("started_at")),
506
+ last_duration_ms=d.get("last_duration_ms"),
507
+ callback_task_id=d.get("callback_task_id"),
465
508
  )
466
509
 
467
510
 
@@ -510,17 +553,30 @@ class AlertChannelList:
510
553
  )
511
554
 
512
555
 
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."""
556
+ CALLBACK_QUEUE = "_callbacks"
557
+
558
+
559
+ @dataclass(frozen=True, slots=True, kw_only=True)
560
+ class Callback:
561
+ """A task Workly enqueues when something completes: a POST to `url` saying what completed.
562
+
563
+ Its JSON body is a `TaskSummary` for a task, a `RunSummary` for a
564
+ schedule's run and a `GroupSummary` for a group. It is delivered as a task
565
+ in the `_callbacks` queue, with that queue's retries: configure it like
566
+ any queue to change them.
567
+ """
516
568
 
517
- queue: str
518
569
  url: str
519
- headers: dict[str, str]
570
+ headers: Mapping[str, str] = field(default_factory=dict[str, str])
571
+ """Headers sent with the callback. `Workly-*` headers are reserved."""
520
572
 
521
573
  @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 {})
574
+ def _from_json(cls, d: dict[str, Any]) -> Callback:
575
+ return cls(url=d["url"], headers=d.get("headers") or {})
576
+
577
+
578
+ GroupCallback = Callback
579
+ """Deprecated: use `Callback`."""
524
580
 
525
581
 
526
582
  @dataclass(frozen=True, slots=True)
@@ -537,7 +593,7 @@ class Group:
537
593
  cancelled: int
538
594
  remaining: int
539
595
  """Tasks still pending or running."""
540
- on_complete: GroupCallback | None
596
+ on_complete: Callback | None
541
597
  callback_task_id: str | None
542
598
  """The callback task, once the group has completed."""
543
599
  created_at: datetime
@@ -558,7 +614,7 @@ class Group:
558
614
  failed=d["failed"],
559
615
  cancelled=d["cancelled"],
560
616
  remaining=d["remaining"],
561
- on_complete=None if cb is None else GroupCallback._from_json(cb),
617
+ on_complete=None if cb is None else Callback._from_json(cb),
562
618
  callback_task_id=d.get("callback_task_id"),
563
619
  created_at=_time(d["created_at"]),
564
620
  closed_at=_opt_time(d.get("closed_at")),
@@ -16,6 +16,7 @@ from urllib.parse import quote
16
16
 
17
17
  from ._models import (
18
18
  AlertChannelType,
19
+ Callback,
19
20
  GroupStatus,
20
21
  HTTPMethod,
21
22
  RunState,
@@ -73,16 +74,31 @@ class TaskSpec(TypedDict, total=False):
73
74
  max_attempts: int
74
75
  timeout_seconds: float
75
76
  group_id: str
77
+ on_complete: Callback | CallbackDict
76
78
 
77
79
 
78
- class GroupCallbackSpec(TypedDict, total=False):
79
- """The `on_complete` callback of `groups.create`: a task to enqueue when the group completes."""
80
+ class CallbackDict(TypedDict, total=False):
81
+ """A `Callback` as a dict, with the same fields."""
80
82
 
81
- queue: Required[str]
82
83
  url: Required[str]
83
84
  headers: Mapping[str, str]
84
85
 
85
86
 
87
+ GroupCallbackSpec = CallbackDict
88
+ """Deprecated: use `Callback` or `CallbackDict`."""
89
+
90
+
91
+ def callback_body(cb: Callback | CallbackDict) -> dict[str, Any]:
92
+ if isinstance(cb, Callback):
93
+ url, headers = cb.url, cb.headers
94
+ else:
95
+ url, headers = cb["url"], cb.get("headers", {})
96
+ body: dict[str, Any] = {"url": url}
97
+ if headers:
98
+ body["headers"] = dict(headers)
99
+ return body
100
+
101
+
86
102
  def task_body(spec: Mapping[str, Any]) -> dict[str, Any]:
87
103
  body: dict[str, Any] = {}
88
104
  for key, value in spec.items():
@@ -92,6 +108,8 @@ def task_body(spec: Mapping[str, Any]) -> dict[str, Any]:
92
108
  value = _timestamp(value, "run_at")
93
109
  elif key == "headers":
94
110
  value = dict(value)
111
+ elif key == "on_complete":
112
+ value = callback_body(value)
95
113
  body[key] = value
96
114
  if "payload" in body and "body" in body:
97
115
  raise ValueError("pass payload or body, not both")
@@ -256,7 +274,7 @@ def retry_task(task_id: str) -> Request:
256
274
 
257
275
 
258
276
  def create_group(
259
- name: str | None, idempotency_key: str | None, on_complete: GroupCallbackSpec | None
277
+ name: str | None, idempotency_key: str | None, on_complete: Callback | CallbackDict | None
260
278
  ) -> Request:
261
279
  body: dict[str, Any] = {}
262
280
  if name is not None:
@@ -264,10 +282,7 @@ def create_group(
264
282
  if idempotency_key is not None:
265
283
  body["idempotency_key"] = idempotency_key
266
284
  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
285
+ body["on_complete"] = callback_body(on_complete)
271
286
  return Request("POST", "/v1/groups", json=body, idempotent=idempotency_key is not None)
272
287
 
273
288
 
@@ -310,7 +325,11 @@ def put_schedule(name: str, config: Mapping[str, Any]) -> Request:
310
325
  for key, value in config.items():
311
326
  if value is None:
312
327
  continue
313
- body[key] = dict(value) if key == "headers" else value
328
+ if key == "headers":
329
+ value = dict(value)
330
+ elif key == "on_complete":
331
+ value = callback_body(value)
332
+ body[key] = value
314
333
  if "payload" in body and "body" in body:
315
334
  raise ValueError("pass payload or body, not both")
316
335
  return Request("PUT", f"/v1/schedules/{_q(name)}", json=body, idempotent=True)
@@ -339,16 +358,18 @@ def trigger_schedule(name: str) -> Request:
339
358
 
340
359
 
341
360
  def list_runs(
342
- name: str, *, state: RunState | None, cursor: str | None, limit: int | None
361
+ *, schedule: str | None, state: RunState | None, cursor: str | None, limit: int | None
343
362
  ) -> Request:
344
363
  params: dict[str, str] = {}
364
+ if schedule is not None:
365
+ params["schedule"] = schedule
345
366
  if state is not None:
346
367
  params["state"] = state
347
368
  if cursor is not None:
348
369
  params["cursor"] = cursor
349
370
  if limit is not None:
350
371
  params["limit"] = str(limit)
351
- return Request("GET", f"/v1/schedules/{_q(name)}/runs", params=params, idempotent=True)
372
+ return Request("GET", "/v1/runs", params=params, idempotent=True)
352
373
 
353
374
 
354
375
  def get_run(run_id: str) -> Request:
File without changes
File without changes
File without changes
File without changes
File without changes