workly 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
workly/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """Python SDK for Workly, the open-source HTTP task queue and scheduler."""
2
+
3
+ from ._async_client import (
4
+ AsyncAlerts,
5
+ AsyncQueues,
6
+ AsyncRuns,
7
+ AsyncSchedules,
8
+ AsyncTasks,
9
+ AsyncWorkly,
10
+ )
11
+ from ._client import Alerts, Queues, Runs, Schedules, Tasks, Workly
12
+ from ._errors import (
13
+ APIConnectionError,
14
+ APIError,
15
+ AuthenticationError,
16
+ BadRequestError,
17
+ ConflictError,
18
+ NotFoundError,
19
+ PayloadTooLargeError,
20
+ PermissionDeniedError,
21
+ RateLimitError,
22
+ SignatureVerificationError,
23
+ WorklyError,
24
+ )
25
+ from ._models import (
26
+ Activity,
27
+ ActivityBucket,
28
+ ActivityRange,
29
+ AlertChannel,
30
+ AlertChannelList,
31
+ AlertChannelType,
32
+ Attempt,
33
+ AttemptOutcome,
34
+ CronPreview,
35
+ Delivery,
36
+ EnqueueResult,
37
+ FailureGroup,
38
+ HTTPMethod,
39
+ LastRun,
40
+ MissedPolicy,
41
+ OverlapPolicy,
42
+ Queue,
43
+ QueueActivity,
44
+ QueueStats,
45
+ Run,
46
+ RunPage,
47
+ RunState,
48
+ RunTrigger,
49
+ Schedule,
50
+ SkipReason,
51
+ Task,
52
+ TaskPage,
53
+ TaskState,
54
+ )
55
+ from ._requests import TaskSpec, __version__
56
+ from ._signing import sign, verify_signature
57
+
58
+ __all__ = [
59
+ "APIConnectionError",
60
+ "APIError",
61
+ "Activity",
62
+ "ActivityBucket",
63
+ "ActivityRange",
64
+ "AlertChannel",
65
+ "AlertChannelList",
66
+ "AlertChannelType",
67
+ "Alerts",
68
+ "AsyncAlerts",
69
+ "AsyncQueues",
70
+ "AsyncRuns",
71
+ "AsyncSchedules",
72
+ "AsyncTasks",
73
+ "AsyncWorkly",
74
+ "Attempt",
75
+ "AttemptOutcome",
76
+ "AuthenticationError",
77
+ "BadRequestError",
78
+ "ConflictError",
79
+ "CronPreview",
80
+ "Delivery",
81
+ "EnqueueResult",
82
+ "FailureGroup",
83
+ "HTTPMethod",
84
+ "LastRun",
85
+ "MissedPolicy",
86
+ "NotFoundError",
87
+ "OverlapPolicy",
88
+ "PayloadTooLargeError",
89
+ "PermissionDeniedError",
90
+ "Queue",
91
+ "QueueActivity",
92
+ "QueueStats",
93
+ "Queues",
94
+ "RateLimitError",
95
+ "Run",
96
+ "RunPage",
97
+ "RunState",
98
+ "RunTrigger",
99
+ "Runs",
100
+ "Schedule",
101
+ "Schedules",
102
+ "SignatureVerificationError",
103
+ "SkipReason",
104
+ "Task",
105
+ "TaskPage",
106
+ "TaskSpec",
107
+ "TaskState",
108
+ "Tasks",
109
+ "Workly",
110
+ "WorklyError",
111
+ "__version__",
112
+ "sign",
113
+ "verify_signature",
114
+ ]
@@ -0,0 +1,494 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator, Iterable, Mapping
5
+ from datetime import datetime
6
+ from types import TracebackType
7
+ from typing import Any, Self
8
+
9
+ import httpx
10
+
11
+ from . import _requests as r
12
+ from . import _transport as t
13
+ from ._models import (
14
+ Activity,
15
+ ActivityRange,
16
+ AlertChannel,
17
+ AlertChannelList,
18
+ AlertChannelType,
19
+ Attempt,
20
+ CronPreview,
21
+ Delivery,
22
+ EnqueueResult,
23
+ FailureGroup,
24
+ HTTPMethod,
25
+ MissedPolicy,
26
+ OverlapPolicy,
27
+ Queue,
28
+ QueueActivity,
29
+ Run,
30
+ RunPage,
31
+ RunState,
32
+ Schedule,
33
+ Task,
34
+ TaskPage,
35
+ TaskState,
36
+ )
37
+
38
+
39
+ class AsyncWorkly:
40
+ """Async client for the Workly API.
41
+
42
+ `url` defaults to `WORKLY_URL`, then http://localhost:7337 (`workly dev`).
43
+ `api_key` defaults to `WORKLY_API_KEY`; `workly dev` needs none.
44
+
45
+ Use it as a context manager, or call `aclose()`, to release connections.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ url: str | None = None,
51
+ api_key: str | None = None,
52
+ *,
53
+ timeout: float = 30.0,
54
+ max_retries: int = 2,
55
+ http_client: httpx.AsyncClient | None = None,
56
+ ) -> None:
57
+ self._config = r.client_config(url, api_key)
58
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
59
+ self._owns_http = http_client is None
60
+ self._max_retries = max_retries
61
+ self.queues = AsyncQueues(self)
62
+ self.tasks = AsyncTasks(self)
63
+ self.schedules = AsyncSchedules(self)
64
+ self.runs = AsyncRuns(self)
65
+ self.alerts = AsyncAlerts(self)
66
+
67
+ @property
68
+ def url(self) -> str:
69
+ return self._config.base_url
70
+
71
+ async def aclose(self) -> None:
72
+ if self._owns_http:
73
+ await self._http.aclose()
74
+
75
+ async def __aenter__(self) -> Self:
76
+ return self
77
+
78
+ async def __aexit__(
79
+ self,
80
+ exc_type: type[BaseException] | None,
81
+ exc: BaseException | None,
82
+ tb: TracebackType | None,
83
+ ) -> None:
84
+ await self.aclose()
85
+
86
+ async def _send(self, req: r.Request) -> Any:
87
+ attempt = 0
88
+ while True:
89
+ try:
90
+ response = await self._http.request(
91
+ req.method,
92
+ self._config.base_url + req.path,
93
+ params=req.params,
94
+ json=req.json,
95
+ headers={**self._config.headers, **req.headers},
96
+ )
97
+ except httpx.TransportError as exc:
98
+ if attempt < self._max_retries and t.retry_after_error(req, exc):
99
+ await asyncio.sleep(t.delay(attempt))
100
+ attempt += 1
101
+ continue
102
+ raise t.connection_error(req, exc) from exc
103
+ if attempt < self._max_retries and t.retry_after_status(req, response):
104
+ await asyncio.sleep(t.delay(attempt, response))
105
+ attempt += 1
106
+ continue
107
+ return t.decode(response)
108
+
109
+
110
+ class AsyncQueues:
111
+ def __init__(self, client: AsyncWorkly) -> None:
112
+ self._client = client
113
+
114
+ async def list(self) -> list[Queue]:
115
+ """All queues, by name, with their stats."""
116
+ data = await self._client._send(r.list_queues())
117
+ return [Queue._from_json(q) for q in data["queues"]]
118
+
119
+ async def get(self, name: str) -> Queue:
120
+ return Queue._from_json(await self._client._send(r.get_queue(name)))
121
+
122
+ async def put(
123
+ self,
124
+ name: str,
125
+ *,
126
+ max_attempts: int | None = None,
127
+ min_backoff_seconds: float | None = None,
128
+ max_backoff_seconds: float | None = None,
129
+ backoff_multiplier: float | None = None,
130
+ timeout_seconds: float | None = None,
131
+ retry_on_4xx: bool | None = None,
132
+ rate_per_second: float | None = None,
133
+ max_concurrency: int | None = None,
134
+ ) -> Queue:
135
+ """Create or update a queue.
136
+
137
+ Declarative: the queue ends up with exactly this configuration, and
138
+ omitted settings are reset to their defaults. It doesn't change
139
+ whether the queue is paused.
140
+ """
141
+ config = dict(
142
+ max_attempts=max_attempts,
143
+ min_backoff_seconds=min_backoff_seconds,
144
+ max_backoff_seconds=max_backoff_seconds,
145
+ backoff_multiplier=backoff_multiplier,
146
+ timeout_seconds=timeout_seconds,
147
+ retry_on_4xx=retry_on_4xx,
148
+ rate_per_second=rate_per_second,
149
+ max_concurrency=max_concurrency,
150
+ )
151
+ return Queue._from_json(await self._client._send(r.put_queue(name, config)))
152
+
153
+ async def delete(self, name: str) -> None:
154
+ """Delete a queue and all its tasks."""
155
+ await self._client._send(r.delete_queue(name))
156
+
157
+ async def pause(self, name: str) -> Queue:
158
+ """Stop dispatching. In-flight deliveries finish; enqueueing still works."""
159
+ return Queue._from_json(await self._client._send(r.pause_queue(name)))
160
+
161
+ async def resume(self, name: str) -> Queue:
162
+ return Queue._from_json(await self._client._send(r.resume_queue(name)))
163
+
164
+ async def cancel_pending(self, name: str) -> int:
165
+ """Cancel the queue's pending tasks, including scheduled ones. Returns how many.
166
+
167
+ Running tasks finish. Cancelled tasks stay in the history and can be retried.
168
+ """
169
+ data = await self._client._send(r.cancel_pending_tasks(name))
170
+ return int(data["cancelled"])
171
+
172
+ async def failures(self, name: str, *, limit: int | None = None) -> list[FailureGroup]:
173
+ """The queue's failed tasks grouped by error, most common first (default: 10 groups)."""
174
+ data = await self._client._send(r.get_queue_failures(name, limit))
175
+ return [FailureGroup._from_json(g) for g in data["groups"]]
176
+
177
+ async def activity(self, name: str, *, since: datetime | None = None) -> QueueActivity:
178
+ """Count the queue's tasks that finished since `since` (default: 7 days ago), by state."""
179
+ return QueueActivity._from_json(await self._client._send(r.get_queue_activity(name, since)))
180
+
181
+ async def retry_failed(self, name: str, *, last_error: str | None = None) -> int:
182
+ """Retry the failed tasks in the queue now, e.g. after an outage.
183
+
184
+ Each task gets a fresh set of attempts. With `last_error`, only tasks
185
+ that failed with exactly that error (a group from `failures`) are
186
+ retried. Returns how many were retried.
187
+ """
188
+ data = await self._client._send(r.retry_failed_tasks(name, last_error))
189
+ return int(data["retried"])
190
+
191
+
192
+ class AsyncTasks:
193
+ def __init__(self, client: AsyncWorkly) -> None:
194
+ self._client = client
195
+
196
+ async def enqueue(
197
+ self,
198
+ queue: str,
199
+ *,
200
+ url: str,
201
+ payload: Any = None,
202
+ body: str | None = None,
203
+ content_type: str | None = None,
204
+ method: HTTPMethod | None = None,
205
+ headers: Mapping[str, str] | None = None,
206
+ delay_seconds: float | None = None,
207
+ run_at: datetime | None = None,
208
+ idempotency_key: str | None = None,
209
+ max_attempts: int | None = None,
210
+ timeout_seconds: float | None = None,
211
+ traceparent: str | None = None,
212
+ ) -> Task:
213
+ """Enqueue a task to deliver to `url`.
214
+
215
+ `payload` is sent as JSON; `body` as raw text (use one). The queue is
216
+ created with default settings if it doesn't exist. If
217
+ `idempotency_key` matches a task already in the queue, that task is
218
+ returned and nothing is enqueued.
219
+ """
220
+ spec = {
221
+ "url": url,
222
+ "payload": payload,
223
+ "body": body,
224
+ "content_type": content_type,
225
+ "method": method,
226
+ "headers": headers,
227
+ "delay_seconds": delay_seconds,
228
+ "run_at": run_at,
229
+ "idempotency_key": idempotency_key,
230
+ "max_attempts": max_attempts,
231
+ "timeout_seconds": timeout_seconds,
232
+ }
233
+ return Task._from_json(await self._client._send(r.enqueue(queue, spec, traceparent)))
234
+
235
+ async def enqueue_many(
236
+ self, queue: str, tasks: Iterable[r.TaskSpec], *, traceparent: str | None = None
237
+ ) -> list[EnqueueResult]:
238
+ """Enqueue up to 100 tasks at once: all of them, or none if any is invalid."""
239
+ data = await self._client._send(r.enqueue_many(queue, list(tasks), traceparent))
240
+ return [
241
+ EnqueueResult(created=res["created"], task=Task._from_json(res["task"]))
242
+ for res in data["results"]
243
+ ]
244
+
245
+ async def get(self, task_id: str) -> Task:
246
+ return Task._from_json(await self._client._send(r.get_task(task_id)))
247
+
248
+ async def attempts(self, task_id: str) -> list[Attempt]:
249
+ """The task's delivery attempts, oldest first."""
250
+ data = await self._client._send(r.list_attempts(task_id))
251
+ return [Attempt._from_json(a) for a in data["attempts"]]
252
+
253
+ async def delivery(self, task_id: str) -> Delivery:
254
+ """The request that delivers the task, as it would be sent now and freshly signed.
255
+
256
+ Nothing is sent. Useful to replay a delivery by hand while fixing an endpoint.
257
+ """
258
+ return Delivery._from_json(await self._client._send(r.get_task_delivery(task_id)))
259
+
260
+ async def cancel(self, task_id: str) -> Task:
261
+ """Cancel a pending or running task. A running attempt finishes but isn't retried."""
262
+ return Task._from_json(await self._client._send(r.cancel_task(task_id)))
263
+
264
+ async def retry(self, task_id: str) -> Task:
265
+ """Make the task due now. A finished task is replayed with fresh attempts."""
266
+ return Task._from_json(await self._client._send(r.retry_task(task_id)))
267
+
268
+ async def list(
269
+ self,
270
+ *,
271
+ queue: str | None = None,
272
+ state: TaskState | None = None,
273
+ idempotency_key: str | None = None,
274
+ last_error: str | None = None,
275
+ created_after: datetime | None = None,
276
+ created_before: datetime | None = None,
277
+ page_size: int | None = None,
278
+ ) -> AsyncIterator[Task]:
279
+ """Tasks matching every filter given, newest first, fetching pages as needed."""
280
+ cursor: str | None = None
281
+ while True:
282
+ page = await self.list_page(
283
+ queue=queue,
284
+ state=state,
285
+ idempotency_key=idempotency_key,
286
+ last_error=last_error,
287
+ created_after=created_after,
288
+ created_before=created_before,
289
+ cursor=cursor,
290
+ limit=page_size,
291
+ )
292
+ for task in page.tasks:
293
+ yield task
294
+ if page.next_cursor is None:
295
+ return
296
+ cursor = page.next_cursor
297
+
298
+ async def list_page(
299
+ self,
300
+ *,
301
+ queue: str | None = None,
302
+ state: TaskState | None = None,
303
+ idempotency_key: str | None = None,
304
+ last_error: str | None = None,
305
+ created_after: datetime | None = None,
306
+ created_before: datetime | None = None,
307
+ cursor: str | None = None,
308
+ limit: int | None = None,
309
+ ) -> TaskPage:
310
+ """One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
311
+ req = r.search_tasks(
312
+ queue=queue,
313
+ state=state,
314
+ idempotency_key=idempotency_key,
315
+ last_error=last_error,
316
+ created_after=created_after,
317
+ created_before=created_before,
318
+ cursor=cursor,
319
+ limit=limit,
320
+ )
321
+ return TaskPage._from_json(await self._client._send(req))
322
+
323
+ async def activity(self, *, range: ActivityRange | None = None) -> Activity:
324
+ """Count the workspace's tasks created over time, by queue (default range: 24h).
325
+
326
+ Buckets are 5 minutes for 1h, 1 hour for 24h and 6 hours for 7d, aligned to UTC.
327
+ """
328
+ return Activity._from_json(await self._client._send(r.get_activity(range)))
329
+
330
+
331
+ class AsyncSchedules:
332
+ """Cron schedules that call an endpoint. Separate from queues: each call is a run."""
333
+
334
+ def __init__(self, client: AsyncWorkly) -> None:
335
+ self._client = client
336
+
337
+ async def list(self) -> list[Schedule]:
338
+ """All schedules, by name, with their next and last run."""
339
+ data = await self._client._send(r.list_schedules())
340
+ return [Schedule._from_json(s) for s in data["schedules"]]
341
+
342
+ async def get(self, name: str) -> Schedule:
343
+ return Schedule._from_json(await self._client._send(r.get_schedule(name)))
344
+
345
+ async def put(
346
+ self,
347
+ name: str,
348
+ *,
349
+ cron: str,
350
+ url: str,
351
+ timezone: str | None = None,
352
+ payload: Any = None,
353
+ body: str | None = None,
354
+ content_type: str | None = None,
355
+ method: HTTPMethod | None = None,
356
+ headers: Mapping[str, str] | None = None,
357
+ timeout_seconds: float | None = None,
358
+ max_attempts: int | None = None,
359
+ min_backoff_seconds: float | None = None,
360
+ max_backoff_seconds: float | None = None,
361
+ backoff_multiplier: float | None = None,
362
+ retry_on_4xx: bool | None = None,
363
+ overlap: OverlapPolicy | None = None,
364
+ missed: MissedPolicy | None = None,
365
+ ) -> Schedule:
366
+ """Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
367
+
368
+ Declarative: the schedule ends up with exactly these settings, and
369
+ omitted ones are reset to their defaults. It doesn't change whether
370
+ the schedule is paused. `next_runs` on the result shows when it runs.
371
+ """
372
+ config = dict(
373
+ cron=cron,
374
+ url=url,
375
+ timezone=timezone,
376
+ payload=payload,
377
+ body=body,
378
+ content_type=content_type,
379
+ method=method,
380
+ headers=headers,
381
+ timeout_seconds=timeout_seconds,
382
+ max_attempts=max_attempts,
383
+ min_backoff_seconds=min_backoff_seconds,
384
+ max_backoff_seconds=max_backoff_seconds,
385
+ backoff_multiplier=backoff_multiplier,
386
+ retry_on_4xx=retry_on_4xx,
387
+ overlap=overlap,
388
+ missed=missed,
389
+ )
390
+ return Schedule._from_json(await self._client._send(r.put_schedule(name, config)))
391
+
392
+ async def delete(self, name: str) -> None:
393
+ """Delete a schedule and all its runs."""
394
+ await self._client._send(r.delete_schedule(name))
395
+
396
+ async def pause(self, name: str) -> Schedule:
397
+ """Stop the schedule from firing. Runs in flight finish; `trigger` still works."""
398
+ return Schedule._from_json(await self._client._send(r.pause_schedule(name)))
399
+
400
+ async def resume(self, name: str) -> Schedule:
401
+ """Fire again from the next slot. Slots that passed while paused aren't run."""
402
+ return Schedule._from_json(await self._client._send(r.resume_schedule(name)))
403
+
404
+ async def preview_cron(self, cron: str, *, timezone: str | None = None) -> CronPreview:
405
+ """The next 5 times a schedule with this cron would run, without creating it.
406
+
407
+ `timezone` is an IANA name; the default is UTC.
408
+ """
409
+ return CronPreview._from_json(await self._client._send(r.preview_cron(cron, timezone)))
410
+
411
+ async def trigger(self, name: str) -> Run:
412
+ """Run the schedule now, even if paused. Doesn't change when it next fires."""
413
+ return Run._from_json(await self._client._send(r.trigger_schedule(name)))
414
+
415
+ async def runs(
416
+ self, name: str, *, state: RunState | None = None, page_size: int | None = None
417
+ ) -> AsyncIterator[Run]:
418
+ """The schedule's runs, newest first, fetching pages as needed."""
419
+ cursor: str | None = None
420
+ while True:
421
+ page = await self.runs_page(name, state=state, cursor=cursor, limit=page_size)
422
+ for run in page.runs:
423
+ yield run
424
+ if page.next_cursor is None:
425
+ return
426
+ cursor = page.next_cursor
427
+
428
+ async def runs_page(
429
+ self,
430
+ name: str,
431
+ *,
432
+ state: RunState | None = None,
433
+ cursor: str | None = None,
434
+ limit: int | None = None,
435
+ ) -> RunPage:
436
+ """One page of `runs`. Pass the previous page's `next_cursor` as `cursor`."""
437
+ req = r.list_runs(name, state=state, cursor=cursor, limit=limit)
438
+ return RunPage._from_json(await self._client._send(req))
439
+
440
+
441
+ class AsyncRuns:
442
+ """Runs of schedules: one per firing, with its own attempts."""
443
+
444
+ def __init__(self, client: AsyncWorkly) -> None:
445
+ self._client = client
446
+
447
+ async def get(self, run_id: str) -> Run:
448
+ return Run._from_json(await self._client._send(r.get_run(run_id)))
449
+
450
+ async def attempts(self, run_id: str) -> list[Attempt]:
451
+ """The run's delivery attempts, oldest first."""
452
+ data = await self._client._send(r.list_run_attempts(run_id))
453
+ return [Attempt._from_json(a) for a in data["attempts"]]
454
+
455
+ async def delivery(self, run_id: str) -> Delivery:
456
+ """The request that delivers the run, as it would be sent now and freshly signed."""
457
+ return Delivery._from_json(await self._client._send(r.get_run_delivery(run_id)))
458
+
459
+ async def cancel(self, run_id: str) -> Run:
460
+ """Cancel a pending or running run. A running attempt finishes but isn't retried."""
461
+ return Run._from_json(await self._client._send(r.cancel_run(run_id)))
462
+
463
+ async def retry(self, run_id: str) -> Run:
464
+ """Deliver the run now. A finished or skipped run gets fresh attempts."""
465
+ return Run._from_json(await self._client._send(r.retry_run(run_id)))
466
+
467
+
468
+ class AsyncAlerts:
469
+ """Where alerts about failed tasks and schedule runs go: email or Slack.
470
+
471
+ When tasks in a queue or runs of a schedule fail, every channel gets an
472
+ alert within about a minute, then at most one every 30 minutes per queue
473
+ or schedule.
474
+ """
475
+
476
+ def __init__(self, client: AsyncWorkly) -> None:
477
+ self._client = client
478
+
479
+ async def list(self) -> AlertChannelList:
480
+ """The workspace's channels, oldest first, and whether email can be sent."""
481
+ return AlertChannelList._from_json(await self._client._send(r.list_alert_channels()))
482
+
483
+ async def create(self, type: AlertChannelType, target: str) -> AlertChannel:
484
+ """Add a channel: an email address, or a Slack incoming webhook URL."""
485
+ return AlertChannel._from_json(
486
+ await self._client._send(r.create_alert_channel(type, target))
487
+ )
488
+
489
+ async def delete(self, channel_id: str) -> None:
490
+ await self._client._send(r.delete_alert_channel(channel_id))
491
+
492
+ async def test(self, channel_id: str) -> None:
493
+ """Send a test alert to the channel now. Raises APIError (alert_failed) if it can't."""
494
+ await self._client._send(r.test_alert_channel(channel_id))