asgi-tools 1.2.0__cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.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.
- asgi_tools/__init__.py +65 -0
- asgi_tools/_compat.py +259 -0
- asgi_tools/app.py +303 -0
- asgi_tools/constants.py +6 -0
- asgi_tools/errors.py +25 -0
- asgi_tools/forms.c +19218 -0
- asgi_tools/forms.cpython-311-aarch64-linux-gnu.so +0 -0
- asgi_tools/forms.py +166 -0
- asgi_tools/forms.pyx +167 -0
- asgi_tools/logs.py +6 -0
- asgi_tools/middleware.py +458 -0
- asgi_tools/multipart.c +19234 -0
- asgi_tools/multipart.cpython-311-aarch64-linux-gnu.so +0 -0
- asgi_tools/multipart.pxd +34 -0
- asgi_tools/multipart.py +589 -0
- asgi_tools/multipart.pyx +565 -0
- asgi_tools/py.typed +0 -0
- asgi_tools/request.py +337 -0
- asgi_tools/response.py +537 -0
- asgi_tools/router.py +15 -0
- asgi_tools/tests.py +405 -0
- asgi_tools/types.py +31 -0
- asgi_tools/utils.py +110 -0
- asgi_tools/view.py +69 -0
- asgi_tools-1.2.0.dist-info/METADATA +214 -0
- asgi_tools-1.2.0.dist-info/RECORD +29 -0
- asgi_tools-1.2.0.dist-info/WHEEL +7 -0
- asgi_tools-1.2.0.dist-info/licenses/LICENSE +21 -0
- asgi_tools-1.2.0.dist-info/top_level.txt +1 -0
asgi_tools/response.py
ADDED
@@ -0,0 +1,537 @@
|
|
1
|
+
"""ASGI responses."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from email.utils import formatdate
|
6
|
+
from enum import Enum
|
7
|
+
from functools import partial
|
8
|
+
from hashlib import md5
|
9
|
+
from http import HTTPStatus
|
10
|
+
from http.cookies import SimpleCookie
|
11
|
+
from mimetypes import guess_type
|
12
|
+
from pathlib import Path
|
13
|
+
from stat import S_ISDIR
|
14
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Mapping, TypeVar
|
15
|
+
from urllib.parse import quote, quote_plus
|
16
|
+
|
17
|
+
from multidict import MultiDict
|
18
|
+
|
19
|
+
from ._compat import FIRST_COMPLETED, aio_stream_file, aio_wait, json_dumps
|
20
|
+
from .constants import BASE_ENCODING, DEFAULT_CHARSET
|
21
|
+
from .errors import ASGIConnectionClosedError, ASGIError
|
22
|
+
from .request import Request
|
23
|
+
|
24
|
+
if TYPE_CHECKING:
|
25
|
+
from .types import TASGIMessage, TASGIReceive, TASGIScope, TASGISend
|
26
|
+
|
27
|
+
T = TypeVar("T")
|
28
|
+
TContent = TypeVar("TContent", str, bytes, dict, list, None)
|
29
|
+
|
30
|
+
|
31
|
+
class Response:
|
32
|
+
"""Base class for creating HTTP responses.
|
33
|
+
|
34
|
+
:param content: A response's body
|
35
|
+
:type content: str | bytes | dict | list | None
|
36
|
+
:param status_code: An HTTP status code
|
37
|
+
:type status_code: int
|
38
|
+
:param headers: A dictionary of HTTP headers
|
39
|
+
:type headers: dict[str, str]
|
40
|
+
:param content_type: A string with the content-type
|
41
|
+
:type content_type: str
|
42
|
+
:param cookies: An initial dictionary of cookies
|
43
|
+
:type cookies: dict[str, str]
|
44
|
+
"""
|
45
|
+
|
46
|
+
headers: MultiDict #: Multidict of response's headers
|
47
|
+
cookies: SimpleCookie
|
48
|
+
""" Set/Update cookies
|
49
|
+
|
50
|
+
* `response.cookies[name] = value` ``str`` -- set a cookie's value
|
51
|
+
* `response.cookies[name]['path'] = value` ``str`` -- set a cookie's path
|
52
|
+
* `response.cookies[name]['expires'] = value` ``int`` -- set a cookie's expire
|
53
|
+
* `response.cookies[name]['domain'] = value` ``str`` -- set a cookie's domain
|
54
|
+
* `response.cookies[name]['max-age'] = value` ``int`` -- set a cookie's max-age
|
55
|
+
* `response.cookies[name]['secure'] = value` ``bool``-- is the cookie
|
56
|
+
should only be sent if request is SSL
|
57
|
+
* `response.cookies[name]['httponly'] = value` ``bool`` -- is the cookie
|
58
|
+
should be available through HTTP request only (not from JS)
|
59
|
+
* `response.cookies[name]['samesite'] = value` ``str`` -- set a cookie's
|
60
|
+
strategy ('lax'|'strict'|'none')
|
61
|
+
|
62
|
+
"""
|
63
|
+
content_type: str | None = None
|
64
|
+
status_code: int = HTTPStatus.OK.value
|
65
|
+
|
66
|
+
def __init__(
|
67
|
+
self,
|
68
|
+
content: TContent,
|
69
|
+
*,
|
70
|
+
status_code: int | None = None,
|
71
|
+
content_type: str | None = None,
|
72
|
+
headers: dict[str, str] | None = None,
|
73
|
+
cookies: dict[str, str] | None = None,
|
74
|
+
):
|
75
|
+
"""Setup the response."""
|
76
|
+
self.content = self.process_content(content)
|
77
|
+
self.headers: MultiDict = MultiDict(headers or {})
|
78
|
+
self.cookies: SimpleCookie = SimpleCookie(cookies)
|
79
|
+
if status_code is not None:
|
80
|
+
self.status_code = status_code
|
81
|
+
|
82
|
+
content_type = content_type or self.content_type
|
83
|
+
if content_type:
|
84
|
+
self.headers.setdefault(
|
85
|
+
"content-type",
|
86
|
+
(content_type.startswith("text/") and f"{content_type}; charset={DEFAULT_CHARSET}")
|
87
|
+
or content_type,
|
88
|
+
)
|
89
|
+
|
90
|
+
def __str__(self) -> str:
|
91
|
+
"""Stringify the response."""
|
92
|
+
return f"{self.status_code}"
|
93
|
+
|
94
|
+
def __repr__(self) -> str:
|
95
|
+
"""Stringify the response."""
|
96
|
+
return f"<{ self.__class__.__name__ } '{ self }'>"
|
97
|
+
|
98
|
+
async def __call__(self, _, __, send: TASGISend):
|
99
|
+
"""Behave as an ASGI application."""
|
100
|
+
self.headers.setdefault("content-length", str(len(self.content)))
|
101
|
+
|
102
|
+
await send(self.msg_start())
|
103
|
+
await send({"type": "http.response.body", "body": self.content})
|
104
|
+
|
105
|
+
@staticmethod
|
106
|
+
def process_content(content: TContent) -> bytes:
|
107
|
+
"""Process content into bytes."""
|
108
|
+
if not isinstance(content, bytes):
|
109
|
+
return str(content).encode(DEFAULT_CHARSET)
|
110
|
+
return content
|
111
|
+
|
112
|
+
def msg_start(self) -> TASGIMessage:
|
113
|
+
"""Get ASGI response start message."""
|
114
|
+
headers = [
|
115
|
+
(key.encode(BASE_ENCODING), str(val).encode(BASE_ENCODING))
|
116
|
+
for key, val in self.headers.items()
|
117
|
+
]
|
118
|
+
|
119
|
+
for cookie in self.cookies.values():
|
120
|
+
headers = [
|
121
|
+
*headers,
|
122
|
+
(b"set-cookie", cookie.output(header="").strip().encode(BASE_ENCODING)),
|
123
|
+
]
|
124
|
+
|
125
|
+
return {
|
126
|
+
"type": "http.response.start",
|
127
|
+
"status": self.status_code,
|
128
|
+
"headers": headers,
|
129
|
+
}
|
130
|
+
|
131
|
+
|
132
|
+
class ResponseText(Response):
|
133
|
+
"""Returns plain text responses (text/plain)."""
|
134
|
+
|
135
|
+
content_type = "text/plain"
|
136
|
+
|
137
|
+
|
138
|
+
class ResponseHTML(Response):
|
139
|
+
"""Returns HTML responses (text/html)."""
|
140
|
+
|
141
|
+
content_type = "text/html"
|
142
|
+
|
143
|
+
|
144
|
+
class ResponseJSON(Response):
|
145
|
+
"""Returns JSON responses (application/json).
|
146
|
+
|
147
|
+
The class optionally supports `ujson <https://pypi.org/project/ujson/>`_ and `orjson
|
148
|
+
<https://pypi.org/project/orjson/>`_ JSON libraries. Install one of them to use instead
|
149
|
+
the standard library.
|
150
|
+
|
151
|
+
"""
|
152
|
+
|
153
|
+
content_type = "application/json"
|
154
|
+
|
155
|
+
@staticmethod
|
156
|
+
def process_content(content) -> bytes:
|
157
|
+
"""Dumps the given content."""
|
158
|
+
return json_dumps(content)
|
159
|
+
|
160
|
+
|
161
|
+
class ResponseStream(Response):
|
162
|
+
"""Streams response body as chunks.
|
163
|
+
|
164
|
+
:param content: An async generator to stream the response's body
|
165
|
+
:type content: AsyncGenerator
|
166
|
+
"""
|
167
|
+
|
168
|
+
def __init__(self, stream: AsyncGenerator[Any, None], **kwargs):
|
169
|
+
super().__init__(b"", **kwargs)
|
170
|
+
self.stream = stream
|
171
|
+
|
172
|
+
async def listen_for_disconnect(self, receive: TASGIReceive):
|
173
|
+
"""Listen for the client has been disconnected."""
|
174
|
+
while True:
|
175
|
+
message = await receive()
|
176
|
+
if message["type"] == "http.disconnect":
|
177
|
+
break
|
178
|
+
|
179
|
+
async def stream_response(self, send: TASGISend):
|
180
|
+
"""Stream response content."""
|
181
|
+
await send(self.msg_start())
|
182
|
+
async for chunk in self.stream:
|
183
|
+
await send(
|
184
|
+
{
|
185
|
+
"type": "http.response.body",
|
186
|
+
"body": self.process_content(chunk),
|
187
|
+
"more_body": True,
|
188
|
+
},
|
189
|
+
)
|
190
|
+
|
191
|
+
await send({"type": "http.response.body", "body": b""})
|
192
|
+
|
193
|
+
async def __call__(self, _, receive, send: TASGISend) -> None:
|
194
|
+
"""Behave as an ASGI application."""
|
195
|
+
await aio_wait(
|
196
|
+
self.listen_for_disconnect(receive),
|
197
|
+
self.stream_response(send),
|
198
|
+
strategy=FIRST_COMPLETED,
|
199
|
+
)
|
200
|
+
|
201
|
+
|
202
|
+
class ResponseSSE(ResponseStream):
|
203
|
+
"""Streams Server-Sent Events (SSE).
|
204
|
+
|
205
|
+
:param content: An async generator to stream the events
|
206
|
+
:type content: AsyncGenerator
|
207
|
+
"""
|
208
|
+
|
209
|
+
content_type = "text/event-stream"
|
210
|
+
|
211
|
+
def msg_start(self) -> TASGIMessage:
|
212
|
+
"""Set cache-control header."""
|
213
|
+
self.headers.setdefault("Cache-Control", "no-cache")
|
214
|
+
return super().msg_start()
|
215
|
+
|
216
|
+
@staticmethod
|
217
|
+
def process_content(content) -> bytes:
|
218
|
+
"""Prepare a chunk from stream generator to send."""
|
219
|
+
if isinstance(content, dict):
|
220
|
+
content = "\n".join(f"{k}: {v}" for k, v in content.items())
|
221
|
+
|
222
|
+
if not isinstance(content, bytes):
|
223
|
+
content = content.encode(DEFAULT_CHARSET)
|
224
|
+
|
225
|
+
return content + b"\n\n"
|
226
|
+
|
227
|
+
|
228
|
+
class ResponseFile(ResponseStream):
|
229
|
+
"""Serves files as HTTP responses.
|
230
|
+
|
231
|
+
:param filepath: The filepath to the file
|
232
|
+
:type filepath: str | Path
|
233
|
+
:param chunk_size: Default chunk size (32768)
|
234
|
+
:type chunk_size: int
|
235
|
+
:param filename: If set, `Content-Disposition` header will be generated
|
236
|
+
:type filename: str
|
237
|
+
:param headers_only: Return only file headers
|
238
|
+
:type headers_only: bool
|
239
|
+
|
240
|
+
"""
|
241
|
+
|
242
|
+
def __init__(
|
243
|
+
self,
|
244
|
+
filepath: str | Path,
|
245
|
+
*,
|
246
|
+
chunk_size: int = 64 * 1024,
|
247
|
+
filename: str | None = None,
|
248
|
+
headers_only: bool = False,
|
249
|
+
**kwargs,
|
250
|
+
) -> None:
|
251
|
+
"""Store filepath to self."""
|
252
|
+
try:
|
253
|
+
stat = Path(filepath).stat()
|
254
|
+
except FileNotFoundError as exc:
|
255
|
+
raise ASGIError(*exc.args) from exc
|
256
|
+
|
257
|
+
if S_ISDIR(stat.st_mode):
|
258
|
+
raise ASGIError(f"It's a directory: {filepath}")
|
259
|
+
|
260
|
+
super().__init__(
|
261
|
+
empty() if headers_only else aio_stream_file(filepath, chunk_size),
|
262
|
+
**kwargs,
|
263
|
+
)
|
264
|
+
|
265
|
+
headers = self.headers
|
266
|
+
if filename and "content-disposition" not in headers:
|
267
|
+
headers["content-disposition"] = f"attachment; filename*=UTF-8''{quote(filename)}"
|
268
|
+
|
269
|
+
if "content-type" not in headers:
|
270
|
+
headers["content-type"] = guess_type(filename or str(filepath))[0] or "text/plain"
|
271
|
+
|
272
|
+
headers.setdefault("content-length", str(stat.st_size))
|
273
|
+
headers.setdefault("last-modified", formatdate(stat.st_mtime, usegmt=True))
|
274
|
+
etag = str(stat.st_mtime) + "-" + str(stat.st_size)
|
275
|
+
headers.setdefault("etag", md5(etag.encode()).hexdigest()) # noqa: S324
|
276
|
+
|
277
|
+
|
278
|
+
class ResponseWebSocket(Response):
|
279
|
+
"""Provides a WebSocket handler interface."""
|
280
|
+
|
281
|
+
class STATES(Enum):
|
282
|
+
"""Represent websocket states."""
|
283
|
+
|
284
|
+
CONNECTING = 0
|
285
|
+
CONNECTED = 1
|
286
|
+
DISCONNECTED = 2
|
287
|
+
|
288
|
+
def __init__(
|
289
|
+
self,
|
290
|
+
scope: TASGIScope,
|
291
|
+
receive: TASGIReceive | None = None,
|
292
|
+
send: TASGISend | None = None,
|
293
|
+
) -> None:
|
294
|
+
"""Initialize the websocket response."""
|
295
|
+
if isinstance(scope, Request):
|
296
|
+
receive, send = scope.receive, scope.send
|
297
|
+
|
298
|
+
if not receive or not send:
|
299
|
+
raise ASGIError("Invalid initialization")
|
300
|
+
|
301
|
+
super().__init__(b"")
|
302
|
+
self._receive: TASGIReceive = receive
|
303
|
+
self._send: TASGISend = send
|
304
|
+
self.state = self.STATES.CONNECTING
|
305
|
+
self.partner_state = self.STATES.CONNECTING
|
306
|
+
|
307
|
+
async def __call__(self, _, __, send: TASGISend):
|
308
|
+
"""Close websocket if the response has been returned."""
|
309
|
+
await send({"type": "websocket.close"})
|
310
|
+
|
311
|
+
async def __aenter__(self):
|
312
|
+
"""Use it as async context manager."""
|
313
|
+
await self.accept()
|
314
|
+
return self
|
315
|
+
|
316
|
+
async def __aexit__(self, *_):
|
317
|
+
"""Exit async context."""
|
318
|
+
await self.close()
|
319
|
+
|
320
|
+
@property
|
321
|
+
def connected(self) -> bool:
|
322
|
+
"""Check that is the websocket connected."""
|
323
|
+
return self.state == self.partner_state == self.STATES.CONNECTED
|
324
|
+
|
325
|
+
async def _connect(self) -> bool:
|
326
|
+
"""Wait for connect message."""
|
327
|
+
if self.partner_state == self.STATES.CONNECTING:
|
328
|
+
msg = await self._receive()
|
329
|
+
assert msg.get("type") == "websocket.connect"
|
330
|
+
self.partner_state = self.STATES.CONNECTED
|
331
|
+
|
332
|
+
return self.partner_state == self.STATES.CONNECTED
|
333
|
+
|
334
|
+
async def accept(self, **params) -> None:
|
335
|
+
"""Accept a websocket connection."""
|
336
|
+
if self.partner_state == self.STATES.CONNECTING:
|
337
|
+
await self._connect()
|
338
|
+
|
339
|
+
await self.send({"type": "websocket.accept", **params})
|
340
|
+
self.state = self.STATES.CONNECTED
|
341
|
+
|
342
|
+
async def close(self, code: int = 1000) -> None:
|
343
|
+
"""Sent by the application to tell the server to close the connection."""
|
344
|
+
if self.connected:
|
345
|
+
await self.send({"type": "websocket.close", "code": code})
|
346
|
+
self.state = self.STATES.DISCONNECTED
|
347
|
+
|
348
|
+
async def send(self, msg: dict | str | bytes, msg_type="websocket.send") -> None:
|
349
|
+
"""Send the given message to a client."""
|
350
|
+
if self.state == self.STATES.DISCONNECTED:
|
351
|
+
raise ASGIConnectionClosedError
|
352
|
+
|
353
|
+
if not isinstance(msg, dict):
|
354
|
+
msg = {"type": msg_type, ((isinstance(msg, str) and "text") or "bytes"): msg}
|
355
|
+
|
356
|
+
return await self._send(msg)
|
357
|
+
|
358
|
+
async def send_json(self, data) -> None:
|
359
|
+
"""Serialize the given data to JSON and send to a client."""
|
360
|
+
return await self._send({"type": "websocket.send", "bytes": json_dumps(data)})
|
361
|
+
|
362
|
+
async def receive(self, *, raw: bool = False) -> TASGIMessage | str:
|
363
|
+
"""Receive messages from a client.
|
364
|
+
|
365
|
+
:param raw: Receive messages as is.
|
366
|
+
"""
|
367
|
+
if self.partner_state == self.STATES.DISCONNECTED:
|
368
|
+
raise ASGIConnectionClosedError
|
369
|
+
|
370
|
+
if self.partner_state == self.STATES.CONNECTING:
|
371
|
+
await self._connect()
|
372
|
+
return await self.receive(raw=raw)
|
373
|
+
|
374
|
+
msg = await self._receive()
|
375
|
+
if msg["type"] == "websocket.disconnect":
|
376
|
+
self.partner_state = self.STATES.DISCONNECTED
|
377
|
+
|
378
|
+
return msg if raw else parse_websocket_msg(msg, charset=DEFAULT_CHARSET)
|
379
|
+
|
380
|
+
|
381
|
+
class ResponseRedirect(Response, BaseException):
|
382
|
+
"""Creates HTTP redirects. Uses a 307 status code by default.
|
383
|
+
|
384
|
+
:param url: A string with the new location
|
385
|
+
:type url: str
|
386
|
+
"""
|
387
|
+
|
388
|
+
status_code: int = HTTPStatus.TEMPORARY_REDIRECT.value
|
389
|
+
|
390
|
+
def __init__(self, url: str, status_code: int | None = None, **kwargs) -> None:
|
391
|
+
"""Set status code and prepare location."""
|
392
|
+
super().__init__(b"", status_code=status_code, **kwargs)
|
393
|
+
assert (
|
394
|
+
300 <= self.status_code < 400
|
395
|
+
), f"Invalid status code for redirection: {self.status_code}"
|
396
|
+
self.headers["location"] = quote_plus(url, safe=":/%#?&=@[]!$&'()*+,;")
|
397
|
+
|
398
|
+
|
399
|
+
class ResponseErrorMeta(type):
|
400
|
+
"""Generate Response Errors by HTTP names."""
|
401
|
+
|
402
|
+
# TODO: From python 3.9 -> partial['ResponseError]
|
403
|
+
def __getattr__(cls, name: str) -> Callable[..., ResponseError]:
|
404
|
+
"""Generate Response Errors by HTTP names."""
|
405
|
+
status = HTTPStatus[name]
|
406
|
+
return partial(
|
407
|
+
lambda *args, **kwargs: cls(*args, **kwargs),
|
408
|
+
status_code=status.value,
|
409
|
+
)
|
410
|
+
|
411
|
+
|
412
|
+
class ResponseError(Response, BaseException, metaclass=ResponseErrorMeta):
|
413
|
+
"""Helper for returning HTTP errors. Uses a 500 status code by default.
|
414
|
+
|
415
|
+
:param message: A string with the error's message (HTTPStatus messages will be used by default)
|
416
|
+
:type message: str
|
417
|
+
|
418
|
+
You able to use :py:class:`http.HTTPStatus` properties with the `ResponseError` class
|
419
|
+
|
420
|
+
.. code-block:: python
|
421
|
+
|
422
|
+
response = ResponseError.BAD_REQUEST('invalid data')
|
423
|
+
response = ResponseError.NOT_FOUND()
|
424
|
+
response = ResponseError.BAD_GATEWAY()
|
425
|
+
# and etc
|
426
|
+
|
427
|
+
"""
|
428
|
+
|
429
|
+
status_code: int = HTTPStatus.INTERNAL_SERVER_ERROR.value
|
430
|
+
|
431
|
+
# Typing annotations
|
432
|
+
if TYPE_CHECKING:
|
433
|
+
BAD_REQUEST: Callable[..., ResponseError] # 400
|
434
|
+
UNAUTHORIZED: Callable[..., ResponseError] # 401
|
435
|
+
PAYMENT_REQUIRED: Callable[..., ResponseError] # 402
|
436
|
+
FORBIDDEN: Callable[..., ResponseError] # 403
|
437
|
+
NOT_FOUND: Callable[..., ResponseError] # 404
|
438
|
+
METHOD_NOT_ALLOWED: Callable[..., ResponseError] # 405
|
439
|
+
NOT_ACCEPTABLE: Callable[..., ResponseError] # 406
|
440
|
+
PROXY_AUTHENTICATION_REQUIRED: Callable[..., ResponseError] # 407
|
441
|
+
REQUEST_TIMEOUT: Callable[..., ResponseError] # 408
|
442
|
+
CONFLICT: Callable[..., ResponseError] # 409
|
443
|
+
GONE: Callable[..., ResponseError] # 410
|
444
|
+
LENGTH_REQUIRED: Callable[..., ResponseError] # 411
|
445
|
+
PRECONDITION_FAILED: Callable[..., ResponseError] # 412
|
446
|
+
REQUEST_ENTITY_TOO_LARGE: Callable[..., ResponseError] # 413
|
447
|
+
REQUEST_URI_TOO_LONG: Callable[..., ResponseError] # 414
|
448
|
+
UNSUPPORTED_MEDIA_TYPE: Callable[..., ResponseError] # 415
|
449
|
+
REQUESTED_RANGE_NOT_SATISFIABLE: Callable[..., ResponseError] # 416
|
450
|
+
EXPECTATION_FAILED: Callable[..., ResponseError] # 417
|
451
|
+
# TODO: From python 3.9
|
452
|
+
# IM_A_TEAPOT: Callable[..., ResponseError] # 418
|
453
|
+
# MISDIRECTED_REQUEST: Callable[..., ResponseError] # 421
|
454
|
+
UNPROCESSABLE_ENTITY: Callable[..., ResponseError] # 422
|
455
|
+
LOCKED: Callable[..., ResponseError] # 423
|
456
|
+
FAILED_DEPENDENCY: Callable[..., ResponseError] # 424
|
457
|
+
TOO_EARLY: Callable[..., ResponseError] # 425
|
458
|
+
UPGRADE_REQUIRED: Callable[..., ResponseError] # 426
|
459
|
+
PRECONDITION_REQUIRED: Callable[..., ResponseError] # 428
|
460
|
+
TOO_MANY_REQUESTS: Callable[..., ResponseError] # 429
|
461
|
+
REQUEST_HEADER_FIELDS_TOO_LARGE: Callable[..., ResponseError] # 431
|
462
|
+
# TODO: From python 3.9
|
463
|
+
# UNAVAILABLE_FOR_LEGAL_REASONS: Callable[..., ResponseError] # 451
|
464
|
+
|
465
|
+
INTERNAL_SERVER_ERROR: Callable[..., ResponseError] # 500
|
466
|
+
NOT_IMPLEMENTED: Callable[..., ResponseError] # 501
|
467
|
+
BAD_GATEWAY: Callable[..., ResponseError] # 502
|
468
|
+
SERVICE_UNAVAILABLE: Callable[..., ResponseError] # 503
|
469
|
+
GATEWAY_TIMEOUT: Callable[..., ResponseError] # 504
|
470
|
+
HTTP_VERSION_NOT_SUPPORTED: Callable[..., ResponseError] # 505
|
471
|
+
VARIANT_ALSO_NEGOTIATES: Callable[..., ResponseError] # 506
|
472
|
+
INSUFFICIENT_STORAGE: Callable[..., ResponseError] # 507
|
473
|
+
LOOP_DETECTED: Callable[..., ResponseError] # 508
|
474
|
+
NOT_EXTENDED: Callable[..., ResponseError] # 510
|
475
|
+
NETWORK_AUTHENTICATION_REQUIRED: Callable[..., ResponseError] # 511
|
476
|
+
|
477
|
+
def __init__(self, message=None, status_code: int | None = None, **kwargs):
|
478
|
+
"""Check error status."""
|
479
|
+
content = message or HTTPStatus(status_code or self.status_code).description
|
480
|
+
super().__init__(content=content, status_code=status_code, **kwargs)
|
481
|
+
assert self.status_code >= 400, f"Invalid status code for an error: {self.status_code}"
|
482
|
+
|
483
|
+
|
484
|
+
CAST_RESPONSE: Mapping[type, type[Response]] = {
|
485
|
+
bool: ResponseJSON,
|
486
|
+
bytes: ResponseHTML,
|
487
|
+
dict: ResponseJSON,
|
488
|
+
int: ResponseJSON,
|
489
|
+
list: ResponseJSON,
|
490
|
+
str: ResponseHTML,
|
491
|
+
type(None): ResponseJSON,
|
492
|
+
}
|
493
|
+
|
494
|
+
|
495
|
+
def parse_response(response, headers: dict | None = None) -> Response:
|
496
|
+
"""Parse the given object and convert it into a asgi_tools.Response."""
|
497
|
+
if isinstance(response, Response):
|
498
|
+
return response
|
499
|
+
|
500
|
+
rtype = type(response)
|
501
|
+
response_type = CAST_RESPONSE.get(rtype)
|
502
|
+
if response_type:
|
503
|
+
return response_type(response, headers=headers)
|
504
|
+
|
505
|
+
if rtype is tuple:
|
506
|
+
status, *contents = response
|
507
|
+
assert isinstance(status, int), "Invalid Response Status"
|
508
|
+
if len(contents) > 1:
|
509
|
+
headers, *contents = contents
|
510
|
+
response = parse_response(
|
511
|
+
contents[0] or "" if contents else "",
|
512
|
+
headers=headers,
|
513
|
+
)
|
514
|
+
response.status_code = status
|
515
|
+
return response
|
516
|
+
|
517
|
+
return ResponseText(str(response), headers=headers)
|
518
|
+
|
519
|
+
|
520
|
+
def parse_websocket_msg(msg: TASGIMessage, charset: str | None = None) -> TASGIMessage | str:
|
521
|
+
"""Prepare websocket message."""
|
522
|
+
data = msg.get("text")
|
523
|
+
if data:
|
524
|
+
return data
|
525
|
+
|
526
|
+
data = msg.get("bytes")
|
527
|
+
if data:
|
528
|
+
return data.decode(charset)
|
529
|
+
|
530
|
+
return msg
|
531
|
+
|
532
|
+
|
533
|
+
async def empty():
|
534
|
+
yield b""
|
535
|
+
|
536
|
+
|
537
|
+
# ruff: noqa: ERA001
|
asgi_tools/router.py
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import ClassVar
|
4
|
+
|
5
|
+
from http_router import Router as HTTPRouter
|
6
|
+
|
7
|
+
from .errors import ASGIError, ASGIInvalidMethodError, ASGINotFoundError
|
8
|
+
|
9
|
+
|
10
|
+
class Router(HTTPRouter):
|
11
|
+
"""Rebind router errors."""
|
12
|
+
|
13
|
+
NotFoundError: ClassVar[type[Exception]] = ASGINotFoundError
|
14
|
+
RouterError: ClassVar[type[Exception]] = ASGIError
|
15
|
+
InvalidMethodError: ClassVar[type[Exception]] = ASGIInvalidMethodError
|