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.
Files changed (43) hide show
  1. vercel/queue/__init__.py +8 -0
  2. vercel/queue/__main__.py +10 -0
  3. vercel/queue/_internal/__init__.py +1 -0
  4. vercel/queue/_internal/api_async.py +219 -0
  5. vercel/queue/_internal/api_common.py +104 -0
  6. vercel/queue/_internal/api_sync.py +137 -0
  7. vercel/queue/_internal/asgi.py +145 -0
  8. vercel/queue/_internal/asynctools.py +40 -0
  9. vercel/queue/_internal/cli.py +313 -0
  10. vercel/queue/_internal/client.py +1109 -0
  11. vercel/queue/_internal/client_sync.py +432 -0
  12. vercel/queue/_internal/config.py +160 -0
  13. vercel/queue/_internal/constants.py +50 -0
  14. vercel/queue/_internal/devserver.py +205 -0
  15. vercel/queue/_internal/embedded.py +1900 -0
  16. vercel/queue/_internal/errors.py +262 -0
  17. vercel/queue/_internal/http.py +634 -0
  18. vercel/queue/_internal/lease.py +1007 -0
  19. vercel/queue/_internal/log.py +143 -0
  20. vercel/queue/_internal/messages.py +122 -0
  21. vercel/queue/_internal/multipart.py +255 -0
  22. vercel/queue/_internal/names.py +101 -0
  23. vercel/queue/_internal/polling.py +200 -0
  24. vercel/queue/_internal/push.py +246 -0
  25. vercel/queue/_internal/response.py +111 -0
  26. vercel/queue/_internal/retry.py +86 -0
  27. vercel/queue/_internal/streams.py +313 -0
  28. vercel/queue/_internal/subscribers.py +1025 -0
  29. vercel/queue/_internal/transports.py +363 -0
  30. vercel/queue/_internal/types.py +300 -0
  31. vercel/queue/_internal/typeutils.py +203 -0
  32. vercel/queue/devserver.py +24 -0
  33. vercel/queue/embedded.py +50 -0
  34. vercel/queue/py.typed +1 -0
  35. vercel/queue/sync.py +8 -0
  36. vercel/queue/testing/__init__.py +14 -0
  37. vercel/queue/testing/pytest.py +42 -0
  38. vercel/queue/testing/state.py +32 -0
  39. vercel/queue/version.py +3 -0
  40. vercel_queue-0.7.0.dist-info/METADATA +681 -0
  41. vercel_queue-0.7.0.dist-info/RECORD +43 -0
  42. vercel_queue-0.7.0.dist-info/WHEEL +4 -0
  43. vercel_queue-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1025 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, ParamSpec, Protocol, TypeAlias, TypeVar, cast, overload
4
+
5
+ import inspect
6
+ import logging
7
+ import threading
8
+ import weakref
9
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
10
+ from dataclasses import dataclass
11
+ from importlib import import_module
12
+ from itertools import count
13
+ from types import MappingProxyType
14
+
15
+ from .errors import (
16
+ DuplicateSubscriptionError,
17
+ PayloadValidationError,
18
+ SubscriptionError,
19
+ UnhandledMessageError,
20
+ )
21
+ from .log import debug_enabled, debug_log_for_msg
22
+ from .names import (
23
+ SanitizedName,
24
+ normalize_name,
25
+ sanitize_name,
26
+ validate_subscription_pattern,
27
+ validate_topic_name,
28
+ )
29
+ from .transports import (
30
+ TransportKind,
31
+ is_untyped_payload_annotation,
32
+ payload_transport_kind,
33
+ reject_invalid_payload_annotation,
34
+ transport_for_kind,
35
+ )
36
+ from .types import (
37
+ Duration,
38
+ Message,
39
+ MessageMetadata,
40
+ QueueDirective,
41
+ RetryAfter,
42
+ StrContainer,
43
+ Topic,
44
+ Transport,
45
+ duration_to_seconds,
46
+ )
47
+ from .typeutils import (
48
+ ResolvedAnnotation,
49
+ TypeAnnotationResolutionError,
50
+ args,
51
+ origin_is,
52
+ resolve_annotation_with_namespace_from_call_stack,
53
+ strip_annotated,
54
+ )
55
+
56
+ _Subscriber: TypeAlias = Callable[..., Any | Awaitable[Any]]
57
+ _SubscriberRef: TypeAlias = weakref.ReferenceType[_Subscriber]
58
+ P = ParamSpec("P")
59
+ R = TypeVar("R")
60
+ R_co = TypeVar("R_co", covariant=True)
61
+ T = TypeVar("T")
62
+
63
+
64
+ class PayloadAdapter(Protocol):
65
+ def validate_python(self, value: Any, /) -> Any: ...
66
+
67
+
68
+ class EmbeddedDispatcher(Protocol):
69
+ def register_subscription(
70
+ self,
71
+ *,
72
+ topic: str,
73
+ consumer_group: str,
74
+ retry_after_seconds: int | None,
75
+ initial_delay_seconds: int | None,
76
+ max_concurrency: int | None,
77
+ max_attempts: int | None,
78
+ ) -> None: ...
79
+
80
+
81
+ class QueueSubscriber(Protocol[P, R_co]):
82
+ __module__: str
83
+ __qualname__: str
84
+ __vercel_queue_subscriber__: Literal[True]
85
+
86
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
87
+
88
+
89
+ class _TypedTopicSubscriberDecorator(Protocol[T]):
90
+ @overload
91
+ def __call__(self, func: Callable[[T], R], /) -> QueueSubscriber[[T], R]: ...
92
+
93
+ @overload
94
+ def __call__(
95
+ self,
96
+ func: Callable[[Message[T]], R],
97
+ /,
98
+ ) -> QueueSubscriber[[Message[T]], R]: ...
99
+
100
+
101
+ InvocationMode: TypeAlias = Literal["payload", "message"]
102
+
103
+
104
+ @dataclass(frozen=True, kw_only=True)
105
+ class InvocationPlan:
106
+ payload_adapter: PayloadAdapter | None
107
+ mode: InvocationMode
108
+ transport_kind: TransportKind
109
+
110
+ def prepare_payload(self, payload: Any) -> Any:
111
+ if self.payload_adapter is None:
112
+ return payload
113
+ try:
114
+ return self.payload_adapter.validate_python(payload)
115
+ except Exception as exc:
116
+ # Keep pydantic optional and private to this implementation.
117
+ if exc.__class__.__name__ == "ValidationError":
118
+ raise PayloadValidationError(str(exc)) from exc
119
+ raise
120
+
121
+
122
+ @dataclass(frozen=True, kw_only=True)
123
+ class _Subscription:
124
+ func_ref: _SubscriberRef
125
+ order: int
126
+ consumer_group: SanitizedName
127
+ invocation: InvocationPlan
128
+ topic: str
129
+ retry_after_seconds: int | None = None
130
+ initial_delay_seconds: int | None = None
131
+ max_concurrency: int | None = None
132
+ max_attempts: int | None = None
133
+
134
+ def func(self) -> _Subscriber | None:
135
+ return self.func_ref()
136
+
137
+
138
+ @dataclass(frozen=True, kw_only=True)
139
+ class _MatchedSubscription:
140
+ subscription: _Subscription
141
+ func: _Subscriber
142
+
143
+
144
+ @dataclass(frozen=True, kw_only=True)
145
+ class _RegistrySnapshot:
146
+ subscriptions: tuple[_Subscription, ...]
147
+ wildcard_by_consumer: Mapping[str, tuple[_Subscription, ...]]
148
+ exact_by_consumer_topic: Mapping[tuple[str, str], tuple[_Subscription, ...]]
149
+ prefix_by_consumer: Mapping[str, tuple[_Subscription, ...]]
150
+ dispatchers: tuple[weakref.ReferenceType[EmbeddedDispatcher], ...]
151
+
152
+
153
+ _EMPTY_SNAPSHOT = _RegistrySnapshot(
154
+ subscriptions=(),
155
+ wildcard_by_consumer={},
156
+ exact_by_consumer_topic={},
157
+ prefix_by_consumer={},
158
+ dispatchers=(),
159
+ )
160
+
161
+ _registry_snapshot = _EMPTY_SNAPSHOT
162
+ _subscriptions_lock = threading.Lock()
163
+ _subscription_order = count()
164
+ _LOGGER = logging.getLogger("vercel.queue")
165
+
166
+
167
+ def _build_registry_snapshot(
168
+ subscriptions: Iterable[_Subscription],
169
+ dispatchers: Iterable[weakref.ReferenceType[EmbeddedDispatcher]],
170
+ ) -> _RegistrySnapshot:
171
+ live_subscriptions = tuple(sub for sub in subscriptions if sub.func() is not None)
172
+ live_dispatchers = tuple(ref for ref in dispatchers if ref() is not None)
173
+
174
+ wildcard: dict[str, list[_Subscription]] = {}
175
+ exact: dict[tuple[str, str], list[_Subscription]] = {}
176
+ prefix: dict[str, list[_Subscription]] = {}
177
+ for sub in live_subscriptions:
178
+ consumer_group = str(sub.consumer_group)
179
+ if sub.topic == "*":
180
+ wildcard.setdefault(consumer_group, []).append(sub)
181
+ elif sub.topic.endswith("*"):
182
+ prefix.setdefault(consumer_group, []).append(sub)
183
+ else:
184
+ exact.setdefault((consumer_group, sub.topic), []).append(sub)
185
+
186
+ return _RegistrySnapshot(
187
+ subscriptions=live_subscriptions,
188
+ wildcard_by_consumer=MappingProxyType({
189
+ key: tuple(value) for key, value in wildcard.items()
190
+ }),
191
+ exact_by_consumer_topic=MappingProxyType({
192
+ key: tuple(value) for key, value in exact.items()
193
+ }),
194
+ prefix_by_consumer=MappingProxyType({key: tuple(value) for key, value in prefix.items()}),
195
+ dispatchers=live_dispatchers,
196
+ )
197
+
198
+
199
+ def _publish_registry_snapshot(snapshot: _RegistrySnapshot) -> None:
200
+ global _registry_snapshot # noqa: PLW0603
201
+ _registry_snapshot = snapshot
202
+
203
+
204
+ def _prune_registry_snapshot_locked() -> _RegistrySnapshot:
205
+ snapshot = _build_registry_snapshot(
206
+ _registry_snapshot.subscriptions,
207
+ _registry_snapshot.dispatchers,
208
+ )
209
+ _publish_registry_snapshot(snapshot)
210
+ return snapshot
211
+
212
+
213
+ def register_embedded_dispatcher(dispatcher: EmbeddedDispatcher) -> None:
214
+ with _subscriptions_lock:
215
+ current = _prune_registry_snapshot_locked()
216
+ if any(ref() is dispatcher for ref in current.dispatchers):
217
+ return
218
+ updated = _build_registry_snapshot(
219
+ current.subscriptions,
220
+ (*current.dispatchers, weakref.ref(dispatcher)),
221
+ )
222
+ _publish_registry_snapshot(updated)
223
+ subscriptions = updated.subscriptions
224
+ for subscription in subscriptions:
225
+ dispatcher.register_subscription(
226
+ topic=subscription.topic,
227
+ consumer_group=str(subscription.consumer_group),
228
+ retry_after_seconds=subscription.retry_after_seconds,
229
+ initial_delay_seconds=subscription.initial_delay_seconds,
230
+ max_concurrency=subscription.max_concurrency,
231
+ max_attempts=subscription.max_attempts,
232
+ )
233
+
234
+
235
+ def unregister_embedded_dispatcher(dispatcher: EmbeddedDispatcher) -> None:
236
+ with _subscriptions_lock:
237
+ live: list[weakref.ReferenceType[EmbeddedDispatcher]] = []
238
+ for ref in _registry_snapshot.dispatchers:
239
+ live_dispatcher = ref()
240
+ if live_dispatcher is None or live_dispatcher is dispatcher:
241
+ continue
242
+ live.append(ref)
243
+ _publish_registry_snapshot(_build_registry_snapshot(_registry_snapshot.subscriptions, live))
244
+
245
+
246
+ def _clear_embedded_dispatchers() -> None:
247
+ with _subscriptions_lock:
248
+ _publish_registry_snapshot(_build_registry_snapshot(_registry_snapshot.subscriptions, ()))
249
+
250
+
251
+ def clear_subscriptions_for_tests() -> None:
252
+ with _subscriptions_lock:
253
+ _publish_registry_snapshot(_EMPTY_SNAPSHOT)
254
+
255
+
256
+ def _notify_embedded_dispatchers(subscription: _Subscription) -> None:
257
+ dispatchers: list[EmbeddedDispatcher] = []
258
+ with _subscriptions_lock:
259
+ live: list[weakref.ReferenceType[EmbeddedDispatcher]] = []
260
+ for ref in _registry_snapshot.dispatchers:
261
+ dispatcher = ref()
262
+ if dispatcher is None:
263
+ continue
264
+ live.append(ref)
265
+ dispatchers.append(dispatcher)
266
+ if len(live) != len(_registry_snapshot.dispatchers):
267
+ _publish_registry_snapshot(
268
+ _build_registry_snapshot(_registry_snapshot.subscriptions, live)
269
+ )
270
+ for dispatcher in dispatchers:
271
+ dispatcher.register_subscription(
272
+ topic=subscription.topic,
273
+ consumer_group=str(subscription.consumer_group),
274
+ retry_after_seconds=subscription.retry_after_seconds,
275
+ initial_delay_seconds=subscription.initial_delay_seconds,
276
+ max_concurrency=subscription.max_concurrency,
277
+ max_attempts=subscription.max_attempts,
278
+ )
279
+
280
+
281
+ @dataclass(frozen=True, kw_only=True)
282
+ class Subscription:
283
+ """Deployment trigger metadata for a registered queue subscriber."""
284
+
285
+ func: Callable[..., Any]
286
+ topic: str
287
+ consumer_group: str
288
+ retry_after_seconds: int | None = None
289
+ initial_delay_seconds: int | None = None
290
+ max_concurrency: int | None = None
291
+ max_attempts: int | None = None
292
+
293
+
294
+ def _subscriber_ref(func: _Subscriber) -> _SubscriberRef:
295
+ try:
296
+ return weakref.WeakMethod(func) # type: ignore[arg-type]
297
+ except TypeError:
298
+ return weakref.ref(func)
299
+
300
+
301
+ def _is_message_annotation(annotation: Any) -> bool:
302
+ annotation = strip_annotated(annotation)
303
+ return annotation is Message or origin_is(annotation, Message)
304
+
305
+
306
+ def _message_payload_annotation(annotation: Any) -> Any:
307
+ annotation = strip_annotated(annotation)
308
+ if annotation is Message:
309
+ return inspect.Signature.empty
310
+ message_args = args(annotation)
311
+ if not message_args:
312
+ return inspect.Signature.empty
313
+ return message_args[0]
314
+
315
+
316
+ def _topic_payload_annotation(topic: str | SanitizedName | Topic[Any]) -> Any:
317
+ if not isinstance(topic, Topic):
318
+ return inspect.Signature.empty
319
+ if getattr(type(topic), "__topic_origin__", None) is not Topic:
320
+ return inspect.Signature.empty
321
+ return type(topic).__topic_payload_type__
322
+
323
+
324
+ def _normalize_subscription_topic(topic: str | SanitizedName | Topic[Any]) -> str:
325
+ if isinstance(topic, SanitizedName):
326
+ return str(topic)
327
+ if isinstance(topic, str):
328
+ validate_subscription_pattern(topic)
329
+ return topic
330
+ if isinstance(topic, Topic):
331
+ return validate_topic_name(topic)
332
+ raise TypeError("topic must be a string or Topic")
333
+
334
+
335
+ def _resolve_invocation_payload_annotation(
336
+ handler_annotation: Any,
337
+ topic_annotation: Any,
338
+ ) -> Any:
339
+ handler_annotation = strip_annotated(handler_annotation)
340
+ topic_annotation = strip_annotated(topic_annotation)
341
+ if is_untyped_payload_annotation(topic_annotation):
342
+ return handler_annotation
343
+ if is_untyped_payload_annotation(handler_annotation):
344
+ return topic_annotation
345
+ if handler_annotation != topic_annotation:
346
+ raise SubscriptionError(
347
+ "queue subscriber payload annotation "
348
+ f"{handler_annotation!r} is incompatible with topic payload "
349
+ f"annotation {topic_annotation!r}"
350
+ )
351
+ return handler_annotation
352
+
353
+
354
+ def _payload_adapter(
355
+ annotation: Any,
356
+ *,
357
+ localns: dict[str, Any] | None = None,
358
+ ) -> PayloadAdapter | None:
359
+ annotation = strip_annotated(annotation)
360
+ if is_untyped_payload_annotation(annotation):
361
+ return None
362
+ reject_invalid_payload_annotation(annotation)
363
+ if _transport_kind(annotation) != "json":
364
+ return None
365
+ try:
366
+ type_adapter = import_module("pydantic").TypeAdapter
367
+ except ImportError as exc:
368
+ raise RuntimeError(
369
+ "Typed queue subscribers require pydantic. Install `vercel-queue[typed]` "
370
+ "or remove the payload type annotation."
371
+ ) from exc
372
+ adapter = type_adapter(annotation)
373
+ if localns and not adapter.pydantic_complete:
374
+ adapter.rebuild(_types_namespace=localns, raise_errors=False)
375
+ if not adapter.pydantic_complete:
376
+ raise SubscriptionError(
377
+ f"could not resolve queue subscriber payload model annotations for {annotation!r}"
378
+ )
379
+ return adapter
380
+
381
+
382
+ def _transport_kind(annotation: Any) -> TransportKind:
383
+ return payload_transport_kind(annotation)
384
+
385
+
386
+ def _resolve_payload_annotation(
387
+ func: _Subscriber,
388
+ payload_param: inspect.Parameter,
389
+ ) -> ResolvedAnnotation:
390
+ annotation = payload_param.annotation
391
+ if annotation in {inspect.Signature.empty, Any}:
392
+ return ResolvedAnnotation(annotation)
393
+ try:
394
+ return resolve_annotation_with_namespace_from_call_stack(
395
+ annotation,
396
+ globalns=getattr(func, "__globals__", {}),
397
+ )
398
+ except TypeAnnotationResolutionError as exc:
399
+ raise SubscriptionError(
400
+ f"could not resolve queue subscriber type annotations for "
401
+ f"{getattr(func, '__qualname__', func)!r}"
402
+ ) from exc.__cause__
403
+
404
+
405
+ def _build_invocation_plan(
406
+ func: _Subscriber,
407
+ *,
408
+ topic_payload_annotation: Any = inspect.Signature.empty,
409
+ ) -> InvocationPlan:
410
+ signature = inspect.signature(func)
411
+ input_params: list[inspect.Parameter] = []
412
+ for param in signature.parameters.values():
413
+ if param.kind in {
414
+ inspect.Parameter.VAR_POSITIONAL,
415
+ inspect.Parameter.VAR_KEYWORD,
416
+ }:
417
+ raise SubscriptionError("queue subscriber must not accept *args or **kwargs")
418
+ if param.kind is inspect.Parameter.KEYWORD_ONLY:
419
+ if param.default is inspect.Parameter.empty:
420
+ raise SubscriptionError(
421
+ "queue subscriber keyword-only parameters must have defaults"
422
+ )
423
+ continue
424
+ if param.default is inspect.Parameter.empty:
425
+ input_params.append(param)
426
+
427
+ if len(input_params) != 1:
428
+ raise SubscriptionError(
429
+ "queue subscriber must accept exactly one required payload parameter"
430
+ )
431
+
432
+ payload_param = input_params[0]
433
+ resolved_annotation = _resolve_payload_annotation(func, payload_param)
434
+ annotation = resolved_annotation.annotation
435
+ mode: InvocationMode = "message" if _is_message_annotation(annotation) else "payload"
436
+ payload_annotation = (
437
+ _message_payload_annotation(annotation) if mode == "message" else annotation
438
+ )
439
+ payload_annotation = _resolve_invocation_payload_annotation(
440
+ payload_annotation,
441
+ topic_payload_annotation,
442
+ )
443
+ reject_invalid_payload_annotation(payload_annotation)
444
+ return InvocationPlan(
445
+ payload_adapter=_payload_adapter(
446
+ payload_annotation,
447
+ localns=resolved_annotation.localns,
448
+ ),
449
+ mode=mode,
450
+ transport_kind=_transport_kind(payload_annotation),
451
+ )
452
+
453
+
454
+ def _call_subscription(
455
+ matched: _MatchedSubscription,
456
+ message: Any,
457
+ metadata: MessageMetadata,
458
+ ) -> Any:
459
+ invocation = matched.subscription.invocation
460
+
461
+ payload = invocation.prepare_payload(message)
462
+ if debug_enabled():
463
+ debug_log_for_msg(
464
+ "message.handler_start",
465
+ metadata,
466
+ handler=_handler_name(matched.func),
467
+ )
468
+ if invocation.mode == "message":
469
+ return matched.func(Message(payload=payload, metadata=metadata))
470
+ return matched.func(payload)
471
+
472
+
473
+ def _handler_name(func: _Subscriber) -> str:
474
+ module = getattr(func, "__module__", type(func).__module__)
475
+ qualname = getattr(func, "__qualname__", type(func).__qualname__)
476
+ return f"{module}.{qualname}"
477
+
478
+
479
+ def _log_handler_exception(exc: BaseException, metadata: MessageMetadata) -> None:
480
+ _LOGGER.error(
481
+ "queue subscriber failed while polling topic %r for consumer group %r",
482
+ metadata.topic,
483
+ metadata.consumer_group,
484
+ exc_info=(type(exc), exc, exc.__traceback__),
485
+ )
486
+
487
+
488
+ def _subscription_for_func(func: _Subscriber) -> _MatchedSubscription:
489
+ with _subscriptions_lock:
490
+ snapshot = _prune_registry_snapshot_locked()
491
+ matches: list[_MatchedSubscription] = []
492
+ for sub in snapshot.subscriptions:
493
+ candidate = sub.func()
494
+ if candidate is func:
495
+ matches.append(_MatchedSubscription(subscription=sub, func=candidate))
496
+ if not matches:
497
+ raise SubscriptionError("queue subscriber is not registered")
498
+ if len(matches) > 1:
499
+ raise SubscriptionError("queue subscriber has multiple registrations")
500
+ return matches[0]
501
+
502
+
503
+ def _subscription_matches_topic(subscription: _Subscription, topic: str) -> bool:
504
+ pattern = subscription.topic
505
+ if pattern == "*":
506
+ return True
507
+ if pattern.endswith("*"):
508
+ return topic.startswith(pattern[:-1])
509
+ return topic == pattern
510
+
511
+
512
+ async def call_subscriber(
513
+ subscriber: _Subscriber,
514
+ message: Message[Any],
515
+ ) -> None:
516
+ matched = _subscription_for_func(subscriber)
517
+ if not _subscription_matches_topic(matched.subscription, message.metadata.topic):
518
+ raise UnhandledMessageError(message.metadata.topic)
519
+ try:
520
+ await _maybe_await_result(_call_subscription(matched, message.payload, message.metadata))
521
+ except QueueDirective:
522
+ raise
523
+ except Exception as exc: # noqa: BLE001
524
+ _log_handler_exception(exc, message.metadata)
525
+ raise RetryAfter from None
526
+
527
+
528
+ def call_subscriber_sync(
529
+ subscriber: _Subscriber,
530
+ message: Message[Any],
531
+ ) -> None:
532
+ matched = _subscription_for_func(subscriber)
533
+ if not _subscription_matches_topic(matched.subscription, message.metadata.topic):
534
+ raise UnhandledMessageError(message.metadata.topic)
535
+ try:
536
+ _call_subscription(matched, message.payload, message.metadata)
537
+ except QueueDirective:
538
+ raise
539
+ except Exception as exc: # noqa: BLE001
540
+ _log_handler_exception(exc, message.metadata)
541
+ raise RetryAfter from None
542
+
543
+
544
+ def reject_async_subscriber_for_sync(subscriber: _Subscriber) -> None:
545
+ matched = _subscription_for_func(subscriber)
546
+ if inspect.iscoroutinefunction(matched.func):
547
+ raise RuntimeError("async subscribers must be polled with an async polling loop")
548
+
549
+
550
+ def poll_targets_for_subscriber(
551
+ subscriber: _Subscriber,
552
+ topics: StrContainer | None,
553
+ ) -> tuple[tuple[str, SanitizedName], ...]:
554
+ matched = _subscription_for_func(subscriber)
555
+ subscription = matched.subscription
556
+ if topics is None:
557
+ if subscription.topic == "*" or subscription.topic.endswith("*"):
558
+ raise SubscriptionError(
559
+ "queue subscriber uses a wildcard topic pattern; pass concrete topics"
560
+ )
561
+ return ((subscription.topic, subscription.consumer_group),)
562
+
563
+ if isinstance(topics, str):
564
+ raise TypeError("topics must be an iterable of topic strings, not a string")
565
+
566
+ targets: list[tuple[str, SanitizedName]] = []
567
+ seen: set[str] = set()
568
+ for topic in topics:
569
+ if not isinstance(topic, str):
570
+ raise TypeError("topics must contain only strings")
571
+ resolved_topic = validate_topic_name(topic)
572
+ if resolved_topic in seen:
573
+ continue
574
+ seen.add(resolved_topic)
575
+ if not _subscription_matches_topic(subscription, resolved_topic):
576
+ raise SubscriptionError(
577
+ f"topic {resolved_topic!r} does not match subscriber topic pattern "
578
+ f"{subscription.topic!r}"
579
+ )
580
+ targets.append((resolved_topic, subscription.consumer_group))
581
+ if not targets:
582
+ raise SubscriptionError("topics must contain at least one topic")
583
+ return tuple(targets)
584
+
585
+
586
+ def _transport_for_kind(kind: TransportKind) -> Transport[Any]:
587
+ return transport_for_kind(kind)
588
+
589
+
590
+ def infer_subscriber_transport(metadata: MessageMetadata) -> Transport[Any]:
591
+ matching = _matching_subscriptions(metadata)
592
+ if not matching:
593
+ raise _no_matching_subscriptions_error(metadata.topic)
594
+
595
+ kinds = {matched.subscription.invocation.transport_kind for matched in matching}
596
+ if len(kinds) != 1:
597
+ raise SubscriptionError(
598
+ "matching queue subscribers require incompatible payload transports: "
599
+ + ", ".join(sorted(kinds))
600
+ )
601
+ return _transport_for_kind(kinds.pop())
602
+
603
+
604
+ async def _maybe_await_result(result: Any) -> Any:
605
+ if inspect.isawaitable(result):
606
+ return await result
607
+ return result
608
+
609
+
610
+ def _default_consumer_group(func: _Subscriber) -> SanitizedName:
611
+ module = getattr(func, "__module__", None)
612
+ qualname = getattr(func, "__qualname__", getattr(func, "__name__", "subscriber"))
613
+ if module:
614
+ return sanitize_name(f"{module}.{qualname}")
615
+ return sanitize_name(str(qualname))
616
+
617
+
618
+ def _fully_qualified_handler_name(func: _Subscriber) -> str:
619
+ module = getattr(func, "__module__", None)
620
+ qualname = getattr(func, "__qualname__", getattr(func, "__name__", repr(func)))
621
+ if module:
622
+ return f"{module}.{qualname}"
623
+ return str(qualname)
624
+
625
+
626
+ def _optional_non_negative_int(name: str, value: int | None) -> int | None:
627
+ if value is None:
628
+ return None
629
+ if not isinstance(value, int) or isinstance(value, bool):
630
+ raise TypeError(f"{name} must be an integer")
631
+ if value < 0:
632
+ raise ValueError(f"{name} must be non-negative")
633
+ return value
634
+
635
+
636
+ def _optional_non_negative_duration(name: str, value: Duration | None) -> int | None:
637
+ if value is None:
638
+ return None
639
+ seconds = duration_to_seconds(value)
640
+ if seconds < 0:
641
+ raise ValueError(f"{name} must be non-negative")
642
+ return seconds
643
+
644
+
645
+ def _optional_bounded_duration(
646
+ name: str,
647
+ value: Duration | None,
648
+ *,
649
+ minimum: int,
650
+ maximum: int,
651
+ ) -> int | None:
652
+ seconds = _optional_non_negative_duration(name, value)
653
+ if seconds is None:
654
+ return None
655
+ if seconds < minimum or seconds > maximum:
656
+ raise ValueError(f"{name} must be between {minimum} and {maximum} seconds")
657
+ return seconds
658
+
659
+
660
+ def _topic_prefix(pattern: str) -> str | None:
661
+ if pattern == "*":
662
+ return ""
663
+ if pattern.endswith("*"):
664
+ return pattern[:-1]
665
+ return None
666
+
667
+
668
+ def _subscription_patterns_overlap(first: str, second: str) -> bool:
669
+ first_prefix = _topic_prefix(first)
670
+ second_prefix = _topic_prefix(second)
671
+ if first_prefix is None and second_prefix is None:
672
+ return first == second
673
+ if first_prefix is None:
674
+ return first.startswith(cast("str", second_prefix))
675
+ if second_prefix is None:
676
+ return second.startswith(first_prefix)
677
+ return first_prefix.startswith(second_prefix) or second_prefix.startswith(first_prefix)
678
+
679
+
680
+ def _validate_subscription_is_unambiguous(
681
+ subscription: _Subscription,
682
+ existing_subscriptions: Iterable[_Subscription],
683
+ ) -> None:
684
+ for existing in existing_subscriptions:
685
+ existing_func = existing.func()
686
+ if existing_func is None:
687
+ continue
688
+ if existing.consumer_group != subscription.consumer_group:
689
+ continue
690
+ if not _subscription_patterns_overlap(subscription.topic, existing.topic):
691
+ continue
692
+ raise DuplicateSubscriptionError(
693
+ "queue subscriber topic pattern "
694
+ f"{subscription.topic!r} overlaps existing topic pattern "
695
+ f"{existing.topic!r} for consumer group {subscription.consumer_group!r}; "
696
+ f"conflicting handler: {_fully_qualified_handler_name(existing_func)}"
697
+ )
698
+
699
+
700
+ def _register_subscription(
701
+ func: Callable[P, R],
702
+ *,
703
+ consumer_group: str | SanitizedName | None = None,
704
+ topic: str | SanitizedName | Topic[Any],
705
+ retry_after: Duration | None = None,
706
+ initial_delay: Duration | None = None,
707
+ max_concurrency: int | None = None,
708
+ max_attempts: int | None = None,
709
+ ) -> QueueSubscriber[P, R]:
710
+ topic_name = _normalize_subscription_topic(topic)
711
+ topic_payload_annotation = _topic_payload_annotation(topic)
712
+
713
+ resolved_consumer_group = (
714
+ _default_consumer_group(func)
715
+ if consumer_group is None
716
+ else normalize_name(
717
+ consumer_group,
718
+ field="consumer_group",
719
+ )
720
+ )
721
+ subscription = _Subscription(
722
+ func_ref=_subscriber_ref(cast("_Subscriber", func)),
723
+ order=next(_subscription_order),
724
+ consumer_group=resolved_consumer_group,
725
+ invocation=_build_invocation_plan(
726
+ cast("_Subscriber", func),
727
+ topic_payload_annotation=topic_payload_annotation,
728
+ ),
729
+ topic=topic_name,
730
+ retry_after_seconds=_optional_bounded_duration(
731
+ "retry_after",
732
+ retry_after,
733
+ minimum=1,
734
+ maximum=86400,
735
+ ),
736
+ initial_delay_seconds=_optional_bounded_duration(
737
+ "initial_delay",
738
+ initial_delay,
739
+ minimum=0,
740
+ maximum=86400,
741
+ ),
742
+ max_concurrency=_optional_non_negative_int("max_concurrency", max_concurrency),
743
+ max_attempts=_optional_non_negative_int("max_attempts", max_attempts),
744
+ )
745
+ with _subscriptions_lock:
746
+ current = _prune_registry_snapshot_locked()
747
+ _validate_subscription_is_unambiguous(subscription, current.subscriptions)
748
+ updated = _build_registry_snapshot(
749
+ (*current.subscriptions, subscription),
750
+ current.dispatchers,
751
+ )
752
+ _publish_registry_snapshot(updated)
753
+ _notify_embedded_dispatchers(subscription)
754
+ return cast("QueueSubscriber[P, R]", func)
755
+
756
+
757
+ @overload
758
+ def subscribe(func: Callable[P, R], /, *, topic: str) -> QueueSubscriber[P, R]: ...
759
+
760
+
761
+ @overload
762
+ def subscribe(
763
+ func: Callable[P, R],
764
+ /,
765
+ *,
766
+ topic: SanitizedName,
767
+ ) -> QueueSubscriber[P, R]: ...
768
+
769
+
770
+ @overload
771
+ def subscribe(func: Callable[[T], R], /, *, topic: Topic[T]) -> QueueSubscriber[[T], R]: ...
772
+
773
+
774
+ @overload
775
+ def subscribe(
776
+ func: Callable[[Message[T]], R],
777
+ /,
778
+ *,
779
+ topic: Topic[T],
780
+ ) -> QueueSubscriber[[Message[T]], R]: ...
781
+
782
+
783
+ @overload
784
+ def subscribe(
785
+ *,
786
+ topic: str,
787
+ consumer_group: str | SanitizedName | None = None,
788
+ retry_after: Duration | None = None,
789
+ initial_delay: Duration | None = None,
790
+ max_concurrency: int | None = None,
791
+ max_attempts: int | None = None,
792
+ ) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
793
+
794
+
795
+ @overload
796
+ def subscribe(
797
+ *,
798
+ topic: SanitizedName,
799
+ consumer_group: str | SanitizedName | None = None,
800
+ retry_after: Duration | None = None,
801
+ initial_delay: Duration | None = None,
802
+ max_concurrency: int | None = None,
803
+ max_attempts: int | None = None,
804
+ ) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
805
+
806
+
807
+ @overload
808
+ def subscribe(
809
+ *,
810
+ topic: Topic[T],
811
+ consumer_group: str | SanitizedName | None = None,
812
+ retry_after: Duration | None = None,
813
+ initial_delay: Duration | None = None,
814
+ max_concurrency: int | None = None,
815
+ max_attempts: int | None = None,
816
+ ) -> _TypedTopicSubscriberDecorator[T]: ...
817
+
818
+
819
+ @overload
820
+ def subscribe(
821
+ func: None,
822
+ /,
823
+ *,
824
+ topic: str,
825
+ consumer_group: str | SanitizedName | None = None,
826
+ retry_after: Duration | None = None,
827
+ initial_delay: Duration | None = None,
828
+ max_concurrency: int | None = None,
829
+ max_attempts: int | None = None,
830
+ ) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
831
+
832
+
833
+ @overload
834
+ def subscribe(
835
+ func: None,
836
+ /,
837
+ *,
838
+ topic: SanitizedName,
839
+ consumer_group: str | SanitizedName | None = None,
840
+ retry_after: Duration | None = None,
841
+ initial_delay: Duration | None = None,
842
+ max_concurrency: int | None = None,
843
+ max_attempts: int | None = None,
844
+ ) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
845
+
846
+
847
+ @overload
848
+ def subscribe(
849
+ func: None,
850
+ /,
851
+ *,
852
+ topic: Topic[T],
853
+ consumer_group: str | SanitizedName | None = None,
854
+ retry_after: Duration | None = None,
855
+ initial_delay: Duration | None = None,
856
+ max_concurrency: int | None = None,
857
+ max_attempts: int | None = None,
858
+ ) -> _TypedTopicSubscriberDecorator[T]: ...
859
+
860
+
861
+ def subscribe(
862
+ func: _Subscriber | None = None,
863
+ /,
864
+ *,
865
+ topic: str | SanitizedName | Topic[Any],
866
+ consumer_group: str | SanitizedName | None = None,
867
+ retry_after: Duration | None = None,
868
+ initial_delay: Duration | None = None,
869
+ max_concurrency: int | None = None,
870
+ max_attempts: int | None = None,
871
+ ) -> _Subscriber | Callable[[_Subscriber], _Subscriber]:
872
+ """Register a function as a queue subscriber.
873
+
874
+ Args:
875
+ func: Function being decorated when ``@subscribe(topic=...)`` is used.
876
+ topic: Required topic filter for this subscriber. A string matches exactly,
877
+ a string ending in ``*`` matches by prefix, and ``"*"`` matches every
878
+ topic.
879
+ consumer_group: Local/in-process consumer group override. When omitted,
880
+ the SDK derives one from the function's fully-qualified Python name.
881
+ retry_after: Optional base retry delay for generated queue trigger
882
+ configuration.
883
+ initial_delay: Optional deploy-time delay before generated queue
884
+ consumers start processing.
885
+ max_concurrency: Optional push dispatcher concurrency cap.
886
+ max_attempts: Optional push dispatcher delivery attempt cap.
887
+
888
+ Returns:
889
+ The original function, or a decorator when called with arguments.
890
+
891
+ Raises:
892
+ RuntimeError: If the subscriber has typed payload validation but
893
+ pydantic is not installed.
894
+ TypeError: If the subscriber signature cannot accept a payload.
895
+ ValueError: If ``consumer_group`` is an empty string.
896
+
897
+ """
898
+
899
+ def decorator(f: Callable[P, R]) -> Callable[P, R]:
900
+ return _register_subscription(
901
+ f,
902
+ consumer_group=consumer_group,
903
+ topic=topic,
904
+ retry_after=retry_after,
905
+ initial_delay=initial_delay,
906
+ max_concurrency=max_concurrency,
907
+ max_attempts=max_attempts,
908
+ )
909
+
910
+ return decorator(func) if func is not None else decorator
911
+
912
+
913
+ def get_subscriptions() -> tuple[Subscription, ...]:
914
+ """Return deployment trigger metadata for registered subscribers."""
915
+ subscriptions: list[Subscription] = []
916
+ with _subscriptions_lock:
917
+ snapshot = _prune_registry_snapshot_locked()
918
+ for sub in snapshot.subscriptions:
919
+ func = sub.func()
920
+ if func is not None:
921
+ subscriptions.append(
922
+ Subscription(
923
+ func=func,
924
+ topic=sub.topic,
925
+ consumer_group=str(sub.consumer_group),
926
+ retry_after_seconds=sub.retry_after_seconds,
927
+ initial_delay_seconds=sub.initial_delay_seconds,
928
+ max_concurrency=sub.max_concurrency,
929
+ max_attempts=sub.max_attempts,
930
+ )
931
+ )
932
+ return tuple(subscriptions)
933
+
934
+
935
+ def _no_matching_subscriptions_error(
936
+ topic: str | None,
937
+ consumer_group: str | None = None,
938
+ ) -> UnhandledMessageError:
939
+ return UnhandledMessageError(topic, consumer_group)
940
+
941
+
942
+ def _matching_prefix_subscriptions(
943
+ snapshot: _RegistrySnapshot,
944
+ *,
945
+ topic: str,
946
+ consumer_group: str,
947
+ ) -> tuple[_Subscription, ...]:
948
+ return tuple(
949
+ sub
950
+ for sub in snapshot.prefix_by_consumer.get(consumer_group, ())
951
+ if topic.startswith(sub.topic[:-1])
952
+ )
953
+
954
+
955
+ def _merge_candidates_in_order(
956
+ first: tuple[_Subscription, ...],
957
+ second: tuple[_Subscription, ...],
958
+ third: tuple[_Subscription, ...],
959
+ ) -> tuple[_Subscription, ...]:
960
+ if not second and not third:
961
+ return first
962
+ if not first and not third:
963
+ return second
964
+ if not first and not second:
965
+ return third
966
+ return tuple(sorted((*first, *second, *third), key=lambda sub: sub.order))
967
+
968
+
969
+ def _live_candidates(candidates: tuple[_Subscription, ...]) -> tuple[_MatchedSubscription, ...]:
970
+ live: list[_MatchedSubscription] = []
971
+ for sub in candidates:
972
+ func = sub.func()
973
+ if func is not None:
974
+ live.append(_MatchedSubscription(subscription=sub, func=func))
975
+ return tuple(live)
976
+
977
+
978
+ def _matching_subscriptions(
979
+ metadata: MessageMetadata,
980
+ ) -> tuple[_MatchedSubscription, ...]:
981
+ snapshot = _registry_snapshot
982
+ consumer_group = str(metadata.consumer_group)
983
+ topic = metadata.topic
984
+ matching = _live_candidates(
985
+ _merge_candidates_in_order(
986
+ snapshot.wildcard_by_consumer.get(consumer_group, ()),
987
+ snapshot.exact_by_consumer_topic.get((consumer_group, topic), ()),
988
+ _matching_prefix_subscriptions(
989
+ snapshot,
990
+ topic=topic,
991
+ consumer_group=consumer_group,
992
+ ),
993
+ )
994
+ )
995
+ if not matching:
996
+ raise _no_matching_subscriptions_error(metadata.topic, str(metadata.consumer_group))
997
+ return matching
998
+
999
+
1000
+ async def call_subscribers(
1001
+ message: Message[Any],
1002
+ ) -> None:
1003
+ sub = _matching_subscriptions(message.metadata)[0]
1004
+ await _maybe_await_result(_call_subscription(sub, message.payload, message.metadata))
1005
+
1006
+
1007
+ def call_subscribers_sync(
1008
+ message: Message[Any],
1009
+ ) -> None:
1010
+ sub = _matching_subscriptions(message.metadata)[0]
1011
+ result = _call_subscription(sub, message.payload, message.metadata)
1012
+ if inspect.isawaitable(result):
1013
+ close = getattr(result, "close", None)
1014
+ if callable(close):
1015
+ close()
1016
+ raise RuntimeError("async subscribers must be polled with an async polling loop")
1017
+
1018
+
1019
+ # Only add public symbols to __all__; internal helpers must stay unexported.
1020
+ __all__ = (
1021
+ "QueueSubscriber",
1022
+ "Subscription",
1023
+ "get_subscriptions",
1024
+ "subscribe",
1025
+ )