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,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
|
+
)
|