vercel-queue 0.7.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.
- vercel/queue/__init__.py +8 -0
- vercel/queue/__main__.py +10 -0
- vercel/queue/_internal/__init__.py +1 -0
- vercel/queue/_internal/api_async.py +219 -0
- vercel/queue/_internal/api_common.py +104 -0
- vercel/queue/_internal/api_sync.py +137 -0
- vercel/queue/_internal/asgi.py +145 -0
- vercel/queue/_internal/asynctools.py +40 -0
- vercel/queue/_internal/cli.py +313 -0
- vercel/queue/_internal/client.py +1109 -0
- vercel/queue/_internal/client_sync.py +432 -0
- vercel/queue/_internal/config.py +160 -0
- vercel/queue/_internal/constants.py +50 -0
- vercel/queue/_internal/devserver.py +205 -0
- vercel/queue/_internal/embedded.py +1900 -0
- vercel/queue/_internal/errors.py +262 -0
- vercel/queue/_internal/http.py +634 -0
- vercel/queue/_internal/lease.py +1007 -0
- vercel/queue/_internal/log.py +143 -0
- vercel/queue/_internal/messages.py +122 -0
- vercel/queue/_internal/multipart.py +255 -0
- vercel/queue/_internal/names.py +101 -0
- vercel/queue/_internal/polling.py +200 -0
- vercel/queue/_internal/push.py +246 -0
- vercel/queue/_internal/response.py +111 -0
- vercel/queue/_internal/retry.py +86 -0
- vercel/queue/_internal/streams.py +313 -0
- vercel/queue/_internal/subscribers.py +1025 -0
- vercel/queue/_internal/transports.py +363 -0
- vercel/queue/_internal/types.py +300 -0
- vercel/queue/_internal/typeutils.py +203 -0
- vercel/queue/devserver.py +24 -0
- vercel/queue/embedded.py +50 -0
- vercel/queue/py.typed +1 -0
- vercel/queue/sync.py +8 -0
- vercel/queue/testing/__init__.py +14 -0
- vercel/queue/testing/pytest.py +42 -0
- vercel/queue/testing/state.py +32 -0
- vercel/queue/version.py +3 -0
- vercel_queue-0.7.0.dist-info/METADATA +681 -0
- vercel_queue-0.7.0.dist-info/RECORD +43 -0
- vercel_queue-0.7.0.dist-info/WHEEL +4 -0
- vercel_queue-0.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, ForwardRef, Generic, Literal, TypeAlias, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
|
|
10
|
+
from .constants import CONTENT_TYPE_JSON, CONTENT_TYPE_OCTET_STREAM, CONTENT_TYPE_TEXT
|
|
11
|
+
from .errors import SubscriptionError
|
|
12
|
+
from .streams import (
|
|
13
|
+
AsyncStreamPayload,
|
|
14
|
+
AsyncTextStreamPayload,
|
|
15
|
+
SyncStreamPayload,
|
|
16
|
+
SyncTextStreamPayload,
|
|
17
|
+
)
|
|
18
|
+
from .types import (
|
|
19
|
+
RequestContent,
|
|
20
|
+
Topic,
|
|
21
|
+
Transport,
|
|
22
|
+
)
|
|
23
|
+
from .typeutils import (
|
|
24
|
+
annotation_needs_resolution,
|
|
25
|
+
args,
|
|
26
|
+
is_classvar,
|
|
27
|
+
is_final,
|
|
28
|
+
is_type_var,
|
|
29
|
+
is_union_type,
|
|
30
|
+
origin_is,
|
|
31
|
+
strip_annotated,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
TransportKind: TypeAlias = Literal[
|
|
37
|
+
"json",
|
|
38
|
+
"byte-buffer",
|
|
39
|
+
"byte-stream",
|
|
40
|
+
"text-buffer",
|
|
41
|
+
"text-stream",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ByteBufferTransport:
|
|
46
|
+
content_type = CONTENT_TYPE_OCTET_STREAM
|
|
47
|
+
|
|
48
|
+
def serialize(self, value: bytes | bytearray | memoryview) -> bytes:
|
|
49
|
+
if isinstance(value, bytes):
|
|
50
|
+
return value
|
|
51
|
+
return bytes(value)
|
|
52
|
+
|
|
53
|
+
async def deserialize(
|
|
54
|
+
self,
|
|
55
|
+
payload: AsyncIterator[bytes],
|
|
56
|
+
*,
|
|
57
|
+
content_type: str,
|
|
58
|
+
) -> bytes:
|
|
59
|
+
return await _collect_bytes_async(payload)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TextBufferTransport:
|
|
63
|
+
content_type = CONTENT_TYPE_TEXT
|
|
64
|
+
|
|
65
|
+
def serialize(self, value: str) -> bytes:
|
|
66
|
+
return value.encode("utf-8")
|
|
67
|
+
|
|
68
|
+
async def deserialize(
|
|
69
|
+
self,
|
|
70
|
+
payload: AsyncIterator[bytes],
|
|
71
|
+
*,
|
|
72
|
+
content_type: str,
|
|
73
|
+
) -> str:
|
|
74
|
+
return (await _collect_bytes_async(payload)).decode("utf-8")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ByteStreamTransport:
|
|
78
|
+
"""Transport that preserves receive payloads as one-shot byte streams.
|
|
79
|
+
|
|
80
|
+
This transport avoids buffering message bytes on receive and passes stream
|
|
81
|
+
payloads through to the HTTP client on send.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
content_type = CONTENT_TYPE_OCTET_STREAM
|
|
85
|
+
|
|
86
|
+
def serialize(
|
|
87
|
+
self,
|
|
88
|
+
value: bytes
|
|
89
|
+
| bytearray
|
|
90
|
+
| memoryview
|
|
91
|
+
| Iterable[bytes]
|
|
92
|
+
| AsyncIterable[bytes]
|
|
93
|
+
| SyncStreamPayload
|
|
94
|
+
| AsyncStreamPayload,
|
|
95
|
+
) -> RequestContent:
|
|
96
|
+
if isinstance(value, bytes):
|
|
97
|
+
return value
|
|
98
|
+
if isinstance(value, (bytearray, memoryview)):
|
|
99
|
+
return bytes(value)
|
|
100
|
+
if isinstance(value, AsyncIterable):
|
|
101
|
+
return value
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
async def deserialize(
|
|
105
|
+
self,
|
|
106
|
+
payload: AsyncIterator[bytes],
|
|
107
|
+
*,
|
|
108
|
+
content_type: str,
|
|
109
|
+
) -> AsyncStreamPayload:
|
|
110
|
+
return AsyncStreamPayload(payload)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class RawJsonTransport(Generic[T]):
|
|
114
|
+
content_type = CONTENT_TYPE_JSON
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
json_encoder: type[json.JSONEncoder] | None = None,
|
|
120
|
+
json_decoder: type[json.JSONDecoder] | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.json_encoder = json_encoder
|
|
123
|
+
self.json_decoder = json_decoder
|
|
124
|
+
|
|
125
|
+
def serialize(self, value: T) -> bytes:
|
|
126
|
+
if self.json_encoder is None:
|
|
127
|
+
return json.dumps(value).encode("utf-8")
|
|
128
|
+
return json.dumps(value, cls=self.json_encoder).encode("utf-8")
|
|
129
|
+
|
|
130
|
+
async def deserialize(
|
|
131
|
+
self,
|
|
132
|
+
payload: AsyncIterator[bytes],
|
|
133
|
+
*,
|
|
134
|
+
content_type: str,
|
|
135
|
+
) -> T:
|
|
136
|
+
text = (await _collect_bytes_async(payload)).decode("utf-8")
|
|
137
|
+
if self.json_decoder is None:
|
|
138
|
+
return cast("T", json.loads(text))
|
|
139
|
+
return cast("T", json.loads(text, cls=self.json_decoder))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TypedJsonTransport(Generic[T]):
|
|
143
|
+
content_type = CONTENT_TYPE_JSON
|
|
144
|
+
|
|
145
|
+
def __init__(self, model: type[T]) -> None:
|
|
146
|
+
self.model = model
|
|
147
|
+
try:
|
|
148
|
+
type_adapter = import_module("pydantic").TypeAdapter
|
|
149
|
+
except ImportError as exc: # pragma: no cover - depends on optional extra
|
|
150
|
+
raise RuntimeError("Install vercel-queue[typed] to use TypedJsonTransport") from exc
|
|
151
|
+
self._adapter = type_adapter(model)
|
|
152
|
+
|
|
153
|
+
def serialize(self, value: T) -> bytes:
|
|
154
|
+
if hasattr(value, "model_dump_json"):
|
|
155
|
+
return cast("Any", value).model_dump_json().encode("utf-8")
|
|
156
|
+
return self._adapter.dump_json(value)
|
|
157
|
+
|
|
158
|
+
async def deserialize(
|
|
159
|
+
self,
|
|
160
|
+
payload: AsyncIterator[bytes],
|
|
161
|
+
*,
|
|
162
|
+
content_type: str,
|
|
163
|
+
) -> T:
|
|
164
|
+
return self._adapter.validate_json(await _collect_bytes_async(payload))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _ModelDumpJsonTransport:
|
|
168
|
+
content_type = CONTENT_TYPE_JSON
|
|
169
|
+
|
|
170
|
+
def __init__(self, model_dump_json: Callable[[], str]) -> None:
|
|
171
|
+
self._model_dump_json = model_dump_json
|
|
172
|
+
|
|
173
|
+
def serialize(self, value: object) -> bytes:
|
|
174
|
+
del value
|
|
175
|
+
return self._model_dump_json().encode("utf-8")
|
|
176
|
+
|
|
177
|
+
async def deserialize(
|
|
178
|
+
self,
|
|
179
|
+
payload: AsyncIterator[bytes],
|
|
180
|
+
*,
|
|
181
|
+
content_type: str,
|
|
182
|
+
) -> object:
|
|
183
|
+
return await RawJsonTransport[Any]().deserialize(payload, content_type=content_type)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TextStreamTransport:
|
|
187
|
+
"""Transport that sends and receives one-shot UTF-8 text streams."""
|
|
188
|
+
|
|
189
|
+
content_type = CONTENT_TYPE_TEXT
|
|
190
|
+
|
|
191
|
+
def serialize(
|
|
192
|
+
self,
|
|
193
|
+
value: str
|
|
194
|
+
| Iterable[str]
|
|
195
|
+
| AsyncIterable[str]
|
|
196
|
+
| SyncTextStreamPayload
|
|
197
|
+
| AsyncTextStreamPayload,
|
|
198
|
+
) -> RequestContent:
|
|
199
|
+
if isinstance(value, str):
|
|
200
|
+
return value.encode("utf-8")
|
|
201
|
+
if isinstance(value, AsyncIterable):
|
|
202
|
+
return _encode_text_async(cast("AsyncIterable[str]", value))
|
|
203
|
+
return _encode_text_sync(value)
|
|
204
|
+
|
|
205
|
+
async def deserialize(
|
|
206
|
+
self,
|
|
207
|
+
payload: AsyncIterator[bytes],
|
|
208
|
+
*,
|
|
209
|
+
content_type: str,
|
|
210
|
+
) -> AsyncTextStreamPayload:
|
|
211
|
+
return AsyncTextStreamPayload(payload)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def infer_send_transport(payload: object) -> object:
|
|
215
|
+
if model_dump_json := _model_dump_json(payload):
|
|
216
|
+
return _ModelDumpJsonTransport(model_dump_json)
|
|
217
|
+
if isinstance(payload, (bytes, bytearray, memoryview)):
|
|
218
|
+
return ByteBufferTransport()
|
|
219
|
+
if isinstance(payload, str):
|
|
220
|
+
return TextBufferTransport()
|
|
221
|
+
if isinstance(payload, (SyncTextStreamPayload, AsyncTextStreamPayload)):
|
|
222
|
+
return TextStreamTransport()
|
|
223
|
+
if isinstance(payload, (SyncStreamPayload, AsyncStreamPayload)):
|
|
224
|
+
return ByteStreamTransport()
|
|
225
|
+
return RawJsonTransport[Any]()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_untyped_payload_annotation(annotation: Any) -> bool:
|
|
229
|
+
annotation = strip_annotated(annotation)
|
|
230
|
+
return annotation in {inspect.Signature.empty, Any, object}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def reject_invalid_payload_annotation(annotation: Any) -> None:
|
|
234
|
+
annotation = strip_annotated(annotation)
|
|
235
|
+
if is_untyped_payload_annotation(annotation):
|
|
236
|
+
return
|
|
237
|
+
if is_type_var(annotation) or is_classvar(annotation) or is_final(annotation):
|
|
238
|
+
raise SubscriptionError(f"unsupported queue subscriber payload annotation: {annotation!r}")
|
|
239
|
+
if annotation in {list, dict, tuple, set, frozenset}:
|
|
240
|
+
raise SubscriptionError(
|
|
241
|
+
f"unsupported bare queue subscriber payload annotation: {annotation!r}"
|
|
242
|
+
)
|
|
243
|
+
if isinstance(annotation, str | ForwardRef) or origin_is(annotation, Literal):
|
|
244
|
+
return
|
|
245
|
+
if is_union_type(annotation):
|
|
246
|
+
for item in args(annotation):
|
|
247
|
+
reject_invalid_payload_annotation(item)
|
|
248
|
+
return
|
|
249
|
+
if origin_is(annotation, Iterable, AsyncIterable):
|
|
250
|
+
stream_args = args(annotation)
|
|
251
|
+
if len(stream_args) != 1 or stream_args[0] not in {bytes, str}:
|
|
252
|
+
raise SubscriptionError(
|
|
253
|
+
"queue stream subscriber annotation must be Iterable[bytes], "
|
|
254
|
+
"Iterable[str], AsyncIterable[bytes], or AsyncIterable[str]"
|
|
255
|
+
)
|
|
256
|
+
return
|
|
257
|
+
for item in args(annotation):
|
|
258
|
+
if item is not Ellipsis:
|
|
259
|
+
reject_invalid_payload_annotation(item)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def payload_transport_kind(annotation: Any) -> TransportKind:
|
|
263
|
+
annotation = strip_annotated(annotation)
|
|
264
|
+
if annotation is bytes:
|
|
265
|
+
return "byte-buffer"
|
|
266
|
+
if annotation is str:
|
|
267
|
+
return "text-buffer"
|
|
268
|
+
if annotation in {SyncStreamPayload, AsyncStreamPayload}:
|
|
269
|
+
return "byte-stream"
|
|
270
|
+
if annotation in {SyncTextStreamPayload, AsyncTextStreamPayload}:
|
|
271
|
+
return "text-stream"
|
|
272
|
+
if origin_is(annotation, Iterable, AsyncIterable):
|
|
273
|
+
stream_args = args(annotation)
|
|
274
|
+
if stream_args == (bytes,):
|
|
275
|
+
return "byte-stream"
|
|
276
|
+
if stream_args == (str,):
|
|
277
|
+
return "text-stream"
|
|
278
|
+
return "json"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def transport_for_kind(kind: TransportKind) -> Transport[Any]:
|
|
282
|
+
if kind == "byte-buffer":
|
|
283
|
+
return ByteBufferTransport()
|
|
284
|
+
if kind == "byte-stream":
|
|
285
|
+
return ByteStreamTransport()
|
|
286
|
+
if kind == "text-buffer":
|
|
287
|
+
return TextBufferTransport()
|
|
288
|
+
if kind == "text-stream":
|
|
289
|
+
return TextStreamTransport()
|
|
290
|
+
return RawJsonTransport[Any]()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def receive_transport_for_annotation(annotation: Any) -> Transport[Any]:
|
|
294
|
+
annotation = strip_annotated(annotation)
|
|
295
|
+
if is_untyped_payload_annotation(annotation):
|
|
296
|
+
return RawJsonTransport[Any]()
|
|
297
|
+
if annotation_needs_resolution(annotation):
|
|
298
|
+
raise SubscriptionError(f"unsupported queue subscriber payload annotation: {annotation!r}")
|
|
299
|
+
reject_invalid_payload_annotation(annotation)
|
|
300
|
+
kind = payload_transport_kind(annotation)
|
|
301
|
+
if kind == "json":
|
|
302
|
+
return TypedJsonTransport[Any](annotation)
|
|
303
|
+
return transport_for_kind(kind)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def receive_transport_for_topic(topic: object) -> Transport[Any]:
|
|
307
|
+
if not isinstance(topic, Topic):
|
|
308
|
+
return RawJsonTransport[Any]()
|
|
309
|
+
if topic.transport is not None:
|
|
310
|
+
return topic.transport
|
|
311
|
+
if getattr(type(topic), "__topic_origin__", None) is not Topic:
|
|
312
|
+
return RawJsonTransport[Any]()
|
|
313
|
+
return receive_transport_for_annotation(type(topic).__topic_payload_type__)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def send_transport_for_topic(topic: object) -> Transport[Any] | None:
|
|
317
|
+
if isinstance(topic, Topic):
|
|
318
|
+
return topic.transport
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _model_dump_json(value: object) -> Callable[[], str] | None:
|
|
323
|
+
model_dump_json = getattr(value, "model_dump_json", None)
|
|
324
|
+
if callable(model_dump_json):
|
|
325
|
+
return cast("Callable[[], str]", model_dump_json)
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def _collect_bytes_async(payload: AsyncIterator[bytes]) -> bytes:
|
|
330
|
+
chunks = bytearray()
|
|
331
|
+
async for chunk in payload:
|
|
332
|
+
chunks.extend(chunk)
|
|
333
|
+
return bytes(chunks)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _encode_text_sync(payload: Iterable[str]) -> Iterable[bytes]:
|
|
337
|
+
for chunk in payload:
|
|
338
|
+
yield chunk.encode("utf-8")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def _encode_text_async(payload: AsyncIterable[str]) -> AsyncIterator[bytes]:
|
|
342
|
+
async for chunk in payload:
|
|
343
|
+
yield chunk.encode("utf-8")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# Only add public symbols to __all__; internal helpers must stay unexported.
|
|
347
|
+
__all__ = (
|
|
348
|
+
"ByteBufferTransport",
|
|
349
|
+
"ByteStreamTransport",
|
|
350
|
+
"RawJsonTransport",
|
|
351
|
+
"TextBufferTransport",
|
|
352
|
+
"TextStreamTransport",
|
|
353
|
+
"TransportKind",
|
|
354
|
+
"TypedJsonTransport",
|
|
355
|
+
"infer_send_transport",
|
|
356
|
+
"is_untyped_payload_annotation",
|
|
357
|
+
"payload_transport_kind",
|
|
358
|
+
"receive_transport_for_annotation",
|
|
359
|
+
"receive_transport_for_topic",
|
|
360
|
+
"reject_invalid_payload_annotation",
|
|
361
|
+
"send_transport_for_topic",
|
|
362
|
+
"transport_for_kind",
|
|
363
|
+
)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar, Generic, Protocol, TypeAlias, TypeGuard, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
from collections.abc import (
|
|
6
|
+
AsyncIterable,
|
|
7
|
+
AsyncIterator,
|
|
8
|
+
Iterable,
|
|
9
|
+
Iterator,
|
|
10
|
+
Mapping,
|
|
11
|
+
)
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
|
|
15
|
+
from .constants import DEFAULT_RETRY_AFTER_SECONDS
|
|
16
|
+
from .names import SanitizedName, validate_name, validate_topic_name
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
_TYPE_VAR_TYPE = type(T)
|
|
20
|
+
|
|
21
|
+
Duration: TypeAlias = int | float | timedelta
|
|
22
|
+
MessageID: TypeAlias = str
|
|
23
|
+
ReceiptHandle: TypeAlias = str
|
|
24
|
+
RequestContent: TypeAlias = bytes | Iterable[bytes] | AsyncIterable[bytes]
|
|
25
|
+
Headers: TypeAlias = Mapping[str, str]
|
|
26
|
+
RawHeaders: TypeAlias = Mapping[str, str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StrContainer(Protocol):
|
|
30
|
+
"""Container of strings that excludes bare strings structurally."""
|
|
31
|
+
|
|
32
|
+
# Bare str has __contains__(str), while normal containers accept object.
|
|
33
|
+
# Requiring the wider signature lets type checkers reject str here.
|
|
34
|
+
|
|
35
|
+
def __iter__(self) -> Iterator[str]: ...
|
|
36
|
+
|
|
37
|
+
def __contains__(self, item: object, /) -> bool: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_duration(value: object) -> TypeGuard[Duration]:
|
|
41
|
+
return isinstance(value, (int, float, timedelta)) and not isinstance(value, bool)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def duration_to_seconds(duration: Duration) -> int:
|
|
45
|
+
if isinstance(duration, timedelta):
|
|
46
|
+
return int(duration.total_seconds())
|
|
47
|
+
if isinstance(duration, int) and not isinstance(duration, bool):
|
|
48
|
+
return duration
|
|
49
|
+
if isinstance(duration, float):
|
|
50
|
+
return int(duration)
|
|
51
|
+
raise TypeError("duration must be an int or float number of seconds or datetime.timedelta")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def duration_to_float_seconds(duration: Duration) -> float:
|
|
55
|
+
if isinstance(duration, timedelta):
|
|
56
|
+
return duration.total_seconds()
|
|
57
|
+
if isinstance(duration, int) and not isinstance(duration, bool):
|
|
58
|
+
return float(duration)
|
|
59
|
+
if isinstance(duration, float):
|
|
60
|
+
return duration
|
|
61
|
+
raise TypeError("duration must be an int or float number of seconds or datetime.timedelta")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, kw_only=True, eq=False)
|
|
65
|
+
class Topic(Generic[T]):
|
|
66
|
+
"""A named Vercel Queues topic.
|
|
67
|
+
|
|
68
|
+
Topics identify the stream that messages are sent to and received from.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: SanitizedName
|
|
72
|
+
"""Topic name to send to or receive from."""
|
|
73
|
+
|
|
74
|
+
transport: Transport[Any] | None = None
|
|
75
|
+
"""Optional transport used when sending to or polling this topic."""
|
|
76
|
+
|
|
77
|
+
__topic_origin__: ClassVar[type[Topic[Any]] | None] = None
|
|
78
|
+
__topic_payload_type__: ClassVar[Any] = None
|
|
79
|
+
_specializations: ClassVar[dict[Any, type[Topic[Any]]]] = {}
|
|
80
|
+
|
|
81
|
+
def __class_getitem__(cls, params: Any) -> type[Topic[Any]]:
|
|
82
|
+
if isinstance(params, tuple):
|
|
83
|
+
if len(params) != 1:
|
|
84
|
+
raise TypeError("Topic expects exactly one type argument")
|
|
85
|
+
params = params[0]
|
|
86
|
+
if isinstance(params, _TYPE_VAR_TYPE):
|
|
87
|
+
return cast("type[Topic[Any]]", cls)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
return cls._specializations[params]
|
|
91
|
+
except KeyError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
payload_repr = _topic_payload_type_repr(params)
|
|
95
|
+
specialization = type(
|
|
96
|
+
f"Topic[{payload_repr}]",
|
|
97
|
+
(cls,),
|
|
98
|
+
{
|
|
99
|
+
"__module__": cls.__module__,
|
|
100
|
+
"__topic_origin__": Topic,
|
|
101
|
+
"__topic_payload_type__": params,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
cls._specializations[params] = specialization
|
|
105
|
+
return cast("type[Topic[Any]]", specialization)
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
name: str | SanitizedName,
|
|
110
|
+
*,
|
|
111
|
+
transport: Transport[Any] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
object.__setattr__(self, "name", SanitizedName(validate_topic_name(name)))
|
|
114
|
+
object.__setattr__(self, "transport", transport)
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
return f"Topic(name={self.name!r})"
|
|
118
|
+
|
|
119
|
+
def __eq__(self, other: object) -> bool:
|
|
120
|
+
if not isinstance(other, Topic):
|
|
121
|
+
return NotImplemented
|
|
122
|
+
return self.name == other.name
|
|
123
|
+
|
|
124
|
+
def __hash__(self) -> int:
|
|
125
|
+
return hash(self.name)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _topic_payload_type_repr(payload_type: object) -> str:
|
|
129
|
+
name = getattr(payload_type, "__qualname__", None)
|
|
130
|
+
if isinstance(name, str):
|
|
131
|
+
return name
|
|
132
|
+
name = getattr(payload_type, "__name__", None)
|
|
133
|
+
if isinstance(name, str):
|
|
134
|
+
return name
|
|
135
|
+
return repr(payload_type)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, kw_only=True)
|
|
139
|
+
class MessageMetadata:
|
|
140
|
+
"""Metadata attached to a queue message delivery.
|
|
141
|
+
|
|
142
|
+
Identifiers and delivery tokens are opaque service values and should be
|
|
143
|
+
passed back unchanged when acknowledging or extending a message.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
message_id: MessageID
|
|
147
|
+
"""Opaque message ID assigned by the service."""
|
|
148
|
+
|
|
149
|
+
delivery_count: int
|
|
150
|
+
"""Number of delivery attempts observed for the message."""
|
|
151
|
+
|
|
152
|
+
created_at: datetime
|
|
153
|
+
"""Message creation timestamp."""
|
|
154
|
+
|
|
155
|
+
topic: str
|
|
156
|
+
"""Topic name the message belongs to."""
|
|
157
|
+
|
|
158
|
+
consumer_group: SanitizedName
|
|
159
|
+
"""Consumer group that owns this delivery."""
|
|
160
|
+
|
|
161
|
+
receipt_handle: ReceiptHandle | None = None
|
|
162
|
+
"""Opaque delivery token used for follow-up operations."""
|
|
163
|
+
|
|
164
|
+
content_type: str | None = None
|
|
165
|
+
"""Stored message content type."""
|
|
166
|
+
|
|
167
|
+
region: str | None = None
|
|
168
|
+
"""Queue region for follow-up operations."""
|
|
169
|
+
|
|
170
|
+
expires_at: datetime | None = None
|
|
171
|
+
"""Message expiration timestamp, when supplied by the service."""
|
|
172
|
+
|
|
173
|
+
visibility_deadline: datetime | None = None
|
|
174
|
+
"""Current processing deadline, when supplied by the service."""
|
|
175
|
+
|
|
176
|
+
def __post_init__(self) -> None:
|
|
177
|
+
object.__setattr__(
|
|
178
|
+
self,
|
|
179
|
+
"consumer_group",
|
|
180
|
+
SanitizedName(validate_name(self.consumer_group, field="consumer_group")),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, kw_only=True)
|
|
185
|
+
class Message(Generic[T]):
|
|
186
|
+
"""A deserialized payload plus metadata for this delivery."""
|
|
187
|
+
|
|
188
|
+
payload: T
|
|
189
|
+
"""Message payload returned by the configured transport."""
|
|
190
|
+
|
|
191
|
+
metadata: MessageMetadata
|
|
192
|
+
"""Metadata for this delivery."""
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def message_id(self) -> MessageID:
|
|
196
|
+
return self.metadata.message_id
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class QueueDirective(Exception): # noqa: N818
|
|
200
|
+
"""Directive raised by a queue subscriber.
|
|
201
|
+
|
|
202
|
+
Queue delivery is at least once. Subscriber directives let a handler choose
|
|
203
|
+
how the SDK resolves the delivery after user code runs.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
reason: object | None
|
|
207
|
+
|
|
208
|
+
def __init__(self, reason: object | None = None) -> None:
|
|
209
|
+
self.reason = reason
|
|
210
|
+
super().__init__(str(reason) if reason is not None else "")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Handoff(QueueDirective):
|
|
214
|
+
"""Directive that tells the SDK a delivery was handed off.
|
|
215
|
+
|
|
216
|
+
``accept_and_handle`` normally owns the full push-delivery lifecycle: it
|
|
217
|
+
accepts the delivery, keeps the processing lease alive while subscribers
|
|
218
|
+
run, and acknowledges the message when all matching subscribers return
|
|
219
|
+
successfully. ``Handoff`` tells the SDK that subscriber code successfully
|
|
220
|
+
handed the delivery to another system with its own processing lifecycle.
|
|
221
|
+
|
|
222
|
+
Raising this directive stops subscriber dispatch and suppresses the SDK's
|
|
223
|
+
automatic follow-up action. The SDK also stops its lease renewal and leaves
|
|
224
|
+
the current queue lease open. External code must take over from that point:
|
|
225
|
+
it is responsible for any further lease renewal needed while processing
|
|
226
|
+
continues, and for eventually acknowledging the message or changing its
|
|
227
|
+
visibility using the original message metadata. If external code does none
|
|
228
|
+
of those things, the message becomes eligible for redelivery when the lease
|
|
229
|
+
expires.
|
|
230
|
+
|
|
231
|
+
This is distinct from ``RetryAfter``, which asks the SDK to update
|
|
232
|
+
visibility immediately, and from raising a normal exception, which reports
|
|
233
|
+
handler failure. For example, the Celery broker uses ``Handoff`` after a
|
|
234
|
+
Vercel Queue push delivery has been accepted into Kombu, because Celery
|
|
235
|
+
must remain responsible for the eventual ACK or reject by delivery tag.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class RetryAfter(QueueDirective):
|
|
240
|
+
"""Directive that retries a message after a delay.
|
|
241
|
+
|
|
242
|
+
Raising ``RetryAfter`` stops subscriber dispatch and asks the SDK to make
|
|
243
|
+
the message visible again after ``delay``. The SDK keeps lifecycle ownership
|
|
244
|
+
through that follow-up action: it stops lease renewal, updates the delivery
|
|
245
|
+
visibility using the current receipt handle, and reports success to the
|
|
246
|
+
push-delivery caller once that update succeeds. Use this when the subscriber
|
|
247
|
+
cannot process the message now, but wants the SDK to schedule the next
|
|
248
|
+
delivery attempt instead of treating the handler as failed.
|
|
249
|
+
|
|
250
|
+
A delay of zero makes the message eligible for immediate redelivery.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
delay: Retry delay in seconds or as a ``datetime.timedelta``. Defaults
|
|
254
|
+
to 60 seconds.
|
|
255
|
+
reason: Optional diagnostic value stored on the directive.
|
|
256
|
+
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
timeout_seconds: int
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
delay: Duration = DEFAULT_RETRY_AFTER_SECONDS,
|
|
264
|
+
reason: object | None = None,
|
|
265
|
+
) -> None:
|
|
266
|
+
self.timeout_seconds = duration_to_seconds(delay)
|
|
267
|
+
if self.timeout_seconds < 0:
|
|
268
|
+
raise ValueError("delay must be non-negative")
|
|
269
|
+
super().__init__(reason)
|
|
270
|
+
|
|
271
|
+
def __repr__(self) -> str:
|
|
272
|
+
return f"RetryAfter(timeout_seconds={self.timeout_seconds!r})"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class Transport(Protocol[T]):
|
|
276
|
+
content_type: str
|
|
277
|
+
|
|
278
|
+
def serialize(self, value: T) -> RequestContent: ...
|
|
279
|
+
|
|
280
|
+
async def deserialize(
|
|
281
|
+
self,
|
|
282
|
+
payload: AsyncIterator[bytes],
|
|
283
|
+
*,
|
|
284
|
+
content_type: str,
|
|
285
|
+
) -> T: ...
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# Only add public symbols to __all__; internal helpers must stay unexported.
|
|
289
|
+
__all__: tuple[str, ...] = (
|
|
290
|
+
"Duration",
|
|
291
|
+
"Handoff",
|
|
292
|
+
"Message",
|
|
293
|
+
"MessageID",
|
|
294
|
+
"MessageMetadata",
|
|
295
|
+
"QueueDirective",
|
|
296
|
+
"ReceiptHandle",
|
|
297
|
+
"RetryAfter",
|
|
298
|
+
"StrContainer",
|
|
299
|
+
"Topic",
|
|
300
|
+
)
|