vercel-queue-bundle 0.7.3__tar.gz → 0.8.0__tar.gz

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 (54) hide show
  1. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/PKG-INFO +1 -1
  2. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/api_common.py +2 -0
  3. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/subscribers.py +72 -22
  4. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/transports.py +17 -4
  5. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/types.py +81 -20
  6. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/version.py +1 -1
  7. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/LICENSE +0 -0
  8. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/README.md +0 -0
  9. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/_vercel_hatch_build.py +0 -0
  10. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/hatch_build.py +0 -0
  11. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/pyproject.toml +0 -0
  12. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/__init__.py +0 -0
  13. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/__main__.py +0 -0
  14. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/__init__.py +0 -0
  15. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/api_async.py +0 -0
  16. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/api_sync.py +0 -0
  17. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/asgi.py +0 -0
  18. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/asynctools.py +0 -0
  19. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/cli.py +0 -0
  20. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/client.py +0 -0
  21. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/client_sync.py +0 -0
  22. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/config.py +0 -0
  23. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/constants.py +0 -0
  24. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/devserver.py +0 -0
  25. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/embedded.py +0 -0
  26. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/errors.py +0 -0
  27. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/http.py +0 -0
  28. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/lease.py +0 -0
  29. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/log.py +0 -0
  30. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/messages.py +0 -0
  31. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/multipart.py +0 -0
  32. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/names.py +0 -0
  33. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/polling.py +0 -0
  34. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/push.py +0 -0
  35. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/response.py +0 -0
  36. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/retry.py +0 -0
  37. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/streams.py +0 -0
  38. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/LICENSE.python-multipart.txt +0 -0
  39. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/__init__.py +0 -0
  40. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/__init__.py +0 -0
  41. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/decoders.py +0 -0
  42. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/exceptions.py +0 -0
  43. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/multipart.py +0 -0
  44. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/__init__.py +0 -0
  45. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/decoders.py +0 -0
  46. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/exceptions.py +0 -0
  47. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/multipart.py +0 -0
  48. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/devserver.py +0 -0
  49. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/embedded.py +0 -0
  50. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/py.typed +0 -0
  51. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/sync.py +0 -0
  52. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/testing/__init__.py +0 -0
  53. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/testing/pytest.py +0 -0
  54. {vercel_queue_bundle-0.7.3 → vercel_queue_bundle-0.8.0}/vercel/queue/testing/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vercel-queue-bundle
3
- Version: 0.7.3
3
+ Version: 0.8.0
4
4
  Summary: Vercel Queue client for Python
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -35,6 +35,7 @@ StrContainer = _types.StrContainer
35
35
  TextBufferTransport = _transports.TextBufferTransport
36
36
  TextStreamTransport = _transports.TextStreamTransport
37
37
  Topic = _types.Topic
38
+ TopicPattern = _types.TopicPattern
38
39
  TypedJsonTransport = _transports.TypedJsonTransport
39
40
  __version__ = _version.__version__
40
41
  asgi_app = _asgi.asgi_app
@@ -93,6 +94,7 @@ __all__ = (
93
94
  "ThrottledError",
94
95
  "TokenResolutionError",
95
96
  "Topic",
97
+ "TopicPattern",
96
98
  "TypedJsonTransport",
97
99
  "UnauthorizedError",
98
100
  "UnhandledMessageError",
@@ -40,9 +40,11 @@ from .transports import (
40
40
  is_untyped_payload_annotation,
41
41
  payload_transport_kind,
42
42
  reject_invalid_payload_annotation,
43
+ topic_payload_annotation,
43
44
  transport_for_kind,
44
45
  )
45
46
  from .types import (
47
+ BaseTopic,
46
48
  Duration,
47
49
  Message,
48
50
  MessageMetadata,
@@ -50,12 +52,15 @@ from .types import (
50
52
  RetryAfter,
51
53
  StrContainer,
52
54
  Topic,
55
+ TopicPattern,
53
56
  Transport,
54
57
  duration_to_seconds,
55
58
  )
56
59
 
57
60
  _Subscriber: TypeAlias = Callable[..., Any | Awaitable[Any]]
58
61
  _SubscriberRef: TypeAlias = weakref.ReferenceType[_Subscriber]
62
+ _SubscriptionTopic: TypeAlias = "str | SanitizedName | Topic[Any] | TopicPattern[Any]"
63
+ """Anything ``subscribe()`` accepts: one topic, or a pattern matching several."""
59
64
  P = ParamSpec("P")
60
65
  R = TypeVar("R")
61
66
  R_co = TypeVar("R_co", covariant=True)
@@ -66,6 +71,14 @@ class PayloadAdapter(Protocol):
66
71
  def validate_python(self, value: Any, /) -> Any: ...
67
72
 
68
73
 
74
+ class _TransportPayloadAdapter:
75
+ def __init__(self, validate_payload: Callable[[Any], Any]) -> None:
76
+ self._validate_payload = validate_payload
77
+
78
+ def validate_python(self, value: Any, /) -> Any:
79
+ return self._validate_payload(value)
80
+
81
+
69
82
  class EmbeddedDispatcher(Protocol):
70
83
  def register_subscription(
71
84
  self,
@@ -107,6 +120,7 @@ class InvocationPlan:
107
120
  payload_adapter: PayloadAdapter | None
108
121
  mode: InvocationMode
109
122
  transport_kind: TransportKind
123
+ transport: Transport[Any] | None = None
110
124
 
111
125
  def prepare_payload(self, payload: Any) -> Any:
112
126
  if self.payload_adapter is None:
@@ -120,6 +134,33 @@ class InvocationPlan:
120
134
  raise
121
135
 
122
136
 
137
+ class _TransportOrKind:
138
+ def __init__(self, plan: InvocationPlan) -> None:
139
+ self.transport_kind = plan.transport_kind
140
+ self.transport = plan.transport
141
+
142
+ def __hash__(self) -> int:
143
+ return hash(self.transport_kind) if self.transport is None else id(self.transport)
144
+
145
+ def __eq__(self, other: object) -> bool:
146
+ if not isinstance(other, _TransportOrKind):
147
+ return NotImplemented
148
+ if self.transport is None and other.transport is None:
149
+ return self.transport_kind == other.transport_kind
150
+ # Two instances of the same class can decode differently
151
+ return self.transport is other.transport
152
+
153
+ def __repr__(self) -> str:
154
+ if self.transport is None:
155
+ return self.transport_kind
156
+ return repr(self.transport)
157
+
158
+ def get_transport(self) -> Transport[Any]:
159
+ if self.transport is None:
160
+ return _transport_for_kind(self.transport_kind)
161
+ return self.transport
162
+
163
+
123
164
  @dataclass(frozen=True, kw_only=True)
124
165
  class _Subscription:
125
166
  func_ref: _SubscriberRef
@@ -314,23 +355,17 @@ def _message_payload_annotation(annotation: Any) -> Any:
314
355
  return message_args[0]
315
356
 
316
357
 
317
- def _topic_payload_annotation(topic: str | SanitizedName | Topic[Any]) -> Any:
318
- if not isinstance(topic, Topic):
319
- return inspect.Signature.empty
320
- if getattr(type(topic), "__topic_origin__", None) is not Topic:
321
- return inspect.Signature.empty
322
- return type(topic).__topic_payload_type__
323
-
324
-
325
- def _normalize_subscription_topic(topic: str | SanitizedName | Topic[Any]) -> str:
358
+ def _normalize_subscription_topic(topic: _SubscriptionTopic) -> str:
326
359
  if isinstance(topic, SanitizedName):
327
360
  return str(topic)
328
361
  if isinstance(topic, str):
329
362
  validate_subscription_pattern(topic)
330
363
  return topic
364
+ if isinstance(topic, TopicPattern):
365
+ return str(topic.name)
331
366
  if isinstance(topic, Topic):
332
367
  return validate_topic_name(topic)
333
- raise TypeError("topic must be a string or Topic")
368
+ raise TypeError("topic must be a string, Topic, or TopicPattern")
334
369
 
335
370
 
336
371
  def _resolve_invocation_payload_annotation(
@@ -356,7 +391,11 @@ def _payload_adapter(
356
391
  annotation: Any,
357
392
  *,
358
393
  localns: dict[str, Any] | None = None,
394
+ transport: Transport[Any] | None = None,
359
395
  ) -> PayloadAdapter | None:
396
+ validate_payload = getattr(transport, "validate_payload", None)
397
+ if callable(validate_payload):
398
+ return _TransportPayloadAdapter(validate_payload)
360
399
  annotation = strip_annotated(annotation)
361
400
  if is_untyped_payload_annotation(annotation):
362
401
  return None
@@ -407,6 +446,7 @@ def _build_invocation_plan(
407
446
  func: _Subscriber,
408
447
  *,
409
448
  topic_payload_annotation: Any = inspect.Signature.empty,
449
+ transport: Transport[Any] | None = None,
410
450
  ) -> InvocationPlan:
411
451
  signature = inspect.signature(func)
412
452
  input_params: list[inspect.Parameter] = []
@@ -446,9 +486,11 @@ def _build_invocation_plan(
446
486
  payload_adapter=_payload_adapter(
447
487
  payload_annotation,
448
488
  localns=resolved_annotation.localns,
489
+ transport=transport,
449
490
  ),
450
491
  mode=mode,
451
492
  transport_kind=_transport_kind(payload_annotation),
493
+ transport=transport,
452
494
  )
453
495
 
454
496
 
@@ -593,13 +635,13 @@ def infer_subscriber_transport(metadata: MessageMetadata) -> Transport[Any]:
593
635
  if not matching:
594
636
  raise _no_matching_subscriptions_error(metadata.topic)
595
637
 
596
- kinds = {matched.subscription.invocation.transport_kind for matched in matching}
597
- if len(kinds) != 1:
638
+ tks = {_TransportOrKind(matched.subscription.invocation) for matched in matching}
639
+ if len(tks) != 1:
598
640
  raise SubscriptionError(
599
641
  "matching queue subscribers require incompatible payload transports: "
600
- + ", ".join(sorted(kinds))
642
+ + ", ".join(sorted(map(repr, tks)))
601
643
  )
602
- return _transport_for_kind(kinds.pop())
644
+ return tks.pop().get_transport()
603
645
 
604
646
 
605
647
  async def _maybe_await_result(result: Any) -> Any:
@@ -702,14 +744,14 @@ def _register_subscription(
702
744
  func: Callable[P, R],
703
745
  *,
704
746
  consumer_group: str | SanitizedName | None = None,
705
- topic: str | SanitizedName | Topic[Any],
747
+ topic: _SubscriptionTopic,
706
748
  retry_after: Duration | None = None,
707
749
  initial_delay: Duration | None = None,
708
750
  max_concurrency: int | None = None,
709
751
  max_attempts: int | None = None,
710
752
  ) -> QueueSubscriber[P, R]:
711
753
  topic_name = _normalize_subscription_topic(topic)
712
- topic_payload_annotation = _topic_payload_annotation(topic)
754
+ payload_annotation = topic_payload_annotation(topic)
713
755
 
714
756
  resolved_consumer_group = (
715
757
  _default_consumer_group(func)
@@ -725,7 +767,10 @@ def _register_subscription(
725
767
  consumer_group=resolved_consumer_group,
726
768
  invocation=_build_invocation_plan(
727
769
  cast("_Subscriber", func),
728
- topic_payload_annotation=topic_payload_annotation,
770
+ topic_payload_annotation=payload_annotation,
771
+ # This is not receive_transport_for_topic() because we are doing
772
+ # more than that in _build_invocation_plan()
773
+ transport=topic.transport if isinstance(topic, BaseTopic) else None,
729
774
  ),
730
775
  topic=topic_name,
731
776
  retry_after_seconds=_optional_bounded_duration(
@@ -769,7 +814,12 @@ def subscribe(
769
814
 
770
815
 
771
816
  @overload
772
- def subscribe(func: Callable[[T], R], /, *, topic: Topic[T]) -> QueueSubscriber[[T], R]: ...
817
+ def subscribe(
818
+ func: Callable[[T], R],
819
+ /,
820
+ *,
821
+ topic: Topic[T] | TopicPattern[T],
822
+ ) -> QueueSubscriber[[T], R]: ...
773
823
 
774
824
 
775
825
  @overload
@@ -777,7 +827,7 @@ def subscribe(
777
827
  func: Callable[[Message[T]], R],
778
828
  /,
779
829
  *,
780
- topic: Topic[T],
830
+ topic: Topic[T] | TopicPattern[T],
781
831
  ) -> QueueSubscriber[[Message[T]], R]: ...
782
832
 
783
833
 
@@ -808,7 +858,7 @@ def subscribe(
808
858
  @overload
809
859
  def subscribe(
810
860
  *,
811
- topic: Topic[T],
861
+ topic: Topic[T] | TopicPattern[T],
812
862
  consumer_group: str | SanitizedName | None = None,
813
863
  retry_after: Duration | None = None,
814
864
  initial_delay: Duration | None = None,
@@ -850,7 +900,7 @@ def subscribe(
850
900
  func: None,
851
901
  /,
852
902
  *,
853
- topic: Topic[T],
903
+ topic: Topic[T] | TopicPattern[T],
854
904
  consumer_group: str | SanitizedName | None = None,
855
905
  retry_after: Duration | None = None,
856
906
  initial_delay: Duration | None = None,
@@ -863,7 +913,7 @@ def subscribe(
863
913
  func: _Subscriber | None = None,
864
914
  /,
865
915
  *,
866
- topic: str | SanitizedName | Topic[Any],
916
+ topic: _SubscriptionTopic,
867
917
  consumer_group: str | SanitizedName | None = None,
868
918
  retry_after: Duration | None = None,
869
919
  initial_delay: Duration | None = None,
@@ -27,8 +27,10 @@ from .streams import (
27
27
  SyncTextStreamPayload,
28
28
  )
29
29
  from .types import (
30
+ BaseTopic,
30
31
  RequestContent,
31
32
  Topic,
33
+ TopicPattern,
32
34
  Transport,
33
35
  )
34
36
 
@@ -304,14 +306,24 @@ def receive_transport_for_annotation(annotation: Any) -> Transport[Any]:
304
306
  return transport_for_kind(kind)
305
307
 
306
308
 
309
+ def topic_payload_annotation(topic: object) -> Any:
310
+ """Return the payload type ``topic`` was specialized with, if any.
311
+
312
+ A bare name, an unspecialized topic, and a user subclass all report "no
313
+ annotation": none of them carries a payload type we put there.
314
+ """
315
+ origin = getattr(type(topic), "__topic_origin__", None)
316
+ if origin is not Topic and origin is not TopicPattern:
317
+ return inspect.Signature.empty
318
+ return getattr(type(topic), "__topic_payload_type__", inspect.Signature.empty)
319
+
320
+
307
321
  def receive_transport_for_topic(topic: object) -> Transport[Any]:
308
- if not isinstance(topic, Topic):
322
+ if not isinstance(topic, BaseTopic):
309
323
  return RawJsonTransport[Any]()
310
324
  if topic.transport is not None:
311
325
  return topic.transport
312
- if getattr(type(topic), "__topic_origin__", None) is not Topic:
313
- return RawJsonTransport[Any]()
314
- return receive_transport_for_annotation(type(topic).__topic_payload_type__)
326
+ return receive_transport_for_annotation(topic_payload_annotation(topic))
315
327
 
316
328
 
317
329
  def send_transport_for_topic(topic: object) -> Transport[Any] | None:
@@ -360,5 +372,6 @@ __all__ = (
360
372
  "receive_transport_for_topic",
361
373
  "reject_invalid_payload_annotation",
362
374
  "send_transport_for_topic",
375
+ "topic_payload_annotation",
363
376
  "transport_for_kind",
364
377
  )
@@ -12,8 +12,15 @@ from collections.abc import (
12
12
  from dataclasses import dataclass
13
13
  from datetime import datetime, timedelta
14
14
 
15
+ from vercel._internal.core.polyfills import Self
16
+
15
17
  from .constants import DEFAULT_RETRY_AFTER_SECONDS
16
- from .names import SanitizedName, validate_name, validate_topic_name
18
+ from .names import (
19
+ SanitizedName,
20
+ validate_name,
21
+ validate_subscription_pattern,
22
+ validate_topic_name,
23
+ )
17
24
 
18
25
  T = TypeVar("T")
19
26
  _TYPE_VAR_TYPE = type(T)
@@ -62,47 +69,62 @@ def duration_to_float_seconds(duration: Duration) -> float:
62
69
 
63
70
 
64
71
  @dataclass(frozen=True, kw_only=True, eq=False)
65
- class Topic(Generic[T]):
66
- """A named Vercel Queues topic.
72
+ class BaseTopic(Generic[T]):
73
+ """Shared behaviour of :class:`Topic` and :class:`TopicPattern`.
67
74
 
68
- Topics identify the stream that messages are sent to and received from.
75
+ Use ``Topic`` to name one topic and ``TopicPattern`` to match several; the
76
+ two are separate types so that a pattern cannot reach an operation that
77
+ needs a single, concrete topic.
69
78
  """
70
79
 
71
- name: SanitizedName
72
- """Topic name to send to or receive from."""
80
+ name: SanitizedName | str
81
+ """The topic name, or the pattern that selects topic names.
82
+
83
+ A pattern is a plain ``str``: ``SanitizedName`` means "safe to put in a
84
+ request path", which a pattern is not.
85
+ """
73
86
 
74
87
  transport: Transport[Any] | None = None
75
- """Optional transport used when sending to or polling this topic."""
88
+ """Optional transport used when sending to, polling, or subscribing to this topic."""
76
89
 
77
- __topic_origin__: ClassVar[type[Topic[Any]] | None] = None
90
+ __topic_origin__: ClassVar[type[BaseTopic[Any]] | None] = None
78
91
  __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]]:
92
+ _specializations: ClassVar[dict[Any, type[BaseTopic[Any]]]] = {}
93
+
94
+ def __init_subclass__(cls, **kwargs: Any) -> None:
95
+ super().__init_subclass__(**kwargs)
96
+ # `Topic` and `TopicPattern` each need their own cache, or the first
97
+ # `Topic[bytes]` would be handed back for `TopicPattern[bytes]`. A
98
+ # specialization (the only thing carrying `__topic_origin__` in its
99
+ # own namespace) keeps sharing the cache of the class it came from.
100
+ if "__topic_origin__" not in cls.__dict__:
101
+ cls._specializations = {}
102
+
103
+ def __class_getitem__(cls, params: Any) -> type[Self]:
82
104
  if isinstance(params, tuple):
83
105
  if len(params) != 1:
84
- raise TypeError("Topic expects exactly one type argument")
106
+ raise TypeError(f"{cls.__name__} expects exactly one type argument")
85
107
  params = params[0]
86
108
  if isinstance(params, _TYPE_VAR_TYPE):
87
- return cast("type[Topic[Any]]", cls)
109
+ return cls
88
110
 
89
111
  try:
90
- return cls._specializations[params]
112
+ return cast("type[Self]", cls._specializations[params])
91
113
  except KeyError:
92
114
  pass
93
115
 
94
116
  payload_repr = _topic_payload_type_repr(params)
95
117
  specialization = type(
96
- f"Topic[{payload_repr}]",
118
+ f"{cls.__name__}[{payload_repr}]",
97
119
  (cls,),
98
120
  {
99
121
  "__module__": cls.__module__,
100
- "__topic_origin__": Topic,
122
+ "__topic_origin__": cls,
101
123
  "__topic_payload_type__": params,
102
124
  },
103
125
  )
104
126
  cls._specializations[params] = specialization
105
- return cast("type[Topic[Any]]", specialization)
127
+ return cast("type[Self]", specialization)
106
128
 
107
129
  def __init__(
108
130
  self,
@@ -110,14 +132,22 @@ class Topic(Generic[T]):
110
132
  *,
111
133
  transport: Transport[Any] | None = None,
112
134
  ) -> None:
113
- object.__setattr__(self, "name", SanitizedName(validate_topic_name(name)))
135
+ object.__setattr__(self, "name", self._validated_name(name))
114
136
  object.__setattr__(self, "transport", transport)
115
137
 
138
+ @staticmethod
139
+ def _validated_name(name: str | SanitizedName) -> SanitizedName | str:
140
+ raise NotImplementedError
141
+
116
142
  def __repr__(self) -> str:
117
- return f"Topic(name={self.name!r})"
143
+ # Report the class the user named, not the synthesized specialization.
144
+ cls = type(self)
145
+ return f"{(cls.__topic_origin__ or cls).__name__}(name={self.name!r})"
118
146
 
119
147
  def __eq__(self, other: object) -> bool:
120
- if not isinstance(other, Topic):
148
+ # A pattern always ends in `*`, which a topic name can never contain,
149
+ # so comparing names is enough to keep the two kinds apart.
150
+ if not isinstance(other, BaseTopic):
121
151
  return NotImplemented
122
152
  return self.name == other.name
123
153
 
@@ -125,6 +155,36 @@ class Topic(Generic[T]):
125
155
  return hash(self.name)
126
156
 
127
157
 
158
+ class Topic(BaseTopic[T]):
159
+ """A named Vercel Queues topic.
160
+
161
+ Topics identify the stream that messages are sent to and received from.
162
+ """
163
+
164
+ name: SanitizedName
165
+ """Topic name to send to, poll, or subscribe to."""
166
+
167
+ @staticmethod
168
+ def _validated_name(name: str | SanitizedName) -> SanitizedName | str:
169
+ return SanitizedName(validate_topic_name(name))
170
+
171
+
172
+ class TopicPattern(BaseTopic[T]):
173
+ """A pattern selecting every Vercel Queues topic it matches.
174
+
175
+ ``"*"`` matches every topic and a prefix ending in ``*`` matches by
176
+ prefix. A pattern can only be subscribed to: sending and polling name one
177
+ topic, so they take a :class:`Topic`.
178
+ """
179
+
180
+ name: str
181
+ """The pattern this subscription matches topics with."""
182
+
183
+ @staticmethod
184
+ def _validated_name(name: str | SanitizedName) -> SanitizedName | str:
185
+ return validate_subscription_pattern(str(name))
186
+
187
+
128
188
  def _topic_payload_type_repr(payload_type: object) -> str:
129
189
  name = getattr(payload_type, "__qualname__", None)
130
190
  if isinstance(name, str):
@@ -297,4 +357,5 @@ __all__: tuple[str, ...] = (
297
357
  "RetryAfter",
298
358
  "StrContainer",
299
359
  "Topic",
360
+ "TopicPattern",
300
361
  )
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "0.7.3"
3
+ __version__ = "0.8.0"