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/__init__.py
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
"""ASGI-Tools -- Tools to make ASGI Applications"""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from http_router import InvalidMethodError, NotFoundError
|
6
|
+
|
7
|
+
from .app import App
|
8
|
+
from .errors import ASGIConnectionClosedError, ASGIError, ASGIInvalidMethodError, ASGINotFoundError
|
9
|
+
from .middleware import (
|
10
|
+
BackgroundMiddleware,
|
11
|
+
LifespanMiddleware,
|
12
|
+
RequestMiddleware,
|
13
|
+
ResponseMiddleware,
|
14
|
+
RouterMiddleware,
|
15
|
+
StaticFilesMiddleware,
|
16
|
+
)
|
17
|
+
from .request import Request
|
18
|
+
from .response import (
|
19
|
+
Response,
|
20
|
+
ResponseError,
|
21
|
+
ResponseFile,
|
22
|
+
ResponseHTML,
|
23
|
+
ResponseJSON,
|
24
|
+
ResponseRedirect,
|
25
|
+
ResponseSSE,
|
26
|
+
ResponseStream,
|
27
|
+
ResponseText,
|
28
|
+
ResponseWebSocket,
|
29
|
+
parse_response,
|
30
|
+
)
|
31
|
+
from .view import HTTPView
|
32
|
+
|
33
|
+
__all__ = ( # noqa: RUF022
|
34
|
+
# Errors
|
35
|
+
"ASGIConnectionClosedError",
|
36
|
+
"ASGIError",
|
37
|
+
"ASGIInvalidMethodError",
|
38
|
+
"ASGINotFoundError",
|
39
|
+
"InvalidMethodError",
|
40
|
+
"NotFoundError",
|
41
|
+
# App/handlers
|
42
|
+
"App",
|
43
|
+
"HTTPView",
|
44
|
+
# Request/Response
|
45
|
+
"Request",
|
46
|
+
"Response",
|
47
|
+
"ResponseError",
|
48
|
+
"ResponseFile",
|
49
|
+
"ResponseHTML",
|
50
|
+
"ResponseJSON",
|
51
|
+
"ResponseRedirect",
|
52
|
+
"ResponseSSE",
|
53
|
+
"ResponseStream",
|
54
|
+
"ResponseText",
|
55
|
+
"ResponseWebSocket",
|
56
|
+
# Middleware
|
57
|
+
"BackgroundMiddleware",
|
58
|
+
"LifespanMiddleware",
|
59
|
+
"RequestMiddleware",
|
60
|
+
"ResponseMiddleware",
|
61
|
+
"RouterMiddleware",
|
62
|
+
"StaticFilesMiddleware",
|
63
|
+
# Utils
|
64
|
+
"parse_response",
|
65
|
+
)
|
asgi_tools/_compat.py
ADDED
@@ -0,0 +1,259 @@
|
|
1
|
+
"""Compatibility layer."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
import inspect
|
7
|
+
from asyncio import create_task, gather, sleep
|
8
|
+
from concurrent.futures import ALL_COMPLETED, FIRST_COMPLETED
|
9
|
+
from contextlib import asynccontextmanager, suppress
|
10
|
+
from typing import (
|
11
|
+
TYPE_CHECKING,
|
12
|
+
Any,
|
13
|
+
AsyncGenerator,
|
14
|
+
Awaitable,
|
15
|
+
Callable,
|
16
|
+
Coroutine,
|
17
|
+
Union,
|
18
|
+
cast,
|
19
|
+
)
|
20
|
+
|
21
|
+
if TYPE_CHECKING:
|
22
|
+
from pathlib import Path
|
23
|
+
|
24
|
+
from .types import TJSON
|
25
|
+
|
26
|
+
|
27
|
+
from json import dumps, loads
|
28
|
+
|
29
|
+
from sniffio import current_async_library
|
30
|
+
|
31
|
+
__all__ = (
|
32
|
+
"aio_cancel",
|
33
|
+
"aio_sleep",
|
34
|
+
"aio_spawn",
|
35
|
+
"aio_stream_file",
|
36
|
+
"aio_wait",
|
37
|
+
"aiofile_installed",
|
38
|
+
"create_task",
|
39
|
+
"curio_installed",
|
40
|
+
"json_dumps",
|
41
|
+
"json_loads",
|
42
|
+
"trio_installed",
|
43
|
+
)
|
44
|
+
|
45
|
+
try:
|
46
|
+
from asyncio import timeout as asyncio_timeout # type: ignore[attr-defined]
|
47
|
+
except ImportError: # python 310
|
48
|
+
from async_timeout import timeout as asyncio_timeout # type: ignore[no-redef]
|
49
|
+
|
50
|
+
|
51
|
+
aiofile_installed = False
|
52
|
+
with suppress(ImportError):
|
53
|
+
import aiofile
|
54
|
+
|
55
|
+
aiofile_installed = True
|
56
|
+
|
57
|
+
|
58
|
+
trio_installed = False
|
59
|
+
with suppress(ImportError):
|
60
|
+
from trio import TooSlowError, open_memory_channel, open_nursery
|
61
|
+
from trio import fail_after as trio_fail_after
|
62
|
+
from trio import open_file as trio_open_file
|
63
|
+
from trio import sleep as trio_sleep
|
64
|
+
|
65
|
+
trio_installed = True
|
66
|
+
|
67
|
+
|
68
|
+
curio_installed = False
|
69
|
+
with suppress(ImportError):
|
70
|
+
from curio import TaskGroup as CurioTaskGroup
|
71
|
+
from curio import TaskTimeout
|
72
|
+
from curio import aopen as curio_open
|
73
|
+
from curio import sleep as curio_sleep
|
74
|
+
from curio import spawn as curio_spawn
|
75
|
+
from curio import timeout_after as curio_fail_after
|
76
|
+
|
77
|
+
curio_installed = True
|
78
|
+
|
79
|
+
|
80
|
+
def aio_sleep(seconds: float = 0) -> Awaitable:
|
81
|
+
"""Return sleep coroutine."""
|
82
|
+
|
83
|
+
if trio_installed and current_async_library() == "trio":
|
84
|
+
return trio_sleep(seconds) # noqa: ASYNC105
|
85
|
+
|
86
|
+
if curio_installed and current_async_library() == "curio":
|
87
|
+
return curio_sleep(seconds)
|
88
|
+
|
89
|
+
return sleep(seconds)
|
90
|
+
|
91
|
+
|
92
|
+
@asynccontextmanager
|
93
|
+
async def aio_spawn(fn: Callable[..., Awaitable], *args, **kwargs):
|
94
|
+
"""Spawn a given coroutine."""
|
95
|
+
if trio_installed and current_async_library() == "trio":
|
96
|
+
async with open_nursery() as tasks:
|
97
|
+
tasks.start_soon(fn, *args, **kwargs)
|
98
|
+
yield tasks
|
99
|
+
|
100
|
+
elif curio_installed and current_async_library() == "curio":
|
101
|
+
task = await curio_spawn(fn, *args, **kwargs)
|
102
|
+
yield task
|
103
|
+
await task.join() # type: ignore [union-attr]
|
104
|
+
|
105
|
+
else:
|
106
|
+
coro = cast("Coroutine", fn(*args, **kwargs))
|
107
|
+
task = create_task(coro)
|
108
|
+
yield task
|
109
|
+
await task
|
110
|
+
|
111
|
+
|
112
|
+
@asynccontextmanager
|
113
|
+
async def aio_timeout(timeout: float): # noqa: ASYNC109
|
114
|
+
"""Fail after the given timeout."""
|
115
|
+
if not timeout:
|
116
|
+
yield
|
117
|
+
return
|
118
|
+
|
119
|
+
if trio_installed and current_async_library() == "trio":
|
120
|
+
try:
|
121
|
+
with trio_fail_after(timeout):
|
122
|
+
yield
|
123
|
+
|
124
|
+
except TooSlowError:
|
125
|
+
raise TimeoutError(f"{timeout}s.") from None
|
126
|
+
|
127
|
+
elif curio_installed and current_async_library() == "curio":
|
128
|
+
try:
|
129
|
+
async with curio_fail_after(timeout):
|
130
|
+
yield
|
131
|
+
|
132
|
+
except TaskTimeout:
|
133
|
+
raise TimeoutError(f"{timeout}s.") from None
|
134
|
+
|
135
|
+
else:
|
136
|
+
async with asyncio_timeout(timeout):
|
137
|
+
yield
|
138
|
+
|
139
|
+
|
140
|
+
async def aio_wait(*aws: Awaitable, strategy: str = ALL_COMPLETED) -> Any:
|
141
|
+
"""Run the coros concurrently, wait for all completed or cancel others.
|
142
|
+
|
143
|
+
Only ALL_COMPLETED, FIRST_COMPLETED are supported.
|
144
|
+
"""
|
145
|
+
if not aws:
|
146
|
+
return None
|
147
|
+
|
148
|
+
if trio_installed and current_async_library() == "trio":
|
149
|
+
send_channel, receive_channel = open_memory_channel(0) # type: ignore[var-annotated]
|
150
|
+
|
151
|
+
async with open_nursery() as n:
|
152
|
+
for aw in aws:
|
153
|
+
n.start_soon(trio_jockey, aw, send_channel)
|
154
|
+
|
155
|
+
results = []
|
156
|
+
for _ in aws:
|
157
|
+
results.append(await receive_channel.receive())
|
158
|
+
if strategy == FIRST_COMPLETED:
|
159
|
+
n.cancel_scope.cancel()
|
160
|
+
return results[0]
|
161
|
+
|
162
|
+
return results
|
163
|
+
|
164
|
+
if curio_installed and current_async_library() == "curio":
|
165
|
+
wait = all if strategy == ALL_COMPLETED else any
|
166
|
+
async with CurioTaskGroup(wait=wait) as g:
|
167
|
+
[await g.spawn(aw) for aw in aws]
|
168
|
+
|
169
|
+
return g.results if strategy == ALL_COMPLETED else g.result
|
170
|
+
|
171
|
+
aws = tuple(create_task(aw) if inspect.iscoroutine(aw) else aw for aw in aws)
|
172
|
+
done, pending = await asyncio.wait(aws, return_when=strategy) # type: ignore[type-var]
|
173
|
+
if strategy != ALL_COMPLETED:
|
174
|
+
for task in pending:
|
175
|
+
task.cancel() # type: ignore[attr-defined]
|
176
|
+
await gather(*pending, return_exceptions=True)
|
177
|
+
return next(iter(done)).result() # type: ignore[attr-defined]
|
178
|
+
|
179
|
+
return [t.result() for t in done] # type: ignore[attr-defined]
|
180
|
+
|
181
|
+
|
182
|
+
async def aio_cancel(task: Union[asyncio.Task, Any]):
|
183
|
+
"""Cancel asyncio task / trio nursery."""
|
184
|
+
if isinstance(task, asyncio.Task):
|
185
|
+
return task.cancel()
|
186
|
+
|
187
|
+
if trio_installed and current_async_library() == "trio":
|
188
|
+
return task.cancel_scope.cancel()
|
189
|
+
|
190
|
+
if curio_installed and current_async_library() == "curio":
|
191
|
+
return await task.cancel()
|
192
|
+
return None
|
193
|
+
|
194
|
+
|
195
|
+
async def aio_stream_file(
|
196
|
+
filepath: Union[str, Path], chunk_size: int = 32 * 1024
|
197
|
+
) -> AsyncGenerator[bytes, None]:
|
198
|
+
if trio_installed and current_async_library() == "trio":
|
199
|
+
async with await trio_open_file(filepath, "rb") as fp:
|
200
|
+
while True:
|
201
|
+
chunk = cast("bytes", await fp.read(chunk_size))
|
202
|
+
if not chunk:
|
203
|
+
break
|
204
|
+
yield chunk
|
205
|
+
|
206
|
+
elif curio_installed and current_async_library() == "curio":
|
207
|
+
async with curio_open(filepath, "rb") as fp:
|
208
|
+
while True:
|
209
|
+
chunk = cast("bytes", await fp.read(chunk_size))
|
210
|
+
if not chunk:
|
211
|
+
break
|
212
|
+
yield chunk
|
213
|
+
|
214
|
+
else:
|
215
|
+
if not aiofile_installed:
|
216
|
+
raise RuntimeError(
|
217
|
+
"`aiofile` is required to return files with asyncio",
|
218
|
+
)
|
219
|
+
|
220
|
+
async with aiofile.AIOFile(filepath, mode="rb") as fp:
|
221
|
+
async for chunk in aiofile.Reader( # type: ignore [assignment]
|
222
|
+
fp, chunk_size=chunk_size
|
223
|
+
):
|
224
|
+
yield cast("bytes", chunk)
|
225
|
+
|
226
|
+
|
227
|
+
async def trio_jockey(coro: Awaitable, channel):
|
228
|
+
"""Wait for the given coroutine and send result back to the given channel."""
|
229
|
+
await channel.send(await coro)
|
230
|
+
|
231
|
+
|
232
|
+
def json_dumps(content) -> bytes:
|
233
|
+
"""Emulate orjson."""
|
234
|
+
return dumps( # type: ignore [call-arg]
|
235
|
+
content,
|
236
|
+
ensure_ascii=False,
|
237
|
+
separators=(",", ":"),
|
238
|
+
).encode("utf-8")
|
239
|
+
|
240
|
+
|
241
|
+
def json_loads(obj: Union[bytes, str]) -> TJSON:
|
242
|
+
"""Emulate orjson."""
|
243
|
+
if isinstance(obj, bytes):
|
244
|
+
obj = obj.decode("utf-8")
|
245
|
+
return loads(obj)
|
246
|
+
|
247
|
+
|
248
|
+
with suppress(ImportError):
|
249
|
+
from ujson import dumps as udumps
|
250
|
+
from ujson import loads as json_loads # type: ignore[assignment]
|
251
|
+
|
252
|
+
def json_dumps(content) -> bytes:
|
253
|
+
"""Emulate orjson."""
|
254
|
+
return udumps(content, ensure_ascii=False).encode("utf-8")
|
255
|
+
|
256
|
+
|
257
|
+
with suppress(ImportError):
|
258
|
+
from orjson import dumps as json_dumps # type: ignore[assignment,no-redef]
|
259
|
+
from orjson import loads as json_loads # type: ignore[assignment,no-redef]
|
asgi_tools/app.py
ADDED
@@ -0,0 +1,303 @@
|
|
1
|
+
"""Simple Base for ASGI Apps."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from functools import partial
|
6
|
+
from inspect import isclass
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
8
|
+
|
9
|
+
from http_router import PrefixedRoute
|
10
|
+
|
11
|
+
from asgi_tools.router import Router
|
12
|
+
|
13
|
+
from .errors import ASGIConnectionClosedError, ASGIInvalidMethodError, ASGINotFoundError
|
14
|
+
from .logs import logger
|
15
|
+
from .middleware import LifespanMiddleware, StaticFilesMiddleware, parse_response
|
16
|
+
from .request import Request
|
17
|
+
from .response import Response, ResponseError
|
18
|
+
from .utils import iscoroutinefunction, to_awaitable
|
19
|
+
|
20
|
+
if TYPE_CHECKING:
|
21
|
+
import logging
|
22
|
+
from pathlib import Path
|
23
|
+
|
24
|
+
from http_router.types import TMethods, TPath
|
25
|
+
|
26
|
+
from .types import (
|
27
|
+
TASGIReceive,
|
28
|
+
TASGIScope,
|
29
|
+
TASGISend,
|
30
|
+
TExceptionHandler,
|
31
|
+
TVExceptionHandler,
|
32
|
+
)
|
33
|
+
|
34
|
+
T = TypeVar("T")
|
35
|
+
TRouteHandler = TypeVar("TRouteHandler", bound=Callable[[Request], Any])
|
36
|
+
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
|
37
|
+
|
38
|
+
|
39
|
+
class App:
|
40
|
+
"""A helper class to build ASGI applications quickly and efficiently.
|
41
|
+
|
42
|
+
Features:
|
43
|
+
- Routing with flexible path and method matching
|
44
|
+
- ASGI-Tools :class:`Request`, :class:`Response` integration
|
45
|
+
- Exception management and custom error handlers
|
46
|
+
- Static files serving
|
47
|
+
- Lifespan events (startup/shutdown)
|
48
|
+
- Simple middleware support
|
49
|
+
|
50
|
+
:param debug: Enable debug mode (more logging, raise unhandled exceptions)
|
51
|
+
:type debug: bool
|
52
|
+
:param logger: Custom logger for the application
|
53
|
+
:type logger: logging.Logger
|
54
|
+
:param static_url_prefix: URL prefix for static files
|
55
|
+
:type static_url_prefix: str
|
56
|
+
:param static_folders: List of folders to serve static files from
|
57
|
+
:type static_folders: Optional[list[str | Path]]
|
58
|
+
:param trim_last_slash: Treat "/path" and "/path/" as the same route
|
59
|
+
:type trim_last_slash: bool
|
60
|
+
:raises ValueError: If static_url_prefix is set without static_folders
|
61
|
+
:raises TypeError: If static_folders is not a list of strings or Paths
|
62
|
+
:raises RuntimeError: If the app is not an ASGI application
|
63
|
+
|
64
|
+
Example:
|
65
|
+
>>> from asgi_tools import App
|
66
|
+
>>> app = App()
|
67
|
+
>>> @app.route("/")
|
68
|
+
... async def homepage(request):
|
69
|
+
... return "Hello, World!"
|
70
|
+
"""
|
71
|
+
|
72
|
+
exception_handlers: dict[type[BaseException], TExceptionHandler]
|
73
|
+
|
74
|
+
def __init__(
|
75
|
+
self,
|
76
|
+
*,
|
77
|
+
debug: bool = False,
|
78
|
+
logger: logging.Logger = logger,
|
79
|
+
static_url_prefix: str = "/static",
|
80
|
+
static_folders: list[str | Path] | None = None,
|
81
|
+
trim_last_slash: bool = False,
|
82
|
+
):
|
83
|
+
"""Initialize the ASGI application with routing, logging, static files,
|
84
|
+
and lifespan support.
|
85
|
+
|
86
|
+
:param debug: Enable debug mode (more logging, raise unhandled exceptions)
|
87
|
+
:type debug: bool
|
88
|
+
:param logger: Custom logger for the application
|
89
|
+
:type logger: logging.Logger
|
90
|
+
:param static_url_prefix: URL prefix for static files
|
91
|
+
:type static_url_prefix: str
|
92
|
+
:param static_folders: List of folders to serve static files from
|
93
|
+
:type static_folders: Optional[list[str|Path]]
|
94
|
+
:param trim_last_slash: Treat "/path" and "/path/" as the same route
|
95
|
+
:type trim_last_slash: bool
|
96
|
+
"""
|
97
|
+
|
98
|
+
# Register base exception handlers
|
99
|
+
self.exception_handlers = {
|
100
|
+
ASGIConnectionClosedError: to_awaitable(lambda _, __: None),
|
101
|
+
}
|
102
|
+
|
103
|
+
# Setup routing
|
104
|
+
self.router = Router(
|
105
|
+
trim_last_slash=trim_last_slash, validator=callable, converter=to_awaitable
|
106
|
+
)
|
107
|
+
|
108
|
+
# Setup logging
|
109
|
+
self.logger = logger
|
110
|
+
|
111
|
+
# Setup app
|
112
|
+
self.__app__ = self.__match__
|
113
|
+
|
114
|
+
# Setup lifespan
|
115
|
+
self.lifespan = LifespanMiddleware(
|
116
|
+
self.__process__, ignore_errors=not debug, logger=self.logger
|
117
|
+
)
|
118
|
+
|
119
|
+
# Enable middleware for static files
|
120
|
+
if static_folders and static_url_prefix:
|
121
|
+
md = StaticFilesMiddleware.setup(folders=static_folders, url_prefix=static_url_prefix)
|
122
|
+
self.middleware(md)
|
123
|
+
|
124
|
+
# Debug mode
|
125
|
+
self.debug = debug
|
126
|
+
|
127
|
+
# Handle unknown exceptions
|
128
|
+
if not debug:
|
129
|
+
|
130
|
+
async def handle_unknown_exception(_: Request, exc: BaseException) -> Response:
|
131
|
+
self.logger.exception(exc)
|
132
|
+
return ResponseError.INTERNAL_SERVER_ERROR()
|
133
|
+
|
134
|
+
self.exception_handlers[Exception] = handle_unknown_exception
|
135
|
+
|
136
|
+
self.internal_middlewares: list = []
|
137
|
+
|
138
|
+
async def __call__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend) -> None:
|
139
|
+
"""ASGI entrypoint. Converts the given scope into a request and processes it."""
|
140
|
+
await self.lifespan(scope, receive, send)
|
141
|
+
|
142
|
+
async def __process__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
|
143
|
+
"""Internal request processing: builds a Request, calls the app, and handles exceptions."""
|
144
|
+
scope["app"] = self
|
145
|
+
request = Request(scope, receive, send)
|
146
|
+
try:
|
147
|
+
response: Response | None = await self.__app__(request, receive, send)
|
148
|
+
if response is not None:
|
149
|
+
await response(scope, receive, send)
|
150
|
+
|
151
|
+
# Handle exceptions
|
152
|
+
except BaseException as exc:
|
153
|
+
for etype in type(exc).mro():
|
154
|
+
if etype in self.exception_handlers:
|
155
|
+
await parse_response(
|
156
|
+
await self.exception_handlers[etype](request, exc),
|
157
|
+
)(scope, receive, send)
|
158
|
+
break
|
159
|
+
else:
|
160
|
+
if isinstance(exc, Response):
|
161
|
+
await exc(scope, receive, send)
|
162
|
+
else:
|
163
|
+
raise
|
164
|
+
|
165
|
+
async def __match__(
|
166
|
+
self, request: Request, _: TASGIReceive, send: TASGISend
|
167
|
+
) -> Response | None:
|
168
|
+
"""Finds and calls a route handler, parses the response, and handles routing exceptions."""
|
169
|
+
scope = request.scope
|
170
|
+
path = f"{ scope.get('root_path', '') }{ scope['path'] }"
|
171
|
+
try:
|
172
|
+
match = self.router(path, scope.get("method", "GET"))
|
173
|
+
|
174
|
+
except ASGINotFoundError as exc:
|
175
|
+
raise ResponseError.NOT_FOUND() from exc
|
176
|
+
|
177
|
+
except ASGIInvalidMethodError as exc:
|
178
|
+
raise ResponseError.METHOD_NOT_ALLOWED() from exc
|
179
|
+
|
180
|
+
scope["endpoint"] = match.target
|
181
|
+
scope["path_params"] = {} if match.params is None else match.params
|
182
|
+
handler = cast("Callable[[Request], Any]", match.target)
|
183
|
+
response = await handler(request)
|
184
|
+
|
185
|
+
if scope["type"] == "http":
|
186
|
+
return parse_response(response)
|
187
|
+
|
188
|
+
# TODO: Do we need to close websockets automatically?
|
189
|
+
# if scope_type == "websocket" send websocket.close
|
190
|
+
|
191
|
+
return None
|
192
|
+
|
193
|
+
def __route__(self, router: Router, *prefixes: str, **_) -> "App":
|
194
|
+
"""Mount this app as a nested application under given prefixes."""
|
195
|
+
for prefix in prefixes:
|
196
|
+
route = RouteApp(prefix, set(), target=self)
|
197
|
+
router.dynamic.insert(0, route)
|
198
|
+
return self
|
199
|
+
|
200
|
+
def middleware(self, md: TCallable, *, insert_first: bool = False) -> TCallable:
|
201
|
+
"""Register a middleware for the application.
|
202
|
+
|
203
|
+
Middleware can be a coroutine (request/response) or a regular callable (lifespan).
|
204
|
+
|
205
|
+
:param md: The middleware function or class
|
206
|
+
:type md: TCallable
|
207
|
+
:param insert_first: If True, insert as the first middleware
|
208
|
+
:type insert_first: bool
|
209
|
+
:return: The registered middleware
|
210
|
+
:rtype: TCallable
|
211
|
+
|
212
|
+
Example:
|
213
|
+
>>> @app.middleware
|
214
|
+
... async def log_requests(handler, request):
|
215
|
+
... print(f"Request: {request.method} {request.url}")
|
216
|
+
... return await handler(request)
|
217
|
+
"""
|
218
|
+
# Register as a simple middleware
|
219
|
+
if iscoroutinefunction(md):
|
220
|
+
if md not in self.internal_middlewares:
|
221
|
+
if insert_first:
|
222
|
+
self.internal_middlewares.insert(0, md)
|
223
|
+
else:
|
224
|
+
self.internal_middlewares.append(md)
|
225
|
+
|
226
|
+
app = self.__match__
|
227
|
+
for md_ in reversed(self.internal_middlewares):
|
228
|
+
app = partial(md_, app)
|
229
|
+
|
230
|
+
self.__app__ = app
|
231
|
+
|
232
|
+
else:
|
233
|
+
self.lifespan.bind(md(self.lifespan.app))
|
234
|
+
|
235
|
+
return md
|
236
|
+
|
237
|
+
def route(
|
238
|
+
self, *paths: TPath, methods: TMethods | None = None, **opts: Any
|
239
|
+
) -> Callable[[TRouteHandler], TRouteHandler]:
|
240
|
+
"""Register a route handler.
|
241
|
+
|
242
|
+
:param paths: One or more URL paths to match
|
243
|
+
:type paths: TPath
|
244
|
+
:param methods: HTTP methods to match (GET, POST, etc.)
|
245
|
+
:type methods: Optional[TMethods]
|
246
|
+
:param opts: Additional options for the route
|
247
|
+
:type opts: Any
|
248
|
+
:return: Decorator function
|
249
|
+
:rtype: Callable[[TRouteHandler], TRouteHandler]
|
250
|
+
"""
|
251
|
+
return self.router.route(*paths, methods=methods, **opts)
|
252
|
+
|
253
|
+
def on_startup(self, fn: Callable) -> None:
|
254
|
+
"""Register a startup event handler.
|
255
|
+
|
256
|
+
:param fn: The function to call on startup
|
257
|
+
:type fn: Callable
|
258
|
+
"""
|
259
|
+
return self.lifespan.on_startup(fn)
|
260
|
+
|
261
|
+
def on_shutdown(self, fn: Callable) -> None:
|
262
|
+
"""Register a shutdown event handler.
|
263
|
+
|
264
|
+
:param fn: The function to call on shutdown
|
265
|
+
:type fn: Callable
|
266
|
+
"""
|
267
|
+
return self.lifespan.on_shutdown(fn)
|
268
|
+
|
269
|
+
def on_error(self, etype: type[BaseException]):
|
270
|
+
"""Register a custom exception handler for a given exception type.
|
271
|
+
|
272
|
+
:param etype: The exception type to handle
|
273
|
+
:type etype: type[BaseException]
|
274
|
+
:return: A decorator to register the handler
|
275
|
+
:rtype: Callable
|
276
|
+
|
277
|
+
Example:
|
278
|
+
>>> @app.on_error(TimeoutError)
|
279
|
+
... async def timeout_handler(request, error):
|
280
|
+
... return 'Timeout occurred'
|
281
|
+
"""
|
282
|
+
assert isclass(etype), f"Invalid exception type: {etype}"
|
283
|
+
assert issubclass(etype, BaseException), f"Invalid exception type: {etype}"
|
284
|
+
|
285
|
+
def recorder(handler: TVExceptionHandler) -> TVExceptionHandler:
|
286
|
+
self.exception_handlers[etype] = to_awaitable(handler)
|
287
|
+
return handler
|
288
|
+
|
289
|
+
return recorder
|
290
|
+
|
291
|
+
|
292
|
+
class RouteApp(PrefixedRoute):
|
293
|
+
"""Custom route to submount an application under a given path prefix."""
|
294
|
+
|
295
|
+
def __init__(self, path: str, methods: set, target: App):
|
296
|
+
"""Create a submounted app callable for the given prefix."""
|
297
|
+
path = path.rstrip("/")
|
298
|
+
|
299
|
+
def app(request: Request):
|
300
|
+
subrequest = request.__copy__(path=request.path[len(path) :])
|
301
|
+
return target.__app__(subrequest, request.receive, request.send)
|
302
|
+
|
303
|
+
super().__init__(path, methods, app)
|
asgi_tools/constants.py
ADDED
asgi_tools/errors.py
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
|
4
|
+
class ASGIError(Exception):
|
5
|
+
"""Base class for ASGI-Tools Errors."""
|
6
|
+
|
7
|
+
|
8
|
+
class ASGIConnectionClosedError(ASGIError):
|
9
|
+
"""ASGI-Tools connection closed error."""
|
10
|
+
|
11
|
+
|
12
|
+
class ASGIDecodeError(ASGIError, ValueError):
|
13
|
+
"""ASGI-Tools decoding error."""
|
14
|
+
|
15
|
+
|
16
|
+
class ASGINotFoundError(ASGIError):
|
17
|
+
"""Raise when http handler not found."""
|
18
|
+
|
19
|
+
|
20
|
+
class ASGIInvalidMethodError(ASGIError):
|
21
|
+
"""Raise when http method not found."""
|
22
|
+
|
23
|
+
|
24
|
+
class ASGIInvalidMessageError(ASGIError):
|
25
|
+
"""Raise when unexpected message received."""
|