asgi-tools 1.2.0__cp310-cp310-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-310-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-310-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/middleware.py
ADDED
@@ -0,0 +1,458 @@
|
|
1
|
+
"""ASGI-Tools Middlewares."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import abc
|
6
|
+
from contextlib import suppress
|
7
|
+
from contextvars import ContextVar
|
8
|
+
from functools import partial
|
9
|
+
from inspect import isawaitable
|
10
|
+
from pathlib import Path
|
11
|
+
from typing import TYPE_CHECKING, Awaitable, Callable, Final, Mapping
|
12
|
+
|
13
|
+
from http_router import Router
|
14
|
+
|
15
|
+
from .errors import ASGIError
|
16
|
+
from .logs import logger
|
17
|
+
from .request import Request
|
18
|
+
from .response import Response, ResponseError, ResponseFile, ResponseRedirect, parse_response
|
19
|
+
from .utils import to_awaitable
|
20
|
+
|
21
|
+
if TYPE_CHECKING:
|
22
|
+
from .types import TASGIApp, TASGIMessage, TASGIReceive, TASGIScope, TASGISend
|
23
|
+
|
24
|
+
|
25
|
+
class BaseMiddleware(metaclass=abc.ABCMeta):
|
26
|
+
"""Base class for ASGI-Tools middlewares."""
|
27
|
+
|
28
|
+
scopes: tuple[str, ...] = ("http", "websocket")
|
29
|
+
|
30
|
+
def __init__(self, app: TASGIApp | None = None) -> None:
|
31
|
+
"""Save ASGI App."""
|
32
|
+
self.bind(app)
|
33
|
+
|
34
|
+
def __call__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend) -> Awaitable:
|
35
|
+
"""Handle ASGI call."""
|
36
|
+
if scope["type"] in self.scopes:
|
37
|
+
return self.__process__(scope, receive, send)
|
38
|
+
|
39
|
+
return self.app(scope, receive, send)
|
40
|
+
|
41
|
+
@abc.abstractmethod
|
42
|
+
async def __process__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
|
43
|
+
"""Do the middleware's logic."""
|
44
|
+
raise NotImplementedError()
|
45
|
+
|
46
|
+
@classmethod
|
47
|
+
def setup(cls, **params) -> Callable:
|
48
|
+
"""Setup the middleware without an initialization."""
|
49
|
+
return partial(cls, **params) # type: ignore[abstract]
|
50
|
+
|
51
|
+
def bind(self, app: TASGIApp | None = None):
|
52
|
+
"""Rebind the middleware to an ASGI application if it has been inited already."""
|
53
|
+
self.app = app or ResponseError.NOT_FOUND()
|
54
|
+
return self
|
55
|
+
|
56
|
+
|
57
|
+
# Backward compatibility
|
58
|
+
BaseMiddeware = BaseMiddleware
|
59
|
+
|
60
|
+
|
61
|
+
class ResponseMiddleware(BaseMiddleware):
|
62
|
+
"""Automatically convert ASGI_ apps results into responses :class:`~asgi_tools.Response` and
|
63
|
+
send them to server as ASGI_ messages.
|
64
|
+
|
65
|
+
.. code-block:: python
|
66
|
+
|
67
|
+
from asgi_tools import ResponseMiddleware, ResponseText, ResponseRedirect
|
68
|
+
|
69
|
+
async def app(scope, receive, send):
|
70
|
+
# ResponseMiddleware catches ResponseError, ResponseRedirect and convert the exceptions
|
71
|
+
# into HTTP response
|
72
|
+
if scope['path'] == '/user':
|
73
|
+
raise ResponseRedirect('/login')
|
74
|
+
|
75
|
+
# Return ResponseHTML
|
76
|
+
if scope['method'] == 'GET':
|
77
|
+
return '<b>HTML is here</b>'
|
78
|
+
|
79
|
+
# Return ResponseJSON
|
80
|
+
if scope['method'] == 'POST':
|
81
|
+
return {'json': 'here'}
|
82
|
+
|
83
|
+
# Return any response explicitly
|
84
|
+
if scope['method'] == 'PUT':
|
85
|
+
return ResponseText('response is here')
|
86
|
+
|
87
|
+
# Short form to responses: (status_code, body) or (status_code, body, headers)
|
88
|
+
return 405, 'Unknown method'
|
89
|
+
|
90
|
+
app = ResponseMiddleware(app)
|
91
|
+
|
92
|
+
The conversion rules:
|
93
|
+
|
94
|
+
* :class:`Response` objects will be directly returned from the view
|
95
|
+
* ``dict``, ``list``, ``int``, ``bool``, ``None`` results will be converted
|
96
|
+
into :class:`ResponseJSON`
|
97
|
+
* ``str``, ``bytes`` results will be converted into :class:`ResponseHTML`
|
98
|
+
* ``tuple[int, Any, dict]`` will be converted into a :class:`Response` with
|
99
|
+
``int`` status code, ``dict`` will be used as headers, ``Any`` will be used
|
100
|
+
to define the response's type
|
101
|
+
|
102
|
+
.. code-block:: python
|
103
|
+
|
104
|
+
from asgi_tools import ResponseMiddleware
|
105
|
+
|
106
|
+
# The result will be converted into HTML 404 response with the 'Not Found' body
|
107
|
+
async def app(request, receive, send):
|
108
|
+
return 404, 'Not Found'
|
109
|
+
|
110
|
+
app = ResponseMiddleware(app)
|
111
|
+
|
112
|
+
You are able to raise :class:`ResponseError` from yours ASGI_ apps and it
|
113
|
+
will be catched and returned as a response
|
114
|
+
|
115
|
+
"""
|
116
|
+
|
117
|
+
async def __process__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
|
118
|
+
"""Parse responses from callbacks."""
|
119
|
+
try:
|
120
|
+
result = await self.app(scope, receive, self.send)
|
121
|
+
response = parse_response(result)
|
122
|
+
await response(scope, receive, send)
|
123
|
+
|
124
|
+
except (ResponseError, ResponseRedirect) as exc:
|
125
|
+
await exc(scope, receive, send)
|
126
|
+
|
127
|
+
def send(self, _: TASGIMessage):
|
128
|
+
raise RuntimeError("You can't use send() method in ResponseMiddleware")
|
129
|
+
|
130
|
+
def bind(self, app: TASGIApp | None = None):
|
131
|
+
"""Rebind the middleware to an ASGI application if it has been inited already."""
|
132
|
+
self.app = app or to_awaitable(lambda *_: ResponseError.NOT_FOUND())
|
133
|
+
return self
|
134
|
+
|
135
|
+
|
136
|
+
class RequestMiddleware(BaseMiddleware):
|
137
|
+
"""Automatically create :class:`asgi_tools.Request` from the scope and pass it to ASGI_ apps.
|
138
|
+
|
139
|
+
.. code-block:: python
|
140
|
+
|
141
|
+
from asgi_tools import RequestMiddleware, Response
|
142
|
+
|
143
|
+
async def app(scope, receive, send):
|
144
|
+
content = f"{ request.method } { request.url.path }"
|
145
|
+
response = Response(content)
|
146
|
+
await response(scope, receive, send)
|
147
|
+
|
148
|
+
app = RequestMiddleware(app)
|
149
|
+
|
150
|
+
"""
|
151
|
+
|
152
|
+
async def __process__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
|
153
|
+
"""Replace scope with request object."""
|
154
|
+
return await self.app(Request(scope, receive, send), receive, send)
|
155
|
+
|
156
|
+
|
157
|
+
class LifespanMiddleware(BaseMiddleware):
|
158
|
+
"""Manage ASGI_ Lifespan events.
|
159
|
+
|
160
|
+
:param ignore_errors: Ignore errors from startup/shutdown handlers
|
161
|
+
:param on_startup: the list of callables to run when the app is starting
|
162
|
+
:param on_shutdown: the list of callables to run when the app is finishing
|
163
|
+
|
164
|
+
.. code-block:: python
|
165
|
+
|
166
|
+
from asgi_tools import LifespanMiddleware, Response
|
167
|
+
|
168
|
+
async def app(scope, receive, send):
|
169
|
+
response = Response('OK')
|
170
|
+
await response(scope, receive, send)
|
171
|
+
|
172
|
+
app = lifespan = LifespanMiddleware(app)
|
173
|
+
|
174
|
+
@lifespan.on_startup
|
175
|
+
async def start():
|
176
|
+
print('The app is starting')
|
177
|
+
|
178
|
+
@lifespan.on_shutdown
|
179
|
+
async def start():
|
180
|
+
print('The app is finishing')
|
181
|
+
|
182
|
+
Lifespan middleware may be used as an async context manager for testing purposes
|
183
|
+
|
184
|
+
.. code-block: python
|
185
|
+
|
186
|
+
async def test_my_app():
|
187
|
+
|
188
|
+
# ...
|
189
|
+
|
190
|
+
# Registered startup/shutdown handlers will be called
|
191
|
+
async with lifespan:
|
192
|
+
# ... do something
|
193
|
+
|
194
|
+
"""
|
195
|
+
|
196
|
+
scopes = ("lifespan",)
|
197
|
+
|
198
|
+
def __init__(
|
199
|
+
self,
|
200
|
+
app: TASGIApp | None = None,
|
201
|
+
*,
|
202
|
+
logger=logger,
|
203
|
+
ignore_errors: bool = False,
|
204
|
+
on_startup: Callable | list[Callable] | None = None,
|
205
|
+
on_shutdown: Callable | list[Callable] | None = None,
|
206
|
+
) -> None:
|
207
|
+
"""Prepare the middleware."""
|
208
|
+
super(LifespanMiddleware, self).__init__(app)
|
209
|
+
self.ignore_errors = ignore_errors
|
210
|
+
self.logger = logger
|
211
|
+
self.__startup__: list[Callable] = []
|
212
|
+
self.__shutdown__: list[Callable] = []
|
213
|
+
self.__register__(on_startup, self.__startup__)
|
214
|
+
self.__register__(on_shutdown, self.__shutdown__)
|
215
|
+
|
216
|
+
async def __process__(self, _: TASGIScope, receive: TASGIReceive, send: TASGISend):
|
217
|
+
"""Manage lifespan cycle."""
|
218
|
+
while True:
|
219
|
+
message = await receive()
|
220
|
+
if message["type"] == "lifespan.startup":
|
221
|
+
msg = await self.run("startup", send)
|
222
|
+
await send(msg)
|
223
|
+
|
224
|
+
elif message["type"] == "lifespan.shutdown":
|
225
|
+
msg = await self.run("shutdown", send)
|
226
|
+
await send(msg)
|
227
|
+
break
|
228
|
+
|
229
|
+
def __register__(
|
230
|
+
self, handlers: Callable | list[Callable] | None, container: list[Callable]
|
231
|
+
) -> None:
|
232
|
+
"""Register lifespan handlers."""
|
233
|
+
if not handlers:
|
234
|
+
return
|
235
|
+
|
236
|
+
if not isinstance(handlers, list):
|
237
|
+
handlers = [handlers]
|
238
|
+
|
239
|
+
container += handlers
|
240
|
+
|
241
|
+
async def __aenter__(self):
|
242
|
+
"""Use the lifespan middleware as a context manager."""
|
243
|
+
await self.run("startup")
|
244
|
+
return self
|
245
|
+
|
246
|
+
async def __aexit__(self, *_):
|
247
|
+
"""Use the lifespan middleware as a context manager."""
|
248
|
+
await self.run("shutdown")
|
249
|
+
|
250
|
+
async def run(self, event: str, _: TASGISend | None = None):
|
251
|
+
"""Run startup/shutdown handlers."""
|
252
|
+
assert event in {"startup", "shutdown"}
|
253
|
+
handlers = getattr(self, f"__{event}__")
|
254
|
+
|
255
|
+
for handler in handlers:
|
256
|
+
try:
|
257
|
+
res = handler()
|
258
|
+
if isawaitable(res):
|
259
|
+
await res
|
260
|
+
|
261
|
+
except Exception as exc: # noqa: PERF203
|
262
|
+
self.logger.exception("%s method '%s' raises an exception.", event.title(), handler)
|
263
|
+
if self.ignore_errors:
|
264
|
+
continue
|
265
|
+
|
266
|
+
self.logger.exception("Lifespans process failed")
|
267
|
+
return {"type": f"lifespan.{event}.failed", "message": str(exc)}
|
268
|
+
|
269
|
+
return {"type": f"lifespan.{event}.complete"}
|
270
|
+
|
271
|
+
def on_startup(self, fn: Callable) -> None:
|
272
|
+
"""Add a function to startup."""
|
273
|
+
self.__register__(fn, self.__startup__)
|
274
|
+
|
275
|
+
def on_shutdown(self, fn: Callable) -> None:
|
276
|
+
"""Add a function to shutdown."""
|
277
|
+
self.__register__(fn, self.__shutdown__)
|
278
|
+
|
279
|
+
|
280
|
+
class RouterMiddleware(BaseMiddleware):
|
281
|
+
r"""Manage routing.
|
282
|
+
|
283
|
+
.. code-block:: python
|
284
|
+
|
285
|
+
from asgi_tools import RouterMiddleware, ResponseHTML, ResponseError
|
286
|
+
|
287
|
+
async def default_app(scope, receive, send):
|
288
|
+
response = ResponseError.NOT_FOUND()
|
289
|
+
await response(scope, receive, send)
|
290
|
+
|
291
|
+
app = router = RouterMiddleware(default_app)
|
292
|
+
|
293
|
+
@router.route('/status', '/stat')
|
294
|
+
async def status(scope, receive, send):
|
295
|
+
response = ResponseHTML('STATUS OK')
|
296
|
+
await response(scope, receive, send)
|
297
|
+
|
298
|
+
# Bind methods
|
299
|
+
# ------------
|
300
|
+
@router.route('/only-post', methods=['POST'])
|
301
|
+
async def only_post(scope, receive, send):
|
302
|
+
response = ResponseHTML('POST OK')
|
303
|
+
await response(scope, receive, send)
|
304
|
+
|
305
|
+
# Regexp paths
|
306
|
+
# ------------
|
307
|
+
import re
|
308
|
+
|
309
|
+
@router.route(re.compile(r'/\d+/?'))
|
310
|
+
async def num(scope, receive, send):
|
311
|
+
num = int(scope['path'].strip('/'))
|
312
|
+
response = ResponseHTML(f'Number { num }')
|
313
|
+
await response(scope, receive, send)
|
314
|
+
|
315
|
+
# Dynamic paths
|
316
|
+
# -------------
|
317
|
+
|
318
|
+
@router.route('/hello/{name}')
|
319
|
+
async def hello(scope, receive, send):
|
320
|
+
name = scope['path_params']['name']
|
321
|
+
response = ResponseHTML(f'Hello { name.title() }')
|
322
|
+
await response(scope, receive, send)
|
323
|
+
|
324
|
+
# Set regexp for params
|
325
|
+
@router.route(r'/multiply/{first:\d+}/{second:\d+}')
|
326
|
+
async def multiply(scope, receive, send):
|
327
|
+
first, second = map(int, scope['path_params'].values())
|
328
|
+
response = ResponseHTML(str(first * second))
|
329
|
+
await response(scope, receive, send)
|
330
|
+
|
331
|
+
Path parameters are made available in the request/scope, as the ``path_params`` dictionary.
|
332
|
+
|
333
|
+
"""
|
334
|
+
|
335
|
+
def __init__(self, app: TASGIApp | None = None, router: Router | None = None) -> None:
|
336
|
+
"""Initialize HTTP router."""
|
337
|
+
super().__init__(app)
|
338
|
+
self.router = router or Router(validator=callable)
|
339
|
+
|
340
|
+
async def __process__(self, scope: TASGIScope, *args):
|
341
|
+
"""Get an app and process."""
|
342
|
+
app, scope["path_params"] = self.__dispatch__(scope)
|
343
|
+
return await app(scope, *args)
|
344
|
+
|
345
|
+
def __dispatch__(self, scope: TASGIScope) -> tuple[Callable, Mapping | None]:
|
346
|
+
"""Lookup for a callback."""
|
347
|
+
path = f"{scope.get('root_path', '')}{scope['path']}"
|
348
|
+
try:
|
349
|
+
match = self.router(path, scope["method"])
|
350
|
+
|
351
|
+
except self.router.RouterError:
|
352
|
+
return self.app, {}
|
353
|
+
|
354
|
+
else:
|
355
|
+
return match.target, match.params # type: ignore[]
|
356
|
+
|
357
|
+
def route(self, *args, **kwargs):
|
358
|
+
"""Register a route."""
|
359
|
+
return self.router.route(*args, **kwargs)
|
360
|
+
|
361
|
+
|
362
|
+
class StaticFilesMiddleware(BaseMiddleware):
|
363
|
+
"""Serve static files.
|
364
|
+
|
365
|
+
:param url_prefix: an URL prefix for static files
|
366
|
+
:type url_prefix: str, "/static"
|
367
|
+
:param folders: Paths to folders with static files
|
368
|
+
:type folders: list[str]
|
369
|
+
|
370
|
+
.. code-block:: python
|
371
|
+
|
372
|
+
from asgi_tools import StaticFilesMiddleware, ResponseHTML
|
373
|
+
|
374
|
+
async def app(scope, receive, send):
|
375
|
+
response = ResponseHTML('OK')
|
376
|
+
await response(scope, receive, send)
|
377
|
+
|
378
|
+
# Files from static folder will be served from /static
|
379
|
+
app = StaticFilesMiddleware(app, folders=['static'])
|
380
|
+
|
381
|
+
"""
|
382
|
+
|
383
|
+
scopes = ("http",)
|
384
|
+
|
385
|
+
def __init__(
|
386
|
+
self,
|
387
|
+
app: TASGIApp | None = None,
|
388
|
+
url_prefix: str = "/static",
|
389
|
+
folders: list[str | Path] | None = None,
|
390
|
+
) -> None:
|
391
|
+
"""Initialize the middleware."""
|
392
|
+
super().__init__(app)
|
393
|
+
self.url_prefix = url_prefix
|
394
|
+
folders = folders or []
|
395
|
+
self.folders: list[Path] = [Path(folder) for folder in folders]
|
396
|
+
|
397
|
+
async def __process__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend) -> None:
|
398
|
+
"""Serve static files for self url prefix."""
|
399
|
+
path = scope["path"]
|
400
|
+
url_prefix = self.url_prefix
|
401
|
+
if path.startswith(url_prefix):
|
402
|
+
response: Response | None = None
|
403
|
+
filename = path[len(url_prefix) :].strip("/")
|
404
|
+
for folder in self.folders:
|
405
|
+
filepath = folder.joinpath(filename).resolve()
|
406
|
+
if folder != filepath.parent:
|
407
|
+
continue
|
408
|
+
with suppress(ASGIError):
|
409
|
+
response = ResponseFile(filepath, headers_only=scope["method"] == "HEAD")
|
410
|
+
break
|
411
|
+
|
412
|
+
response = response or ResponseError(status_code=404)
|
413
|
+
await response(scope, receive, send)
|
414
|
+
|
415
|
+
else:
|
416
|
+
await self.app(scope, receive, send)
|
417
|
+
|
418
|
+
|
419
|
+
BACKGROUND_TASK: Final = ContextVar[list[Awaitable] | None]("background_task", default=None)
|
420
|
+
|
421
|
+
|
422
|
+
class BackgroundMiddleware(BaseMiddleware):
|
423
|
+
"""Run background tasks.
|
424
|
+
|
425
|
+
|
426
|
+
.. code-block:: python
|
427
|
+
|
428
|
+
from asgi_tools import BackgroundMiddleware, ResponseText
|
429
|
+
|
430
|
+
async def app(scope, receive, send):
|
431
|
+
response = ResponseText('OK)
|
432
|
+
|
433
|
+
# Schedule any awaitable for later execution
|
434
|
+
BackgroundMiddleware.set_task(asyncio.sleep(1))
|
435
|
+
|
436
|
+
# Return response immediately
|
437
|
+
await response(scope, receive, send)
|
438
|
+
|
439
|
+
# The task will be executed after the response is sent
|
440
|
+
|
441
|
+
app = BackgroundMiddleware(app)
|
442
|
+
|
443
|
+
"""
|
444
|
+
|
445
|
+
async def __process__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
|
446
|
+
"""Run background tasks."""
|
447
|
+
await self.app(scope, receive, send)
|
448
|
+
for task in BACKGROUND_TASK.get() or []:
|
449
|
+
await task
|
450
|
+
|
451
|
+
BACKGROUND_TASK.set(None) # Clear the context variable
|
452
|
+
|
453
|
+
@staticmethod
|
454
|
+
def set_task(task: Awaitable):
|
455
|
+
"""Set a task for background execution."""
|
456
|
+
tasks = BACKGROUND_TASK.get() or []
|
457
|
+
tasks.append(task)
|
458
|
+
BACKGROUND_TASK.set(tasks)
|