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.
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from tigrcorn_core.types import Receive, Scope, Send
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ASGIConnection:
10
+ scope: Scope
11
+ receive: Receive
12
+ send: Send
@@ -0,0 +1,2 @@
1
+ class ASGIProtocolError(Exception):
2
+ """Raised when the application sends an invalid ASGI message sequence."""
@@ -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,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def lifespan_startup() -> dict:
5
+ return {"type": "lifespan.startup"}
6
+
7
+
8
+ def lifespan_shutdown() -> dict:
9
+ return {"type": "lifespan.shutdown"}
@@ -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
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def websocket_denial_extension() -> dict:
5
+ return {'websocket.http.response': {}}
tigrcorn_asgi/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -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