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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workly
3
- Version: 0.3.0
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 :: 3 - Alpha
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 server binary (about 7 MB), which you usually want on dev machines and not in production images.
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.create(
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", # creating it again returns this group
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
- for company in companies:
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 server binary (about 7 MB), which you usually want on dev machines and not in production images.
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.create(
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", # creating it again returns this group
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
- for company in companies:
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.0"
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 :: 3 - Alpha",
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.0"
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 :: 3 - Alpha",
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 Alerts, Groups, Queues, Runs, Schedules, Tasks, Workly
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(self, cron: str, *, timezone: str | None = None) -> CronPreview:
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(await self._client._send(r.preview_cron(cron, timezone)))
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(self, cron: str, *, timezone: str | None = None) -> CronPreview:
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(self._client._send(r.preview_cron(cron, timezone)))
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"]], interval_seconds=d["interval_seconds"]
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
- """The workspace's tasks created over time: every bucket of the range, oldest first."""
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(start=_time(b["start"]), counts=dict(b["counts"]))
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(cron: str, timezone: str | None) -> Request:
351
- params = {"cron": cron} if timezone is None else {"cron": cron, "timezone": timezone}
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