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.
- {workly-0.2.0 → workly-0.3.0}/PKG-INFO +29 -5
- {workly-0.2.0 → workly-0.3.0}/README.md +28 -4
- {workly-0.2.0 → workly-0.3.0}/pyproject.toml +1 -1
- {workly-0.2.0 → workly-0.3.0}/pyproject.toml.orig +1 -1
- {workly-0.2.0 → workly-0.3.0}/src/workly/__init__.py +4 -1
- {workly-0.2.0 → workly-0.3.0}/src/workly/_async_client.py +34 -20
- {workly-0.2.0 → workly-0.3.0}/src/workly/_client.py +32 -20
- {workly-0.2.0 → workly-0.3.0}/src/workly/_models.py +67 -11
- {workly-0.2.0 → workly-0.3.0}/src/workly/_requests.py +32 -11
- {workly-0.2.0 → workly-0.3.0}/LICENSE +0 -0
- {workly-0.2.0 → workly-0.3.0}/src/workly/_errors.py +0 -0
- {workly-0.2.0 → workly-0.3.0}/src/workly/_signing.py +0 -0
- {workly-0.2.0 → workly-0.3.0}/src/workly/_transport.py +0 -0
- {workly-0.2.0 → workly-0.3.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.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://
|
|
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.
|
|
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://
|
|
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
|
-
|
|
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://
|
|
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.
|
|
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://
|
|
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
|
-
|
|
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:
|
|
@@ -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.
|
|
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
|
-
`
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
"""
|
|
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.
|
|
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
|
|
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 `
|
|
532
|
-
req = r.list_runs(
|
|
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.
|
|
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
|
-
`
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
"""
|
|
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.
|
|
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
|
|
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 `
|
|
529
|
-
req = r.list_runs(
|
|
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
|
|
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
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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]) ->
|
|
523
|
-
return cls(
|
|
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:
|
|
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
|
|
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
|
|
79
|
-
"""
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|