ringo-task-queue 0.1.0.dev6__py3-none-win_amd64.whl → 0.1.0.dev7__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.
@@ -1,364 +1,449 @@
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]
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
+ history_id: int = 0
238
+
239
+ @classmethod
240
+ def from_proto(cls, msg: queue_pb2.Attempt) -> "Attempt":
241
+ return cls(
242
+ number=msg.number,
243
+ worker_id=msg.worker_id,
244
+ started_at=_pb.ts_to_dt(msg.started_at) if msg.HasField("started_at") else None,
245
+ finished_at=_pb.ts_to_dt(msg.finished_at)
246
+ if msg.HasField("finished_at")
247
+ else None,
248
+ outcome=msg.outcome,
249
+ error=msg.error,
250
+ error_truncated=msg.error_truncated,
251
+ history_id=getattr(msg, "history_id", 0),
252
+ )
253
+
254
+
255
+ @dataclass
256
+ class AttemptPage:
257
+ """Oldest-first durable history page within the initial high-water mark.
258
+
259
+ A page may contain fewer records than requested due to the byte budget.
260
+ Start again without a cursor to include attempts appended after this read.
261
+ """
262
+
263
+ attempts: list[Attempt]
264
+ next_cursor: str | None = None
265
+
266
+ @classmethod
267
+ def from_proto(cls, msg: queue_pb2.GetTaskAttemptsResponse) -> "AttemptPage":
268
+ return cls(
269
+ attempts=[Attempt.from_proto(a) for a in msg.attempts],
270
+ next_cursor=msg.next_cursor or None,
271
+ )
272
+
273
+
274
+ @dataclass
275
+ class Progress:
276
+ current: float
277
+ total: float
278
+ message: str
279
+ updated_at: datetime | None
280
+
281
+
282
+ @dataclass
283
+ class LeaseInfo:
284
+ attempt: int
285
+ worker_id: str
286
+ until: datetime | None
287
+
288
+
289
+ @dataclass
290
+ class Task:
291
+ id: str
292
+ queue: str
293
+ unique_key: str
294
+ task_type: str
295
+ payload: Any
296
+ priority: int
297
+ status: TaskStatus
298
+ attempt: int
299
+ max_attempts: int
300
+ available_at: datetime | None
301
+ created_at: datetime | None
302
+ updated_at: datetime | None
303
+ completed_at: datetime | None
304
+ last_error: str
305
+ last_error_truncated: bool
306
+ attempts: list[Attempt] = field(default_factory=list)
307
+ progress: Progress | None = None
308
+ current_lease: LeaseInfo | None = None
309
+ #: True means ``attempts`` is only a bounded recent preview.
310
+ attempts_truncated: bool = False
311
+
312
+ @classmethod
313
+ def from_proto(cls, msg: queue_pb2.Task) -> "Task":
314
+ return cls(
315
+ id=msg.id,
316
+ queue=msg.queue,
317
+ unique_key=msg.unique_key,
318
+ task_type=msg.type,
319
+ payload=_pb.value_to_py(msg.payload),
320
+ priority=msg.priority,
321
+ status=_status_from_proto(msg.status),
322
+ attempt=msg.attempt,
323
+ max_attempts=msg.max_attempts,
324
+ available_at=_pb.ts_to_dt(msg.available_at)
325
+ if msg.HasField("available_at")
326
+ else None,
327
+ created_at=_pb.ts_to_dt(msg.created_at)
328
+ if msg.HasField("created_at")
329
+ else None,
330
+ updated_at=_pb.ts_to_dt(msg.updated_at)
331
+ if msg.HasField("updated_at")
332
+ else None,
333
+ completed_at=_pb.ts_to_dt(msg.completed_at)
334
+ if msg.HasField("completed_at")
335
+ else None,
336
+ last_error=msg.last_error,
337
+ last_error_truncated=msg.last_error_truncated,
338
+ attempts=[Attempt.from_proto(a) for a in msg.attempts],
339
+ attempts_truncated=getattr(msg, "attempts_truncated", False),
340
+ progress=Progress(
341
+ current=msg.progress.current,
342
+ total=msg.progress.total,
343
+ message=msg.progress.message,
344
+ updated_at=_pb.ts_to_dt(msg.progress.updated_at)
345
+ if msg.progress.HasField("updated_at")
346
+ else None,
347
+ )
348
+ if msg.HasField("progress")
349
+ else None,
350
+ current_lease=LeaseInfo(
351
+ attempt=msg.current_lease.attempt,
352
+ worker_id=msg.current_lease.worker_id,
353
+ until=_pb.ts_to_dt(msg.current_lease.until)
354
+ if msg.current_lease.HasField("until")
355
+ else None,
356
+ )
357
+ if msg.HasField("current_lease")
358
+ else None,
359
+ )
360
+
361
+
362
+ @dataclass
363
+ class EnqueueResult:
364
+ task_id: str
365
+ created: bool
366
+ task: Task | None = None
367
+
368
+ @classmethod
369
+ def from_proto(cls, msg: queue_pb2.EnqueueResult) -> "EnqueueResult":
370
+ return cls(
371
+ task_id=msg.task_id,
372
+ created=msg.created,
373
+ task=Task.from_proto(msg.task) if msg.HasField("task") else None,
374
+ )
375
+
376
+
377
+ @dataclass
378
+ class BatchResult:
379
+ """Per-item results for a batch enqueue; never loses per-task meaning."""
380
+
381
+ results: list[EnqueueResult]
382
+ created_count: int
383
+ duplicate_count: int
384
+
385
+ @property
386
+ def task_ids(self) -> list[str]:
387
+ return [r.task_id for r in self.results]
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Queue statistics (protocol minor >= 4, additive GetQueueStats)
392
+
393
+
394
+ def _optional_ts(msg: Any, field: str) -> datetime | None:
395
+ """Read an optional ``Timestamp`` field from a protobuf message.
396
+
397
+ Tolerates light stand-ins used by unit tests that expose the attribute
398
+ but no ``HasField``.
399
+ """
400
+ has_field = getattr(msg, "HasField", None)
401
+ if callable(has_field):
402
+ try:
403
+ if not has_field(field):
404
+ return None
405
+ except (AttributeError, ValueError):
406
+ return None
407
+ value = getattr(msg, field, None)
408
+ return _pb.ts_to_dt(value) if value is not None else None
409
+
410
+
411
+ @dataclass
412
+ class QueueStats:
413
+ """Point-in-time per-status task counts for one queue.
414
+
415
+ Counts always cover all six canonical task statuses, including zero
416
+ values, for the queue and optional task-type scope that was requested.
417
+ Timestamps are timezone-aware UTC datetimes; ``None`` means the server
418
+ had no matching observation (for example no READY task for
419
+ ``oldest_ready_at``, or a queue without recorded activity yet).
420
+ """
421
+
422
+ scheduled: int
423
+ ready: int
424
+ leased: int
425
+ succeeded: int
426
+ dead: int
427
+ cancelled: int
428
+ observed_at: datetime | None
429
+ oldest_ready_at: datetime | None
430
+ last_activity_at: datetime | None
431
+
432
+ @property
433
+ def active(self) -> int:
434
+ """Tasks still in flight: ``SCHEDULED + READY + LEASED``."""
435
+ return self.scheduled + self.ready + self.leased
436
+
437
+ @classmethod
438
+ def from_proto(cls, msg: Any) -> QueueStats:
439
+ return cls(
440
+ scheduled=msg.scheduled_count,
441
+ ready=msg.ready_count,
442
+ leased=msg.leased_count,
443
+ succeeded=msg.succeeded_count,
444
+ dead=msg.dead_count,
445
+ cancelled=msg.cancelled_count,
446
+ observed_at=_optional_ts(msg, "observed_at"),
447
+ oldest_ready_at=_optional_ts(msg, "oldest_ready_at"),
448
+ last_activity_at=_optional_ts(msg, "last_activity_at"),
449
+ )