workly 0.3.0__tar.gz → 0.4.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.3.0 → workly-0.4.0}/PKG-INFO +2 -2
- {workly-0.3.0 → workly-0.4.0}/pyproject.toml +2 -2
- {workly-0.3.0 → workly-0.4.0}/pyproject.toml.orig +2 -2
- {workly-0.3.0 → workly-0.4.0}/src/workly/__init__.py +43 -2
- {workly-0.3.0 → workly-0.4.0}/src/workly/_async_client.py +129 -4
- {workly-0.3.0 → workly-0.4.0}/src/workly/_client.py +126 -4
- {workly-0.3.0 → workly-0.4.0}/src/workly/_models.py +320 -5
- {workly-0.3.0 → workly-0.4.0}/src/workly/_requests.py +122 -2
- {workly-0.3.0 → workly-0.4.0}/LICENSE +0 -0
- {workly-0.3.0 → workly-0.4.0}/README.md +0 -0
- {workly-0.3.0 → workly-0.4.0}/src/workly/_errors.py +0 -0
- {workly-0.3.0 → workly-0.4.0}/src/workly/_signing.py +0 -0
- {workly-0.3.0 → workly-0.4.0}/src/workly/_transport.py +0 -0
- {workly-0.3.0 → workly-0.4.0}/src/workly/py.typed +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "workly"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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"
|
|
@@ -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
|
+
version = "0.4.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"
|
|
@@ -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
|
|
@@ -446,7 +453,8 @@ class AsyncSchedules:
|
|
|
446
453
|
name: str,
|
|
447
454
|
*,
|
|
448
455
|
cron: str,
|
|
449
|
-
url: str,
|
|
456
|
+
url: str | None = None,
|
|
457
|
+
workflow: str | None = None,
|
|
450
458
|
timezone: str | None = None,
|
|
451
459
|
payload: Any = None,
|
|
452
460
|
body: str | None = None,
|
|
@@ -465,6 +473,9 @@ class AsyncSchedules:
|
|
|
465
473
|
) -> Schedule:
|
|
466
474
|
"""Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
|
|
467
475
|
|
|
476
|
+
With `workflow` instead of `url`, each run starts a run of that
|
|
477
|
+
workflow, with `payload` as its input.
|
|
478
|
+
|
|
468
479
|
Declarative: the schedule ends up with exactly these settings, and
|
|
469
480
|
omitted ones are reset to their defaults. It doesn't change whether
|
|
470
481
|
the schedule is paused. `next_runs` on the result shows when it runs
|
|
@@ -474,6 +485,7 @@ class AsyncSchedules:
|
|
|
474
485
|
config = dict(
|
|
475
486
|
cron=cron,
|
|
476
487
|
url=url,
|
|
488
|
+
workflow=workflow,
|
|
477
489
|
timezone=timezone,
|
|
478
490
|
payload=payload,
|
|
479
491
|
body=body,
|
|
@@ -504,12 +516,22 @@ class AsyncSchedules:
|
|
|
504
516
|
"""Fire again from the next slot. Slots that passed while paused aren't run."""
|
|
505
517
|
return Schedule._from_json(await self._client._send(r.resume_schedule(name)))
|
|
506
518
|
|
|
507
|
-
async def preview_cron(
|
|
519
|
+
async def preview_cron(
|
|
520
|
+
self,
|
|
521
|
+
cron: str,
|
|
522
|
+
*,
|
|
523
|
+
timezone: str | None = None,
|
|
524
|
+
from_: datetime | None = None,
|
|
525
|
+
until: datetime | None = None,
|
|
526
|
+
) -> CronPreview:
|
|
508
527
|
"""The next 5 times a schedule with this cron would run, without creating it.
|
|
509
528
|
|
|
510
|
-
`timezone` is an IANA name; the default is UTC.
|
|
529
|
+
`timezone` is an IANA name; the default is UTC. With `from_` and `until` (at
|
|
530
|
+
most 35 days apart), `spans` lists every run in that window.
|
|
511
531
|
"""
|
|
512
|
-
return CronPreview._from_json(
|
|
532
|
+
return CronPreview._from_json(
|
|
533
|
+
await self._client._send(r.preview_cron(cron, timezone, from_, until))
|
|
534
|
+
)
|
|
513
535
|
|
|
514
536
|
async def trigger(self, name: str) -> Run:
|
|
515
537
|
"""Run the schedule now, even if paused. Doesn't change when it next fires."""
|
|
@@ -601,3 +623,106 @@ class AsyncAlerts:
|
|
|
601
623
|
async def test(self, channel_id: str) -> None:
|
|
602
624
|
"""Send a test alert to the channel now. Raises APIError (alert_failed) if it can't."""
|
|
603
625
|
await self._client._send(r.test_alert_channel(channel_id))
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class AsyncWorkflows:
|
|
629
|
+
"""Workflows: DAGs of HTTP steps, each delivered as a task once its dependencies succeed.
|
|
630
|
+
|
|
631
|
+
A step's endpoint can hand its completion to a task group by answering
|
|
632
|
+
2xx with `Workly-Wait-For: group/<id>`; the step then finishes when the
|
|
633
|
+
group does. Define workflows in workly.yaml (`workly deploy`) or with `put`.
|
|
634
|
+
"""
|
|
635
|
+
|
|
636
|
+
def __init__(self, client: AsyncWorkly) -> None:
|
|
637
|
+
self._client = client
|
|
638
|
+
|
|
639
|
+
async def list(self) -> list[Workflow]:
|
|
640
|
+
"""All workflows, by name, with their latest run."""
|
|
641
|
+
data = await self._client._send(r.list_workflows())
|
|
642
|
+
return [Workflow._from_json(w) for w in data["workflows"]]
|
|
643
|
+
|
|
644
|
+
async def get(self, name: str) -> Workflow:
|
|
645
|
+
return Workflow._from_json(await self._client._send(r.get_workflow(name)))
|
|
646
|
+
|
|
647
|
+
async def put(
|
|
648
|
+
self,
|
|
649
|
+
name: str,
|
|
650
|
+
*,
|
|
651
|
+
steps: list[r.WorkflowStepSpec],
|
|
652
|
+
max_duration_seconds: float | None = None,
|
|
653
|
+
) -> Workflow:
|
|
654
|
+
"""Create or update a workflow with exactly these steps.
|
|
655
|
+
|
|
656
|
+
Runs already started keep the definition they started with. A run
|
|
657
|
+
still going after `max_duration_seconds` (default a day) fails.
|
|
658
|
+
"""
|
|
659
|
+
req = r.put_workflow(name, steps, max_duration_seconds)
|
|
660
|
+
return Workflow._from_json(await self._client._send(req))
|
|
661
|
+
|
|
662
|
+
async def delete(self, name: str) -> None:
|
|
663
|
+
"""Delete the definition. Its runs are kept, and those going finish."""
|
|
664
|
+
await self._client._send(r.delete_workflow(name))
|
|
665
|
+
|
|
666
|
+
async def trigger(
|
|
667
|
+
self, name: str, *, input: Any = None, idempotency_key: str | None = None
|
|
668
|
+
) -> WorkflowRun:
|
|
669
|
+
"""Start a run. `input` (any JSON) is sent to every step without a body of its own.
|
|
670
|
+
|
|
671
|
+
With an `idempotency_key` already used for a run of this workflow,
|
|
672
|
+
that run is returned and nothing starts.
|
|
673
|
+
"""
|
|
674
|
+
req = r.start_workflow_run(name, input, idempotency_key)
|
|
675
|
+
return WorkflowRun._from_json(await self._client._send(req))
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class AsyncWorkflowRuns:
|
|
679
|
+
"""Runs of workflows, with each step's status, task and what it waits for."""
|
|
680
|
+
|
|
681
|
+
def __init__(self, client: AsyncWorkly) -> None:
|
|
682
|
+
self._client = client
|
|
683
|
+
|
|
684
|
+
async def list(
|
|
685
|
+
self,
|
|
686
|
+
*,
|
|
687
|
+
workflow: str | None = None,
|
|
688
|
+
status: WorkflowRunStatus | None = None,
|
|
689
|
+
page_size: int | None = None,
|
|
690
|
+
) -> AsyncIterator[WorkflowRunSummary]:
|
|
691
|
+
"""Runs of every workflow, or of one, newest first, fetching pages as needed."""
|
|
692
|
+
cursor: str | None = None
|
|
693
|
+
while True:
|
|
694
|
+
page = await self.list_page(
|
|
695
|
+
workflow=workflow, status=status, cursor=cursor, limit=page_size
|
|
696
|
+
)
|
|
697
|
+
for run in page.workflow_runs:
|
|
698
|
+
yield run
|
|
699
|
+
if page.next_cursor is None:
|
|
700
|
+
return
|
|
701
|
+
cursor = page.next_cursor
|
|
702
|
+
|
|
703
|
+
async def list_page(
|
|
704
|
+
self,
|
|
705
|
+
*,
|
|
706
|
+
workflow: str | None = None,
|
|
707
|
+
status: WorkflowRunStatus | None = None,
|
|
708
|
+
cursor: str | None = None,
|
|
709
|
+
limit: int | None = None,
|
|
710
|
+
) -> WorkflowRunPage:
|
|
711
|
+
"""One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
712
|
+
req = r.list_workflow_runs(workflow=workflow, status=status, cursor=cursor, limit=limit)
|
|
713
|
+
return WorkflowRunPage._from_json(await self._client._send(req))
|
|
714
|
+
|
|
715
|
+
async def get(self, run_id: str) -> WorkflowRun:
|
|
716
|
+
return WorkflowRun._from_json(await self._client._send(r.get_workflow_run(run_id)))
|
|
717
|
+
|
|
718
|
+
async def cancel(self, run_id: str) -> WorkflowRun:
|
|
719
|
+
"""Stop a run: steps not yet started are skipped. Requests in flight finish."""
|
|
720
|
+
return WorkflowRun._from_json(await self._client._send(r.cancel_workflow_run(run_id)))
|
|
721
|
+
|
|
722
|
+
async def retry(self, run_id: str) -> WorkflowRun:
|
|
723
|
+
"""Reopen a failed or cancelled run under the definition it started with.
|
|
724
|
+
|
|
725
|
+
Steps that succeeded stay; the rest run again, each on its old task with a
|
|
726
|
+
fresh set of attempts. A step that was waiting for a group follows the group.
|
|
727
|
+
"""
|
|
728
|
+
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
|
|
@@ -444,7 +451,8 @@ class Schedules:
|
|
|
444
451
|
name: str,
|
|
445
452
|
*,
|
|
446
453
|
cron: str,
|
|
447
|
-
url: str,
|
|
454
|
+
url: str | None = None,
|
|
455
|
+
workflow: str | None = None,
|
|
448
456
|
timezone: str | None = None,
|
|
449
457
|
payload: Any = None,
|
|
450
458
|
body: str | None = None,
|
|
@@ -463,6 +471,9 @@ class Schedules:
|
|
|
463
471
|
) -> Schedule:
|
|
464
472
|
"""Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
|
|
465
473
|
|
|
474
|
+
With `workflow` instead of `url`, each run starts a run of that
|
|
475
|
+
workflow, with `payload` as its input.
|
|
476
|
+
|
|
466
477
|
Declarative: the schedule ends up with exactly these settings, and
|
|
467
478
|
omitted ones are reset to their defaults. It doesn't change whether
|
|
468
479
|
the schedule is paused. `next_runs` on the result shows when it runs
|
|
@@ -472,6 +483,7 @@ class Schedules:
|
|
|
472
483
|
config = dict(
|
|
473
484
|
cron=cron,
|
|
474
485
|
url=url,
|
|
486
|
+
workflow=workflow,
|
|
475
487
|
timezone=timezone,
|
|
476
488
|
payload=payload,
|
|
477
489
|
body=body,
|
|
@@ -502,12 +514,22 @@ class Schedules:
|
|
|
502
514
|
"""Fire again from the next slot. Slots that passed while paused aren't run."""
|
|
503
515
|
return Schedule._from_json(self._client._send(r.resume_schedule(name)))
|
|
504
516
|
|
|
505
|
-
def preview_cron(
|
|
517
|
+
def preview_cron(
|
|
518
|
+
self,
|
|
519
|
+
cron: str,
|
|
520
|
+
*,
|
|
521
|
+
timezone: str | None = None,
|
|
522
|
+
from_: datetime | None = None,
|
|
523
|
+
until: datetime | None = None,
|
|
524
|
+
) -> CronPreview:
|
|
506
525
|
"""The next 5 times a schedule with this cron would run, without creating it.
|
|
507
526
|
|
|
508
|
-
`timezone` is an IANA name; the default is UTC.
|
|
527
|
+
`timezone` is an IANA name; the default is UTC. With `from_` and `until` (at
|
|
528
|
+
most 35 days apart), `spans` lists every run in that window.
|
|
509
529
|
"""
|
|
510
|
-
return CronPreview._from_json(
|
|
530
|
+
return CronPreview._from_json(
|
|
531
|
+
self._client._send(r.preview_cron(cron, timezone, from_, until))
|
|
532
|
+
)
|
|
511
533
|
|
|
512
534
|
def trigger(self, name: str) -> Run:
|
|
513
535
|
"""Run the schedule now, even if paused. Doesn't change when it next fires."""
|
|
@@ -594,3 +616,103 @@ class Alerts:
|
|
|
594
616
|
def test(self, channel_id: str) -> None:
|
|
595
617
|
"""Send a test alert to the channel now. Raises APIError (alert_failed) if it can't."""
|
|
596
618
|
self._client._send(r.test_alert_channel(channel_id))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class Workflows:
|
|
622
|
+
"""Workflows: DAGs of HTTP steps, each delivered as a task once its dependencies succeed.
|
|
623
|
+
|
|
624
|
+
A step's endpoint can hand its completion to a task group by answering
|
|
625
|
+
2xx with `Workly-Wait-For: group/<id>`; the step then finishes when the
|
|
626
|
+
group does. Define workflows in workly.yaml (`workly deploy`) or with `put`.
|
|
627
|
+
"""
|
|
628
|
+
|
|
629
|
+
def __init__(self, client: Workly) -> None:
|
|
630
|
+
self._client = client
|
|
631
|
+
|
|
632
|
+
def list(self) -> list[Workflow]:
|
|
633
|
+
"""All workflows, by name, with their latest run."""
|
|
634
|
+
data = self._client._send(r.list_workflows())
|
|
635
|
+
return [Workflow._from_json(w) for w in data["workflows"]]
|
|
636
|
+
|
|
637
|
+
def get(self, name: str) -> Workflow:
|
|
638
|
+
return Workflow._from_json(self._client._send(r.get_workflow(name)))
|
|
639
|
+
|
|
640
|
+
def put(
|
|
641
|
+
self,
|
|
642
|
+
name: str,
|
|
643
|
+
*,
|
|
644
|
+
steps: list[r.WorkflowStepSpec],
|
|
645
|
+
max_duration_seconds: float | None = None,
|
|
646
|
+
) -> Workflow:
|
|
647
|
+
"""Create or update a workflow with exactly these steps.
|
|
648
|
+
|
|
649
|
+
Runs already started keep the definition they started with. A run
|
|
650
|
+
still going after `max_duration_seconds` (default a day) fails.
|
|
651
|
+
"""
|
|
652
|
+
req = r.put_workflow(name, steps, max_duration_seconds)
|
|
653
|
+
return Workflow._from_json(self._client._send(req))
|
|
654
|
+
|
|
655
|
+
def delete(self, name: str) -> None:
|
|
656
|
+
"""Delete the definition. Its runs are kept, and those going finish."""
|
|
657
|
+
self._client._send(r.delete_workflow(name))
|
|
658
|
+
|
|
659
|
+
def trigger(
|
|
660
|
+
self, name: str, *, input: Any = None, idempotency_key: str | None = None
|
|
661
|
+
) -> WorkflowRun:
|
|
662
|
+
"""Start a run. `input` (any JSON) is sent to every step without a body of its own.
|
|
663
|
+
|
|
664
|
+
With an `idempotency_key` already used for a run of this workflow,
|
|
665
|
+
that run is returned and nothing starts.
|
|
666
|
+
"""
|
|
667
|
+
req = r.start_workflow_run(name, input, idempotency_key)
|
|
668
|
+
return WorkflowRun._from_json(self._client._send(req))
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
class WorkflowRuns:
|
|
672
|
+
"""Runs of workflows, with each step's status, task and what it waits for."""
|
|
673
|
+
|
|
674
|
+
def __init__(self, client: Workly) -> None:
|
|
675
|
+
self._client = client
|
|
676
|
+
|
|
677
|
+
def list(
|
|
678
|
+
self,
|
|
679
|
+
*,
|
|
680
|
+
workflow: str | None = None,
|
|
681
|
+
status: WorkflowRunStatus | None = None,
|
|
682
|
+
page_size: int | None = None,
|
|
683
|
+
) -> Iterator[WorkflowRunSummary]:
|
|
684
|
+
"""Runs of every workflow, or of one, newest first, fetching pages as needed."""
|
|
685
|
+
cursor: str | None = None
|
|
686
|
+
while True:
|
|
687
|
+
page = self.list_page(workflow=workflow, status=status, cursor=cursor, limit=page_size)
|
|
688
|
+
yield from page.workflow_runs
|
|
689
|
+
if page.next_cursor is None:
|
|
690
|
+
return
|
|
691
|
+
cursor = page.next_cursor
|
|
692
|
+
|
|
693
|
+
def list_page(
|
|
694
|
+
self,
|
|
695
|
+
*,
|
|
696
|
+
workflow: str | None = None,
|
|
697
|
+
status: WorkflowRunStatus | None = None,
|
|
698
|
+
cursor: str | None = None,
|
|
699
|
+
limit: int | None = None,
|
|
700
|
+
) -> WorkflowRunPage:
|
|
701
|
+
"""One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
702
|
+
req = r.list_workflow_runs(workflow=workflow, status=status, cursor=cursor, limit=limit)
|
|
703
|
+
return WorkflowRunPage._from_json(self._client._send(req))
|
|
704
|
+
|
|
705
|
+
def get(self, run_id: str) -> WorkflowRun:
|
|
706
|
+
return WorkflowRun._from_json(self._client._send(r.get_workflow_run(run_id)))
|
|
707
|
+
|
|
708
|
+
def cancel(self, run_id: str) -> WorkflowRun:
|
|
709
|
+
"""Stop a run: steps not yet started are skipped. Requests in flight finish."""
|
|
710
|
+
return WorkflowRun._from_json(self._client._send(r.cancel_workflow_run(run_id)))
|
|
711
|
+
|
|
712
|
+
def retry(self, run_id: str) -> WorkflowRun:
|
|
713
|
+
"""Reopen a failed or cancelled run under the definition it started with.
|
|
714
|
+
|
|
715
|
+
Steps that succeeded stay; the rest run again, each on its old task with a
|
|
716
|
+
fresh set of attempts. A step that was waiting for a group follows the group.
|
|
717
|
+
"""
|
|
718
|
+
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
|
+
)
|
|
@@ -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,93 @@ 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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|