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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workly
3
- Version: 0.3.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 :: 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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workly"
3
- version = "0.3.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 :: 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.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 :: 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
@@ -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(self, cron: str, *, timezone: str | None = None) -> CronPreview:
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(await self._client._send(r.preview_cron(cron, timezone)))
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(self, cron: str, *, timezone: str | None = None) -> CronPreview:
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(self._client._send(r.preview_cron(cron, timezone)))
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"]], 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
+ )
@@ -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,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