asgi-tools 1.2.0__cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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 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)
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Final
4
+
5
+ BASE_ENCODING: Final = "latin-1"
6
+ DEFAULT_CHARSET: Final = "utf-8"
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."""