workly 0.2.1__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.2.1
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
@@ -156,7 +156,7 @@ client.queues.delete("emails") # delete the queue and all its tasks
156
156
 
157
157
  ## Schedules
158
158
 
159
- A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://docs.workly.run/schedules/).
159
+ A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://workly.run/docs/schedules/).
160
160
 
161
161
  ```python
162
162
  schedule = client.schedules.put(
@@ -171,7 +171,7 @@ schedule = client.schedules.put(
171
171
  print(schedule.next_runs) # the next five times
172
172
 
173
173
  run = client.schedules.trigger("nightly-report") # run it now
174
- for run in client.schedules.runs("nightly-report", state="failed"):
174
+ for run in client.runs.list(schedule="nightly-report", state="failed"):
175
175
  print(run.id, run.scheduled_for, run.last_error)
176
176
  client.runs.attempts(run.id)
177
177
  client.runs.retry(run.id) # deliver it again, e.g. a failed or skipped run
@@ -184,7 +184,7 @@ Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled
184
184
 
185
185
  ## Task groups
186
186
 
187
- A group tracks a set of tasks and enqueues a callback task once it's closed and they've all finished: a `POST` of the group's status and counts. See [Task groups](https://docs.workly.run/groups/). The callback goes to Workly's `_callbacks` queue unless it names a `queue`.
187
+ A group tracks a set of tasks and enqueues a callback task once it's closed and they've all finished: a `POST` of the group's status and counts. See [Task groups](https://workly.run/docs/groups/). Like every callback, it goes to Workly's `_callbacks` queue.
188
188
 
189
189
  ```python
190
190
  from workly import Callback
@@ -212,6 +212,27 @@ for group in client.groups.list(name="daily-stock-prices", status="failed"):
212
212
  print(group.id, group.finished_at)
213
213
  ```
214
214
 
215
+ ## Callbacks
216
+
217
+ Tasks and schedules take the same `on_complete`: each time the task, or a run of the schedule, succeeds, fails or is cancelled, Workly POSTs a summary of it to the URL. See [Callbacks](https://workly.run/docs/callbacks/).
218
+
219
+ ```python
220
+ client.tasks.enqueue(
221
+ "exports",
222
+ url="https://api.example.com/jobs/export",
223
+ payload={"export_id": 42},
224
+ on_complete=Callback(url="https://api.example.com/jobs/export-done"),
225
+ )
226
+ client.schedules.put(
227
+ "nightly-report",
228
+ cron="0 3 * * *",
229
+ url="https://api.example.com/reports/nightly",
230
+ on_complete={"url": "https://api.example.com/reports/nightly-done"},
231
+ )
232
+ ```
233
+
234
+ The summary arrives as the callback task's JSON body: `task_id`, `queue`, `state`, `attempt_count`, `last_status`, `last_error` and `finished_at` for a task; `run_id`, `schedule` and the same outcome fields for a run.
235
+
215
236
  ## Async
216
237
 
217
238
  `AsyncWorkly` has the same methods, as coroutines:
@@ -136,7 +136,7 @@ client.queues.delete("emails") # delete the queue and all its tasks
136
136
 
137
137
  ## Schedules
138
138
 
139
- A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://docs.workly.run/schedules/).
139
+ A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://workly.run/docs/schedules/).
140
140
 
141
141
  ```python
142
142
  schedule = client.schedules.put(
@@ -151,7 +151,7 @@ schedule = client.schedules.put(
151
151
  print(schedule.next_runs) # the next five times
152
152
 
153
153
  run = client.schedules.trigger("nightly-report") # run it now
154
- for run in client.schedules.runs("nightly-report", state="failed"):
154
+ for run in client.runs.list(schedule="nightly-report", state="failed"):
155
155
  print(run.id, run.scheduled_for, run.last_error)
156
156
  client.runs.attempts(run.id)
157
157
  client.runs.retry(run.id) # deliver it again, e.g. a failed or skipped run
@@ -164,7 +164,7 @@ Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled
164
164
 
165
165
  ## Task groups
166
166
 
167
- A group tracks a set of tasks and enqueues a callback task once it's closed and they've all finished: a `POST` of the group's status and counts. See [Task groups](https://docs.workly.run/groups/). The callback goes to Workly's `_callbacks` queue unless it names a `queue`.
167
+ A group tracks a set of tasks and enqueues a callback task once it's closed and they've all finished: a `POST` of the group's status and counts. See [Task groups](https://workly.run/docs/groups/). Like every callback, it goes to Workly's `_callbacks` queue.
168
168
 
169
169
  ```python
170
170
  from workly import Callback
@@ -192,6 +192,27 @@ for group in client.groups.list(name="daily-stock-prices", status="failed"):
192
192
  print(group.id, group.finished_at)
193
193
  ```
194
194
 
195
+ ## Callbacks
196
+
197
+ Tasks and schedules take the same `on_complete`: each time the task, or a run of the schedule, succeeds, fails or is cancelled, Workly POSTs a summary of it to the URL. See [Callbacks](https://workly.run/docs/callbacks/).
198
+
199
+ ```python
200
+ client.tasks.enqueue(
201
+ "exports",
202
+ url="https://api.example.com/jobs/export",
203
+ payload={"export_id": 42},
204
+ on_complete=Callback(url="https://api.example.com/jobs/export-done"),
205
+ )
206
+ client.schedules.put(
207
+ "nightly-report",
208
+ cron="0 3 * * *",
209
+ url="https://api.example.com/reports/nightly",
210
+ on_complete={"url": "https://api.example.com/reports/nightly-done"},
211
+ )
212
+ ```
213
+
214
+ The summary arrives as the callback task's JSON body: `task_id`, `queue`, `state`, `attempt_count`, `last_status`, `last_error` and `finished_at` for a task; `run_id`, `schedule` and the same outcome fields for a run.
215
+
195
216
  ## Async
196
217
 
197
218
  `AsyncWorkly` has the same methods, as coroutines:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workly"
3
- version = "0.2.1"
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.2.1"
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
@@ -216,6 +223,7 @@ class AsyncTasks:
216
223
  max_attempts: int | None = None,
217
224
  timeout_seconds: float | None = None,
218
225
  group_id: str | None = None,
226
+ on_complete: Callback | r.CallbackDict | None = None,
219
227
  traceparent: str | None = None,
220
228
  ) -> Task:
221
229
  """Enqueue a task to deliver to `url`.
@@ -224,7 +232,8 @@ class AsyncTasks:
224
232
  created with default settings if it doesn't exist. If
225
233
  `idempotency_key` matches a task already in the queue, that task is
226
234
  returned and nothing is enqueued. With `group_id`, the task joins
227
- that group, which must be open.
235
+ that group, which must be open. `on_complete` is called each time the
236
+ task succeeds, fails or is cancelled, with a `TaskSummary`.
228
237
  """
229
238
  spec = {
230
239
  "url": url,
@@ -239,6 +248,7 @@ class AsyncTasks:
239
248
  "max_attempts": max_attempts,
240
249
  "timeout_seconds": timeout_seconds,
241
250
  "group_id": group_id,
251
+ "on_complete": on_complete,
242
252
  }
243
253
  return Task._from_json(await self._client._send(r.enqueue(queue, spec, traceparent)))
244
254
 
@@ -443,7 +453,8 @@ class AsyncSchedules:
443
453
  name: str,
444
454
  *,
445
455
  cron: str,
446
- url: str,
456
+ url: str | None = None,
457
+ workflow: str | None = None,
447
458
  timezone: str | None = None,
448
459
  payload: Any = None,
449
460
  body: str | None = None,
@@ -458,16 +469,23 @@ class AsyncSchedules:
458
469
  retry_on_4xx: bool | None = None,
459
470
  overlap: OverlapPolicy | None = None,
460
471
  missed: MissedPolicy | None = None,
472
+ on_complete: Callback | r.CallbackDict | None = None,
461
473
  ) -> Schedule:
462
474
  """Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
463
475
 
476
+ With `workflow` instead of `url`, each run starts a run of that
477
+ workflow, with `payload` as its input.
478
+
464
479
  Declarative: the schedule ends up with exactly these settings, and
465
480
  omitted ones are reset to their defaults. It doesn't change whether
466
- the schedule is paused. `next_runs` on the result shows when it runs.
481
+ the schedule is paused. `next_runs` on the result shows when it runs
482
+ (`next_runs_if_resumed` while it is paused). `on_complete` is called
483
+ each time a run succeeds, fails or is cancelled, with a `RunSummary`.
467
484
  """
468
485
  config = dict(
469
486
  cron=cron,
470
487
  url=url,
488
+ workflow=workflow,
471
489
  timezone=timezone,
472
490
  payload=payload,
473
491
  body=body,
@@ -482,6 +500,7 @@ class AsyncSchedules:
482
500
  retry_on_4xx=retry_on_4xx,
483
501
  overlap=overlap,
484
502
  missed=missed,
503
+ on_complete=on_complete,
485
504
  )
486
505
  return Schedule._from_json(await self._client._send(r.put_schedule(name, config)))
487
506
 
@@ -497,49 +516,65 @@ class AsyncSchedules:
497
516
  """Fire again from the next slot. Slots that passed while paused aren't run."""
498
517
  return Schedule._from_json(await self._client._send(r.resume_schedule(name)))
499
518
 
500
- 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:
501
527
  """The next 5 times a schedule with this cron would run, without creating it.
502
528
 
503
- `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.
504
531
  """
505
- 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
+ )
506
535
 
507
536
  async def trigger(self, name: str) -> Run:
508
537
  """Run the schedule now, even if paused. Doesn't change when it next fires."""
509
538
  return Run._from_json(await self._client._send(r.trigger_schedule(name)))
510
539
 
511
- async def runs(
512
- self, name: str, *, state: RunState | None = None, page_size: int | None = None
540
+
541
+ class AsyncRuns:
542
+ """Runs of schedules: one per firing, with its own attempts."""
543
+
544
+ def __init__(self, client: AsyncWorkly) -> None:
545
+ self._client = client
546
+
547
+ async def list(
548
+ self,
549
+ *,
550
+ schedule: str | None = None,
551
+ state: RunState | None = None,
552
+ page_size: int | None = None,
513
553
  ) -> AsyncIterator[Run]:
514
- """The schedule's runs, newest first, fetching pages as needed."""
554
+ """Runs of every schedule, or of one, newest first, fetching pages as needed."""
515
555
  cursor: str | None = None
516
556
  while True:
517
- page = await self.runs_page(name, state=state, cursor=cursor, limit=page_size)
557
+ page = await self.list_page(
558
+ schedule=schedule, state=state, cursor=cursor, limit=page_size
559
+ )
518
560
  for run in page.runs:
519
561
  yield run
520
562
  if page.next_cursor is None:
521
563
  return
522
564
  cursor = page.next_cursor
523
565
 
524
- async def runs_page(
566
+ async def list_page(
525
567
  self,
526
- name: str,
527
568
  *,
569
+ schedule: str | None = None,
528
570
  state: RunState | None = None,
529
571
  cursor: str | None = None,
530
572
  limit: int | None = None,
531
573
  ) -> RunPage:
532
- """One page of `runs`. Pass the previous page's `next_cursor` as `cursor`."""
533
- req = r.list_runs(name, state=state, cursor=cursor, limit=limit)
574
+ """One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
575
+ req = r.list_runs(schedule=schedule, state=state, cursor=cursor, limit=limit)
534
576
  return RunPage._from_json(await self._client._send(req))
535
577
 
536
-
537
- class AsyncRuns:
538
- """Runs of schedules: one per firing, with its own attempts."""
539
-
540
- def __init__(self, client: AsyncWorkly) -> None:
541
- self._client = client
542
-
543
578
  async def get(self, run_id: str) -> Run:
544
579
  return Run._from_json(await self._client._send(r.get_run(run_id)))
545
580
 
@@ -588,3 +623,106 @@ class AsyncAlerts:
588
623
  async def test(self, channel_id: str) -> None:
589
624
  """Send a test alert to the channel now. Raises APIError (alert_failed) if it can't."""
590
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)))