asgi-tools 1.2.0__cp313-cp313-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/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