ringo-task-queue 0.1.0.dev0__py3-none-win_amd64.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.
@@ -0,0 +1,364 @@
1
+ """Public data model for the Ringo Task Queue Python SDK.
2
+
3
+ Dataclasses here mirror the frozen ``ringo.v1`` protobuf contract but expose
4
+ plain Python values (timezone-aware datetimes, timedeltas, arbitrary JSON
5
+ payloads). Conversion to/from wire messages lives in :mod:`._pb`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timedelta
12
+ from enum import Enum
13
+ import json
14
+ import math
15
+ from typing import Any
16
+
17
+ from . import _pb
18
+ from ._proto.ringo.v1 import queue_pb2
19
+ from .errors import InternalError, InvalidArgumentError
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Enums
23
+
24
+
25
+ class TaskStatus(str, Enum):
26
+ SCHEDULED = "SCHEDULED"
27
+ READY = "READY"
28
+ LEASED = "LEASED"
29
+ SUCCEEDED = "SUCCEEDED"
30
+ DEAD = "DEAD"
31
+ CANCELLED = "CANCELLED"
32
+
33
+
34
+ class RetryStrategy(str, Enum):
35
+ FIXED = "FIXED"
36
+ EXPONENTIAL = "EXPONENTIAL"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class SQLiteStorage:
41
+ """Use the embedded daemon's SQLite database in ``data_dir``."""
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class PostgresStorage:
46
+ """Connect the embedded daemon to user-managed PostgreSQL."""
47
+
48
+ dsn: str = field(repr=False)
49
+
50
+ def __post_init__(self) -> None:
51
+ if not self.dsn.strip():
52
+ raise InvalidArgumentError("PostgreSQL DSN is required")
53
+
54
+
55
+ _STATUS_FROM_PROTO = {
56
+ queue_pb2.TASK_STATUS_SCHEDULED: TaskStatus.SCHEDULED,
57
+ queue_pb2.TASK_STATUS_READY: TaskStatus.READY,
58
+ queue_pb2.TASK_STATUS_LEASED: TaskStatus.LEASED,
59
+ queue_pb2.TASK_STATUS_SUCCEEDED: TaskStatus.SUCCEEDED,
60
+ queue_pb2.TASK_STATUS_DEAD: TaskStatus.DEAD,
61
+ queue_pb2.TASK_STATUS_CANCELLED: TaskStatus.CANCELLED,
62
+ }
63
+
64
+ _STRATEGY_TO_PROTO = {
65
+ RetryStrategy.FIXED: queue_pb2.RETRY_STRATEGY_FIXED,
66
+ RetryStrategy.EXPONENTIAL: queue_pb2.RETRY_STRATEGY_EXPONENTIAL,
67
+ }
68
+
69
+ _STRATEGY_FROM_PROTO = {value: key for key, value in _STRATEGY_TO_PROTO.items()}
70
+
71
+ #: Maximum encoded JSON payload size (docs/contracts/defaults.md), measured
72
+ #: in UTF-8 bytes exactly like the server enforces it.
73
+ _MAX_PAYLOAD_BYTES = 1_048_576
74
+
75
+
76
+ def _status_from_proto(value: int) -> TaskStatus:
77
+ status = _STATUS_FROM_PROTO.get(value)
78
+ if status is None:
79
+ raise InternalError(f"server returned unknown task status enum {value}")
80
+ return status
81
+
82
+
83
+ def _strategy_from_proto(value: int) -> RetryStrategy:
84
+ strategy = _STRATEGY_FROM_PROTO.get(value)
85
+ if strategy is None:
86
+ raise InternalError(f"server returned unknown retry strategy enum {value}")
87
+ return strategy
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Producer-facing spec types
92
+
93
+
94
+ @dataclass
95
+ class RetryPolicy:
96
+ """Retry configuration; ``None`` fields fall back to server defaults."""
97
+
98
+ max_attempts: int = 3
99
+ strategy: RetryStrategy = RetryStrategy.EXPONENTIAL
100
+ initial_delay: timedelta = timedelta(seconds=1)
101
+ max_delay: timedelta = timedelta(seconds=60)
102
+ multiplier: float = 2.0
103
+ jitter: float = 0.2
104
+ decrease_priority: bool = False
105
+
106
+ def to_proto(self) -> queue_pb2.RetryPolicy:
107
+ # Mirrors internal/domain/retry.go Validate exactly.
108
+ if self.max_attempts < 1 or self.max_attempts > 32:
109
+ raise InvalidArgumentError("max_attempts must be between 1 and 32")
110
+ if not isinstance(self.strategy, RetryStrategy):
111
+ raise InvalidArgumentError(f"unknown retry strategy {self.strategy!r}")
112
+ if self.initial_delay < timedelta(0) or self.max_delay < self.initial_delay:
113
+ raise InvalidArgumentError(
114
+ "retry delays are invalid: initial_delay must be >= 0 and"
115
+ " max_delay must be >= initial_delay"
116
+ )
117
+ if (
118
+ math.isnan(self.multiplier)
119
+ or math.isinf(self.multiplier)
120
+ or (self.strategy is RetryStrategy.EXPONENTIAL and self.multiplier < 1.0)
121
+ ):
122
+ raise InvalidArgumentError(
123
+ "exponential retry multiplier must be finite and at least one"
124
+ )
125
+ if (
126
+ math.isnan(self.jitter)
127
+ or math.isinf(self.jitter)
128
+ or not 0.0 <= self.jitter <= 1.0
129
+ ):
130
+ raise InvalidArgumentError("jitter must be finite and between zero and one")
131
+ return queue_pb2.RetryPolicy(
132
+ max_attempts=self.max_attempts,
133
+ strategy=_STRATEGY_TO_PROTO[self.strategy],
134
+ initial_delay=_pb.td_to_dur(self.initial_delay),
135
+ max_delay=_pb.td_to_dur(self.max_delay),
136
+ multiplier=self.multiplier,
137
+ jitter=self.jitter,
138
+ decrease_priority=self.decrease_priority,
139
+ )
140
+
141
+ @classmethod
142
+ def from_proto(cls, msg: queue_pb2.RetryPolicy) -> "RetryPolicy":
143
+ return cls(
144
+ max_attempts=msg.max_attempts,
145
+ strategy=_strategy_from_proto(msg.strategy),
146
+ initial_delay=_pb.dur_to_td(msg.initial_delay) or timedelta(0),
147
+ max_delay=_pb.dur_to_td(msg.max_delay) or timedelta(0),
148
+ multiplier=msg.multiplier,
149
+ jitter=msg.jitter,
150
+ decrease_priority=msg.decrease_priority,
151
+ )
152
+
153
+
154
+ def _check_payload_size(payload: Any) -> None:
155
+ """Client-side precheck of the 1 MiB encoded-JSON payload ceiling.
156
+
157
+ Mirrors the server contract: the limit is measured in UTF-8 encoded
158
+ bytes. The server remains the authoritative enforcer; this catches the
159
+ common case without a round trip and without diverging semantics.
160
+ """
161
+ try:
162
+ encoded = json.dumps(
163
+ payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False
164
+ ).encode("utf-8")
165
+ except (TypeError, ValueError) as exc:
166
+ raise InvalidArgumentError(f"payload is not JSON-serializable: {exc}") from exc
167
+ if len(encoded) > _MAX_PAYLOAD_BYTES:
168
+ raise InvalidArgumentError(
169
+ f"encoded payload is {len(encoded)} bytes, exceeding the"
170
+ f" {_MAX_PAYLOAD_BYTES}-byte limit"
171
+ )
172
+
173
+
174
+ class _UnsetType:
175
+ def __repr__(self) -> str: # pragma: no cover
176
+ return "UNSET"
177
+
178
+
179
+ #: Sentinel distinguishing "payload not supplied" (INVALID_ARGUMENT) from an
180
+ #: explicit JSON ``None`` payload.
181
+ UNSET = _UnsetType()
182
+
183
+
184
+ @dataclass
185
+ class TaskSpec:
186
+ """Producer-supplied task fields only.
187
+
188
+ Internal lifecycle fields (status, attempt, lease, ...) are server-owned
189
+ and intentionally absent here; the wire contract rejects enqueueing them.
190
+ """
191
+
192
+ unique_key: str
193
+ task_type: str
194
+ payload: Any = UNSET
195
+ priority: int = 0
196
+ retry_policy: RetryPolicy | None = None
197
+ available_at: datetime | None = None
198
+
199
+ def to_proto(self) -> queue_pb2.TaskSpec:
200
+ if not self.unique_key:
201
+ raise InvalidArgumentError("unique_key is required")
202
+ if not self.task_type:
203
+ raise InvalidArgumentError("task_type is required")
204
+ if self.payload is UNSET:
205
+ raise InvalidArgumentError(
206
+ "payload is required; pass None explicitly for a JSON null payload"
207
+ )
208
+ if not -(2**31) <= self.priority < 2**31:
209
+ raise InvalidArgumentError("priority must fit in a signed 32-bit integer")
210
+ _check_payload_size(self.payload)
211
+ msg = queue_pb2.TaskSpec(
212
+ unique_key=self.unique_key,
213
+ type=self.task_type,
214
+ payload=_pb.py_to_value(self.payload),
215
+ priority=self.priority,
216
+ )
217
+ if self.retry_policy is not None:
218
+ msg.retry_policy.CopyFrom(self.retry_policy.to_proto())
219
+ if self.available_at is not None:
220
+ msg.available_at.CopyFrom(_pb.dt_to_ts(self.available_at))
221
+ return msg
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Server-facing read models
226
+
227
+
228
+ @dataclass
229
+ class Attempt:
230
+ number: int
231
+ worker_id: str
232
+ started_at: datetime | None
233
+ finished_at: datetime | None
234
+ outcome: str
235
+ error: str
236
+ error_truncated: bool
237
+
238
+
239
+ @dataclass
240
+ class Progress:
241
+ current: float
242
+ total: float
243
+ message: str
244
+ updated_at: datetime | None
245
+
246
+
247
+ @dataclass
248
+ class LeaseInfo:
249
+ attempt: int
250
+ worker_id: str
251
+ until: datetime | None
252
+
253
+
254
+ @dataclass
255
+ class Task:
256
+ id: str
257
+ queue: str
258
+ unique_key: str
259
+ task_type: str
260
+ payload: Any
261
+ priority: int
262
+ status: TaskStatus
263
+ attempt: int
264
+ max_attempts: int
265
+ available_at: datetime | None
266
+ created_at: datetime | None
267
+ updated_at: datetime | None
268
+ completed_at: datetime | None
269
+ last_error: str
270
+ last_error_truncated: bool
271
+ attempts: list[Attempt] = field(default_factory=list)
272
+ progress: Progress | None = None
273
+ current_lease: LeaseInfo | None = None
274
+
275
+ @classmethod
276
+ def from_proto(cls, msg: queue_pb2.Task) -> "Task":
277
+ return cls(
278
+ id=msg.id,
279
+ queue=msg.queue,
280
+ unique_key=msg.unique_key,
281
+ task_type=msg.type,
282
+ payload=_pb.value_to_py(msg.payload),
283
+ priority=msg.priority,
284
+ status=_status_from_proto(msg.status),
285
+ attempt=msg.attempt,
286
+ max_attempts=msg.max_attempts,
287
+ available_at=_pb.ts_to_dt(msg.available_at)
288
+ if msg.HasField("available_at")
289
+ else None,
290
+ created_at=_pb.ts_to_dt(msg.created_at)
291
+ if msg.HasField("created_at")
292
+ else None,
293
+ updated_at=_pb.ts_to_dt(msg.updated_at)
294
+ if msg.HasField("updated_at")
295
+ else None,
296
+ completed_at=_pb.ts_to_dt(msg.completed_at)
297
+ if msg.HasField("completed_at")
298
+ else None,
299
+ last_error=msg.last_error,
300
+ last_error_truncated=msg.last_error_truncated,
301
+ attempts=[
302
+ Attempt(
303
+ number=a.number,
304
+ worker_id=a.worker_id,
305
+ started_at=_pb.ts_to_dt(a.started_at)
306
+ if a.HasField("started_at")
307
+ else None,
308
+ finished_at=_pb.ts_to_dt(a.finished_at)
309
+ if a.HasField("finished_at")
310
+ else None,
311
+ outcome=a.outcome,
312
+ error=a.error,
313
+ error_truncated=a.error_truncated,
314
+ )
315
+ for a in msg.attempts
316
+ ],
317
+ progress=Progress(
318
+ current=msg.progress.current,
319
+ total=msg.progress.total,
320
+ message=msg.progress.message,
321
+ updated_at=_pb.ts_to_dt(msg.progress.updated_at)
322
+ if msg.progress.HasField("updated_at")
323
+ else None,
324
+ )
325
+ if msg.HasField("progress")
326
+ else None,
327
+ current_lease=LeaseInfo(
328
+ attempt=msg.current_lease.attempt,
329
+ worker_id=msg.current_lease.worker_id,
330
+ until=_pb.ts_to_dt(msg.current_lease.until)
331
+ if msg.current_lease.HasField("until")
332
+ else None,
333
+ )
334
+ if msg.HasField("current_lease")
335
+ else None,
336
+ )
337
+
338
+
339
+ @dataclass
340
+ class EnqueueResult:
341
+ task_id: str
342
+ created: bool
343
+ task: Task | None = None
344
+
345
+ @classmethod
346
+ def from_proto(cls, msg: queue_pb2.EnqueueResult) -> "EnqueueResult":
347
+ return cls(
348
+ task_id=msg.task_id,
349
+ created=msg.created,
350
+ task=Task.from_proto(msg.task) if msg.HasField("task") else None,
351
+ )
352
+
353
+
354
+ @dataclass
355
+ class BatchResult:
356
+ """Per-item results for a batch enqueue; never loses per-task meaning."""
357
+
358
+ results: list[EnqueueResult]
359
+ created_count: int
360
+ duplicate_count: int
361
+
362
+ @property
363
+ def task_ids(self) -> list[str]:
364
+ return [r.task_id for r in self.results]
File without changes