asgi-tools 1.1.0__cp313-cp313-musllinux_1_2_x86_64.whl → 1.3.1__cp313-cp313-musllinux_1_2_x86_64.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 +3 -2
- asgi_tools/_compat.py +10 -13
- asgi_tools/app.py +105 -51
- asgi_tools/forms.c +6646 -7185
- asgi_tools/forms.cpython-313-x86_64-linux-musl.so +0 -0
- asgi_tools/forms.py +9 -9
- asgi_tools/middleware.py +38 -29
- asgi_tools/multipart.c +5818 -5562
- asgi_tools/multipart.cpython-313-x86_64-linux-musl.so +0 -0
- asgi_tools/multipart.py +6 -9
- asgi_tools/request.py +63 -47
- asgi_tools/response.py +49 -60
- asgi_tools/tests.py +20 -21
- asgi_tools/view.py +2 -2
- {asgi_tools-1.1.0.dist-info → asgi_tools-1.3.1.dist-info}/METADATA +23 -23
- asgi_tools-1.3.1.dist-info/RECORD +29 -0
- {asgi_tools-1.1.0.dist-info → asgi_tools-1.3.1.dist-info}/WHEEL +1 -1
- asgi_tools-1.1.0.dist-info/RECORD +0 -29
- {asgi_tools-1.1.0.dist-info → asgi_tools-1.3.1.dist-info/licenses}/LICENSE +0 -0
- {asgi_tools-1.1.0.dist-info → asgi_tools-1.3.1.dist-info}/top_level.txt +0 -0
asgi_tools/__init__.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
|
-
"""
|
1
|
+
"""ASGI-Tools -- Tools to make ASGI Applications"""
|
2
|
+
|
2
3
|
from __future__ import annotations
|
3
4
|
|
4
5
|
from http_router import InvalidMethodError, NotFoundError
|
@@ -29,7 +30,7 @@ from .response import (
|
|
29
30
|
)
|
30
31
|
from .view import HTTPView
|
31
32
|
|
32
|
-
__all__ = (
|
33
|
+
__all__ = ( # noqa: RUF022
|
33
34
|
# Errors
|
34
35
|
"ASGIConnectionClosedError",
|
35
36
|
"ASGIError",
|
asgi_tools/_compat.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
"""
|
1
|
+
"""Compatibility layer."""
|
2
2
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
@@ -34,17 +34,17 @@ __all__ = (
|
|
34
34
|
"aio_spawn",
|
35
35
|
"aio_stream_file",
|
36
36
|
"aio_wait",
|
37
|
+
"aiofile_installed",
|
37
38
|
"create_task",
|
39
|
+
"curio_installed",
|
38
40
|
"json_dumps",
|
39
41
|
"json_loads",
|
40
|
-
"aiofile_installed",
|
41
42
|
"trio_installed",
|
42
|
-
"curio_installed",
|
43
43
|
)
|
44
44
|
|
45
45
|
try:
|
46
46
|
from asyncio import timeout as asyncio_timeout # type: ignore[attr-defined]
|
47
|
-
except ImportError: # python
|
47
|
+
except ImportError: # python 310
|
48
48
|
from async_timeout import timeout as asyncio_timeout # type: ignore[no-redef]
|
49
49
|
|
50
50
|
|
@@ -103,7 +103,7 @@ async def aio_spawn(fn: Callable[..., Awaitable], *args, **kwargs):
|
|
103
103
|
await task.join() # type: ignore [union-attr]
|
104
104
|
|
105
105
|
else:
|
106
|
-
coro = cast(Coroutine, fn(*args, **kwargs))
|
106
|
+
coro = cast("Coroutine", fn(*args, **kwargs))
|
107
107
|
task = create_task(coro)
|
108
108
|
yield task
|
109
109
|
await task
|
@@ -138,7 +138,7 @@ async def aio_timeout(timeout: float): # noqa: ASYNC109
|
|
138
138
|
|
139
139
|
|
140
140
|
async def aio_wait(*aws: Awaitable, strategy: str = ALL_COMPLETED) -> Any:
|
141
|
-
"""Run the coros
|
141
|
+
"""Run the coros concurrently, wait for all completed or cancel others.
|
142
142
|
|
143
143
|
Only ALL_COMPLETED, FIRST_COMPLETED are supported.
|
144
144
|
"""
|
@@ -198,7 +198,7 @@ async def aio_stream_file(
|
|
198
198
|
if trio_installed and current_async_library() == "trio":
|
199
199
|
async with await trio_open_file(filepath, "rb") as fp:
|
200
200
|
while True:
|
201
|
-
chunk = cast(bytes, await fp.read(chunk_size))
|
201
|
+
chunk = cast("bytes", await fp.read(chunk_size))
|
202
202
|
if not chunk:
|
203
203
|
break
|
204
204
|
yield chunk
|
@@ -206,14 +206,14 @@ async def aio_stream_file(
|
|
206
206
|
elif curio_installed and current_async_library() == "curio":
|
207
207
|
async with curio_open(filepath, "rb") as fp:
|
208
208
|
while True:
|
209
|
-
chunk = cast(bytes, await fp.read(chunk_size))
|
209
|
+
chunk = cast("bytes", await fp.read(chunk_size))
|
210
210
|
if not chunk:
|
211
211
|
break
|
212
212
|
yield chunk
|
213
213
|
|
214
214
|
else:
|
215
215
|
if not aiofile_installed:
|
216
|
-
raise RuntimeError(
|
216
|
+
raise RuntimeError(
|
217
217
|
"`aiofile` is required to return files with asyncio",
|
218
218
|
)
|
219
219
|
|
@@ -221,7 +221,7 @@ async def aio_stream_file(
|
|
221
221
|
async for chunk in aiofile.Reader( # type: ignore [assignment]
|
222
222
|
fp, chunk_size=chunk_size
|
223
223
|
):
|
224
|
-
yield cast(bytes, chunk)
|
224
|
+
yield cast("bytes", chunk)
|
225
225
|
|
226
226
|
|
227
227
|
async def trio_jockey(coro: Awaitable, channel):
|
@@ -257,6 +257,3 @@ with suppress(ImportError):
|
|
257
257
|
with suppress(ImportError):
|
258
258
|
from orjson import dumps as json_dumps # type: ignore[assignment,no-redef]
|
259
259
|
from orjson import loads as json_loads # type: ignore[assignment,no-redef]
|
260
|
-
|
261
|
-
|
262
|
-
# ruff: noqa: PGH003, F811
|
asgi_tools/app.py
CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
4
4
|
|
5
5
|
from functools import partial
|
6
6
|
from inspect import isclass
|
7
|
-
from typing import TYPE_CHECKING, Callable,
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
8
8
|
|
9
9
|
from http_router import PrefixedRoute
|
10
10
|
|
@@ -28,38 +28,45 @@ if TYPE_CHECKING:
|
|
28
28
|
TASGIScope,
|
29
29
|
TASGISend,
|
30
30
|
TExceptionHandler,
|
31
|
-
TVCallable,
|
32
31
|
TVExceptionHandler,
|
33
32
|
)
|
34
33
|
|
34
|
+
T = TypeVar("T")
|
35
|
+
TRouteHandler = TypeVar("TRouteHandler", bound=Callable[[Request], Any])
|
36
|
+
TCallable = TypeVar("TCallable", bound=Callable[..., Any])
|
37
|
+
|
35
38
|
|
36
39
|
class App:
|
37
|
-
"""A helper to build ASGI
|
40
|
+
"""A helper class to build ASGI applications quickly and efficiently.
|
38
41
|
|
39
42
|
Features:
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
* Simplest middlewares
|
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
|
47
49
|
|
48
50
|
:param debug: Enable debug mode (more logging, raise unhandled exceptions)
|
49
|
-
:type debug: bool
|
50
|
-
|
51
|
+
:type debug: bool
|
51
52
|
:param logger: Custom logger for the application
|
52
53
|
:type logger: logging.Logger
|
53
|
-
|
54
|
-
:
|
55
|
-
:
|
56
|
-
|
57
|
-
:param
|
58
|
-
:type
|
59
|
-
|
60
|
-
:
|
61
|
-
:
|
62
|
-
|
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!"
|
63
70
|
"""
|
64
71
|
|
65
72
|
exception_handlers: dict[type[BaseException], TExceptionHandler]
|
@@ -70,10 +77,23 @@ class App:
|
|
70
77
|
debug: bool = False,
|
71
78
|
logger: logging.Logger = logger,
|
72
79
|
static_url_prefix: str = "/static",
|
73
|
-
static_folders:
|
80
|
+
static_folders: list[str | Path] | None = None,
|
74
81
|
trim_last_slash: bool = False,
|
75
82
|
):
|
76
|
-
"""Initialize
|
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
|
+
"""
|
77
97
|
|
78
98
|
# Register base exception handlers
|
79
99
|
self.exception_handlers = {
|
@@ -116,15 +136,15 @@ class App:
|
|
116
136
|
self.internal_middlewares: list = []
|
117
137
|
|
118
138
|
async def __call__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend) -> None:
|
119
|
-
"""
|
139
|
+
"""ASGI entrypoint. Converts the given scope into a request and processes it."""
|
120
140
|
await self.lifespan(scope, receive, send)
|
121
141
|
|
122
142
|
async def __process__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
|
123
|
-
"""
|
143
|
+
"""Internal request processing: builds a Request, calls the app, and handles exceptions."""
|
124
144
|
scope["app"] = self
|
125
145
|
request = Request(scope, receive, send)
|
126
146
|
try:
|
127
|
-
response:
|
147
|
+
response: Response | None = await self.__app__(request, receive, send)
|
128
148
|
if response is not None:
|
129
149
|
await response(scope, receive, send)
|
130
150
|
|
@@ -144,8 +164,8 @@ class App:
|
|
144
164
|
|
145
165
|
async def __match__(
|
146
166
|
self, request: Request, _: TASGIReceive, send: TASGISend
|
147
|
-
) ->
|
148
|
-
"""
|
167
|
+
) -> Response | None:
|
168
|
+
"""Finds and calls a route handler, parses the response, and handles routing exceptions."""
|
149
169
|
scope = request.scope
|
150
170
|
path = f"{ scope.get('root_path', '') }{ scope['path'] }"
|
151
171
|
try:
|
@@ -159,7 +179,8 @@ class App:
|
|
159
179
|
|
160
180
|
scope["endpoint"] = match.target
|
161
181
|
scope["path_params"] = {} if match.params is None else match.params
|
162
|
-
|
182
|
+
handler = cast("Callable[[Request], Any]", match.target)
|
183
|
+
response = await handler(request)
|
163
184
|
|
164
185
|
if scope["type"] == "http":
|
165
186
|
return parse_response(response)
|
@@ -170,14 +191,30 @@ class App:
|
|
170
191
|
return None
|
171
192
|
|
172
193
|
def __route__(self, router: Router, *prefixes: str, **_) -> "App":
|
173
|
-
"""Mount
|
194
|
+
"""Mount this app as a nested application under given prefixes."""
|
174
195
|
for prefix in prefixes:
|
175
196
|
route = RouteApp(prefix, set(), target=self)
|
176
197
|
router.dynamic.insert(0, route)
|
177
198
|
return self
|
178
199
|
|
179
|
-
def middleware(self, md:
|
180
|
-
"""Register a middleware.
|
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
|
+
"""
|
181
218
|
# Register as a simple middleware
|
182
219
|
if iscoroutinefunction(md):
|
183
220
|
if md not in self.internal_middlewares:
|
@@ -197,33 +234,50 @@ class App:
|
|
197
234
|
|
198
235
|
return md
|
199
236
|
|
200
|
-
def route(
|
201
|
-
|
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
|
+
"""
|
202
251
|
return self.router.route(*paths, methods=methods, **opts)
|
203
252
|
|
204
253
|
def on_startup(self, fn: Callable) -> None:
|
205
|
-
"""Register a startup handler.
|
254
|
+
"""Register a startup event handler.
|
255
|
+
|
256
|
+
:param fn: The function to call on startup
|
257
|
+
:type fn: Callable
|
258
|
+
"""
|
206
259
|
return self.lifespan.on_startup(fn)
|
207
260
|
|
208
261
|
def on_shutdown(self, fn: Callable) -> None:
|
209
|
-
"""Register a shutdown handler.
|
262
|
+
"""Register a shutdown event handler.
|
263
|
+
|
264
|
+
:param fn: The function to call on shutdown
|
265
|
+
:type fn: Callable
|
266
|
+
"""
|
210
267
|
return self.lifespan.on_shutdown(fn)
|
211
268
|
|
212
269
|
def on_error(self, etype: type[BaseException]):
|
213
|
-
"""Register
|
214
|
-
|
215
|
-
.. code-block::
|
216
|
-
|
217
|
-
@app.on_error(TimeoutError)
|
218
|
-
async def timeout(request, error):
|
219
|
-
return 'Something bad happens'
|
270
|
+
"""Register a custom exception handler for a given exception type.
|
220
271
|
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
return response_error
|
272
|
+
:param etype: The exception type to handle
|
273
|
+
:type etype: type[BaseException]
|
274
|
+
:return: A decorator to register the handler
|
275
|
+
:rtype: Callable
|
226
276
|
|
277
|
+
Example:
|
278
|
+
>>> @app.on_error(TimeoutError)
|
279
|
+
... async def timeout_handler(request, error):
|
280
|
+
... return 'Timeout occurred'
|
227
281
|
"""
|
228
282
|
assert isclass(etype), f"Invalid exception type: {etype}"
|
229
283
|
assert issubclass(etype, BaseException), f"Invalid exception type: {etype}"
|
@@ -236,10 +290,10 @@ class App:
|
|
236
290
|
|
237
291
|
|
238
292
|
class RouteApp(PrefixedRoute):
|
239
|
-
"""Custom route to submount an application."""
|
293
|
+
"""Custom route to submount an application under a given path prefix."""
|
240
294
|
|
241
295
|
def __init__(self, path: str, methods: set, target: App):
|
242
|
-
"""Create app callable."""
|
296
|
+
"""Create a submounted app callable for the given prefix."""
|
243
297
|
path = path.rstrip("/")
|
244
298
|
|
245
299
|
def app(request: Request):
|