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/__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-313-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-313-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/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()
|