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/_models.py ADDED
@@ -0,0 +1,502 @@
1
+ """Response types. They mirror the schemas in api/openapi.yaml.
2
+
3
+ Unknown fields are ignored, so newer servers don't break older SDKs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Any, Literal
11
+
12
+ TaskState = Literal["pending", "running", "succeeded", "failed", "cancelled"]
13
+ HTTPMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
14
+ AttemptOutcome = Literal["success", "http_error", "timeout", "network_error", "lost"]
15
+ RunState = Literal["pending", "running", "succeeded", "failed", "cancelled", "skipped"]
16
+ RunTrigger = Literal["schedule", "manual"]
17
+ SkipReason = Literal["overlap", "missed"]
18
+ OverlapPolicy = Literal["skip", "allow"]
19
+ MissedPolicy = Literal["skip", "catch_up"]
20
+
21
+
22
+ def _time(value: str) -> datetime:
23
+ return datetime.fromisoformat(value)
24
+
25
+
26
+ def _opt_time(value: str | None) -> datetime | None:
27
+ return None if value is None else _time(value)
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class QueueStats:
32
+ """A queue's current work. Counts stop at 10,000."""
33
+
34
+ due: int
35
+ scheduled: int
36
+ running: int
37
+ failed: int
38
+ """Failed tasks still retained by the server."""
39
+ oldest_due_at: datetime | None
40
+ """When the longest-waiting due task became due; None when nothing is due."""
41
+
42
+ @classmethod
43
+ def _from_json(cls, d: dict[str, Any]) -> QueueStats:
44
+ return cls(
45
+ due=d["due"],
46
+ scheduled=d["scheduled"],
47
+ running=d["running"],
48
+ failed=d.get("failed", 0),
49
+ oldest_due_at=_opt_time(d.get("oldest_due_at")),
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class QueueActivity:
55
+ """A queue's finished tasks since `since`, by final state. Counts stop at 10,000."""
56
+
57
+ since: datetime
58
+ succeeded: int
59
+ failed: int
60
+ cancelled: int
61
+ retention_seconds: float | None
62
+ """How long the server keeps finished tasks; None if forever. Older ones aren't counted."""
63
+
64
+ @classmethod
65
+ def _from_json(cls, d: dict[str, Any]) -> QueueActivity:
66
+ return cls(
67
+ since=_time(d["since"]),
68
+ succeeded=d["succeeded"],
69
+ failed=d["failed"],
70
+ cancelled=d["cancelled"],
71
+ retention_seconds=d.get("retention_seconds"),
72
+ )
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class CronPreview:
77
+ """When a cron timetable would run, from now."""
78
+
79
+ next_runs: list[datetime]
80
+ interval_seconds: int
81
+ """The shortest time between two of its runs."""
82
+
83
+ @classmethod
84
+ def _from_json(cls, d: dict[str, Any]) -> CronPreview:
85
+ return cls(
86
+ next_runs=[_time(t) for t in d["next_runs"]], interval_seconds=d["interval_seconds"]
87
+ )
88
+
89
+
90
+ ActivityRange = Literal["1h", "24h", "7d"]
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class ActivityBucket:
95
+ """Tasks created from `start` until the next bucket, by queue."""
96
+
97
+ start: datetime
98
+ counts: dict[str, int]
99
+ """Queues with none are left out."""
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class Activity:
104
+ """The workspace's tasks created over time: every bucket of the range, oldest first."""
105
+
106
+ range: ActivityRange
107
+ bucket_seconds: int
108
+ buckets: list[ActivityBucket]
109
+ retention_seconds: float | None
110
+ """How long the server keeps finished tasks; None if forever. Older ones aren't counted."""
111
+
112
+ @classmethod
113
+ def _from_json(cls, d: dict[str, Any]) -> Activity:
114
+ return cls(
115
+ range=d["range"],
116
+ bucket_seconds=d["bucket_seconds"],
117
+ buckets=[
118
+ ActivityBucket(start=_time(b["start"]), counts=dict(b["counts"]))
119
+ for b in d["buckets"]
120
+ ],
121
+ retention_seconds=d.get("retention_seconds"),
122
+ )
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class FailureGroup:
127
+ """Failed tasks in a queue whose latest attempt ended with the same error."""
128
+
129
+ error: str
130
+ """The tasks' `last_error`. Pass it as `last_error` to list or retry them."""
131
+ count: int
132
+ outcome: AttemptOutcome | None
133
+ status_code: int | None
134
+ """None if there was no response."""
135
+ last_failed_at: datetime
136
+ latest_task_id: str
137
+ """The group's most recently failed task."""
138
+ response_body: str | None
139
+ """The start of the latest task's last response body, if it had one."""
140
+
141
+ @classmethod
142
+ def _from_json(cls, d: dict[str, Any]) -> FailureGroup:
143
+ return cls(
144
+ error=d["error"],
145
+ count=d["count"],
146
+ outcome=d.get("outcome"),
147
+ status_code=d.get("status_code"),
148
+ last_failed_at=_time(d["last_failed_at"]),
149
+ latest_task_id=d["latest_task_id"],
150
+ response_body=d.get("response_body"),
151
+ )
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class Queue:
156
+ name: str
157
+ paused: bool
158
+ max_attempts: int
159
+ min_backoff_seconds: float
160
+ max_backoff_seconds: float
161
+ backoff_multiplier: float
162
+ timeout_seconds: float
163
+ retry_on_4xx: bool
164
+ rate_per_second: float | None
165
+ max_concurrency: int
166
+ stats: QueueStats | None
167
+ created_at: datetime
168
+ updated_at: datetime
169
+
170
+ @classmethod
171
+ def _from_json(cls, d: dict[str, Any]) -> Queue:
172
+ stats = d.get("stats")
173
+ return cls(
174
+ name=d["name"],
175
+ paused=d["paused"],
176
+ max_attempts=d["max_attempts"],
177
+ min_backoff_seconds=d["min_backoff_seconds"],
178
+ max_backoff_seconds=d["max_backoff_seconds"],
179
+ backoff_multiplier=d["backoff_multiplier"],
180
+ timeout_seconds=d["timeout_seconds"],
181
+ retry_on_4xx=d["retry_on_4xx"],
182
+ rate_per_second=d.get("rate_per_second"),
183
+ max_concurrency=d["max_concurrency"],
184
+ stats=None if stats is None else QueueStats._from_json(stats),
185
+ created_at=_time(d["created_at"]),
186
+ updated_at=_time(d["updated_at"]),
187
+ )
188
+
189
+
190
+ @dataclass(frozen=True, slots=True)
191
+ class Task:
192
+ id: str
193
+ queue: str
194
+ state: TaskState
195
+ method: HTTPMethod
196
+ url: str
197
+ headers: dict[str, str]
198
+ payload: Any
199
+ """The JSON body, decoded. None when the task has a raw `body` (or no body)."""
200
+ body: str | None
201
+ run_at: datetime
202
+ """When the task is next eligible for delivery."""
203
+ attempt_count: int
204
+ max_attempts: int | None
205
+ """Per-task override, if set."""
206
+ timeout_seconds: float | None
207
+ """Per-task override, if set."""
208
+ idempotency_key: str | None
209
+ last_status: int | None
210
+ last_outcome: AttemptOutcome | None
211
+ """How the latest attempt ended; None before the first attempt."""
212
+ last_error: str | None
213
+ created_at: datetime
214
+ updated_at: datetime
215
+ finished_at: datetime | None
216
+ """When the task succeeded, failed or was cancelled."""
217
+ last_attempt_at: datetime | None
218
+ """When the latest attempt finished."""
219
+ retried_at: datetime | None
220
+ """When the task was last retried by hand."""
221
+
222
+ @classmethod
223
+ def _from_json(cls, d: dict[str, Any]) -> Task:
224
+ return cls(
225
+ id=d["id"],
226
+ queue=d["queue"],
227
+ state=d["state"],
228
+ method=d["method"],
229
+ url=d["url"],
230
+ headers=d.get("headers") or {},
231
+ payload=d.get("payload"),
232
+ body=d.get("body"),
233
+ run_at=_time(d["run_at"]),
234
+ attempt_count=d["attempt_count"],
235
+ max_attempts=d.get("max_attempts"),
236
+ timeout_seconds=d.get("timeout_seconds"),
237
+ idempotency_key=d.get("idempotency_key"),
238
+ last_status=d.get("last_status"),
239
+ last_outcome=d.get("last_outcome"),
240
+ last_error=d.get("last_error"),
241
+ created_at=_time(d["created_at"]),
242
+ updated_at=_time(d["updated_at"]),
243
+ finished_at=_opt_time(d.get("finished_at")),
244
+ last_attempt_at=_opt_time(d.get("last_attempt_at")),
245
+ retried_at=_opt_time(d.get("retried_at")),
246
+ )
247
+
248
+
249
+ @dataclass(frozen=True, slots=True)
250
+ class Delivery:
251
+ """The HTTP request that delivers a task or run, freshly signed."""
252
+
253
+ method: HTTPMethod
254
+ url: str
255
+ headers: dict[str, str]
256
+ """Every header Workly sets, including the task's own and `Workly-Signature`."""
257
+ body: str
258
+ """The body exactly as sent; the signature covers it."""
259
+
260
+ @classmethod
261
+ def _from_json(cls, d: dict[str, Any]) -> Delivery:
262
+ return cls(method=d["method"], url=d["url"], headers=d.get("headers") or {}, body=d["body"])
263
+
264
+
265
+ @dataclass(frozen=True, slots=True)
266
+ class Attempt:
267
+ id: str
268
+ number: int
269
+ outcome: AttemptOutcome
270
+ """`lost` means the result is unknown and the task is delivered again."""
271
+ status_code: int | None
272
+ error: str | None
273
+ response_body: str | None
274
+ """The first 4 KB of the response body."""
275
+ response_headers: dict[str, str]
276
+ started_at: datetime
277
+ finished_at: datetime
278
+ duration_ms: int
279
+ retried_at: datetime | None
280
+ """Set when a manual retry came just before this attempt: when it happened."""
281
+
282
+ @classmethod
283
+ def _from_json(cls, d: dict[str, Any]) -> Attempt:
284
+ return cls(
285
+ id=d["id"],
286
+ number=d["number"],
287
+ outcome=d["outcome"],
288
+ status_code=d.get("status_code"),
289
+ error=d.get("error"),
290
+ response_body=d.get("response_body"),
291
+ response_headers=d.get("response_headers") or {},
292
+ started_at=_time(d["started_at"]),
293
+ finished_at=_time(d["finished_at"]),
294
+ duration_ms=d["duration_ms"],
295
+ retried_at=_opt_time(d.get("retried_at")),
296
+ )
297
+
298
+
299
+ @dataclass(frozen=True, slots=True)
300
+ class TaskPage:
301
+ tasks: list[Task]
302
+ next_cursor: str | None
303
+ """Pass as `cursor` to get the next page. None on the last page."""
304
+
305
+ @classmethod
306
+ def _from_json(cls, d: dict[str, Any]) -> TaskPage:
307
+ return cls(
308
+ tasks=[Task._from_json(t) for t in d["tasks"]],
309
+ next_cursor=d.get("next_cursor"),
310
+ )
311
+
312
+
313
+ @dataclass(frozen=True, slots=True)
314
+ class EnqueueResult:
315
+ created: bool
316
+ """False if a task with the same idempotency key already existed."""
317
+ task: Task
318
+
319
+
320
+ @dataclass(frozen=True, slots=True)
321
+ class LastRun:
322
+ """A schedule's most recent run."""
323
+
324
+ id: str
325
+ state: RunState
326
+ trigger: RunTrigger
327
+ scheduled_for: datetime
328
+ finished_at: datetime | None
329
+ last_status: int | None
330
+ last_error: str | None
331
+
332
+ @classmethod
333
+ def _from_json(cls, d: dict[str, Any]) -> LastRun:
334
+ return cls(
335
+ id=d["id"],
336
+ state=d["state"],
337
+ trigger=d["trigger"],
338
+ scheduled_for=_time(d["scheduled_for"]),
339
+ finished_at=_opt_time(d.get("finished_at")),
340
+ last_status=d.get("last_status"),
341
+ last_error=d.get("last_error"),
342
+ )
343
+
344
+
345
+ @dataclass(frozen=True, slots=True)
346
+ class Schedule:
347
+ """A cron schedule that calls `url`. Separate from queues: its calls are runs."""
348
+
349
+ name: str
350
+ cron: str
351
+ timezone: str
352
+ method: HTTPMethod
353
+ url: str
354
+ headers: dict[str, str]
355
+ payload: Any
356
+ """The JSON body, decoded. None when the schedule has a raw `body` (or no body)."""
357
+ body: str | None
358
+ timeout_seconds: float
359
+ max_attempts: int
360
+ min_backoff_seconds: float
361
+ max_backoff_seconds: float
362
+ backoff_multiplier: float
363
+ retry_on_4xx: bool
364
+ overlap: OverlapPolicy
365
+ missed: MissedPolicy
366
+ paused: bool
367
+ next_run_at: datetime | None
368
+ """When the schedule next fires; None while paused."""
369
+ next_runs: list[datetime]
370
+ """The next five slots (as if resumed now, while paused)."""
371
+ last_run: LastRun | None
372
+ created_at: datetime
373
+ updated_at: datetime
374
+
375
+ @classmethod
376
+ def _from_json(cls, d: dict[str, Any]) -> Schedule:
377
+ last = d.get("last_run")
378
+ next_runs: list[str] = d.get("next_runs") or []
379
+ return cls(
380
+ name=d["name"],
381
+ cron=d["cron"],
382
+ timezone=d["timezone"],
383
+ method=d["method"],
384
+ url=d["url"],
385
+ headers=d.get("headers") or {},
386
+ payload=d.get("payload"),
387
+ body=d.get("body"),
388
+ timeout_seconds=d["timeout_seconds"],
389
+ max_attempts=d["max_attempts"],
390
+ min_backoff_seconds=d["min_backoff_seconds"],
391
+ max_backoff_seconds=d["max_backoff_seconds"],
392
+ backoff_multiplier=d["backoff_multiplier"],
393
+ retry_on_4xx=d["retry_on_4xx"],
394
+ overlap=d["overlap"],
395
+ missed=d["missed"],
396
+ paused=d["paused"],
397
+ next_run_at=_opt_time(d.get("next_run_at")),
398
+ next_runs=[_time(t) for t in next_runs],
399
+ last_run=None if last is None else LastRun._from_json(last),
400
+ created_at=_time(d["created_at"]),
401
+ updated_at=_time(d["updated_at"]),
402
+ )
403
+
404
+
405
+ @dataclass(frozen=True, slots=True)
406
+ class Run:
407
+ """One firing of a schedule, delivered and retried like a task."""
408
+
409
+ id: str
410
+ schedule: str
411
+ state: RunState
412
+ trigger: RunTrigger
413
+ scheduled_for: datetime
414
+ """The cron slot, or when a manual run was triggered."""
415
+ skip_reason: SkipReason | None
416
+ """Why a `skipped` run wasn't delivered."""
417
+ method: HTTPMethod
418
+ url: str
419
+ headers: dict[str, str]
420
+ payload: Any
421
+ body: str | None
422
+ run_at: datetime
423
+ attempt_count: int
424
+ last_status: int | None
425
+ last_outcome: AttemptOutcome | None
426
+ last_error: str | None
427
+ created_at: datetime
428
+ updated_at: datetime
429
+ finished_at: datetime | None
430
+ last_attempt_at: datetime | None
431
+ retried_at: datetime | None
432
+
433
+ @classmethod
434
+ def _from_json(cls, d: dict[str, Any]) -> Run:
435
+ return cls(
436
+ id=d["id"],
437
+ schedule=d["schedule"],
438
+ state=d["state"],
439
+ trigger=d["trigger"],
440
+ scheduled_for=_time(d["scheduled_for"]),
441
+ skip_reason=d.get("skip_reason"),
442
+ method=d["method"],
443
+ url=d["url"],
444
+ headers=d.get("headers") or {},
445
+ payload=d.get("payload"),
446
+ body=d.get("body"),
447
+ run_at=_time(d["run_at"]),
448
+ attempt_count=d["attempt_count"],
449
+ last_status=d.get("last_status"),
450
+ last_outcome=d.get("last_outcome"),
451
+ last_error=d.get("last_error"),
452
+ created_at=_time(d["created_at"]),
453
+ updated_at=_time(d["updated_at"]),
454
+ finished_at=_opt_time(d.get("finished_at")),
455
+ last_attempt_at=_opt_time(d.get("last_attempt_at")),
456
+ retried_at=_opt_time(d.get("retried_at")),
457
+ )
458
+
459
+
460
+ @dataclass(frozen=True, slots=True)
461
+ class RunPage:
462
+ runs: list[Run]
463
+ next_cursor: str | None
464
+ """Pass as `cursor` to get the next page. None on the last page."""
465
+
466
+ @classmethod
467
+ def _from_json(cls, d: dict[str, Any]) -> RunPage:
468
+ return cls(runs=[Run._from_json(r) for r in d["runs"]], next_cursor=d.get("next_cursor"))
469
+
470
+
471
+ AlertChannelType = Literal["email", "slack"]
472
+
473
+
474
+ @dataclass(frozen=True, slots=True)
475
+ class AlertChannel:
476
+ """Where the workspace's alerts about failed tasks and runs go."""
477
+
478
+ id: str
479
+ type: AlertChannelType
480
+ target: str
481
+ """The email address, or the Slack webhook URL with its secret part hidden."""
482
+ created_at: datetime
483
+
484
+ @classmethod
485
+ def _from_json(cls, d: dict[str, Any]) -> AlertChannel:
486
+ return cls(
487
+ id=d["id"], type=d["type"], target=d["target"], created_at=_time(d["created_at"])
488
+ )
489
+
490
+
491
+ @dataclass(frozen=True, slots=True)
492
+ class AlertChannelList:
493
+ channels: list[AlertChannel]
494
+ email_enabled: bool
495
+ """Whether the server can send email, so email channels can be added."""
496
+
497
+ @classmethod
498
+ def _from_json(cls, d: dict[str, Any]) -> AlertChannelList:
499
+ return cls(
500
+ channels=[AlertChannel._from_json(c) for c in d["channels"]],
501
+ email_enabled=d["email_enabled"],
502
+ )