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/request.py ADDED
@@ -0,0 +1,337 @@
1
+ """ASGI-Tools includes a `asgi_tools.Request` class that gives you a nicer interface onto the
2
+ incoming request.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from http import cookies
8
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Iterator, Mapping, Protocol, TypeVar
9
+
10
+ from yarl import URL
11
+
12
+ from ._compat import json_loads
13
+ from .constants import DEFAULT_CHARSET
14
+ from .errors import ASGIDecodeError
15
+ from .forms import read_formdata
16
+ from .types import TJSON, TASGIReceive, TASGIScope, TASGISend
17
+ from .utils import CIMultiDict, parse_headers, parse_options_header
18
+
19
+ if TYPE_CHECKING:
20
+ from pathlib import Path
21
+
22
+ from multidict import MultiDict, MultiDictProxy
23
+
24
+ T = TypeVar("T")
25
+
26
+
27
+ class UploadHandler(Protocol):
28
+ """Protocol for file upload handlers."""
29
+
30
+ async def __call__(self, filename: str, content_type: str, content: bytes) -> str | Path: ...
31
+
32
+
33
+ class Request(TASGIScope):
34
+ """Provides a convenient, high-level interface for incoming HTTP requests.
35
+
36
+ :param scope: HTTP ASGI Scope
37
+ :param receive: an asynchronous callable which lets the application
38
+ receive event messages from the client
39
+ :param send: an asynchronous callable which lets the application
40
+ send event messages to the client
41
+
42
+ """
43
+
44
+ __slots__ = (
45
+ "_body",
46
+ "_cookies",
47
+ "_form",
48
+ "_headers",
49
+ "_is_read",
50
+ "_media",
51
+ "_url",
52
+ "receive",
53
+ "scope",
54
+ "send",
55
+ )
56
+
57
+ def __init__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
58
+ """Create a request based on the given scope."""
59
+ self.scope = scope
60
+ self.receive = receive
61
+ self.send = send
62
+
63
+ self._is_read: bool = False
64
+ self._url: URL | None = None
65
+ self._body: bytes | None = None
66
+ self._form: MultiDict | None = None
67
+ self._headers: CIMultiDict | None = None
68
+ self._media: dict[str, str] | None = None
69
+ self._cookies: dict[str, str] | None = None
70
+
71
+ def __str__(self) -> str:
72
+ """Return the request's params."""
73
+ scope_type = self.scope["type"]
74
+ if scope_type == "websocket":
75
+ return f"{scope_type} {self.path}"
76
+
77
+ return f"{scope_type} {self.method} {self.url.path}"
78
+
79
+ def __repr__(self):
80
+ """Represent the request."""
81
+ return f"<Request {self}>"
82
+
83
+ def __getitem__(self, key: str) -> Any:
84
+ """Proxy the method to the scope."""
85
+ return self.scope[key]
86
+
87
+ def __setitem__(self, key: str, value: Any) -> None:
88
+ """Proxy the method to the scope."""
89
+ self.scope[key] = value
90
+
91
+ def __delitem__(self, key: str) -> None:
92
+ """Proxy the method to the scope."""
93
+ del self.scope[key]
94
+
95
+ def __iter__(self) -> Iterator[str]:
96
+ """Proxy the method to the scope."""
97
+ return iter(self.scope)
98
+
99
+ def __len__(self) -> int:
100
+ """Proxy the method to the scope."""
101
+ return len(self.scope)
102
+
103
+ def __getattr__(self, name: str) -> Any:
104
+ """Proxy the request's unknown attributes to scope."""
105
+ return self.scope[name]
106
+
107
+ def __copy__(self, **mutations) -> Request:
108
+ """Copy the request to a new one."""
109
+ return Request(dict(self.scope, **mutations), self.receive, self.send)
110
+
111
+ @property
112
+ def url(self) -> URL:
113
+ """A lazy property that parses the current URL and returns :class:`yarl.URL` object.
114
+
115
+ .. code-block:: python
116
+
117
+ request = Request(scope)
118
+ assert str(request.url) == '... the full http URL ..'
119
+ assert request.url.scheme
120
+ assert request.url.host
121
+ assert request.url.query is not None
122
+ assert request.url.query_string is not None
123
+
124
+ See :py:mod:`yarl` documentation for further reference.
125
+ """
126
+ if self._url is None:
127
+ scope = self.scope
128
+ host = self.headers.get("host")
129
+ if host is None:
130
+ if "server" in scope:
131
+ host, port = scope["server"]
132
+ if port:
133
+ host = f"{host}:{port}"
134
+ else:
135
+ host = "localhost"
136
+
137
+ self._url = URL.build(
138
+ host=host,
139
+ scheme=scope.get("scheme", "http"),
140
+ encoded=True,
141
+ path=f"{ scope.get('root_path', '') }{ scope['path'] }",
142
+ query_string=scope["query_string"].decode(encoding="ascii"),
143
+ )
144
+
145
+ return self._url
146
+
147
+ @property
148
+ def headers(self) -> CIMultiDict:
149
+ """A lazy property that parses the current scope's headers, decodes them as strings and
150
+ returns case-insensitive multi-dict :py:class:`multidict.CIMultiDict`.
151
+
152
+ .. code-block:: python
153
+
154
+ request = Request(scope)
155
+
156
+ assert request.headers['content-type']
157
+ assert request.headers['authorization']
158
+
159
+ See :py:mod:`multidict` documentation for futher reference.
160
+
161
+ """
162
+ if self._headers is None:
163
+ self._headers = parse_headers(self.scope["headers"])
164
+ return self._headers
165
+
166
+ @property
167
+ def cookies(self) -> dict[str, str]:
168
+ """A lazy property that parses the current scope's cookies and returns a dictionary.
169
+
170
+ .. code-block:: python
171
+
172
+ request = Request(scope)
173
+ ses = request.cookies.get('session')
174
+
175
+ """
176
+ if self._cookies is None:
177
+ self._cookies = {}
178
+ cookie = self.headers.get("cookie")
179
+ if cookie:
180
+ for chunk in cookie.split(";"):
181
+ key, _, val = chunk.partition("=")
182
+ self._cookies[key.strip()] = cookies._unquote(val.strip())
183
+
184
+ return self._cookies
185
+
186
+ @property
187
+ def media(self) -> Mapping[str, str]:
188
+ """Prepare a media data for the request."""
189
+ if self._media is None:
190
+ content_type_header = self.headers.get("content-type", "")
191
+ content_type, opts = parse_options_header(content_type_header)
192
+ self._media = dict(opts, content_type=content_type)
193
+
194
+ return self._media
195
+
196
+ @property
197
+ def charset(self) -> str:
198
+ """Get an encoding charset for the current scope."""
199
+ return self.media.get("charset", DEFAULT_CHARSET)
200
+
201
+ @property
202
+ def query(self) -> MultiDictProxy[str]:
203
+ """A lazy property that parse the current query string and returns it as a
204
+ :py:class:`multidict.MultiDict`.
205
+
206
+ """
207
+ return self.url.query
208
+
209
+ @property
210
+ def content_type(self) -> str:
211
+ """Get a content type for the current scope."""
212
+ return self.media["content_type"]
213
+
214
+ async def stream(self) -> AsyncGenerator:
215
+ """Stream the request's body.
216
+
217
+ The method provides byte chunks without storing the entire body to memory.
218
+ Any subsequent calls to :py:meth:`body`, :py:meth:`form`, :py:meth:`json`
219
+ or :py:meth:`data` will raise an error.
220
+
221
+ .. warning::
222
+ You can only read stream once. Second call raises an error. Save a readed stream into a
223
+ variable if you need.
224
+
225
+ """
226
+ if self._is_read:
227
+ if self._body is None:
228
+ raise RuntimeError("Stream has been read")
229
+ yield self._body
230
+
231
+ else:
232
+ self._is_read = True
233
+ while True:
234
+ message = await self.receive()
235
+ yield message.get("body", b"")
236
+ if not message.get("more_body"):
237
+ break
238
+
239
+ async def body(self) -> bytes:
240
+ """Read and return the request's body as bytes.
241
+
242
+ `body = await request.body()`
243
+ """
244
+ if self._body is None:
245
+ data = bytearray()
246
+ async for chunk in self.stream():
247
+ data.extend(chunk)
248
+ self._body = bytes(data)
249
+
250
+ return self._body
251
+
252
+ async def text(self) -> str:
253
+ """Read and return the request's body as a string.
254
+
255
+ `text = await request.text()`
256
+ """
257
+ body = await self.body()
258
+ try:
259
+ return body.decode(self.charset or DEFAULT_CHARSET)
260
+ except (LookupError, ValueError) as exc:
261
+ raise ASGIDecodeError from exc
262
+
263
+ async def json(self) -> TJSON:
264
+ """Read and return the request's body as a JSON.
265
+
266
+ `json = await request.json()`
267
+
268
+ """
269
+ try:
270
+ return json_loads(await self.body())
271
+ except (LookupError, ValueError) as exc:
272
+ raise ASGIDecodeError from exc
273
+
274
+ async def form(
275
+ self,
276
+ max_size: int = 0,
277
+ upload_to: UploadHandler | None = None,
278
+ file_memory_limit: int = 1024 * 1024,
279
+ ) -> MultiDict:
280
+ """Read and return the request's form data.
281
+
282
+ :param max_size: Maximum size of the form data in bytes
283
+ :type max_size: int
284
+ :param upload_to: Callable to handle file uploads
285
+ :type upload_to: Optional[UploadHandler]
286
+ :param file_memory_limit: Maximum size of file to keep in memory
287
+ :type file_memory_limit: int
288
+ :return: Form data as MultiDict
289
+ :rtype: MultiDict
290
+
291
+ `formdata = await request.form()`
292
+ """
293
+ if self._form is None:
294
+ self._form = await read_formdata(
295
+ self,
296
+ max_size=max_size,
297
+ upload_to=upload_to,
298
+ file_memory_limit=file_memory_limit,
299
+ )
300
+ return self._form
301
+
302
+ async def data(self, *, raise_errors: bool = False) -> str | bytes | MultiDict | TJSON:
303
+ """Read and return the request's data based on content type.
304
+
305
+ :param raise_errors: Raise an error if the given data is invalid.
306
+ :return: Request data in appropriate format
307
+ :rtype: Union[str, bytes, MultiDict, TJSON]
308
+ :raises ASGIDecodeError: If data cannot be decoded and raise_errors is True
309
+
310
+ `data = await request.data()`
311
+
312
+ If `raise_errors` is false (by default) and the given data is invalid (ex. invalid json)
313
+ the request's body would be returned.
314
+
315
+ Returns data from :py:meth:`json` for `application/json`, :py:meth:`form` for
316
+ `application/x-www-form-urlencoded`, `multipart/form-data` and :py:meth:`text` otherwise.
317
+ """
318
+ content_type = self.content_type
319
+ try:
320
+ if content_type in {
321
+ "multipart/form-data",
322
+ "application/x-www-form-urlencoded",
323
+ }:
324
+ return await self.form()
325
+
326
+ if content_type == "application/json":
327
+ return await self.json()
328
+
329
+ except ASGIDecodeError:
330
+ if raise_errors:
331
+ raise
332
+ return await self.body()
333
+
334
+ if content_type.startswith("text/"):
335
+ return await self.text()
336
+
337
+ return await self.body()