asgi-tools 1.1.0__tar.gz → 1.3.1__tar.gz

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.
Files changed (44) hide show
  1. {asgi_tools-1.1.0/asgi_tools.egg-info → asgi_tools-1.3.1}/PKG-INFO +9 -9
  2. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/README.rst +3 -3
  3. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/__init__.py +3 -2
  4. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/_compat.py +10 -13
  5. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/app.py +105 -51
  6. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/forms.py +9 -9
  7. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/middleware.py +38 -29
  8. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/multipart.py +6 -9
  9. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/request.py +63 -47
  10. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/response.py +49 -60
  11. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/tests.py +20 -21
  12. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/view.py +2 -2
  13. {asgi_tools-1.1.0 → asgi_tools-1.3.1/asgi_tools.egg-info}/PKG-INFO +9 -9
  14. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools.egg-info/requires.txt +2 -2
  15. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/pyproject.toml +10 -9
  16. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_client.py +3 -6
  17. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_compat.py +1 -1
  18. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_examples.py +1 -1
  19. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_middlewares.py +19 -0
  20. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_request.py +2 -1
  21. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_responses.py +2 -1
  22. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/LICENSE +0 -0
  23. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/MANIFEST.in +0 -0
  24. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/constants.py +0 -0
  25. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/errors.py +0 -0
  26. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/forms.pyx +0 -0
  27. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/logs.py +0 -0
  28. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/multipart.pxd +0 -0
  29. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/multipart.pyx +0 -0
  30. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/py.typed +0 -0
  31. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/router.py +0 -0
  32. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/types.py +0 -0
  33. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools/utils.py +0 -0
  34. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools.egg-info/SOURCES.txt +0 -0
  35. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools.egg-info/dependency_links.txt +0 -0
  36. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/asgi_tools.egg-info/top_level.txt +0 -0
  37. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/setup.cfg +0 -0
  38. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/setup.py +0 -0
  39. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_app.py +0 -0
  40. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_benchmarks.py +0 -0
  41. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_compatibility.py +0 -0
  42. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_formdata.py +0 -0
  43. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_types.py +0 -0
  44. {asgi_tools-1.1.0 → asgi_tools-1.3.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: asgi-tools
3
- Version: 1.1.0
3
+ Version: 1.3.1
4
4
  Summary: ASGI Toolkit to build web applications
5
5
  Author-email: Kirill Klenov <horneds@gmail.com>
6
6
  License: MIT License
@@ -13,7 +13,6 @@ Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Programming Language :: Python
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.9
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
@@ -23,18 +22,18 @@ Classifier: Programming Language :: Cython
23
22
  Classifier: Topic :: Internet :: WWW/HTTP
24
23
  Classifier: Framework :: AsyncIO
25
24
  Classifier: Framework :: Trio
26
- Requires-Python: >=3.9
25
+ Requires-Python: >=3.10
27
26
  Description-Content-Type: text/x-rst
28
27
  License-File: LICENSE
29
28
  Requires-Dist: http-router>=4.0.0
30
- Requires-Dist: multidict
29
+ Requires-Dist: multidict>=6.6.3
31
30
  Requires-Dist: sniffio
32
31
  Requires-Dist: yarl>=1.8.2
33
32
  Requires-Dist: async-timeout; python_version < "3.11"
34
33
  Provides-Extra: tests
35
34
  Requires-Dist: aiofile; extra == "tests"
36
35
  Requires-Dist: pytest; extra == "tests"
37
- Requires-Dist: pytest-aio[curio,trio]>=1.1.0; extra == "tests"
36
+ Requires-Dist: pytest-aio[curio,trio]>=1.3.1; extra == "tests"
38
37
  Requires-Dist: pytest-benchmark; extra == "tests"
39
38
  Requires-Dist: PyYAML; extra == "tests"
40
39
  Requires-Dist: ruff; extra == "tests"
@@ -58,6 +57,7 @@ Requires-Dist: cython; extra == "dev"
58
57
  Requires-Dist: pre-commit; extra == "dev"
59
58
  Requires-Dist: sphinx; extra == "dev"
60
59
  Requires-Dist: pydata-sphinx-theme; extra == "dev"
60
+ Dynamic: license-file
61
61
 
62
62
  .. image:: https://raw.githubusercontent.com/klen/asgi-tools/develop/.github/assets/asgi-tools.png
63
63
  :height: 100
@@ -124,7 +124,7 @@ For instance these middlewares were built with the library:
124
124
  Requirements
125
125
  =============
126
126
 
127
- - python >= 3.9
127
+ - python >= 3.10
128
128
 
129
129
  .. note:: pypy3 is also supported
130
130
 
@@ -145,7 +145,7 @@ A Quick Example
145
145
 
146
146
  You can use any of ASGI-Tools components independently.
147
147
 
148
- Dispite this ASGI-Tools contains App_ helper to quickly build ASGI
148
+ Despite this ASGI-Tools contains App_ helper to quickly build ASGI
149
149
  applications. For instance:
150
150
 
151
151
  Save this to ``app.py``.
@@ -201,7 +201,7 @@ Licensed under a `MIT license`_.
201
201
  .. _klen: https://github.com/klen
202
202
  .. _uvicorn: http://www.uvicorn.org/
203
203
  .. _daphne: https://github.com/django/daphne/
204
- .. _hypercorn: https://pgjones.gitlab.io/hypercorn/
204
+ .. _hypercorn: https://github.com/pgjones/hypercorn/
205
205
 
206
206
  .. _Request: https://klen.github.io/asgi-tools/api.html#request
207
207
  .. _Response: https://klen.github.io/asgi-tools/api.html#responses
@@ -63,7 +63,7 @@ For instance these middlewares were built with the library:
63
63
  Requirements
64
64
  =============
65
65
 
66
- - python >= 3.9
66
+ - python >= 3.10
67
67
 
68
68
  .. note:: pypy3 is also supported
69
69
 
@@ -84,7 +84,7 @@ A Quick Example
84
84
 
85
85
  You can use any of ASGI-Tools components independently.
86
86
 
87
- Dispite this ASGI-Tools contains App_ helper to quickly build ASGI
87
+ Despite this ASGI-Tools contains App_ helper to quickly build ASGI
88
88
  applications. For instance:
89
89
 
90
90
  Save this to ``app.py``.
@@ -140,7 +140,7 @@ Licensed under a `MIT license`_.
140
140
  .. _klen: https://github.com/klen
141
141
  .. _uvicorn: http://www.uvicorn.org/
142
142
  .. _daphne: https://github.com/django/daphne/
143
- .. _hypercorn: https://pgjones.gitlab.io/hypercorn/
143
+ .. _hypercorn: https://github.com/pgjones/hypercorn/
144
144
 
145
145
  .. _Request: https://klen.github.io/asgi-tools/api.html#request
146
146
  .. _Response: https://klen.github.io/asgi-tools/api.html#responses
@@ -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",
@@ -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
@@ -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):
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from io import BytesIO
6
6
  from tempfile import SpooledTemporaryFile
7
- from typing import TYPE_CHECKING, Callable, Optional
7
+ from typing import TYPE_CHECKING, Callable
8
8
  from urllib.parse import unquote_to_bytes
9
9
 
10
10
  from multidict import MultiDict
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
19
19
  async def read_formdata(
20
20
  request: "Request",
21
21
  max_size: int,
22
- upload_to: Optional[Callable],
22
+ upload_to: Callable | None,
23
23
  file_memory_limit: int = 1024 * 1024,
24
24
  ) -> MultiDict:
25
25
  """Read formdata from the given request."""
@@ -43,7 +43,7 @@ async def read_formdata(
43
43
  class FormReader:
44
44
  """Process querystring form data."""
45
45
 
46
- __slots__ = "form", "curname", "curvalue", "charset"
46
+ __slots__ = "charset", "curname", "curvalue", "form"
47
47
 
48
48
  def __init__(self, charset: str):
49
49
  self.charset = charset
@@ -80,18 +80,18 @@ class MultipartReader(FormReader):
80
80
  """Process multipart formdata."""
81
81
 
82
82
  __slots__ = (
83
- "form",
83
+ "charset",
84
84
  "curname",
85
85
  "curvalue",
86
- "charset",
86
+ "file_memory_limit",
87
+ "form",
88
+ "headers",
87
89
  "name",
88
90
  "partdata",
89
- "headers",
90
91
  "upload_to",
91
- "file_memory_limit",
92
92
  )
93
93
 
94
- def __init__(self, charset: str, upload_to: Optional[Callable], file_memory_limit: int):
94
+ def __init__(self, charset: str, upload_to: Callable | None, file_memory_limit: int):
95
95
  super().__init__(charset)
96
96
  self.name = ""
97
97
  self.headers: dict[bytes, bytes] = {}
@@ -102,7 +102,7 @@ class MultipartReader(FormReader):
102
102
  def init_parser(self, request: "Request", max_size: int) -> BaseParser:
103
103
  boundary = request.media.get("boundary", "")
104
104
  if not boundary:
105
- raise ValueError("Invalid content type boundary") # noqa: TRY003
105
+ raise ValueError("Invalid content type boundary")
106
106
 
107
107
  return MultipartParser(
108
108
  boundary,