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.
@@ -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)