workly 0.3.0__tar.gz → 0.4.3__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.3.0 → workly-0.4.3}/PKG-INFO +16 -13
- {workly-0.3.0 → workly-0.4.3}/README.md +14 -11
- {workly-0.3.0 → workly-0.4.3}/pyproject.toml +2 -2
- {workly-0.3.0 → workly-0.4.3}/pyproject.toml.orig +2 -2
- {workly-0.3.0 → workly-0.4.3}/src/workly/__init__.py +43 -2
- {workly-0.3.0 → workly-0.4.3}/src/workly/_async_client.py +160 -4
- {workly-0.3.0 → workly-0.4.3}/src/workly/_client.py +155 -4
- {workly-0.3.0 → workly-0.4.3}/src/workly/_models.py +320 -5
- {workly-0.3.0 → workly-0.4.3}/src/workly/_requests.py +146 -3
- {workly-0.3.0 → workly-0.4.3}/LICENSE +0 -0
- {workly-0.3.0 → workly-0.4.3}/src/workly/_errors.py +0 -0
- {workly-0.3.0 → workly-0.4.3}/src/workly/_signing.py +0 -0
- {workly-0.3.0 → workly-0.4.3}/src/workly/_transport.py +0 -0
- {workly-0.3.0 → workly-0.4.3}/src/workly/py.typed +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workly
|
|
3
|
-
Version: 0.3
|
|
3
|
+
Version: 0.4.3
|
|
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
|
|
7
7
|
License-File: LICENSE
|
|
8
|
-
Classifier: Development Status ::
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
9
|
Classifier: Framework :: AsyncIO
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -27,7 +27,7 @@ pip install workly # the SDK
|
|
|
27
27
|
pip install "workly[cli]" # the SDK plus the workly binary, for `workly dev`
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Requires Python 3.11+. The only dependency is [httpx](https://www.python-httpx.org). The `cli` extra adds [`workly-cli`](https://pypi.org/project/workly-cli/), the
|
|
30
|
+
Requires Python 3.11+. The only dependency is [httpx](https://www.python-httpx.org). The `cli` extra adds [`workly-cli`](https://pypi.org/project/workly-cli/), the `workly` binary (about 7 MB): the dev server (`workly dev`) and the CLI, including `workly validate`, `plan` and `deploy` for [configuration as code](https://workly.run/docs/deploy/). Install it on dev machines and in CI; production images that only enqueue tasks don't need it.
|
|
31
31
|
|
|
32
32
|
## Quickstart
|
|
33
33
|
|
|
@@ -186,24 +186,27 @@ Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled
|
|
|
186
186
|
|
|
187
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
|
+
`groups.enqueue` does the whole job in one call: it creates the group, enqueues the tasks into it in batches of 100 and closes it, and it's safe to retry with the same `idempotency_key`.
|
|
190
|
+
|
|
189
191
|
```python
|
|
190
192
|
from workly import Callback
|
|
191
193
|
|
|
192
|
-
group = client.groups.
|
|
194
|
+
group = client.groups.enqueue(
|
|
195
|
+
"stock-prices",
|
|
196
|
+
(
|
|
197
|
+
{"url": "https://api.example.com/jobs/update-stock-price", "payload": {"symbol": c.symbol}}
|
|
198
|
+
for c in companies
|
|
199
|
+
),
|
|
193
200
|
name="daily-stock-prices",
|
|
194
|
-
idempotency_key="stock-prices-2026-10-19", #
|
|
201
|
+
idempotency_key="stock-prices-2026-10-19", # a retry finishes this group rather than start another
|
|
195
202
|
# or a dict: {"url": "https://api.example.com/jobs/refresh-views"}
|
|
196
203
|
on_complete=Callback(url="https://api.example.com/jobs/refresh-views"),
|
|
197
204
|
)
|
|
198
|
-
|
|
199
|
-
client.tasks.enqueue(
|
|
200
|
-
"stock-prices",
|
|
201
|
-
url="https://api.example.com/jobs/update-stock-price",
|
|
202
|
-
payload={"symbol": company.symbol},
|
|
203
|
-
group_id=group.id,
|
|
204
|
-
)
|
|
205
|
-
client.groups.close(group.id) # no more tasks; it completes once they've finished
|
|
205
|
+
```
|
|
206
206
|
|
|
207
|
+
Or step by step: `client.groups.create(...)`, then `client.tasks.enqueue(..., group_id=group.id)` or `enqueue_many` for each task, then `client.groups.close(group.id)`, after which it completes once its tasks have finished.
|
|
208
|
+
|
|
209
|
+
```python
|
|
207
210
|
group = client.groups.get(group.id)
|
|
208
211
|
print(group.status, group.total, group.succeeded, group.failed, group.remaining)
|
|
209
212
|
for task in client.tasks.list(group_id=group.id, state="failed"):
|
|
@@ -7,7 +7,7 @@ pip install workly # the SDK
|
|
|
7
7
|
pip install "workly[cli]" # the SDK plus the workly binary, for `workly dev`
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
Requires Python 3.11+. The only dependency is [httpx](https://www.python-httpx.org). The `cli` extra adds [`workly-cli`](https://pypi.org/project/workly-cli/), the
|
|
10
|
+
Requires Python 3.11+. The only dependency is [httpx](https://www.python-httpx.org). The `cli` extra adds [`workly-cli`](https://pypi.org/project/workly-cli/), the `workly` binary (about 7 MB): the dev server (`workly dev`) and the CLI, including `workly validate`, `plan` and `deploy` for [configuration as code](https://workly.run/docs/deploy/). Install it on dev machines and in CI; production images that only enqueue tasks don't need it.
|
|
11
11
|
|
|
12
12
|
## Quickstart
|
|
13
13
|
|
|
@@ -166,24 +166,27 @@ Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled
|
|
|
166
166
|
|
|
167
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
|
+
`groups.enqueue` does the whole job in one call: it creates the group, enqueues the tasks into it in batches of 100 and closes it, and it's safe to retry with the same `idempotency_key`.
|
|
170
|
+
|
|
169
171
|
```python
|
|
170
172
|
from workly import Callback
|
|
171
173
|
|
|
172
|
-
group = client.groups.
|
|
174
|
+
group = client.groups.enqueue(
|
|
175
|
+
"stock-prices",
|
|
176
|
+
(
|
|
177
|
+
{"url": "https://api.example.com/jobs/update-stock-price", "payload": {"symbol": c.symbol}}
|
|
178
|
+
for c in companies
|
|
179
|
+
),
|
|
173
180
|
name="daily-stock-prices",
|
|
174
|
-
idempotency_key="stock-prices-2026-10-19", #
|
|
181
|
+
idempotency_key="stock-prices-2026-10-19", # a retry finishes this group rather than start another
|
|
175
182
|
# or a dict: {"url": "https://api.example.com/jobs/refresh-views"}
|
|
176
183
|
on_complete=Callback(url="https://api.example.com/jobs/refresh-views"),
|
|
177
184
|
)
|
|
178
|
-
|
|
179
|
-
client.tasks.enqueue(
|
|
180
|
-
"stock-prices",
|
|
181
|
-
url="https://api.example.com/jobs/update-stock-price",
|
|
182
|
-
payload={"symbol": company.symbol},
|
|
183
|
-
group_id=group.id,
|
|
184
|
-
)
|
|
185
|
-
client.groups.close(group.id) # no more tasks; it completes once they've finished
|
|
185
|
+
```
|
|
186
186
|
|
|
187
|
+
Or step by step: `client.groups.create(...)`, then `client.tasks.enqueue(..., group_id=group.id)` or `enqueue_many` for each task, then `client.groups.close(group.id)`, after which it completes once its tasks have finished.
|
|
188
|
+
|
|
189
|
+
```python
|
|
187
190
|
group = client.groups.get(group.id)
|
|
188
191
|
print(group.status, group.total, group.succeeded, group.failed, group.remaining)
|
|
189
192
|
for task in client.tasks.list(group_id=group.id, state="failed"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "workly"
|
|
3
|
-
version = "0.3
|
|
3
|
+
version = "0.4.3"
|
|
4
4
|
description = "Python SDK for Workly, the open-source HTTP task queue"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -14,7 +14,7 @@ keywords = [
|
|
|
14
14
|
"http",
|
|
15
15
|
]
|
|
16
16
|
classifiers = [
|
|
17
|
-
"Development Status ::
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
18
|
"Framework :: AsyncIO",
|
|
19
19
|
"Intended Audience :: Developers",
|
|
20
20
|
"Programming Language :: Python :: 3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "workly"
|
|
3
|
-
version = "0.3
|
|
3
|
+
version = "0.4.3"
|
|
4
4
|
description = "Python SDK for Workly, the open-source HTTP task queue"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -9,7 +9,7 @@ requires-python = ">=3.11"
|
|
|
9
9
|
dependencies = ["httpx>=0.27,<1"]
|
|
10
10
|
keywords = ["task queue", "background jobs", "webhooks", "http"]
|
|
11
11
|
classifiers = [
|
|
12
|
-
"Development Status ::
|
|
12
|
+
"Development Status :: 5 - Production/Stable",
|
|
13
13
|
"Framework :: AsyncIO",
|
|
14
14
|
"Intended Audience :: Developers",
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
@@ -7,9 +7,21 @@ from ._async_client import (
|
|
|
7
7
|
AsyncRuns,
|
|
8
8
|
AsyncSchedules,
|
|
9
9
|
AsyncTasks,
|
|
10
|
+
AsyncWorkflowRuns,
|
|
11
|
+
AsyncWorkflows,
|
|
10
12
|
AsyncWorkly,
|
|
11
13
|
)
|
|
12
|
-
from ._client import
|
|
14
|
+
from ._client import (
|
|
15
|
+
Alerts,
|
|
16
|
+
Groups,
|
|
17
|
+
Queues,
|
|
18
|
+
Runs,
|
|
19
|
+
Schedules,
|
|
20
|
+
Tasks,
|
|
21
|
+
WorkflowRuns,
|
|
22
|
+
Workflows,
|
|
23
|
+
Workly,
|
|
24
|
+
)
|
|
13
25
|
from ._errors import (
|
|
14
26
|
APIConnectionError,
|
|
15
27
|
APIError,
|
|
@@ -34,6 +46,7 @@ from ._models import (
|
|
|
34
46
|
AttemptOutcome,
|
|
35
47
|
Callback,
|
|
36
48
|
CronPreview,
|
|
49
|
+
CronSpan,
|
|
37
50
|
Delivery,
|
|
38
51
|
EnqueueResult,
|
|
39
52
|
FailureGroup,
|
|
@@ -55,12 +68,23 @@ from ._models import (
|
|
|
55
68
|
Schedule,
|
|
56
69
|
SkipReason,
|
|
57
70
|
SortOrder,
|
|
71
|
+
StepStatus,
|
|
58
72
|
Task,
|
|
59
73
|
TaskPage,
|
|
60
74
|
TaskSort,
|
|
61
75
|
TaskState,
|
|
76
|
+
TaskWorkflowStep,
|
|
77
|
+
WaitFor,
|
|
78
|
+
Workflow,
|
|
79
|
+
WorkflowRun,
|
|
80
|
+
WorkflowRunPage,
|
|
81
|
+
WorkflowRunStatus,
|
|
82
|
+
WorkflowRunSummary,
|
|
83
|
+
WorkflowStep,
|
|
84
|
+
WorkflowStepConfig,
|
|
85
|
+
WorkflowStepTask,
|
|
62
86
|
)
|
|
63
|
-
from ._requests import CallbackDict, GroupCallbackSpec, TaskSpec, __version__
|
|
87
|
+
from ._requests import CallbackDict, GroupCallbackSpec, TaskSpec, WorkflowStepSpec, __version__
|
|
64
88
|
from ._signing import sign, verify_signature
|
|
65
89
|
|
|
66
90
|
__all__ = [
|
|
@@ -79,6 +103,8 @@ __all__ = [
|
|
|
79
103
|
"AsyncRuns",
|
|
80
104
|
"AsyncSchedules",
|
|
81
105
|
"AsyncTasks",
|
|
106
|
+
"AsyncWorkflowRuns",
|
|
107
|
+
"AsyncWorkflows",
|
|
82
108
|
"AsyncWorkly",
|
|
83
109
|
"Attempt",
|
|
84
110
|
"AttemptOutcome",
|
|
@@ -88,6 +114,7 @@ __all__ = [
|
|
|
88
114
|
"CallbackDict",
|
|
89
115
|
"ConflictError",
|
|
90
116
|
"CronPreview",
|
|
117
|
+
"CronSpan",
|
|
91
118
|
"Delivery",
|
|
92
119
|
"EnqueueResult",
|
|
93
120
|
"FailureGroup",
|
|
@@ -119,12 +146,26 @@ __all__ = [
|
|
|
119
146
|
"SignatureVerificationError",
|
|
120
147
|
"SkipReason",
|
|
121
148
|
"SortOrder",
|
|
149
|
+
"StepStatus",
|
|
122
150
|
"Task",
|
|
123
151
|
"TaskPage",
|
|
124
152
|
"TaskSort",
|
|
125
153
|
"TaskSpec",
|
|
126
154
|
"TaskState",
|
|
155
|
+
"TaskWorkflowStep",
|
|
127
156
|
"Tasks",
|
|
157
|
+
"WaitFor",
|
|
158
|
+
"Workflow",
|
|
159
|
+
"WorkflowRun",
|
|
160
|
+
"WorkflowRunPage",
|
|
161
|
+
"WorkflowRunStatus",
|
|
162
|
+
"WorkflowRunSummary",
|
|
163
|
+
"WorkflowRuns",
|
|
164
|
+
"WorkflowStep",
|
|
165
|
+
"WorkflowStepConfig",
|
|
166
|
+
"WorkflowStepSpec",
|
|
167
|
+
"WorkflowStepTask",
|
|
168
|
+
"Workflows",
|
|
128
169
|
"Workly",
|
|
129
170
|
"WorklyError",
|
|
130
171
|
"__version__",
|
|
@@ -39,6 +39,11 @@ from ._models import (
|
|
|
39
39
|
TaskPage,
|
|
40
40
|
TaskSort,
|
|
41
41
|
TaskState,
|
|
42
|
+
Workflow,
|
|
43
|
+
WorkflowRun,
|
|
44
|
+
WorkflowRunPage,
|
|
45
|
+
WorkflowRunStatus,
|
|
46
|
+
WorkflowRunSummary,
|
|
42
47
|
)
|
|
43
48
|
|
|
44
49
|
|
|
@@ -69,6 +74,8 @@ class AsyncWorkly:
|
|
|
69
74
|
self.groups = AsyncGroups(self)
|
|
70
75
|
self.schedules = AsyncSchedules(self)
|
|
71
76
|
self.runs = AsyncRuns(self)
|
|
77
|
+
self.workflows = AsyncWorkflows(self)
|
|
78
|
+
self.workflow_runs = AsyncWorkflowRuns(self)
|
|
72
79
|
self.alerts = AsyncAlerts(self)
|
|
73
80
|
|
|
74
81
|
@property
|
|
@@ -386,6 +393,37 @@ class AsyncGroups:
|
|
|
386
393
|
req = r.create_group(name, idempotency_key, on_complete)
|
|
387
394
|
return Group._from_json(await self._client._send(req))
|
|
388
395
|
|
|
396
|
+
async def enqueue(
|
|
397
|
+
self,
|
|
398
|
+
queue: str,
|
|
399
|
+
tasks: Iterable[r.TaskSpec],
|
|
400
|
+
*,
|
|
401
|
+
name: str | None = None,
|
|
402
|
+
idempotency_key: str | None = None,
|
|
403
|
+
on_complete: Callback | r.CallbackDict | None = None,
|
|
404
|
+
traceparent: str | None = None,
|
|
405
|
+
) -> Group:
|
|
406
|
+
"""Create a group, enqueue `tasks` into it and close it: the whole job in one call.
|
|
407
|
+
|
|
408
|
+
Tasks go in batches of 100, each joining the group. Returns the closed
|
|
409
|
+
group, which completes (and enqueues `on_complete`) once its tasks
|
|
410
|
+
have finished.
|
|
411
|
+
|
|
412
|
+
Safe to retry with the same `idempotency_key` and tasks: the group is
|
|
413
|
+
found again instead of created, a task without its own
|
|
414
|
+
`idempotency_key` gets one from the group and its position so none is
|
|
415
|
+
enqueued twice, and a group that is already closed is returned as it
|
|
416
|
+
is. Without `idempotency_key`, a retry starts a second group.
|
|
417
|
+
"""
|
|
418
|
+
group = await self.create(
|
|
419
|
+
name=name, idempotency_key=idempotency_key, on_complete=on_complete
|
|
420
|
+
)
|
|
421
|
+
if group.status != "open":
|
|
422
|
+
return group
|
|
423
|
+
for batch in r.group_batches(group.id, tasks):
|
|
424
|
+
await self._client.tasks.enqueue_many(queue, batch, traceparent=traceparent)
|
|
425
|
+
return await self.close(group.id)
|
|
426
|
+
|
|
389
427
|
async def get(self, group_id: str) -> Group:
|
|
390
428
|
return Group._from_json(await self._client._send(r.get_group(group_id)))
|
|
391
429
|
|
|
@@ -446,7 +484,8 @@ class AsyncSchedules:
|
|
|
446
484
|
name: str,
|
|
447
485
|
*,
|
|
448
486
|
cron: str,
|
|
449
|
-
url: str,
|
|
487
|
+
url: str | None = None,
|
|
488
|
+
workflow: str | None = None,
|
|
450
489
|
timezone: str | None = None,
|
|
451
490
|
payload: Any = None,
|
|
452
491
|
body: str | None = None,
|
|
@@ -465,6 +504,9 @@ class AsyncSchedules:
|
|
|
465
504
|
) -> Schedule:
|
|
466
505
|
"""Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
|
|
467
506
|
|
|
507
|
+
With `workflow` instead of `url`, each run starts a run of that
|
|
508
|
+
workflow, with `payload` as its input.
|
|
509
|
+
|
|
468
510
|
Declarative: the schedule ends up with exactly these settings, and
|
|
469
511
|
omitted ones are reset to their defaults. It doesn't change whether
|
|
470
512
|
the schedule is paused. `next_runs` on the result shows when it runs
|
|
@@ -474,6 +516,7 @@ class AsyncSchedules:
|
|
|
474
516
|
config = dict(
|
|
475
517
|
cron=cron,
|
|
476
518
|
url=url,
|
|
519
|
+
workflow=workflow,
|
|
477
520
|
timezone=timezone,
|
|
478
521
|
payload=payload,
|
|
479
522
|
body=body,
|
|
@@ -504,12 +547,22 @@ class AsyncSchedules:
|
|
|
504
547
|
"""Fire again from the next slot. Slots that passed while paused aren't run."""
|
|
505
548
|
return Schedule._from_json(await self._client._send(r.resume_schedule(name)))
|
|
506
549
|
|
|
507
|
-
async def preview_cron(
|
|
550
|
+
async def preview_cron(
|
|
551
|
+
self,
|
|
552
|
+
cron: str,
|
|
553
|
+
*,
|
|
554
|
+
timezone: str | None = None,
|
|
555
|
+
from_: datetime | None = None,
|
|
556
|
+
until: datetime | None = None,
|
|
557
|
+
) -> CronPreview:
|
|
508
558
|
"""The next 5 times a schedule with this cron would run, without creating it.
|
|
509
559
|
|
|
510
|
-
`timezone` is an IANA name; the default is UTC.
|
|
560
|
+
`timezone` is an IANA name; the default is UTC. With `from_` and `until` (at
|
|
561
|
+
most 35 days apart), `spans` lists every run in that window.
|
|
511
562
|
"""
|
|
512
|
-
return CronPreview._from_json(
|
|
563
|
+
return CronPreview._from_json(
|
|
564
|
+
await self._client._send(r.preview_cron(cron, timezone, from_, until))
|
|
565
|
+
)
|
|
513
566
|
|
|
514
567
|
async def trigger(self, name: str) -> Run:
|
|
515
568
|
"""Run the schedule now, even if paused. Doesn't change when it next fires."""
|
|
@@ -601,3 +654,106 @@ class AsyncAlerts:
|
|
|
601
654
|
async def test(self, channel_id: str) -> None:
|
|
602
655
|
"""Send a test alert to the channel now. Raises APIError (alert_failed) if it can't."""
|
|
603
656
|
await self._client._send(r.test_alert_channel(channel_id))
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class AsyncWorkflows:
|
|
660
|
+
"""Workflows: DAGs of HTTP steps, each delivered as a task once its dependencies succeed.
|
|
661
|
+
|
|
662
|
+
A step's endpoint can hand its completion to a task group by answering
|
|
663
|
+
2xx with `Workly-Wait-For: group/<id>`; the step then finishes when the
|
|
664
|
+
group does. Define workflows in workly.yaml (`workly deploy`) or with `put`.
|
|
665
|
+
"""
|
|
666
|
+
|
|
667
|
+
def __init__(self, client: AsyncWorkly) -> None:
|
|
668
|
+
self._client = client
|
|
669
|
+
|
|
670
|
+
async def list(self) -> list[Workflow]:
|
|
671
|
+
"""All workflows, by name, with their latest run."""
|
|
672
|
+
data = await self._client._send(r.list_workflows())
|
|
673
|
+
return [Workflow._from_json(w) for w in data["workflows"]]
|
|
674
|
+
|
|
675
|
+
async def get(self, name: str) -> Workflow:
|
|
676
|
+
return Workflow._from_json(await self._client._send(r.get_workflow(name)))
|
|
677
|
+
|
|
678
|
+
async def put(
|
|
679
|
+
self,
|
|
680
|
+
name: str,
|
|
681
|
+
*,
|
|
682
|
+
steps: list[r.WorkflowStepSpec],
|
|
683
|
+
max_duration_seconds: float | None = None,
|
|
684
|
+
) -> Workflow:
|
|
685
|
+
"""Create or update a workflow with exactly these steps.
|
|
686
|
+
|
|
687
|
+
Runs already started keep the definition they started with. A run
|
|
688
|
+
still going after `max_duration_seconds` (default a day) fails.
|
|
689
|
+
"""
|
|
690
|
+
req = r.put_workflow(name, steps, max_duration_seconds)
|
|
691
|
+
return Workflow._from_json(await self._client._send(req))
|
|
692
|
+
|
|
693
|
+
async def delete(self, name: str) -> None:
|
|
694
|
+
"""Delete the definition. Its runs are kept, and those going finish."""
|
|
695
|
+
await self._client._send(r.delete_workflow(name))
|
|
696
|
+
|
|
697
|
+
async def trigger(
|
|
698
|
+
self, name: str, *, input: Any = None, idempotency_key: str | None = None
|
|
699
|
+
) -> WorkflowRun:
|
|
700
|
+
"""Start a run. `input` (any JSON) is sent to every step without a body of its own.
|
|
701
|
+
|
|
702
|
+
With an `idempotency_key` already used for a run of this workflow,
|
|
703
|
+
that run is returned and nothing starts.
|
|
704
|
+
"""
|
|
705
|
+
req = r.start_workflow_run(name, input, idempotency_key)
|
|
706
|
+
return WorkflowRun._from_json(await self._client._send(req))
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
class AsyncWorkflowRuns:
|
|
710
|
+
"""Runs of workflows, with each step's status, task and what it waits for."""
|
|
711
|
+
|
|
712
|
+
def __init__(self, client: AsyncWorkly) -> None:
|
|
713
|
+
self._client = client
|
|
714
|
+
|
|
715
|
+
async def list(
|
|
716
|
+
self,
|
|
717
|
+
*,
|
|
718
|
+
workflow: str | None = None,
|
|
719
|
+
status: WorkflowRunStatus | None = None,
|
|
720
|
+
page_size: int | None = None,
|
|
721
|
+
) -> AsyncIterator[WorkflowRunSummary]:
|
|
722
|
+
"""Runs of every workflow, or of one, newest first, fetching pages as needed."""
|
|
723
|
+
cursor: str | None = None
|
|
724
|
+
while True:
|
|
725
|
+
page = await self.list_page(
|
|
726
|
+
workflow=workflow, status=status, cursor=cursor, limit=page_size
|
|
727
|
+
)
|
|
728
|
+
for run in page.workflow_runs:
|
|
729
|
+
yield run
|
|
730
|
+
if page.next_cursor is None:
|
|
731
|
+
return
|
|
732
|
+
cursor = page.next_cursor
|
|
733
|
+
|
|
734
|
+
async def list_page(
|
|
735
|
+
self,
|
|
736
|
+
*,
|
|
737
|
+
workflow: str | None = None,
|
|
738
|
+
status: WorkflowRunStatus | None = None,
|
|
739
|
+
cursor: str | None = None,
|
|
740
|
+
limit: int | None = None,
|
|
741
|
+
) -> WorkflowRunPage:
|
|
742
|
+
"""One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
743
|
+
req = r.list_workflow_runs(workflow=workflow, status=status, cursor=cursor, limit=limit)
|
|
744
|
+
return WorkflowRunPage._from_json(await self._client._send(req))
|
|
745
|
+
|
|
746
|
+
async def get(self, run_id: str) -> WorkflowRun:
|
|
747
|
+
return WorkflowRun._from_json(await self._client._send(r.get_workflow_run(run_id)))
|
|
748
|
+
|
|
749
|
+
async def cancel(self, run_id: str) -> WorkflowRun:
|
|
750
|
+
"""Stop a run: steps not yet started are skipped. Requests in flight finish."""
|
|
751
|
+
return WorkflowRun._from_json(await self._client._send(r.cancel_workflow_run(run_id)))
|
|
752
|
+
|
|
753
|
+
async def retry(self, run_id: str) -> WorkflowRun:
|
|
754
|
+
"""Reopen a failed or cancelled run under the definition it started with.
|
|
755
|
+
|
|
756
|
+
Steps that succeeded stay; the rest run again, each on its old task with a
|
|
757
|
+
fresh set of attempts. A step that was waiting for a group follows the group.
|
|
758
|
+
"""
|
|
759
|
+
return WorkflowRun._from_json(await self._client._send(r.retry_workflow_run(run_id)))
|
|
@@ -39,6 +39,11 @@ from ._models import (
|
|
|
39
39
|
TaskPage,
|
|
40
40
|
TaskSort,
|
|
41
41
|
TaskState,
|
|
42
|
+
Workflow,
|
|
43
|
+
WorkflowRun,
|
|
44
|
+
WorkflowRunPage,
|
|
45
|
+
WorkflowRunStatus,
|
|
46
|
+
WorkflowRunSummary,
|
|
42
47
|
)
|
|
43
48
|
|
|
44
49
|
|
|
@@ -69,6 +74,8 @@ class Workly:
|
|
|
69
74
|
self.groups = Groups(self)
|
|
70
75
|
self.schedules = Schedules(self)
|
|
71
76
|
self.runs = Runs(self)
|
|
77
|
+
self.workflows = Workflows(self)
|
|
78
|
+
self.workflow_runs = WorkflowRuns(self)
|
|
72
79
|
self.alerts = Alerts(self)
|
|
73
80
|
|
|
74
81
|
@property
|
|
@@ -385,6 +392,35 @@ class Groups:
|
|
|
385
392
|
req = r.create_group(name, idempotency_key, on_complete)
|
|
386
393
|
return Group._from_json(self._client._send(req))
|
|
387
394
|
|
|
395
|
+
def enqueue(
|
|
396
|
+
self,
|
|
397
|
+
queue: str,
|
|
398
|
+
tasks: Iterable[r.TaskSpec],
|
|
399
|
+
*,
|
|
400
|
+
name: str | None = None,
|
|
401
|
+
idempotency_key: str | None = None,
|
|
402
|
+
on_complete: Callback | r.CallbackDict | None = None,
|
|
403
|
+
traceparent: str | None = None,
|
|
404
|
+
) -> Group:
|
|
405
|
+
"""Create a group, enqueue `tasks` into it and close it: the whole job in one call.
|
|
406
|
+
|
|
407
|
+
Tasks go in batches of 100, each joining the group. Returns the closed
|
|
408
|
+
group, which completes (and enqueues `on_complete`) once its tasks
|
|
409
|
+
have finished.
|
|
410
|
+
|
|
411
|
+
Safe to retry with the same `idempotency_key` and tasks: the group is
|
|
412
|
+
found again instead of created, a task without its own
|
|
413
|
+
`idempotency_key` gets one from the group and its position so none is
|
|
414
|
+
enqueued twice, and a group that is already closed is returned as it
|
|
415
|
+
is. Without `idempotency_key`, a retry starts a second group.
|
|
416
|
+
"""
|
|
417
|
+
group = self.create(name=name, idempotency_key=idempotency_key, on_complete=on_complete)
|
|
418
|
+
if group.status != "open":
|
|
419
|
+
return group
|
|
420
|
+
for batch in r.group_batches(group.id, tasks):
|
|
421
|
+
self._client.tasks.enqueue_many(queue, batch, traceparent=traceparent)
|
|
422
|
+
return self.close(group.id)
|
|
423
|
+
|
|
388
424
|
def get(self, group_id: str) -> Group:
|
|
389
425
|
return Group._from_json(self._client._send(r.get_group(group_id)))
|
|
390
426
|
|
|
@@ -444,7 +480,8 @@ class Schedules:
|
|
|
444
480
|
name: str,
|
|
445
481
|
*,
|
|
446
482
|
cron: str,
|
|
447
|
-
url: str,
|
|
483
|
+
url: str | None = None,
|
|
484
|
+
workflow: str | None = None,
|
|
448
485
|
timezone: str | None = None,
|
|
449
486
|
payload: Any = None,
|
|
450
487
|
body: str | None = None,
|
|
@@ -463,6 +500,9 @@ class Schedules:
|
|
|
463
500
|
) -> Schedule:
|
|
464
501
|
"""Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
|
|
465
502
|
|
|
503
|
+
With `workflow` instead of `url`, each run starts a run of that
|
|
504
|
+
workflow, with `payload` as its input.
|
|
505
|
+
|
|
466
506
|
Declarative: the schedule ends up with exactly these settings, and
|
|
467
507
|
omitted ones are reset to their defaults. It doesn't change whether
|
|
468
508
|
the schedule is paused. `next_runs` on the result shows when it runs
|
|
@@ -472,6 +512,7 @@ class Schedules:
|
|
|
472
512
|
config = dict(
|
|
473
513
|
cron=cron,
|
|
474
514
|
url=url,
|
|
515
|
+
workflow=workflow,
|
|
475
516
|
timezone=timezone,
|
|
476
517
|
payload=payload,
|
|
477
518
|
body=body,
|
|
@@ -502,12 +543,22 @@ class Schedules:
|
|
|
502
543
|
"""Fire again from the next slot. Slots that passed while paused aren't run."""
|
|
503
544
|
return Schedule._from_json(self._client._send(r.resume_schedule(name)))
|
|
504
545
|
|
|
505
|
-
def preview_cron(
|
|
546
|
+
def preview_cron(
|
|
547
|
+
self,
|
|
548
|
+
cron: str,
|
|
549
|
+
*,
|
|
550
|
+
timezone: str | None = None,
|
|
551
|
+
from_: datetime | None = None,
|
|
552
|
+
until: datetime | None = None,
|
|
553
|
+
) -> CronPreview:
|
|
506
554
|
"""The next 5 times a schedule with this cron would run, without creating it.
|
|
507
555
|
|
|
508
|
-
`timezone` is an IANA name; the default is UTC.
|
|
556
|
+
`timezone` is an IANA name; the default is UTC. With `from_` and `until` (at
|
|
557
|
+
most 35 days apart), `spans` lists every run in that window.
|
|
509
558
|
"""
|
|
510
|
-
return CronPreview._from_json(
|
|
559
|
+
return CronPreview._from_json(
|
|
560
|
+
self._client._send(r.preview_cron(cron, timezone, from_, until))
|
|
561
|
+
)
|
|
511
562
|
|
|
512
563
|
def trigger(self, name: str) -> Run:
|
|
513
564
|
"""Run the schedule now, even if paused. Doesn't change when it next fires."""
|
|
@@ -594,3 +645,103 @@ class Alerts:
|
|
|
594
645
|
def test(self, channel_id: str) -> None:
|
|
595
646
|
"""Send a test alert to the channel now. Raises APIError (alert_failed) if it can't."""
|
|
596
647
|
self._client._send(r.test_alert_channel(channel_id))
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
class Workflows:
|
|
651
|
+
"""Workflows: DAGs of HTTP steps, each delivered as a task once its dependencies succeed.
|
|
652
|
+
|
|
653
|
+
A step's endpoint can hand its completion to a task group by answering
|
|
654
|
+
2xx with `Workly-Wait-For: group/<id>`; the step then finishes when the
|
|
655
|
+
group does. Define workflows in workly.yaml (`workly deploy`) or with `put`.
|
|
656
|
+
"""
|
|
657
|
+
|
|
658
|
+
def __init__(self, client: Workly) -> None:
|
|
659
|
+
self._client = client
|
|
660
|
+
|
|
661
|
+
def list(self) -> list[Workflow]:
|
|
662
|
+
"""All workflows, by name, with their latest run."""
|
|
663
|
+
data = self._client._send(r.list_workflows())
|
|
664
|
+
return [Workflow._from_json(w) for w in data["workflows"]]
|
|
665
|
+
|
|
666
|
+
def get(self, name: str) -> Workflow:
|
|
667
|
+
return Workflow._from_json(self._client._send(r.get_workflow(name)))
|
|
668
|
+
|
|
669
|
+
def put(
|
|
670
|
+
self,
|
|
671
|
+
name: str,
|
|
672
|
+
*,
|
|
673
|
+
steps: list[r.WorkflowStepSpec],
|
|
674
|
+
max_duration_seconds: float | None = None,
|
|
675
|
+
) -> Workflow:
|
|
676
|
+
"""Create or update a workflow with exactly these steps.
|
|
677
|
+
|
|
678
|
+
Runs already started keep the definition they started with. A run
|
|
679
|
+
still going after `max_duration_seconds` (default a day) fails.
|
|
680
|
+
"""
|
|
681
|
+
req = r.put_workflow(name, steps, max_duration_seconds)
|
|
682
|
+
return Workflow._from_json(self._client._send(req))
|
|
683
|
+
|
|
684
|
+
def delete(self, name: str) -> None:
|
|
685
|
+
"""Delete the definition. Its runs are kept, and those going finish."""
|
|
686
|
+
self._client._send(r.delete_workflow(name))
|
|
687
|
+
|
|
688
|
+
def trigger(
|
|
689
|
+
self, name: str, *, input: Any = None, idempotency_key: str | None = None
|
|
690
|
+
) -> WorkflowRun:
|
|
691
|
+
"""Start a run. `input` (any JSON) is sent to every step without a body of its own.
|
|
692
|
+
|
|
693
|
+
With an `idempotency_key` already used for a run of this workflow,
|
|
694
|
+
that run is returned and nothing starts.
|
|
695
|
+
"""
|
|
696
|
+
req = r.start_workflow_run(name, input, idempotency_key)
|
|
697
|
+
return WorkflowRun._from_json(self._client._send(req))
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class WorkflowRuns:
|
|
701
|
+
"""Runs of workflows, with each step's status, task and what it waits for."""
|
|
702
|
+
|
|
703
|
+
def __init__(self, client: Workly) -> None:
|
|
704
|
+
self._client = client
|
|
705
|
+
|
|
706
|
+
def list(
|
|
707
|
+
self,
|
|
708
|
+
*,
|
|
709
|
+
workflow: str | None = None,
|
|
710
|
+
status: WorkflowRunStatus | None = None,
|
|
711
|
+
page_size: int | None = None,
|
|
712
|
+
) -> Iterator[WorkflowRunSummary]:
|
|
713
|
+
"""Runs of every workflow, or of one, newest first, fetching pages as needed."""
|
|
714
|
+
cursor: str | None = None
|
|
715
|
+
while True:
|
|
716
|
+
page = self.list_page(workflow=workflow, status=status, cursor=cursor, limit=page_size)
|
|
717
|
+
yield from page.workflow_runs
|
|
718
|
+
if page.next_cursor is None:
|
|
719
|
+
return
|
|
720
|
+
cursor = page.next_cursor
|
|
721
|
+
|
|
722
|
+
def list_page(
|
|
723
|
+
self,
|
|
724
|
+
*,
|
|
725
|
+
workflow: str | None = None,
|
|
726
|
+
status: WorkflowRunStatus | None = None,
|
|
727
|
+
cursor: str | None = None,
|
|
728
|
+
limit: int | None = None,
|
|
729
|
+
) -> WorkflowRunPage:
|
|
730
|
+
"""One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
731
|
+
req = r.list_workflow_runs(workflow=workflow, status=status, cursor=cursor, limit=limit)
|
|
732
|
+
return WorkflowRunPage._from_json(self._client._send(req))
|
|
733
|
+
|
|
734
|
+
def get(self, run_id: str) -> WorkflowRun:
|
|
735
|
+
return WorkflowRun._from_json(self._client._send(r.get_workflow_run(run_id)))
|
|
736
|
+
|
|
737
|
+
def cancel(self, run_id: str) -> WorkflowRun:
|
|
738
|
+
"""Stop a run: steps not yet started are skipped. Requests in flight finish."""
|
|
739
|
+
return WorkflowRun._from_json(self._client._send(r.cancel_workflow_run(run_id)))
|
|
740
|
+
|
|
741
|
+
def retry(self, run_id: str) -> WorkflowRun:
|
|
742
|
+
"""Reopen a failed or cancelled run under the definition it started with.
|
|
743
|
+
|
|
744
|
+
Steps that succeeded stay; the rest run again, each on its old task with a
|
|
745
|
+
fresh set of attempts. A step that was waiting for a group follows the group.
|
|
746
|
+
"""
|
|
747
|
+
return WorkflowRun._from_json(self._client._send(r.retry_workflow_run(run_id)))
|
|
@@ -23,12 +23,21 @@ OverlapPolicy = Literal["skip", "allow"]
|
|
|
23
23
|
MissedPolicy = Literal["skip", "catch_up"]
|
|
24
24
|
GroupStatus = Literal["open", "running", "succeeded", "failed"]
|
|
25
25
|
"""`open` accepts tasks; `running` is closed with tasks unfinished; the other two are final."""
|
|
26
|
+
WorkflowRunStatus = Literal["running", "succeeded", "failed", "cancelled"]
|
|
27
|
+
StepStatus = Literal["blocked", "pending", "running", "waiting", "succeeded", "failed", "skipped"]
|
|
28
|
+
"""`blocked` waits for its dependencies; `pending` has its task queued or retrying; `running` is
|
|
29
|
+
being delivered; `waiting` succeeded and waits for its `wait_for` group; `skipped` never ran,
|
|
30
|
+
or stopped waiting, because the run stopped."""
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
def _time(value: str) -> datetime:
|
|
29
34
|
return datetime.fromisoformat(value)
|
|
30
35
|
|
|
31
36
|
|
|
37
|
+
def _list(value: Any) -> list[dict[str, Any]]:
|
|
38
|
+
return value or []
|
|
39
|
+
|
|
40
|
+
|
|
32
41
|
def _opt_time(value: str | None) -> datetime | None:
|
|
33
42
|
return None if value is None else _time(value)
|
|
34
43
|
|
|
@@ -70,6 +79,8 @@ class QueueActivity:
|
|
|
70
79
|
"""Median duration of the latest attempt of the succeeded and failed tasks counted."""
|
|
71
80
|
p95_duration_ms: int | None = None
|
|
72
81
|
"""95th percentile of the same durations."""
|
|
82
|
+
duration_samples: int | None = None
|
|
83
|
+
"""How many durations the percentiles are taken over; None from older servers."""
|
|
73
84
|
|
|
74
85
|
@classmethod
|
|
75
86
|
def _from_json(cls, d: dict[str, Any]) -> QueueActivity:
|
|
@@ -81,9 +92,20 @@ class QueueActivity:
|
|
|
81
92
|
retention_seconds=d.get("retention_seconds"),
|
|
82
93
|
p50_duration_ms=d.get("p50_duration_ms"),
|
|
83
94
|
p95_duration_ms=d.get("p95_duration_ms"),
|
|
95
|
+
duration_samples=d.get("duration_samples"),
|
|
84
96
|
)
|
|
85
97
|
|
|
86
98
|
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class CronSpan:
|
|
101
|
+
"""`count` runs, `every_seconds` apart, the first at `start`."""
|
|
102
|
+
|
|
103
|
+
start: datetime
|
|
104
|
+
every_seconds: int
|
|
105
|
+
"""0 when `count` is 1."""
|
|
106
|
+
count: int
|
|
107
|
+
|
|
108
|
+
|
|
87
109
|
@dataclass(frozen=True, slots=True)
|
|
88
110
|
class CronPreview:
|
|
89
111
|
"""When a cron timetable would run, from now."""
|
|
@@ -91,11 +113,23 @@ class CronPreview:
|
|
|
91
113
|
next_runs: list[datetime]
|
|
92
114
|
interval_seconds: int
|
|
93
115
|
"""The shortest time between two of its runs."""
|
|
116
|
+
spans: list[CronSpan] | None = None
|
|
117
|
+
"""With a window: every run in it, earliest first, as runs at a fixed interval."""
|
|
94
118
|
|
|
95
119
|
@classmethod
|
|
96
120
|
def _from_json(cls, d: dict[str, Any]) -> CronPreview:
|
|
121
|
+
spans = d.get("spans")
|
|
97
122
|
return cls(
|
|
98
|
-
next_runs=[_time(t) for t in d["next_runs"]],
|
|
123
|
+
next_runs=[_time(t) for t in d["next_runs"]],
|
|
124
|
+
interval_seconds=d["interval_seconds"],
|
|
125
|
+
spans=None
|
|
126
|
+
if spans is None
|
|
127
|
+
else [
|
|
128
|
+
CronSpan(
|
|
129
|
+
start=_time(s["start"]), every_seconds=s["every_seconds"], count=s["count"]
|
|
130
|
+
)
|
|
131
|
+
for s in spans
|
|
132
|
+
],
|
|
99
133
|
)
|
|
100
134
|
|
|
101
135
|
|
|
@@ -104,16 +138,20 @@ ActivityRange = Literal["1h", "24h", "7d"]
|
|
|
104
138
|
|
|
105
139
|
@dataclass(frozen=True, slots=True)
|
|
106
140
|
class ActivityBucket:
|
|
107
|
-
"""Tasks created from `start` until the next bucket, by queue."""
|
|
141
|
+
"""Tasks created from `start` until the next bucket, by queue, and tasks that finished in it."""
|
|
108
142
|
|
|
109
143
|
start: datetime
|
|
110
144
|
counts: dict[str, int]
|
|
111
|
-
"""Queues with none are left out."""
|
|
145
|
+
"""Tasks created, by queue. Queues with none are left out."""
|
|
146
|
+
succeeded: int = 0
|
|
147
|
+
"""Tasks of every queue that finished in the bucket and succeeded."""
|
|
148
|
+
failed: int = 0
|
|
149
|
+
"""Tasks of every queue that finished in the bucket and failed."""
|
|
112
150
|
|
|
113
151
|
|
|
114
152
|
@dataclass(frozen=True, slots=True)
|
|
115
153
|
class Activity:
|
|
116
|
-
"""
|
|
154
|
+
"""Tasks created and finished over time: every bucket of the range, oldest first."""
|
|
117
155
|
|
|
118
156
|
range: ActivityRange
|
|
119
157
|
bucket_seconds: int
|
|
@@ -127,7 +165,12 @@ class Activity:
|
|
|
127
165
|
range=d["range"],
|
|
128
166
|
bucket_seconds=d["bucket_seconds"],
|
|
129
167
|
buckets=[
|
|
130
|
-
ActivityBucket(
|
|
168
|
+
ActivityBucket(
|
|
169
|
+
start=_time(b["start"]),
|
|
170
|
+
counts=dict(b["counts"]),
|
|
171
|
+
succeeded=b.get("succeeded", 0),
|
|
172
|
+
failed=b.get("failed", 0),
|
|
173
|
+
)
|
|
131
174
|
for b in d["buckets"]
|
|
132
175
|
],
|
|
133
176
|
retention_seconds=d.get("retention_seconds"),
|
|
@@ -240,10 +283,13 @@ class Task:
|
|
|
240
283
|
"""Called each time the task finishes."""
|
|
241
284
|
callback_task_id: str | None = None
|
|
242
285
|
"""The callback task enqueued when the task last finished, if it has `on_complete`."""
|
|
286
|
+
workflow_step: TaskWorkflowStep | None = None
|
|
287
|
+
"""The workflow step the task delivers, if it is one."""
|
|
243
288
|
|
|
244
289
|
@classmethod
|
|
245
290
|
def _from_json(cls, d: dict[str, Any]) -> Task:
|
|
246
291
|
cb = d.get("on_complete")
|
|
292
|
+
step = d.get("workflow_step")
|
|
247
293
|
return cls(
|
|
248
294
|
id=d["id"],
|
|
249
295
|
queue=d["queue"],
|
|
@@ -271,9 +317,26 @@ class Task:
|
|
|
271
317
|
last_duration_ms=d.get("last_duration_ms"),
|
|
272
318
|
on_complete=None if cb is None else Callback._from_json(cb),
|
|
273
319
|
callback_task_id=d.get("callback_task_id"),
|
|
320
|
+
workflow_step=None if step is None else TaskWorkflowStep._from_json(step),
|
|
274
321
|
)
|
|
275
322
|
|
|
276
323
|
|
|
324
|
+
@dataclass(frozen=True, slots=True)
|
|
325
|
+
class TaskWorkflowStep:
|
|
326
|
+
"""The workflow step a task delivers. Its task can't be retried alone: retry its run."""
|
|
327
|
+
|
|
328
|
+
id: str
|
|
329
|
+
"""The step, `wfs_…`."""
|
|
330
|
+
name: str
|
|
331
|
+
workflow: str
|
|
332
|
+
run_id: str
|
|
333
|
+
"""The workflow run, `wfr_…`."""
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def _from_json(cls, d: dict[str, Any]) -> TaskWorkflowStep:
|
|
337
|
+
return cls(id=d["id"], name=d["name"], workflow=d["workflow"], run_id=d["run_id"])
|
|
338
|
+
|
|
339
|
+
|
|
277
340
|
@dataclass(frozen=True, slots=True)
|
|
278
341
|
class Delivery:
|
|
279
342
|
"""The HTTP request that delivers a task or run, freshly signed."""
|
|
@@ -405,6 +468,8 @@ class Schedule:
|
|
|
405
468
|
"""While paused, the five slots it would run at if resumed now; None while it runs."""
|
|
406
469
|
on_complete: Callback | None = None
|
|
407
470
|
"""Called each time a run finishes, except skipped runs."""
|
|
471
|
+
workflow: str | None = None
|
|
472
|
+
"""The workflow each run starts, for a schedule that starts one instead of calling `url`."""
|
|
408
473
|
|
|
409
474
|
@classmethod
|
|
410
475
|
def _from_json(cls, d: dict[str, Any]) -> Schedule:
|
|
@@ -441,6 +506,7 @@ class Schedule:
|
|
|
441
506
|
else [_time(t) for t in resumed]
|
|
442
507
|
),
|
|
443
508
|
on_complete=None if cb is None else Callback._from_json(cb),
|
|
509
|
+
workflow=d.get("workflow"),
|
|
444
510
|
)
|
|
445
511
|
|
|
446
512
|
|
|
@@ -477,6 +543,8 @@ class Run:
|
|
|
477
543
|
"""How long the latest finished attempt took."""
|
|
478
544
|
callback_task_id: str | None = None
|
|
479
545
|
"""The callback task enqueued when the run last finished, if its schedule has `on_complete`."""
|
|
546
|
+
workflow_run_id: str | None = None
|
|
547
|
+
"""The workflow run it started, for a schedule that starts a workflow."""
|
|
480
548
|
|
|
481
549
|
@classmethod
|
|
482
550
|
def _from_json(cls, d: dict[str, Any]) -> Run:
|
|
@@ -505,6 +573,7 @@ class Run:
|
|
|
505
573
|
started_at=_opt_time(d.get("started_at")),
|
|
506
574
|
last_duration_ms=d.get("last_duration_ms"),
|
|
507
575
|
callback_task_id=d.get("callback_task_id"),
|
|
576
|
+
workflow_run_id=d.get("workflow_run_id"),
|
|
508
577
|
)
|
|
509
578
|
|
|
510
579
|
|
|
@@ -634,3 +703,249 @@ class GroupPage:
|
|
|
634
703
|
groups=[Group._from_json(g) for g in d["groups"]],
|
|
635
704
|
next_cursor=d.get("next_cursor"),
|
|
636
705
|
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
WORKFLOW_QUEUE = "_workflows"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@dataclass(frozen=True, slots=True)
|
|
712
|
+
class WorkflowStepConfig:
|
|
713
|
+
"""A step of a workflow's definition: the request its task sends."""
|
|
714
|
+
|
|
715
|
+
name: str
|
|
716
|
+
url: str
|
|
717
|
+
depends_on: list[str]
|
|
718
|
+
method: HTTPMethod
|
|
719
|
+
queue: str
|
|
720
|
+
headers: dict[str, str]
|
|
721
|
+
payload: Any
|
|
722
|
+
"""The JSON body, decoded. None for a raw `body`, or none: then the run's context is sent."""
|
|
723
|
+
body: str | None
|
|
724
|
+
timeout_seconds: float | None
|
|
725
|
+
"""Overrides the queue's timeout."""
|
|
726
|
+
max_attempts: int | None
|
|
727
|
+
"""Overrides the queue's max attempts."""
|
|
728
|
+
|
|
729
|
+
@classmethod
|
|
730
|
+
def _from_json(cls, d: dict[str, Any]) -> WorkflowStepConfig:
|
|
731
|
+
return cls(
|
|
732
|
+
name=d["name"],
|
|
733
|
+
url=d["url"],
|
|
734
|
+
depends_on=d.get("depends_on") or [],
|
|
735
|
+
method=d.get("method") or "POST",
|
|
736
|
+
queue=d.get("queue") or WORKFLOW_QUEUE,
|
|
737
|
+
headers=d.get("headers") or {},
|
|
738
|
+
payload=d.get("payload"),
|
|
739
|
+
body=d.get("body"),
|
|
740
|
+
timeout_seconds=d.get("timeout_seconds"),
|
|
741
|
+
max_attempts=d.get("max_attempts"),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@dataclass(frozen=True, slots=True)
|
|
746
|
+
class WorkflowRunSummary:
|
|
747
|
+
"""A workflow run without its steps, as lists show it."""
|
|
748
|
+
|
|
749
|
+
id: str
|
|
750
|
+
workflow: str
|
|
751
|
+
version: int
|
|
752
|
+
"""The definition's version the run started with."""
|
|
753
|
+
status: WorkflowRunStatus
|
|
754
|
+
error: str | None
|
|
755
|
+
"""Why the run failed or was cancelled."""
|
|
756
|
+
idempotency_key: str | None
|
|
757
|
+
schedule_run_id: str | None
|
|
758
|
+
"""The schedule run that started it, if any."""
|
|
759
|
+
created_at: datetime
|
|
760
|
+
deadline_at: datetime | None
|
|
761
|
+
"""When a run still going fails (`max_duration_seconds`)."""
|
|
762
|
+
finished_at: datetime | None
|
|
763
|
+
duration_ms: int | None
|
|
764
|
+
"""From start to finish, or to now while running."""
|
|
765
|
+
attempt: int = 1
|
|
766
|
+
"""1, plus one for each time the run was retried."""
|
|
767
|
+
retried_at: datetime | None = None
|
|
768
|
+
"""When the run was last retried."""
|
|
769
|
+
|
|
770
|
+
@classmethod
|
|
771
|
+
def _from_json(cls, d: dict[str, Any]) -> WorkflowRunSummary:
|
|
772
|
+
return cls(**_run_summary_fields(d))
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _run_summary_fields(d: dict[str, Any]) -> dict[str, Any]:
|
|
776
|
+
return dict(
|
|
777
|
+
id=d["id"],
|
|
778
|
+
workflow=d["workflow"],
|
|
779
|
+
version=d.get("version", 0),
|
|
780
|
+
status=d["status"],
|
|
781
|
+
error=d.get("error"),
|
|
782
|
+
idempotency_key=d.get("idempotency_key"),
|
|
783
|
+
schedule_run_id=d.get("schedule_run_id"),
|
|
784
|
+
created_at=_time(d["created_at"]),
|
|
785
|
+
deadline_at=_opt_time(d.get("deadline_at")),
|
|
786
|
+
finished_at=_opt_time(d.get("finished_at")),
|
|
787
|
+
duration_ms=d.get("duration_ms"),
|
|
788
|
+
attempt=d.get("attempt", 1),
|
|
789
|
+
retried_at=_opt_time(d.get("retried_at")),
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
@dataclass(frozen=True, slots=True)
|
|
794
|
+
class Workflow:
|
|
795
|
+
"""A DAG of HTTP steps, each delivered as a task once its dependencies have succeeded."""
|
|
796
|
+
|
|
797
|
+
name: str
|
|
798
|
+
steps: list[WorkflowStepConfig]
|
|
799
|
+
max_duration_seconds: float
|
|
800
|
+
version: int
|
|
801
|
+
"""Counts changes to the definition, from 1."""
|
|
802
|
+
last_run: WorkflowRunSummary | None
|
|
803
|
+
created_at: datetime
|
|
804
|
+
updated_at: datetime
|
|
805
|
+
|
|
806
|
+
@classmethod
|
|
807
|
+
def _from_json(cls, d: dict[str, Any]) -> Workflow:
|
|
808
|
+
last = d.get("last_run")
|
|
809
|
+
return cls(
|
|
810
|
+
name=d["name"],
|
|
811
|
+
steps=[WorkflowStepConfig._from_json(s) for s in d["steps"]],
|
|
812
|
+
max_duration_seconds=d["max_duration_seconds"],
|
|
813
|
+
version=d["version"],
|
|
814
|
+
last_run=None if last is None else WorkflowRunSummary._from_json(last),
|
|
815
|
+
created_at=_time(d["created_at"]),
|
|
816
|
+
updated_at=_time(d["updated_at"]),
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@dataclass(frozen=True, slots=True)
|
|
821
|
+
class WaitFor:
|
|
822
|
+
"""The resource a step handed its completion to with `Workly-Wait-For`."""
|
|
823
|
+
|
|
824
|
+
type: Literal["group"]
|
|
825
|
+
id: str
|
|
826
|
+
|
|
827
|
+
@classmethod
|
|
828
|
+
def _from_json(cls, d: dict[str, Any]) -> WaitFor:
|
|
829
|
+
return cls(type=d["type"], id=d["id"])
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@dataclass(frozen=True, slots=True)
|
|
833
|
+
class WorkflowStepTask:
|
|
834
|
+
"""A step's task, which records its delivery attempts (`tasks.attempts`)."""
|
|
835
|
+
|
|
836
|
+
id: str
|
|
837
|
+
queue: str
|
|
838
|
+
state: TaskState
|
|
839
|
+
attempt_count: int
|
|
840
|
+
last_status: int | None
|
|
841
|
+
last_outcome: AttemptOutcome | None
|
|
842
|
+
last_error: str | None
|
|
843
|
+
run_at: datetime | None
|
|
844
|
+
"""When a pending task is next delivered."""
|
|
845
|
+
|
|
846
|
+
@classmethod
|
|
847
|
+
def _from_json(cls, d: dict[str, Any]) -> WorkflowStepTask:
|
|
848
|
+
return cls(
|
|
849
|
+
id=d["id"],
|
|
850
|
+
queue=d["queue"],
|
|
851
|
+
state=d["state"],
|
|
852
|
+
attempt_count=d["attempt_count"],
|
|
853
|
+
last_status=d.get("last_status"),
|
|
854
|
+
last_outcome=d.get("last_outcome"),
|
|
855
|
+
last_error=d.get("last_error"),
|
|
856
|
+
run_at=_opt_time(d.get("run_at")),
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
@dataclass(frozen=True, slots=True)
|
|
861
|
+
class WorkflowStep:
|
|
862
|
+
"""A step of a run: where it is, and why."""
|
|
863
|
+
|
|
864
|
+
id: str
|
|
865
|
+
"""Stable across retries of its request: use it as an idempotency key."""
|
|
866
|
+
name: str
|
|
867
|
+
status: StepStatus
|
|
868
|
+
depends_on: list[str]
|
|
869
|
+
blocked_by: list[str]
|
|
870
|
+
"""Dependencies that haven't succeeded yet."""
|
|
871
|
+
task_id: str | None
|
|
872
|
+
task: WorkflowStepTask | None
|
|
873
|
+
wait_for: WaitFor | None
|
|
874
|
+
error: str | None
|
|
875
|
+
created_at: datetime | None
|
|
876
|
+
ready_at: datetime | None
|
|
877
|
+
"""When its task was enqueued."""
|
|
878
|
+
http_done_at: datetime | None
|
|
879
|
+
"""When its request succeeded."""
|
|
880
|
+
finished_at: datetime | None
|
|
881
|
+
blocked_ms: int | None
|
|
882
|
+
waiting_ms: int | None
|
|
883
|
+
duration_ms: int | None
|
|
884
|
+
|
|
885
|
+
@classmethod
|
|
886
|
+
def _from_json(cls, d: dict[str, Any]) -> WorkflowStep:
|
|
887
|
+
task, wait = d.get("task"), d.get("wait_for")
|
|
888
|
+
return cls(
|
|
889
|
+
id=d["id"],
|
|
890
|
+
name=d["name"],
|
|
891
|
+
status=d["status"],
|
|
892
|
+
depends_on=d.get("depends_on") or [],
|
|
893
|
+
blocked_by=d.get("blocked_by") or [],
|
|
894
|
+
task_id=d.get("task_id"),
|
|
895
|
+
task=None if task is None else WorkflowStepTask._from_json(task),
|
|
896
|
+
wait_for=None if wait is None else WaitFor._from_json(wait),
|
|
897
|
+
error=d.get("error"),
|
|
898
|
+
created_at=_opt_time(d.get("created_at")),
|
|
899
|
+
ready_at=_opt_time(d.get("ready_at")),
|
|
900
|
+
http_done_at=_opt_time(d.get("http_done_at")),
|
|
901
|
+
finished_at=_opt_time(d.get("finished_at")),
|
|
902
|
+
blocked_ms=d.get("blocked_ms"),
|
|
903
|
+
waiting_ms=d.get("waiting_ms"),
|
|
904
|
+
duration_ms=d.get("duration_ms"),
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
@dataclass(frozen=True, slots=True)
|
|
909
|
+
class WorkflowRun:
|
|
910
|
+
"""One execution of a workflow, under the definition it started with."""
|
|
911
|
+
|
|
912
|
+
id: str
|
|
913
|
+
workflow: str
|
|
914
|
+
version: int
|
|
915
|
+
status: WorkflowRunStatus
|
|
916
|
+
error: str | None
|
|
917
|
+
idempotency_key: str | None
|
|
918
|
+
schedule_run_id: str | None
|
|
919
|
+
created_at: datetime
|
|
920
|
+
deadline_at: datetime | None
|
|
921
|
+
finished_at: datetime | None
|
|
922
|
+
duration_ms: int | None
|
|
923
|
+
input: Any
|
|
924
|
+
steps: list[WorkflowStep]
|
|
925
|
+
"""In definition order."""
|
|
926
|
+
attempt: int = 1
|
|
927
|
+
"""1, plus one for each time the run was retried."""
|
|
928
|
+
retried_at: datetime | None = None
|
|
929
|
+
"""When the run was last retried."""
|
|
930
|
+
|
|
931
|
+
@classmethod
|
|
932
|
+
def _from_json(cls, d: dict[str, Any]) -> WorkflowRun:
|
|
933
|
+
return cls(
|
|
934
|
+
**_run_summary_fields(d),
|
|
935
|
+
input=d.get("input"),
|
|
936
|
+
steps=[WorkflowStep._from_json(s) for s in _list(d.get("steps"))],
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
@dataclass(frozen=True, slots=True)
|
|
941
|
+
class WorkflowRunPage:
|
|
942
|
+
workflow_runs: list[WorkflowRunSummary]
|
|
943
|
+
next_cursor: str | None
|
|
944
|
+
"""Pass as `cursor` to get the next page. None on the last page."""
|
|
945
|
+
|
|
946
|
+
@classmethod
|
|
947
|
+
def _from_json(cls, d: dict[str, Any]) -> WorkflowRunPage:
|
|
948
|
+
return cls(
|
|
949
|
+
workflow_runs=[WorkflowRunSummary._from_json(r) for r in d["workflow_runs"]],
|
|
950
|
+
next_cursor=d.get("next_cursor"),
|
|
951
|
+
)
|
|
@@ -7,7 +7,7 @@ send it and decode the response, so the two stay in step.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
-
from collections.abc import Mapping
|
|
10
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
12
|
from datetime import datetime
|
|
13
13
|
from importlib.metadata import PackageNotFoundError, version
|
|
@@ -23,6 +23,7 @@ from ._models import (
|
|
|
23
23
|
SortOrder,
|
|
24
24
|
TaskSort,
|
|
25
25
|
TaskState,
|
|
26
|
+
WorkflowRunStatus,
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
DEFAULT_URL = "http://localhost:7337"
|
|
@@ -88,6 +89,27 @@ GroupCallbackSpec = CallbackDict
|
|
|
88
89
|
"""Deprecated: use `Callback` or `CallbackDict`."""
|
|
89
90
|
|
|
90
91
|
|
|
92
|
+
class WorkflowStepSpec(TypedDict, total=False):
|
|
93
|
+
"""One step of a workflow for `workflows.put`.
|
|
94
|
+
|
|
95
|
+
An HTTP request, sent once the steps in `depends_on` have succeeded.
|
|
96
|
+
Without `payload` or `body`, the step is sent the run's context as JSON.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
name: Required[str]
|
|
100
|
+
url: Required[str]
|
|
101
|
+
depends_on: list[str]
|
|
102
|
+
method: HTTPMethod
|
|
103
|
+
headers: Mapping[str, str]
|
|
104
|
+
payload: Any
|
|
105
|
+
body: str
|
|
106
|
+
content_type: str
|
|
107
|
+
queue: str
|
|
108
|
+
"""The queue its task goes to; `_workflows` by default."""
|
|
109
|
+
timeout_seconds: float
|
|
110
|
+
max_attempts: int
|
|
111
|
+
|
|
112
|
+
|
|
91
113
|
def callback_body(cb: Callback | CallbackDict) -> dict[str, Any]:
|
|
92
114
|
if isinstance(cb, Callback):
|
|
93
115
|
url, headers = cb.url, cb.headers
|
|
@@ -347,8 +369,16 @@ def resume_schedule(name: str) -> Request:
|
|
|
347
369
|
return Request("POST", f"/v1/schedules/{_q(name)}/resume", idempotent=True)
|
|
348
370
|
|
|
349
371
|
|
|
350
|
-
def preview_cron(
|
|
351
|
-
|
|
372
|
+
def preview_cron(
|
|
373
|
+
cron: str, timezone: str | None, from_: datetime | None, until: datetime | None
|
|
374
|
+
) -> Request:
|
|
375
|
+
params = {"cron": cron}
|
|
376
|
+
if timezone is not None:
|
|
377
|
+
params["timezone"] = timezone
|
|
378
|
+
if from_ is not None:
|
|
379
|
+
params["from"] = _timestamp(from_, "from_")
|
|
380
|
+
if until is not None:
|
|
381
|
+
params["until"] = _timestamp(until, "until")
|
|
352
382
|
return Request("GET", "/v1/cron/preview", params=params, idempotent=True)
|
|
353
383
|
|
|
354
384
|
|
|
@@ -409,3 +439,116 @@ def delete_alert_channel(channel_id: str) -> Request:
|
|
|
409
439
|
def test_alert_channel(channel_id: str) -> Request:
|
|
410
440
|
# Not idempotent: a resend sends a second test message.
|
|
411
441
|
return Request("POST", f"/v1/alert-channels/{_q(channel_id)}/test")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# Workflows
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def list_workflows() -> Request:
|
|
448
|
+
return Request("GET", "/v1/workflows", idempotent=True)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def get_workflow(name: str) -> Request:
|
|
452
|
+
return Request("GET", f"/v1/workflows/{_q(name)}", idempotent=True)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def put_workflow(
|
|
456
|
+
name: str, steps: list[WorkflowStepSpec], max_duration_seconds: float | None
|
|
457
|
+
) -> Request:
|
|
458
|
+
body: dict[str, Any] = {"steps": [_step_body(s) for s in steps]}
|
|
459
|
+
if max_duration_seconds is not None:
|
|
460
|
+
body["max_duration_seconds"] = max_duration_seconds
|
|
461
|
+
return Request("PUT", f"/v1/workflows/{_q(name)}", json=body, idempotent=True)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _step_body(step: WorkflowStepSpec) -> dict[str, Any]:
|
|
465
|
+
body: dict[str, Any] = {"name": step["name"], "url": step["url"]}
|
|
466
|
+
if "depends_on" in step:
|
|
467
|
+
body["depends_on"] = list(step["depends_on"])
|
|
468
|
+
if "headers" in step:
|
|
469
|
+
body["headers"] = dict(step["headers"])
|
|
470
|
+
for key in (
|
|
471
|
+
"method",
|
|
472
|
+
"payload",
|
|
473
|
+
"body",
|
|
474
|
+
"content_type",
|
|
475
|
+
"queue",
|
|
476
|
+
"timeout_seconds",
|
|
477
|
+
"max_attempts",
|
|
478
|
+
):
|
|
479
|
+
if step.get(key) is not None:
|
|
480
|
+
body[key] = step.get(key)
|
|
481
|
+
if "payload" in body and "body" in body:
|
|
482
|
+
raise ValueError(f"step {step['name']!r}: pass payload or body, not both")
|
|
483
|
+
return body
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def delete_workflow(name: str) -> Request:
|
|
487
|
+
return Request("DELETE", f"/v1/workflows/{_q(name)}", idempotent=True)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def start_workflow_run(name: str, input: Any, idempotency_key: str | None) -> Request:
|
|
491
|
+
body: dict[str, Any] = {}
|
|
492
|
+
if input is not None:
|
|
493
|
+
body["input"] = input
|
|
494
|
+
if idempotency_key is not None:
|
|
495
|
+
body["idempotency_key"] = idempotency_key
|
|
496
|
+
# Only safe to resend with a key: without one it could start a second run.
|
|
497
|
+
return Request(
|
|
498
|
+
"POST", f"/v1/workflows/{_q(name)}/runs", json=body, idempotent=idempotency_key is not None
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def list_workflow_runs(
|
|
503
|
+
*,
|
|
504
|
+
workflow: str | None,
|
|
505
|
+
status: WorkflowRunStatus | None,
|
|
506
|
+
cursor: str | None,
|
|
507
|
+
limit: int | None,
|
|
508
|
+
) -> Request:
|
|
509
|
+
params: dict[str, str] = {}
|
|
510
|
+
if workflow is not None:
|
|
511
|
+
params["workflow"] = workflow
|
|
512
|
+
if status is not None:
|
|
513
|
+
params["status"] = status
|
|
514
|
+
if cursor is not None:
|
|
515
|
+
params["cursor"] = cursor
|
|
516
|
+
if limit is not None:
|
|
517
|
+
params["limit"] = str(limit)
|
|
518
|
+
return Request("GET", "/v1/workflow-runs", params=params, idempotent=True)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def get_workflow_run(run_id: str) -> Request:
|
|
522
|
+
return Request("GET", f"/v1/workflow-runs/{_q(run_id)}", idempotent=True)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def cancel_workflow_run(run_id: str) -> Request:
|
|
526
|
+
return Request("POST", f"/v1/workflow-runs/{_q(run_id)}/cancel", idempotent=True)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def retry_workflow_run(run_id: str) -> Request:
|
|
530
|
+
# Not idempotent: once it is running again, retrying is invalid_state.
|
|
531
|
+
return Request("POST", f"/v1/workflow-runs/{_q(run_id)}/retry")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# The most tasks one batch enqueue takes.
|
|
535
|
+
MAX_BATCH = 100
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def group_batches(group_id: str, specs: Iterable[TaskSpec]) -> Iterator[list[TaskSpec]]:
|
|
539
|
+
"""Split a group's tasks into batches, each task joining the group.
|
|
540
|
+
|
|
541
|
+
A task without an idempotency key gets one from the group and its
|
|
542
|
+
position, so enqueueing the same tasks again (a retry) adds none twice.
|
|
543
|
+
"""
|
|
544
|
+
batch: list[TaskSpec] = []
|
|
545
|
+
for i, spec in enumerate(specs):
|
|
546
|
+
task = TaskSpec(**spec)
|
|
547
|
+
task["group_id"] = group_id
|
|
548
|
+
task.setdefault("idempotency_key", f"{group_id}:{i}")
|
|
549
|
+
batch.append(task)
|
|
550
|
+
if len(batch) == MAX_BATCH:
|
|
551
|
+
yield batch
|
|
552
|
+
batch = []
|
|
553
|
+
if batch:
|
|
554
|
+
yield batch
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|