tigrcorn-asgi 0.3.16.dev5__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.
- tigrcorn_asgi/__init__.py +1 -0
- tigrcorn_asgi/connection.py +12 -0
- tigrcorn_asgi/errors.py +2 -0
- tigrcorn_asgi/events/__init__.py +1 -0
- tigrcorn_asgi/events/custom.py +13 -0
- tigrcorn_asgi/events/http.py +13 -0
- tigrcorn_asgi/events/lifespan.py +9 -0
- tigrcorn_asgi/events/websocket.py +17 -0
- tigrcorn_asgi/extensions/__init__.py +1 -0
- tigrcorn_asgi/extensions/tls.py +10 -0
- tigrcorn_asgi/extensions/websocket_denial.py +5 -0
- tigrcorn_asgi/py.typed +1 -0
- tigrcorn_asgi/receive.py +200 -0
- tigrcorn_asgi/scopes/__init__.py +1 -0
- tigrcorn_asgi/scopes/custom.py +12 -0
- tigrcorn_asgi/scopes/http.py +51 -0
- tigrcorn_asgi/scopes/lifespan.py +12 -0
- tigrcorn_asgi/scopes/websocket.py +59 -0
- tigrcorn_asgi/send.py +670 -0
- tigrcorn_asgi/state.py +9 -0
- tigrcorn_asgi-0.3.16.dev5.dist-info/METADATA +235 -0
- tigrcorn_asgi-0.3.16.dev5.dist-info/RECORD +25 -0
- tigrcorn_asgi-0.3.16.dev5.dist-info/WHEEL +5 -0
- tigrcorn_asgi-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
- tigrcorn_asgi-0.3.16.dev5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
tigrcorn_asgi/errors.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def custom_event(event_type: str, **payload) -> dict:
|
|
5
|
+
return {"type": event_type, **payload}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def stream_receive(data: bytes, *, more_data: bool = False) -> dict:
|
|
9
|
+
return custom_event("tigrcorn.stream.receive", data=data, more_data=more_data)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stream_send(data: bytes, *, more_data: bool = False) -> dict:
|
|
13
|
+
return custom_event("tigrcorn.stream.send", data=data, more_data=more_data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def http_request(body: bytes = b"", more_body: bool = False) -> dict:
|
|
5
|
+
return {"type": "http.request", "body": body, "more_body": more_body}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def http_request_trailers(trailers: list[tuple[bytes, bytes]]) -> dict:
|
|
9
|
+
return {"type": "http.request.trailers", "trailers": trailers}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def http_disconnect() -> dict:
|
|
13
|
+
return {"type": "http.disconnect"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def websocket_connect() -> dict:
|
|
5
|
+
return {"type": "websocket.connect"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def websocket_receive_text(text: str) -> dict:
|
|
9
|
+
return {"type": "websocket.receive", "text": text, "bytes": None}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def websocket_receive_bytes(data: bytes) -> dict:
|
|
13
|
+
return {"type": "websocket.receive", "text": None, "bytes": data}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def websocket_disconnect(code: int = 1005, reason: str = "") -> dict:
|
|
17
|
+
return {"type": "websocket.disconnect", "code": code, "reason": reason}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def tls_extension(selected_alpn_protocol: str | None = None, peer_cert: dict | None = None) -> dict:
|
|
5
|
+
ext = {}
|
|
6
|
+
if selected_alpn_protocol is not None:
|
|
7
|
+
ext['selected_alpn_protocol'] = selected_alpn_protocol
|
|
8
|
+
if peer_cert is not None:
|
|
9
|
+
ext['peer_cert'] = peer_cert
|
|
10
|
+
return ext
|
tigrcorn_asgi/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
tigrcorn_asgi/receive.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from tigrcorn_asgi.events.http import http_disconnect, http_request, http_request_trailers
|
|
7
|
+
from tigrcorn_asgi.events.lifespan import lifespan_shutdown, lifespan_startup
|
|
8
|
+
from tigrcorn_core.errors import ProtocolError
|
|
9
|
+
from tigrcorn_protocols.http1.parser import _validate_header_name, _validate_header_value
|
|
10
|
+
from tigrcorn_core.types import Message, StreamReaderLike
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
FORBIDDEN_REQUEST_TRAILER_NAMES = {
|
|
15
|
+
b'content-length',
|
|
16
|
+
b'transfer-encoding',
|
|
17
|
+
b'host',
|
|
18
|
+
b'trailer',
|
|
19
|
+
b'content-encoding',
|
|
20
|
+
b'content-type',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apply_request_trailer_policy(
|
|
25
|
+
trailers: list[tuple[bytes, bytes]] | tuple[tuple[bytes, bytes], ...],
|
|
26
|
+
policy: str,
|
|
27
|
+
) -> list[tuple[bytes, bytes]]:
|
|
28
|
+
normalized = [(bytes(name).lower(), bytes(value)) for name, value in trailers]
|
|
29
|
+
if policy == 'drop':
|
|
30
|
+
return []
|
|
31
|
+
if policy == 'strict':
|
|
32
|
+
forbidden = [name for name, _value in normalized if name in FORBIDDEN_REQUEST_TRAILER_NAMES]
|
|
33
|
+
if forbidden:
|
|
34
|
+
raise ProtocolError(f'forbidden request trailer fields: {forbidden!r}')
|
|
35
|
+
return normalized
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HTTPRequestReceive:
|
|
39
|
+
"""Buffered HTTP request body exposed as ASGI receive events."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, body: bytes, *, trailers: list[tuple[bytes, bytes]] | None = None, trailer_policy: str = 'pass') -> None:
|
|
42
|
+
self._body = body
|
|
43
|
+
self._trailers = apply_request_trailer_policy(list(trailers or ()), trailer_policy)
|
|
44
|
+
self._sent_body = False
|
|
45
|
+
self._sent_trailers = False
|
|
46
|
+
|
|
47
|
+
async def __call__(self) -> Message:
|
|
48
|
+
if not self._sent_body:
|
|
49
|
+
self._sent_body = True
|
|
50
|
+
return http_request(self._body, False)
|
|
51
|
+
if self._trailers and not self._sent_trailers:
|
|
52
|
+
self._sent_trailers = True
|
|
53
|
+
return http_request_trailers(self._trailers)
|
|
54
|
+
return http_disconnect()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HTTPStreamingRequestReceive:
|
|
58
|
+
"""Reader-backed HTTP/1.1 request body exposed incrementally as ASGI events."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
reader: StreamReaderLike,
|
|
64
|
+
content_length: int | None,
|
|
65
|
+
chunked: bool,
|
|
66
|
+
max_body_size: int,
|
|
67
|
+
expect_continue: bool = False,
|
|
68
|
+
on_expect_continue: Callable[[], Awaitable[None]] | None = None,
|
|
69
|
+
max_chunk_size: int = 65_536,
|
|
70
|
+
trailer_policy: str = 'pass',
|
|
71
|
+
) -> None:
|
|
72
|
+
if content_length is not None and content_length < 0:
|
|
73
|
+
raise ValueError('content_length must be non-negative')
|
|
74
|
+
self._reader = reader
|
|
75
|
+
self._remaining = content_length
|
|
76
|
+
self._chunked = chunked
|
|
77
|
+
self._max_body_size = max_body_size
|
|
78
|
+
self._max_chunk_size = max_chunk_size
|
|
79
|
+
self._expect_continue = expect_continue
|
|
80
|
+
self._on_expect_continue = on_expect_continue
|
|
81
|
+
self._continue_sent = False
|
|
82
|
+
self._sent_final = False
|
|
83
|
+
self._disconnected = False
|
|
84
|
+
self._total_read = 0
|
|
85
|
+
self.body_complete = not chunked and (content_length is None or content_length == 0)
|
|
86
|
+
self._trailers_sent = False
|
|
87
|
+
self.trailer_policy = trailer_policy
|
|
88
|
+
self.trailers: list[tuple[bytes, bytes]] = []
|
|
89
|
+
|
|
90
|
+
async def __call__(self) -> Message:
|
|
91
|
+
if self._disconnected:
|
|
92
|
+
return http_disconnect()
|
|
93
|
+
if self._sent_final:
|
|
94
|
+
if self.trailers and not self._trailers_sent:
|
|
95
|
+
self._trailers_sent = True
|
|
96
|
+
return http_request_trailers(self.trailers)
|
|
97
|
+
self._disconnected = True
|
|
98
|
+
return http_disconnect()
|
|
99
|
+
await self._maybe_send_continue()
|
|
100
|
+
if self._chunked:
|
|
101
|
+
return await self._next_chunked_event()
|
|
102
|
+
if self._remaining is None or self._remaining == 0:
|
|
103
|
+
self.body_complete = True
|
|
104
|
+
self._sent_final = True
|
|
105
|
+
return http_request(b'', False)
|
|
106
|
+
amount = min(self._remaining, self._max_chunk_size)
|
|
107
|
+
data = await self._readexactly(amount)
|
|
108
|
+
self._remaining -= len(data)
|
|
109
|
+
self._total_read += len(data)
|
|
110
|
+
if self._total_read > self._max_body_size:
|
|
111
|
+
raise ProtocolError('request body exceeds configured max_body_size')
|
|
112
|
+
more_body = self._remaining > 0
|
|
113
|
+
if not more_body:
|
|
114
|
+
self.body_complete = True
|
|
115
|
+
self._sent_final = True
|
|
116
|
+
return http_request(data, more_body)
|
|
117
|
+
|
|
118
|
+
async def _maybe_send_continue(self) -> None:
|
|
119
|
+
if (
|
|
120
|
+
self._expect_continue
|
|
121
|
+
and not self._continue_sent
|
|
122
|
+
and not self.body_complete
|
|
123
|
+
and self._on_expect_continue is not None
|
|
124
|
+
):
|
|
125
|
+
self._continue_sent = True
|
|
126
|
+
await self._on_expect_continue()
|
|
127
|
+
|
|
128
|
+
async def _read_line(self) -> bytes:
|
|
129
|
+
try:
|
|
130
|
+
return await self._reader.readuntil(b'\r\n')
|
|
131
|
+
except asyncio.IncompleteReadError as exc:
|
|
132
|
+
raise ProtocolError('unexpected EOF while reading HTTP/1.1 body') from exc
|
|
133
|
+
|
|
134
|
+
async def _readexactly(self, amount: int) -> bytes:
|
|
135
|
+
try:
|
|
136
|
+
return await self._reader.readexactly(amount)
|
|
137
|
+
except asyncio.IncompleteReadError as exc:
|
|
138
|
+
raise ProtocolError('unexpected EOF while reading HTTP/1.1 body') from exc
|
|
139
|
+
|
|
140
|
+
async def _consume_trailers(self) -> None:
|
|
141
|
+
trailers: list[tuple[bytes, bytes]] = []
|
|
142
|
+
while True:
|
|
143
|
+
trailer = await self._read_line()
|
|
144
|
+
if trailer == b'\r\n':
|
|
145
|
+
self.trailers = apply_request_trailer_policy(trailers, self.trailer_policy)
|
|
146
|
+
return
|
|
147
|
+
if trailer[:1] in {b' ', b'\t'}:
|
|
148
|
+
raise ProtocolError('obsolete line folding is not supported')
|
|
149
|
+
if b':' not in trailer[:-2]:
|
|
150
|
+
raise ProtocolError('malformed chunk trailer line')
|
|
151
|
+
name, value = trailer[:-2].split(b':', 1)
|
|
152
|
+
normalized_name = name.strip().lower()
|
|
153
|
+
normalized_value = value.strip()
|
|
154
|
+
_validate_header_name(normalized_name)
|
|
155
|
+
_validate_header_value(normalized_value)
|
|
156
|
+
trailers.append((normalized_name, normalized_value))
|
|
157
|
+
|
|
158
|
+
async def _next_chunked_event(self) -> Message:
|
|
159
|
+
line = await self._read_line()
|
|
160
|
+
size_token = line[:-2].split(b';', 1)[0].strip()
|
|
161
|
+
try:
|
|
162
|
+
size = int(size_token, 16)
|
|
163
|
+
except ValueError as exc:
|
|
164
|
+
raise ProtocolError('invalid chunk size') from exc
|
|
165
|
+
if size < 0:
|
|
166
|
+
raise ProtocolError('invalid chunk size')
|
|
167
|
+
if size == 0:
|
|
168
|
+
await self._consume_trailers()
|
|
169
|
+
self.body_complete = True
|
|
170
|
+
self._sent_final = True
|
|
171
|
+
return http_request(b'', False)
|
|
172
|
+
data = await self._readexactly(size)
|
|
173
|
+
terminator = await self._readexactly(2)
|
|
174
|
+
if terminator != b'\r\n':
|
|
175
|
+
raise ProtocolError('invalid chunk terminator')
|
|
176
|
+
self._total_read += size
|
|
177
|
+
if self._total_read > self._max_body_size:
|
|
178
|
+
raise ProtocolError('request body exceeds configured max_body_size')
|
|
179
|
+
return http_request(data, True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class QueueReceive:
|
|
183
|
+
def __init__(self, max_size: int | None = None) -> None:
|
|
184
|
+
self.max_size = max_size
|
|
185
|
+
self._queue: asyncio.Queue[Message] = asyncio.Queue(maxsize=0 if not max_size else max_size)
|
|
186
|
+
|
|
187
|
+
async def put(self, message: Message) -> None:
|
|
188
|
+
await self._queue.put(message)
|
|
189
|
+
|
|
190
|
+
async def __call__(self) -> Message:
|
|
191
|
+
return await self._queue.get()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class LifespanReceive(QueueReceive):
|
|
196
|
+
async def startup(self) -> None:
|
|
197
|
+
await self.put(lifespan_startup())
|
|
198
|
+
|
|
199
|
+
async def shutdown(self) -> None:
|
|
200
|
+
await self.put(lifespan_shutdown())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrcorn_core.constants import ASGI_SPEC_VERSION, ASGI_VERSION
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_custom_scope(scope_type: str, **fields) -> dict:
|
|
7
|
+
scope = {
|
|
8
|
+
"type": scope_type,
|
|
9
|
+
"asgi": {"version": ASGI_VERSION, "spec_version": ASGI_SPEC_VERSION},
|
|
10
|
+
}
|
|
11
|
+
scope.update(fields)
|
|
12
|
+
return scope
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tigrcorn_core.constants import ASGI_SPEC_VERSION, ASGI_VERSION
|
|
6
|
+
from tigrcorn_protocols.http1.parser import ParsedRequest, ParsedRequestHead
|
|
7
|
+
from tigrcorn_core.types import Scope
|
|
8
|
+
from tigrcorn_core.utils.proxy import resolve_proxy_view, strip_root_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_http_scope(
|
|
12
|
+
request: ParsedRequest | ParsedRequestHead,
|
|
13
|
+
*,
|
|
14
|
+
client: tuple[str, int] | None,
|
|
15
|
+
server: tuple[str, int] | tuple[str, None] | None,
|
|
16
|
+
scheme: str = "http",
|
|
17
|
+
extensions: dict | None = None,
|
|
18
|
+
root_path: str = "",
|
|
19
|
+
proxy: Any | None = None,
|
|
20
|
+
) -> Scope:
|
|
21
|
+
if proxy is not None:
|
|
22
|
+
proxy_view = resolve_proxy_view(
|
|
23
|
+
request.headers,
|
|
24
|
+
client=client,
|
|
25
|
+
server=server,
|
|
26
|
+
scheme=scheme,
|
|
27
|
+
root_path=root_path,
|
|
28
|
+
enabled=bool(getattr(proxy, 'proxy_headers', False)),
|
|
29
|
+
forwarded_allow_ips=tuple(getattr(proxy, 'forwarded_allow_ips', []) or ()),
|
|
30
|
+
)
|
|
31
|
+
client = proxy_view.client
|
|
32
|
+
server = proxy_view.server
|
|
33
|
+
scheme = proxy_view.scheme
|
|
34
|
+
root_path = proxy_view.root_path
|
|
35
|
+
path, raw_path = strip_root_path(request.path, request.raw_path, root_path)
|
|
36
|
+
scope: Scope = {
|
|
37
|
+
"type": "http",
|
|
38
|
+
"asgi": {"version": ASGI_VERSION, "spec_version": ASGI_SPEC_VERSION},
|
|
39
|
+
"http_version": request.http_version,
|
|
40
|
+
"method": request.method,
|
|
41
|
+
"scheme": scheme,
|
|
42
|
+
"path": path,
|
|
43
|
+
"raw_path": raw_path,
|
|
44
|
+
"query_string": request.query_string,
|
|
45
|
+
"root_path": root_path,
|
|
46
|
+
"headers": request.headers,
|
|
47
|
+
"client": client,
|
|
48
|
+
"server": server,
|
|
49
|
+
"extensions": extensions or {},
|
|
50
|
+
}
|
|
51
|
+
return scope
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrcorn_core.constants import ASGI_SPEC_VERSION, ASGI_VERSION
|
|
4
|
+
from tigrcorn_core.types import Scope
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_lifespan_scope() -> Scope:
|
|
8
|
+
return {
|
|
9
|
+
"type": "lifespan",
|
|
10
|
+
"asgi": {"version": ASGI_VERSION, "spec_version": ASGI_SPEC_VERSION},
|
|
11
|
+
"state": {},
|
|
12
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tigrcorn_core.constants import ASGI_VERSION, WEBSOCKET_SPEC_VERSION
|
|
6
|
+
from tigrcorn_protocols.http1.parser import ParsedRequest
|
|
7
|
+
from tigrcorn_core.types import Scope
|
|
8
|
+
from tigrcorn_core.utils.headers import get_header
|
|
9
|
+
from tigrcorn_core.utils.proxy import resolve_proxy_view, strip_root_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_websocket_scope(
|
|
13
|
+
request: ParsedRequest,
|
|
14
|
+
*,
|
|
15
|
+
client: tuple[str, int] | None,
|
|
16
|
+
server: tuple[str, int] | tuple[str, None] | None,
|
|
17
|
+
scheme: str = "ws",
|
|
18
|
+
extensions: dict | None = None,
|
|
19
|
+
root_path: str = "",
|
|
20
|
+
proxy: Any | None = None,
|
|
21
|
+
) -> Scope:
|
|
22
|
+
if proxy is not None:
|
|
23
|
+
proxy_view = resolve_proxy_view(
|
|
24
|
+
request.headers,
|
|
25
|
+
client=client,
|
|
26
|
+
server=server,
|
|
27
|
+
scheme=scheme,
|
|
28
|
+
root_path=root_path,
|
|
29
|
+
enabled=bool(getattr(proxy, 'proxy_headers', False)),
|
|
30
|
+
forwarded_allow_ips=tuple(getattr(proxy, 'forwarded_allow_ips', []) or ()),
|
|
31
|
+
)
|
|
32
|
+
client = proxy_view.client
|
|
33
|
+
server = proxy_view.server
|
|
34
|
+
scheme = proxy_view.scheme
|
|
35
|
+
root_path = proxy_view.root_path
|
|
36
|
+
path, raw_path = strip_root_path(request.path, request.raw_path, root_path)
|
|
37
|
+
subprotocol_header = get_header(request.headers, b"sec-websocket-protocol")
|
|
38
|
+
subprotocols = []
|
|
39
|
+
if subprotocol_header:
|
|
40
|
+
subprotocols = [part.strip().decode("ascii", "ignore") for part in subprotocol_header.split(b",") if part.strip()]
|
|
41
|
+
scope_extensions = {"websocket.http.response": {}}
|
|
42
|
+
if extensions:
|
|
43
|
+
scope_extensions.update(extensions)
|
|
44
|
+
scope: Scope = {
|
|
45
|
+
"type": "websocket",
|
|
46
|
+
"asgi": {"version": ASGI_VERSION, "spec_version": WEBSOCKET_SPEC_VERSION},
|
|
47
|
+
"http_version": request.http_version,
|
|
48
|
+
"scheme": scheme,
|
|
49
|
+
"path": path,
|
|
50
|
+
"raw_path": raw_path,
|
|
51
|
+
"query_string": request.query_string,
|
|
52
|
+
"root_path": root_path,
|
|
53
|
+
"headers": request.headers,
|
|
54
|
+
"client": client,
|
|
55
|
+
"server": server,
|
|
56
|
+
"subprotocols": subprotocols,
|
|
57
|
+
"extensions": scope_extensions,
|
|
58
|
+
}
|
|
59
|
+
return scope
|