asgi-tools 1.1.0__cp313-cp313-musllinux_1_2_aarch64.whl → 1.3.1__cp313-cp313-musllinux_1_2_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 CHANGED
@@ -1,4 +1,5 @@
1
- """ ASGI-Tools -- Tools to make ASGI Applications """
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
- """Compatability layer."""
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 39, 310
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 concurently, wait for all completed or cancel others.
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( # noqa: TRY003
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, Optional, Union # py39
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 Applications.
40
+ """A helper class to build ASGI applications quickly and efficiently.
38
41
 
39
42
  Features:
40
-
41
- * Routing
42
- * ASGI-Tools :class:`Request`, :class:`Response`
43
- * Exception management
44
- * Static files
45
- * Lifespan events
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, False
50
-
51
+ :type debug: bool
51
52
  :param logger: Custom logger for the application
52
53
  :type logger: logging.Logger
53
-
54
- :param static_url_prefix: A prefix for static files
55
- :type static_url_prefix: str, "/static"
56
-
57
- :param static_folders: A list of folders to look static files
58
- :type static_folders: list[str]
59
-
60
- :param trim_last_slash: Consider "/path" and "/path/" as the same
61
- :type trim_last_slash: bool, False
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: Optional[list[Union[str, Path]]] = None,
80
+ static_folders: list[str | Path] | None = None,
74
81
  trim_last_slash: bool = False,
75
82
  ):
76
- """Initialize router and lifespan middleware."""
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
- """Convert the given scope into a request and process."""
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
- """Send ASGI messages."""
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: Optional[Response] = await self.__app__(request, receive, send)
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
- ) -> Optional[Response]:
148
- """Find and call a callback, parse a response, handle exceptions."""
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
- response = await match.target(request) # type: ignore[]
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 self as a nested application."""
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: TVCallable, *, insert_first: bool = False) -> TVCallable:
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(self, *paths: TPath, methods: Optional[TMethods] = None, **opts):
201
- """Register a route."""
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 an exception handler.
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
- @app.on_error(ResponseError)
222
- async def process_http_errors(request, response_error):
223
- if response_error.status_code == 404:
224
- return render_template('page_not_found.html'), 404
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):