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,143 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, cast
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ from collections.abc import Mapping
10
+ from urllib.parse import unquote, urlsplit, urlunsplit
11
+
12
+ if TYPE_CHECKING:
13
+ from .types import Message, MessageMetadata
14
+
15
+ _LOGGER_NAME = "vercel.queue"
16
+ _REDACTED = "<redacted>"
17
+ _REDACTED_RECEIPT_HANDLE = "rh_1_REDACTED"
18
+ _DEBUG_ENV = "VERCEL_QUEUE_DEBUG"
19
+ _SENSITIVE_FIELD_NAMES = frozenset({
20
+ "authorization",
21
+ "body",
22
+ "content",
23
+ "payload",
24
+ "raw_body",
25
+ "receipt_handle",
26
+ "receipthandle",
27
+ "token",
28
+ })
29
+ _SENSITIVE_HEADER_NAMES = frozenset({
30
+ "authorization",
31
+ "cookie",
32
+ "set-cookie",
33
+ "x-vercel-oidc-token",
34
+ })
35
+ _BEARER_RE = re.compile(r"Bearer\s+[^\s,;]+")
36
+ _RECEIPT_RE = re.compile(r"rh_[^\s\"']+")
37
+
38
+
39
+ def debug_enabled() -> bool:
40
+ return os.environ.get(_DEBUG_ENV) in {"1", "true"}
41
+
42
+
43
+ def configure_asgi_logger() -> None:
44
+ logging.getLogger(_LOGGER_NAME).setLevel(logging.INFO if debug_enabled() else logging.WARNING)
45
+
46
+
47
+ def debug_log(event: str, **fields: Any) -> None:
48
+ if not debug_enabled():
49
+ return
50
+ payload = {"event": event, **_redact_fields(fields)}
51
+ logging.getLogger(_LOGGER_NAME).info(
52
+ json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
53
+ )
54
+
55
+
56
+ def _message_metadata(message: Message[Any] | MessageMetadata) -> MessageMetadata:
57
+ return cast("MessageMetadata", getattr(message, "metadata", message))
58
+
59
+
60
+ def message_debug_fields(message: Message[Any] | MessageMetadata) -> dict[str, object]:
61
+ metadata = _message_metadata(message)
62
+ fields: dict[str, object] = {
63
+ "message_id": metadata.message_id,
64
+ "topic": metadata.topic,
65
+ "consumer_group": str(metadata.consumer_group),
66
+ "delivery_count": metadata.delivery_count,
67
+ }
68
+ if metadata.region is not None:
69
+ fields["region"] = metadata.region
70
+ return fields
71
+
72
+
73
+ def debug_log_for_msg(
74
+ event: str,
75
+ message: Message[Any] | MessageMetadata,
76
+ **fields: Any,
77
+ ) -> None:
78
+ if not debug_enabled():
79
+ return
80
+ debug_log(event, **message_debug_fields(message), **fields)
81
+
82
+
83
+ def safe_header_names(headers: Mapping[Any, object]) -> list[str]:
84
+ names = [str(name) for name in headers]
85
+ return sorted(name for name in names if name.lower() not in _SENSITIVE_HEADER_NAMES)
86
+
87
+
88
+ def safe_url(url: str) -> str:
89
+ parts = urlsplit(url)
90
+ path_parts = parts.path.split("/")
91
+ redacted_parts: list[str] = []
92
+ redact_next = False
93
+ for part in path_parts:
94
+ if redact_next and part:
95
+ redacted_parts.append(_REDACTED_RECEIPT_HANDLE)
96
+ redact_next = False
97
+ continue
98
+ redacted_parts.append(part)
99
+ redact_next = unquote(part) == "lease"
100
+ return urlunsplit((parts.scheme, parts.netloc, "/".join(redacted_parts), "", ""))
101
+
102
+
103
+ def redact_text(value: str) -> str:
104
+ return _RECEIPT_RE.sub(
105
+ _REDACTED_RECEIPT_HANDLE,
106
+ _BEARER_RE.sub(f"Bearer {_REDACTED}", value),
107
+ )
108
+
109
+
110
+ def content_type(headers: Mapping[str, str]) -> str | None:
111
+ for name, value in headers.items():
112
+ if name.lower() == "content-type":
113
+ return value
114
+ return None
115
+
116
+
117
+ def redact_value(name: str, value: Any) -> Any:
118
+ lower_name = name.lower()
119
+ if lower_name in {"receipt_handle", "receipthandle"}:
120
+ return _REDACTED_RECEIPT_HANDLE
121
+ if lower_name in _SENSITIVE_FIELD_NAMES:
122
+ return _REDACTED
123
+ if isinstance(value, str):
124
+ return redact_text(value)
125
+ if isinstance(value, Mapping):
126
+ return {str(key): redact_value(str(key), item) for key, item in sorted(value.items())}
127
+ if isinstance(value, tuple | list | set | frozenset):
128
+ return [redact_value(name, item) for item in value]
129
+ return _jsonable(value)
130
+
131
+
132
+ def _redact_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
133
+ return {name: redact_value(name, value) for name, value in fields.items() if value is not None}
134
+
135
+
136
+ def _jsonable(value: Any) -> Any:
137
+ if value is None or isinstance(value, str | int | float | bool):
138
+ return value
139
+ if isinstance(value, Mapping):
140
+ return {str(key): _jsonable(item) for key, item in sorted(value.items())}
141
+ if isinstance(value, tuple | list | set | frozenset):
142
+ return [_jsonable(item) for item in value]
143
+ return str(value)
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypeVar, cast
4
+
5
+ from collections.abc import AsyncIterator
6
+ from dataclasses import replace
7
+ from datetime import datetime, timezone
8
+
9
+ from .constants import (
10
+ HEADER_CONTENT_TYPE,
11
+ VQS_HEADER_DELIVERY_COUNT,
12
+ VQS_HEADER_EXPIRES_AT,
13
+ VQS_HEADER_MESSAGE_ID,
14
+ VQS_HEADER_RECEIPT_HANDLE,
15
+ VQS_HEADER_TIMESTAMP,
16
+ )
17
+ from .errors import MessageCorruptedError
18
+ from .http import headers_from_raw
19
+ from .names import SanitizedName
20
+ from .streams import AsyncStreamPayload, AsyncTextStreamPayload
21
+ from .types import (
22
+ Message,
23
+ MessageMetadata,
24
+ RawHeaders,
25
+ ReceiptHandle,
26
+ Transport,
27
+ )
28
+
29
+ T = TypeVar("T")
30
+
31
+
32
+ def _ensure_sanitized_name(value: str | SanitizedName) -> SanitizedName:
33
+ if isinstance(value, SanitizedName):
34
+ return value
35
+ return SanitizedName(value)
36
+
37
+
38
+ async def message_from_part_async(
39
+ topic: str,
40
+ consumer_group: str | SanitizedName,
41
+ transport: Transport[T],
42
+ headers: RawHeaders,
43
+ payload: AsyncIterator[bytes],
44
+ ) -> Message[T]:
45
+ metadata = _message_metadata_from_part(topic, consumer_group, headers)
46
+ try:
47
+ decoded = await transport.deserialize(payload, content_type=metadata.content_type or "")
48
+ except Exception as exc:
49
+ raise MessageCorruptedError(
50
+ metadata.message_id,
51
+ f"Failed to parse payload: {exc}",
52
+ ) from exc
53
+ return Message(payload=decoded, metadata=metadata)
54
+
55
+
56
+ def _message_metadata_from_part(
57
+ topic: str,
58
+ consumer_group: str | SanitizedName,
59
+ headers: RawHeaders,
60
+ ) -> MessageMetadata:
61
+ headers = headers_from_raw(headers)
62
+ message_id = headers.get(VQS_HEADER_MESSAGE_ID)
63
+ receipt_handle = headers.get(VQS_HEADER_RECEIPT_HANDLE)
64
+ if not message_id:
65
+ raise MessageCorruptedError("<unknown>", "Missing Vqs-Message-Id multipart header")
66
+ if not receipt_handle:
67
+ raise MessageCorruptedError(message_id, "Missing Vqs-Receipt-Handle multipart header")
68
+ delivery_count_raw = headers.get(VQS_HEADER_DELIVERY_COUNT) or "0"
69
+ try:
70
+ delivery_count = int(delivery_count_raw)
71
+ except ValueError:
72
+ delivery_count = 0
73
+ return MessageMetadata(
74
+ message_id=message_id,
75
+ delivery_count=delivery_count,
76
+ created_at=parse_required_datetime(
77
+ headers.get(VQS_HEADER_TIMESTAMP),
78
+ VQS_HEADER_TIMESTAMP,
79
+ ),
80
+ expires_at=parse_optional_datetime(
81
+ headers.get(VQS_HEADER_EXPIRES_AT),
82
+ VQS_HEADER_EXPIRES_AT,
83
+ ),
84
+ topic=topic,
85
+ consumer_group=_ensure_sanitized_name(consumer_group),
86
+ receipt_handle=ReceiptHandle(receipt_handle),
87
+ content_type=headers.get(HEADER_CONTENT_TYPE, ""),
88
+ )
89
+
90
+
91
+ def message_with_region(message: Message[T], region: str | None) -> Message[T]:
92
+ if region is None:
93
+ return message
94
+ return Message(payload=message.payload, metadata=replace(message.metadata, region=region))
95
+
96
+
97
+ def sync_message_payload(message: Message[T]) -> Message[T]:
98
+ if isinstance(message.payload, AsyncStreamPayload):
99
+ return Message(payload=cast("T", message.payload.to_sync()), metadata=message.metadata)
100
+ if isinstance(message.payload, AsyncTextStreamPayload):
101
+ return Message(payload=cast("T", message.payload.to_sync()), metadata=message.metadata)
102
+ return message
103
+
104
+
105
+ def parse_required_datetime(value: str | None, header_name: str) -> datetime:
106
+ parsed = parse_optional_datetime(value, header_name)
107
+ if parsed is None:
108
+ raise ValueError(f"missing required {header_name} header")
109
+ return parsed
110
+
111
+
112
+ def parse_optional_datetime(value: str | None, header_name: str) -> datetime | None:
113
+ if not value:
114
+ return None
115
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
116
+ try:
117
+ parsed = datetime.fromisoformat(normalized)
118
+ except ValueError as exc:
119
+ raise ValueError(f"invalid {header_name} header: {value!r}") from exc
120
+ if parsed.tzinfo is None:
121
+ parsed = parsed.replace(tzinfo=timezone.utc)
122
+ return parsed.astimezone(timezone.utc)
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from typing_extensions import Self
5
+
6
+ from collections import deque
7
+ from collections.abc import AsyncIterator, Awaitable, Callable
8
+ from dataclasses import dataclass
9
+
10
+ from python_multipart.exceptions import MultipartParseError
11
+ from python_multipart.multipart import (
12
+ MultipartParser,
13
+ parse_options_header,
14
+ )
15
+
16
+ from .errors import ProtocolError
17
+ from .http import AsyncHttpResponse
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Mapping
21
+
22
+
23
+ DEFAULT_MAX_HEADER_BUFFER_SIZE = 64 * 1024
24
+ DEFAULT_MAX_BOUNDARY_BUFFER_SIZE = 8 * 1024
25
+ DEFAULT_MAX_HEADER_COUNT = 64
26
+ MULTIPART_MIXED_MEDIA_TYPE = b"multipart/mixed"
27
+
28
+
29
+ class _PartBody:
30
+ """Async iterator over a single multipart part body.
31
+
32
+ The parser feeds this object as bytes arrive. If the consumer asks for more
33
+ bytes before the current part has ended, ``PartBody`` pumps the parent
34
+ parser by one input chunk.
35
+ """
36
+
37
+ def __init__(self, pump: Callable[[], Awaitable[None]]) -> None:
38
+ self._pump = pump
39
+ self._chunks: deque[bytes] = deque()
40
+ self._done = False
41
+
42
+ def __aiter__(self) -> Self:
43
+ return self
44
+
45
+ async def __anext__(self) -> bytes:
46
+ while not self._chunks and not self._done:
47
+ await self._pump()
48
+ if self._chunks:
49
+ return self._chunks.popleft()
50
+ raise StopAsyncIteration
51
+
52
+ async def drain(self) -> None:
53
+ """Consume and discard unread bytes for this part."""
54
+ async for _ in self:
55
+ pass
56
+
57
+ def feed(self, chunk: bytes) -> None:
58
+ if chunk:
59
+ self._chunks.append(chunk)
60
+
61
+ def feed_slice(self, data: bytes, start: int, end: int) -> None:
62
+ if start == end:
63
+ return
64
+ if start == 0 and end == len(data):
65
+ self._chunks.append(data)
66
+ return
67
+ self._chunks.append(data[start:end])
68
+
69
+ def close(self) -> None:
70
+ self._done = True
71
+
72
+
73
+ @dataclass(frozen=True, kw_only=True)
74
+ class _Part:
75
+ """A streamed multipart part with parsed headers and body iterator."""
76
+
77
+ headers: dict[str, str]
78
+ body: _PartBody
79
+
80
+
81
+ class _MultipartParser:
82
+ """Incremental multipart/mixed parser.
83
+
84
+ python-multipart is callback-based and synchronous. This wrapper turns it
85
+ into an async iterator of parts while preserving streaming behavior: a part
86
+ is yielded as soon as its headers are complete, and its body continues to be
87
+ filled as the caller iterates it.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ data: AsyncIterator[bytes],
93
+ /,
94
+ *,
95
+ content_type: str,
96
+ ) -> None:
97
+ self._input = data
98
+ self._max_header_buffer_size = DEFAULT_MAX_HEADER_BUFFER_SIZE
99
+
100
+ self._parts: deque[_Part] = deque()
101
+
102
+ self._headers: dict[str, str] = {}
103
+ self._field = bytearray()
104
+ self._value = bytearray()
105
+ self._header_bytes = 0
106
+ self._body: _PartBody | None = None
107
+ self._yielded_body: _PartBody | None = None
108
+
109
+ self._ended = False
110
+ self._exhausted = False
111
+
112
+ self._parser = MultipartParser(
113
+ _multipart_boundary(content_type),
114
+ max_size=float("inf"),
115
+ max_header_count=DEFAULT_MAX_HEADER_COUNT,
116
+ max_header_size=self._max_header_buffer_size,
117
+ callbacks={
118
+ "on_header_begin": self._on_header_begin,
119
+ "on_header_field": self._on_header_field,
120
+ "on_header_value": self._on_header_value,
121
+ "on_header_end": self._on_header_end,
122
+ "on_headers_finished": self._on_headers_finished,
123
+ "on_part_data": self._on_part_data,
124
+ "on_part_end": self._on_part_end,
125
+ "on_end": self._on_end,
126
+ },
127
+ )
128
+
129
+ def __aiter__(self) -> Self:
130
+ return self
131
+
132
+ async def __anext__(self) -> tuple[Mapping[str, str], AsyncIterator[bytes]]:
133
+ if self._yielded_body is not None:
134
+ await self._yielded_body.drain()
135
+ self._yielded_body = None
136
+
137
+ while not self._parts:
138
+ if self._ended:
139
+ raise StopAsyncIteration
140
+ await self._advance()
141
+ part = self._parts.popleft()
142
+ self._yielded_body = part.body
143
+ return part.headers, part.body
144
+
145
+ async def _advance(self) -> None:
146
+ if self._exhausted:
147
+ if self._ended:
148
+ return
149
+ raise ProtocolError("unexpected end of multipart stream")
150
+ try:
151
+ chunk = await anext(self._input)
152
+ except StopAsyncIteration:
153
+ self._exhausted = True
154
+ if not self._ended:
155
+ raise ProtocolError("unexpected end of multipart stream") from None
156
+ return
157
+ try:
158
+ self._parser.write(chunk)
159
+ except ProtocolError:
160
+ raise
161
+ except MultipartParseError as exc:
162
+ if "Maximum segment count exceeded" in str(exc):
163
+ raise ProtocolError(
164
+ f"multipart header count exceeded {DEFAULT_MAX_HEADER_COUNT}"
165
+ ) from exc
166
+ if "Maximum header size exceeded" in str(exc):
167
+ raise ProtocolError(
168
+ f"multipart header buffer exceeded {self._max_header_buffer_size} bytes"
169
+ ) from exc
170
+ raise ProtocolError(f"invalid multipart stream: {exc}") from exc
171
+
172
+ def _on_header_begin(self) -> None:
173
+ self._field.clear()
174
+ self._value.clear()
175
+
176
+ def _on_header_field(self, data: bytes, start: int, end: int) -> None:
177
+ self._add_header_bytes(end - start)
178
+ self._field.extend(data[start:end])
179
+
180
+ def _on_header_value(self, data: bytes, start: int, end: int) -> None:
181
+ self._add_header_bytes(end - start)
182
+ self._value.extend(data[start:end])
183
+
184
+ def _on_header_end(self) -> None:
185
+ name = self._field.decode("latin1").strip()
186
+ if name:
187
+ self._headers[name] = self._value.decode("latin1").strip()
188
+
189
+ def _on_headers_finished(self) -> None:
190
+ self._body = _PartBody(self._advance)
191
+ part = _Part(headers=dict(self._headers), body=self._body)
192
+ self._headers.clear()
193
+ self._header_bytes = 0
194
+ self._parts.append(part)
195
+
196
+ def _on_part_data(self, data: bytes, start: int, end: int) -> None:
197
+ if self._body is None:
198
+ raise RuntimeError("MultipartParser.on_part_data called out of order")
199
+ self._body.feed_slice(data, start, end)
200
+
201
+ def _on_part_end(self) -> None:
202
+ if self._body is None:
203
+ raise RuntimeError("MultipartParser.on_part_end called out of order")
204
+ self._body.close()
205
+ self._body = None
206
+
207
+ def _on_end(self) -> None:
208
+ self._ended = True
209
+
210
+ def _add_header_bytes(self, size: int) -> None:
211
+ self._header_bytes += size
212
+ if self._header_bytes > self._max_header_buffer_size:
213
+ raise ProtocolError(
214
+ f"multipart header buffer exceeded {self._max_header_buffer_size} bytes"
215
+ )
216
+
217
+
218
+ def parse_multipart_messages(
219
+ response: AsyncHttpResponse,
220
+ ) -> AsyncIterator[tuple[Mapping[str, str], AsyncIterator[bytes]]]:
221
+ """Stream multipart parts from an HTTP response-like.
222
+
223
+ The response must have a ``multipart/mixed`` Content-Type with a boundary.
224
+ Each yielded part exposes its headers and an async byte iterator for the
225
+ body. If a caller advances to the next part without consuming the current
226
+ body, the parser drains the current body first.
227
+
228
+ Raises:
229
+ ProtocolError on malformed headers or other response deformity.
230
+
231
+ """
232
+ content_type = response.headers.get("Content-Type", "")
233
+ return _MultipartParser(
234
+ response.aiter_bytes(),
235
+ content_type=content_type,
236
+ )
237
+
238
+
239
+ def _multipart_boundary(content_type: str) -> bytes:
240
+ media_type, options = parse_options_header(content_type)
241
+ if media_type.lower() != MULTIPART_MIXED_MEDIA_TYPE:
242
+ raise ProtocolError(
243
+ f"expected a multipart/mixed response, got Content-Type={content_type!r}"
244
+ )
245
+ value = options.get(b"boundary")
246
+ if not value:
247
+ raise ProtocolError("response did not specify a valid multipart boundary")
248
+ value = value.strip()
249
+ if not value:
250
+ raise ProtocolError("response did not specify a valid multipart boundary")
251
+ if len(value) > DEFAULT_MAX_BOUNDARY_BUFFER_SIZE:
252
+ raise ProtocolError(
253
+ f"multipart boundary buffer exceeded {DEFAULT_MAX_BOUNDARY_BUFFER_SIZE} bytes"
254
+ )
255
+ return value
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import UserString
5
+
6
+ VQS_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
7
+
8
+
9
+ class SanitizedName(UserString):
10
+ """A VQS-safe name that should not be sanitized again."""
11
+
12
+ __slots__ = ()
13
+
14
+ def __init__(self, value: str) -> None:
15
+ if not value:
16
+ raise ValueError("name must be a non-empty string")
17
+ if not _is_valid_vqs_name(value):
18
+ raise ValueError(f"Invalid queue name: {value!r}; must match {VQS_NAME_PATTERN}")
19
+ super().__init__(value)
20
+
21
+
22
+ def _is_valid_vqs_name(name: str) -> bool:
23
+ return bool(VQS_NAME_PATTERN.fullmatch(name))
24
+
25
+
26
+ # Keep this in sync with @vercel/build-utils:lambda.ts/sanitizeConsumerName
27
+ # https://raw.githubusercontent.com/vercel/vercel/refs/heads/main/packages/build-utils/src/lambda.ts
28
+ def _sanitize_name(name: str) -> str:
29
+ result = ""
30
+ for char in name:
31
+ if char == "_":
32
+ result += "__"
33
+ elif char == ".":
34
+ result += "_D"
35
+ elif char == "/":
36
+ result += "_S"
37
+ elif char.isascii() and (char.isalnum() or char == "-"):
38
+ result += char
39
+ else:
40
+ result += f"_{ord(char):02X}"
41
+ if not result:
42
+ raise ValueError("name must be a non-empty string")
43
+ return result
44
+
45
+
46
+ def sanitize_name(name: str | SanitizedName) -> SanitizedName:
47
+ """Return a VQS-safe name marker for arbitrary input.
48
+
49
+ Plain strings are reversibly encoded, including underscores. Use
50
+ ``SanitizedName`` for values that are already VQS-safe and must not be
51
+ encoded again.
52
+ """
53
+ if isinstance(name, SanitizedName):
54
+ return name
55
+ return SanitizedName(_sanitize_name(name))
56
+
57
+
58
+ def validate_name(name: object, *, field: str = "name") -> str:
59
+ name = getattr(name, "name", name)
60
+ if isinstance(name, SanitizedName):
61
+ return str(name)
62
+ if not isinstance(name, str) or not name:
63
+ raise ValueError(f"{field} must be a non-empty string")
64
+ if not _is_valid_vqs_name(name):
65
+ raise ValueError(f"Invalid queue {field}: {name!r}; must match {VQS_NAME_PATTERN}")
66
+ return name
67
+
68
+
69
+ def validate_topic_name(topic: object) -> str:
70
+ return validate_name(topic, field="topic")
71
+
72
+
73
+ def validate_subscription_pattern(topic: str) -> str:
74
+ if topic == "*":
75
+ return topic
76
+ topic_name = topic.removesuffix("*")
77
+ if not _is_valid_vqs_name(topic_name):
78
+ raise ValueError(f"Invalid queue topic: {topic!r}; must match {VQS_NAME_PATTERN}")
79
+ return topic
80
+
81
+
82
+ def normalize_name(
83
+ name: str | SanitizedName,
84
+ *,
85
+ field: str = "name",
86
+ ) -> SanitizedName:
87
+ if isinstance(name, SanitizedName):
88
+ return name
89
+ if not name:
90
+ raise ValueError(f"{field} must be a non-empty string")
91
+ return sanitize_name(name)
92
+
93
+
94
+ # Only add public symbols to __all__; internal helpers must stay unexported.
95
+ __all__ = (
96
+ "SanitizedName",
97
+ "normalize_name",
98
+ "sanitize_name",
99
+ "validate_name",
100
+ "validate_subscription_pattern",
101
+ )