asgi-tools 1.1.0__cp313-cp313-musllinux_1_2_aarch64.whl → 1.2.0__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/multipart.py CHANGED
@@ -256,18 +256,18 @@ class MultipartParser(BaseParser):
256
256
  """
257
257
 
258
258
  __slots__ = (
259
+ "boundary",
260
+ "boundary_chars",
259
261
  "callbacks",
260
262
  "cursize",
261
- "max_size",
262
- "state",
263
- "index",
264
263
  "flags",
265
264
  "header_field_pos",
266
265
  "header_value_pos",
267
- "part_data_pos",
268
- "boundary",
269
- "boundary_chars",
266
+ "index",
270
267
  "lookbehind",
268
+ "max_size",
269
+ "part_data_pos",
270
+ "state",
271
271
  )
272
272
 
273
273
  def __init__(self, boundary, callbacks: dict, max_size: int = 0):
@@ -587,6 +587,3 @@ def prune_data(data_len: int, cursize: int, max_size: int) -> int:
587
587
  return max_size - cursize
588
588
 
589
589
  return data_len
590
-
591
-
592
- # ruff: noqa: TRY003
asgi_tools/request.py CHANGED
@@ -5,7 +5,7 @@ incoming request.
5
5
  from __future__ import annotations
6
6
 
7
7
  from http import cookies
8
- from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Iterator, Optional, Union
8
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Iterator, Mapping, Protocol, TypeVar
9
9
 
10
10
  from yarl import URL
11
11
 
@@ -17,11 +17,21 @@ from .types import TJSON, TASGIReceive, TASGIScope, TASGISend
17
17
  from .utils import CIMultiDict, parse_headers, parse_options_header
18
18
 
19
19
  if TYPE_CHECKING:
20
+ from pathlib import Path
21
+
20
22
  from multidict import MultiDict, MultiDictProxy
21
23
 
24
+ T = TypeVar("T")
25
+
26
+
27
+ class UploadHandler(Protocol):
28
+ """Protocol for file upload handlers."""
29
+
30
+ async def __call__(self, filename: str, content_type: str, content: bytes) -> str | Path: ...
31
+
22
32
 
23
33
  class Request(TASGIScope):
24
- """Represent a HTTP Request.
34
+ """Provides a convenient, high-level interface for incoming HTTP requests.
25
35
 
26
36
  :param scope: HTTP ASGI Scope
27
37
  :param receive: an asynchronous callable which lets the application
@@ -32,16 +42,16 @@ class Request(TASGIScope):
32
42
  """
33
43
 
34
44
  __slots__ = (
35
- "scope",
36
- "receive",
37
- "send",
38
- "_is_read",
39
- "_url",
40
45
  "_body",
46
+ "_cookies",
41
47
  "_form",
42
48
  "_headers",
49
+ "_is_read",
43
50
  "_media",
44
- "_cookies",
51
+ "_url",
52
+ "receive",
53
+ "scope",
54
+ "send",
45
55
  )
46
56
 
47
57
  def __init__(self, scope: TASGIScope, receive: TASGIReceive, send: TASGISend):
@@ -51,12 +61,12 @@ class Request(TASGIScope):
51
61
  self.send = send
52
62
 
53
63
  self._is_read: bool = False
54
- self._url: Optional[URL] = None
55
- self._body: Optional[bytes] = None
56
- self._form: Optional[MultiDict] = None
57
- self._headers: Optional[CIMultiDict] = None
58
- self._media: Optional[dict[str, str]] = None
59
- self._cookies: Optional[dict[str, str]] = None
64
+ self._url: URL | None = None
65
+ self._body: bytes | None = None
66
+ self._form: MultiDict | None = None
67
+ self._headers: CIMultiDict | None = None
68
+ self._media: dict[str, str] | None = None
69
+ self._cookies: dict[str, str] | None = None
60
70
 
61
71
  def __str__(self) -> str:
62
72
  """Return the request's params."""
@@ -174,11 +184,11 @@ class Request(TASGIScope):
174
184
  return self._cookies
175
185
 
176
186
  @property
177
- def media(self) -> dict[str, str]:
187
+ def media(self) -> Mapping[str, str]:
178
188
  """Prepare a media data for the request."""
179
189
  if self._media is None:
180
- conten_type_header = self.headers.get("content-type", "")
181
- content_type, opts = parse_options_header(conten_type_header)
190
+ content_type_header = self.headers.get("content-type", "")
191
+ content_type, opts = parse_options_header(content_type_header)
182
192
  self._media = dict(opts, content_type=content_type)
183
193
 
184
194
  return self._media
@@ -215,16 +225,16 @@ class Request(TASGIScope):
215
225
  """
216
226
  if self._is_read:
217
227
  if self._body is None:
218
- raise RuntimeError("Stream has been read") # noqa: TRY003
228
+ raise RuntimeError("Stream has been read")
219
229
  yield self._body
220
230
 
221
231
  else:
222
232
  self._is_read = True
223
- message = await self.receive()
224
- yield message.get("body", b"")
225
- while message.get("more_body"):
233
+ while True:
226
234
  message = await self.receive()
227
235
  yield message.get("body", b"")
236
+ if not message.get("more_body"):
237
+ break
228
238
 
229
239
  async def body(self) -> bytes:
230
240
  """Read and return the request's body as bytes.
@@ -232,7 +242,10 @@ class Request(TASGIScope):
232
242
  `body = await request.body()`
233
243
  """
234
244
  if self._body is None:
235
- self._body = b"".join([chunk async for chunk in self.stream()])
245
+ data = bytearray()
246
+ async for chunk in self.stream():
247
+ data.extend(chunk)
248
+ self._body = bytes(data)
236
249
 
237
250
  return self._body
238
251
 
@@ -261,38 +274,38 @@ class Request(TASGIScope):
261
274
  async def form(
262
275
  self,
263
276
  max_size: int = 0,
264
- upload_to: Optional[Callable] = None,
277
+ upload_to: UploadHandler | None = None,
265
278
  file_memory_limit: int = 1024 * 1024,
266
279
  ) -> MultiDict:
267
- """Read and return the request's multipart formdata as a multidict.
268
-
269
- The method reads the request's stream stright into memory formdata.
270
- Any subsequent calls to :py:meth:`body`, :py:meth:`json` will raise an error.
280
+ """Read and return the request's form data.
271
281
 
272
- :param max_size: The maximum size of the request body in bytes.
273
- :param upload_to: A callable to be used to determine the upload path for files.
274
- :param file_memory_limit: The maximum size of the file to be stored in memory in bytes.
282
+ :param max_size: Maximum size of the form data in bytes
283
+ :type max_size: int
284
+ :param upload_to: Callable to handle file uploads
285
+ :type upload_to: Optional[UploadHandler]
286
+ :param file_memory_limit: Maximum size of file to keep in memory
287
+ :type file_memory_limit: int
288
+ :return: Form data as MultiDict
289
+ :rtype: MultiDict
275
290
 
276
291
  `formdata = await request.form()`
277
-
278
292
  """
279
293
  if self._form is None:
280
- try:
281
- self._form = await read_formdata(
282
- self,
283
- max_size,
284
- upload_to,
285
- file_memory_limit,
286
- )
287
- except (LookupError, ValueError) as exc:
288
- raise ASGIDecodeError from exc
289
-
294
+ self._form = await read_formdata(
295
+ self,
296
+ max_size=max_size,
297
+ upload_to=upload_to,
298
+ file_memory_limit=file_memory_limit,
299
+ )
290
300
  return self._form
291
301
 
292
- async def data(self, *, raise_errors: bool = False) -> Union[str, bytes, MultiDict, TJSON]:
293
- """The method checks Content-Type Header and parse the request's data automatically.
302
+ async def data(self, *, raise_errors: bool = False) -> str | bytes | MultiDict | TJSON:
303
+ """Read and return the request's data based on content type.
294
304
 
295
305
  :param raise_errors: Raise an error if the given data is invalid.
306
+ :return: Request data in appropriate format
307
+ :rtype: Union[str, bytes, MultiDict, TJSON]
308
+ :raises ASGIDecodeError: If data cannot be decoded and raise_errors is True
296
309
 
297
310
  `data = await request.data()`
298
311
 
@@ -302,14 +315,15 @@ class Request(TASGIScope):
302
315
  Returns data from :py:meth:`json` for `application/json`, :py:meth:`form` for
303
316
  `application/x-www-form-urlencoded`, `multipart/form-data` and :py:meth:`text` otherwise.
304
317
  """
318
+ content_type = self.content_type
305
319
  try:
306
- if self.content_type in {
307
- "application/x-www-form-urlencoded",
320
+ if content_type in {
308
321
  "multipart/form-data",
322
+ "application/x-www-form-urlencoded",
309
323
  }:
310
324
  return await self.form()
311
325
 
312
- if self.content_type == "application/json":
326
+ if content_type == "application/json":
313
327
  return await self.json()
314
328
 
315
329
  except ASGIDecodeError:
@@ -317,5 +331,7 @@ class Request(TASGIScope):
317
331
  raise
318
332
  return await self.body()
319
333
 
320
- else:
334
+ if content_type.startswith("text/"):
321
335
  return await self.text()
336
+
337
+ return await self.body()
asgi_tools/response.py CHANGED
@@ -11,7 +11,7 @@ from http.cookies import SimpleCookie
11
11
  from mimetypes import guess_type
12
12
  from pathlib import Path
13
13
  from stat import S_ISDIR
14
- from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Mapping, Optional, Union
14
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Mapping, TypeVar
15
15
  from urllib.parse import quote, quote_plus
16
16
 
17
17
  from multidict import MultiDict
@@ -24,12 +24,15 @@ from .request import Request
24
24
  if TYPE_CHECKING:
25
25
  from .types import TASGIMessage, TASGIReceive, TASGIScope, TASGISend
26
26
 
27
+ T = TypeVar("T")
28
+ TContent = TypeVar("TContent", str, bytes, dict, list, None)
29
+
27
30
 
28
31
  class Response:
29
- """A base class to make ASGI_ responses.
32
+ """Base class for creating HTTP responses.
30
33
 
31
34
  :param content: A response's body
32
- :type content: str | bytes
35
+ :type content: str | bytes | dict | list | None
33
36
  :param status_code: An HTTP status code
34
37
  :type status_code: int
35
38
  :param headers: A dictionary of HTTP headers
@@ -57,17 +60,17 @@ class Response:
57
60
  strategy ('lax'|'strict'|'none')
58
61
 
59
62
  """
60
- content_type: Optional[str] = None
63
+ content_type: str | None = None
61
64
  status_code: int = HTTPStatus.OK.value
62
65
 
63
66
  def __init__(
64
67
  self,
65
- content,
68
+ content: TContent,
66
69
  *,
67
- status_code: Optional[int] = None,
68
- content_type: Optional[str] = None,
69
- headers: Optional[dict[str, str]] = None,
70
- cookies: Optional[dict[str, str]] = None,
70
+ status_code: int | None = None,
71
+ content_type: str | None = None,
72
+ headers: dict[str, str] | None = None,
73
+ cookies: dict[str, str] | None = None,
71
74
  ):
72
75
  """Setup the response."""
73
76
  self.content = self.process_content(content)
@@ -80,8 +83,7 @@ class Response:
80
83
  if content_type:
81
84
  self.headers.setdefault(
82
85
  "content-type",
83
- content_type.startswith("text/")
84
- and f"{content_type}; charset={DEFAULT_CHARSET}"
86
+ (content_type.startswith("text/") and f"{content_type}; charset={DEFAULT_CHARSET}")
85
87
  or content_type,
86
88
  )
87
89
 
@@ -101,7 +103,8 @@ class Response:
101
103
  await send({"type": "http.response.body", "body": self.content})
102
104
 
103
105
  @staticmethod
104
- def process_content(content) -> bytes:
106
+ def process_content(content: TContent) -> bytes:
107
+ """Process content into bytes."""
105
108
  if not isinstance(content, bytes):
106
109
  return str(content).encode(DEFAULT_CHARSET)
107
110
  return content
@@ -127,19 +130,19 @@ class Response:
127
130
 
128
131
 
129
132
  class ResponseText(Response):
130
- """A helper to return plain text responses (text/plain)."""
133
+ """Returns plain text responses (text/plain)."""
131
134
 
132
135
  content_type = "text/plain"
133
136
 
134
137
 
135
138
  class ResponseHTML(Response):
136
- """A helper to return HTML responses (text/html)."""
139
+ """Returns HTML responses (text/html)."""
137
140
 
138
141
  content_type = "text/html"
139
142
 
140
143
 
141
144
  class ResponseJSON(Response):
142
- """A helper to return JSON responses (application/json).
145
+ """Returns JSON responses (application/json).
143
146
 
144
147
  The class optionally supports `ujson <https://pypi.org/project/ujson/>`_ and `orjson
145
148
  <https://pypi.org/project/orjson/>`_ JSON libraries. Install one of them to use instead
@@ -156,7 +159,7 @@ class ResponseJSON(Response):
156
159
 
157
160
 
158
161
  class ResponseStream(Response):
159
- """A helper to stream a response's body.
162
+ """Streams response body as chunks.
160
163
 
161
164
  :param content: An async generator to stream the response's body
162
165
  :type content: AsyncGenerator
@@ -197,7 +200,7 @@ class ResponseStream(Response):
197
200
 
198
201
 
199
202
  class ResponseSSE(ResponseStream):
200
- """A helper to stream SSE (server side events).
203
+ """Streams Server-Sent Events (SSE).
201
204
 
202
205
  :param content: An async generator to stream the events
203
206
  :type content: AsyncGenerator
@@ -211,19 +214,19 @@ class ResponseSSE(ResponseStream):
211
214
  return super().msg_start()
212
215
 
213
216
  @staticmethod
214
- def process_content(chunk) -> bytes:
217
+ def process_content(content) -> bytes:
215
218
  """Prepare a chunk from stream generator to send."""
216
- if isinstance(chunk, dict):
217
- chunk = "\n".join(f"{k}: {v}" for k, v in chunk.items())
219
+ if isinstance(content, dict):
220
+ content = "\n".join(f"{k}: {v}" for k, v in content.items())
218
221
 
219
- if not isinstance(chunk, bytes):
220
- chunk = chunk.encode(DEFAULT_CHARSET)
222
+ if not isinstance(content, bytes):
223
+ content = content.encode(DEFAULT_CHARSET)
221
224
 
222
- return chunk + b"\n\n"
225
+ return content + b"\n\n"
223
226
 
224
227
 
225
228
  class ResponseFile(ResponseStream):
226
- """A helper to stream files as a response body.
229
+ """Serves files as HTTP responses.
227
230
 
228
231
  :param filepath: The filepath to the file
229
232
  :type filepath: str | Path
@@ -238,10 +241,10 @@ class ResponseFile(ResponseStream):
238
241
 
239
242
  def __init__(
240
243
  self,
241
- filepath: Union[str, Path],
244
+ filepath: str | Path,
242
245
  *,
243
246
  chunk_size: int = 64 * 1024,
244
- filename: Optional[str] = None,
247
+ filename: str | None = None,
245
248
  headers_only: bool = False,
246
249
  **kwargs,
247
250
  ) -> None:
@@ -252,7 +255,7 @@ class ResponseFile(ResponseStream):
252
255
  raise ASGIError(*exc.args) from exc
253
256
 
254
257
  if S_ISDIR(stat.st_mode):
255
- raise ASGIError(f"It's a directory: {filepath}") # noqa: TRY003
258
+ raise ASGIError(f"It's a directory: {filepath}")
256
259
 
257
260
  super().__init__(
258
261
  empty() if headers_only else aio_stream_file(filepath, chunk_size),
@@ -261,7 +264,7 @@ class ResponseFile(ResponseStream):
261
264
 
262
265
  headers = self.headers
263
266
  if filename and "content-disposition" not in headers:
264
- headers["content-disposition"] = f'attachment; filename="{quote(filename)}"'
267
+ headers["content-disposition"] = f"attachment; filename*=UTF-8''{quote(filename)}"
265
268
 
266
269
  if "content-type" not in headers:
267
270
  headers["content-type"] = guess_type(filename or str(filepath))[0] or "text/plain"
@@ -273,13 +276,7 @@ class ResponseFile(ResponseStream):
273
276
 
274
277
 
275
278
  class ResponseWebSocket(Response):
276
- """A helper to work with websockets.
277
-
278
- :param scope: Request info (ASGI Scope | ASGI-Tools Request)
279
- :type scope: dict
280
- :param receive: ASGI receive function
281
- :param send: ASGI send function
282
- """
279
+ """Provides a WebSocket handler interface."""
283
280
 
284
281
  class STATES(Enum):
285
282
  """Represent websocket states."""
@@ -291,15 +288,15 @@ class ResponseWebSocket(Response):
291
288
  def __init__(
292
289
  self,
293
290
  scope: TASGIScope,
294
- receive: Optional[TASGIReceive] = None,
295
- send: Optional[TASGISend] = None,
291
+ receive: TASGIReceive | None = None,
292
+ send: TASGISend | None = None,
296
293
  ) -> None:
297
294
  """Initialize the websocket response."""
298
295
  if isinstance(scope, Request):
299
296
  receive, send = scope.receive, scope.send
300
297
 
301
298
  if not receive or not send:
302
- raise ASGIError("Invalid initialization") # noqa: TRY003
299
+ raise ASGIError("Invalid initialization")
303
300
 
304
301
  super().__init__(b"")
305
302
  self._receive: TASGIReceive = receive
@@ -317,7 +314,7 @@ class ResponseWebSocket(Response):
317
314
  return self
318
315
 
319
316
  async def __aexit__(self, *_):
320
- """Use it as async context manager."""
317
+ """Exit async context."""
321
318
  await self.close()
322
319
 
323
320
  @property
@@ -348,13 +345,13 @@ class ResponseWebSocket(Response):
348
345
  await self.send({"type": "websocket.close", "code": code})
349
346
  self.state = self.STATES.DISCONNECTED
350
347
 
351
- async def send(self, msg: Union[dict, str, bytes], msg_type="websocket.send") -> None:
348
+ async def send(self, msg: dict | str | bytes, msg_type="websocket.send") -> None:
352
349
  """Send the given message to a client."""
353
350
  if self.state == self.STATES.DISCONNECTED:
354
351
  raise ASGIConnectionClosedError
355
352
 
356
353
  if not isinstance(msg, dict):
357
- msg = {"type": msg_type, (isinstance(msg, str) and "text" or "bytes"): msg}
354
+ msg = {"type": msg_type, ((isinstance(msg, str) and "text") or "bytes"): msg}
358
355
 
359
356
  return await self._send(msg)
360
357
 
@@ -362,7 +359,7 @@ class ResponseWebSocket(Response):
362
359
  """Serialize the given data to JSON and send to a client."""
363
360
  return await self._send({"type": "websocket.send", "bytes": json_dumps(data)})
364
361
 
365
- async def receive(self, *, raw: bool = False) -> Union[TASGIMessage, str]:
362
+ async def receive(self, *, raw: bool = False) -> TASGIMessage | str:
366
363
  """Receive messages from a client.
367
364
 
368
365
  :param raw: Receive messages as is.
@@ -382,7 +379,7 @@ class ResponseWebSocket(Response):
382
379
 
383
380
 
384
381
  class ResponseRedirect(Response, BaseException):
385
- """A helper to return HTTP redirects. Uses a 307 status code by default.
382
+ """Creates HTTP redirects. Uses a 307 status code by default.
386
383
 
387
384
  :param url: A string with the new location
388
385
  :type url: str
@@ -390,7 +387,7 @@ class ResponseRedirect(Response, BaseException):
390
387
 
391
388
  status_code: int = HTTPStatus.TEMPORARY_REDIRECT.value
392
389
 
393
- def __init__(self, url: str, status_code: Optional[int] = None, **kwargs) -> None:
390
+ def __init__(self, url: str, status_code: int | None = None, **kwargs) -> None:
394
391
  """Set status code and prepare location."""
395
392
  super().__init__(b"", status_code=status_code, **kwargs)
396
393
  assert (
@@ -413,7 +410,7 @@ class ResponseErrorMeta(type):
413
410
 
414
411
 
415
412
  class ResponseError(Response, BaseException, metaclass=ResponseErrorMeta):
416
- """A helper to return HTTP errors. Uses a 500 status code by default.
413
+ """Helper for returning HTTP errors. Uses a 500 status code by default.
417
414
 
418
415
  :param message: A string with the error's message (HTTPStatus messages will be used by default)
419
416
  :type message: str
@@ -477,7 +474,7 @@ class ResponseError(Response, BaseException, metaclass=ResponseErrorMeta):
477
474
  NOT_EXTENDED: Callable[..., ResponseError] # 510
478
475
  NETWORK_AUTHENTICATION_REQUIRED: Callable[..., ResponseError] # 511
479
476
 
480
- def __init__(self, message=None, status_code: Optional[int] = None, **kwargs):
477
+ def __init__(self, message=None, status_code: int | None = None, **kwargs):
481
478
  """Check error status."""
482
479
  content = message or HTTPStatus(status_code or self.status_code).description
483
480
  super().__init__(content=content, status_code=status_code, **kwargs)
@@ -495,7 +492,7 @@ CAST_RESPONSE: Mapping[type, type[Response]] = {
495
492
  }
496
493
 
497
494
 
498
- def parse_response(response, headers: Optional[dict] = None) -> Response:
495
+ def parse_response(response, headers: dict | None = None) -> Response:
499
496
  """Parse the given object and convert it into a asgi_tools.Response."""
500
497
  if isinstance(response, Response):
501
498
  return response
@@ -520,9 +517,7 @@ def parse_response(response, headers: Optional[dict] = None) -> Response:
520
517
  return ResponseText(str(response), headers=headers)
521
518
 
522
519
 
523
- def parse_websocket_msg(
524
- msg: TASGIMessage, charset: Optional[str] = None
525
- ) -> Union[TASGIMessage, str]:
520
+ def parse_websocket_msg(msg: TASGIMessage, charset: str | None = None) -> TASGIMessage | str:
526
521
  """Prepare websocket message."""
527
522
  data = msg.get("text")
528
523
  if data:
asgi_tools/tests.py CHANGED
@@ -22,13 +22,10 @@ from typing import (
22
22
  Callable,
23
23
  Coroutine,
24
24
  Deque,
25
- Optional,
26
- Union,
27
25
  cast,
28
26
  )
29
27
  from urllib.parse import urlencode
30
28
 
31
- from multidict import MultiDict
32
29
  from yarl import URL
33
30
 
34
31
  from ._compat import aio_cancel, aio_sleep, aio_spawn, aio_timeout, aio_wait
@@ -38,6 +35,8 @@ from .response import Response, ResponseJSON, ResponseWebSocket, parse_websocket
38
35
  from .utils import CIMultiDict, parse_headers
39
36
 
40
37
  if TYPE_CHECKING:
38
+ from multidict import MultiDict
39
+
41
40
  from .types import TJSON, TASGIApp, TASGIMessage, TASGIReceive, TASGIScope, TASGISend
42
41
 
43
42
 
@@ -53,7 +52,7 @@ class TestResponse(Response):
53
52
  msg = await self._receive()
54
53
  assert msg.get("type") == "http.response.start", "Invalid Response"
55
54
  self.status_code = int(msg.get("status", 502))
56
- self.headers = cast(MultiDict, parse_headers(msg.get("headers", [])))
55
+ self.headers = cast("MultiDict", parse_headers(msg.get("headers", [])))
57
56
  self.content_type = self.headers.get("content-type")
58
57
  for cookie in self.headers.getall("set-cookie", []):
59
58
  self.cookies.load(cookie)
@@ -123,7 +122,7 @@ class TestWebSocketResponse(ResponseWebSocket):
123
122
 
124
123
 
125
124
  class ASGITestClient:
126
- """The test client allows you to make requests against an ASGI application.
125
+ """Built-in test client for ASGI applications.
127
126
 
128
127
  Features:
129
128
 
@@ -151,10 +150,10 @@ class ASGITestClient:
151
150
  path: str,
152
151
  method: str = "GET",
153
152
  *,
154
- query: Union[str, dict] = "",
155
- headers: Optional[dict[str, str]] = None,
156
- cookies: Optional[dict[str, str]] = None,
157
- data: Union[bytes, str, dict, AsyncGenerator[Any, bytes]] = b"",
153
+ query: str | dict = "",
154
+ headers: dict[str, str] | None = None,
155
+ cookies: dict[str, str] | None = None,
156
+ data: bytes | str | dict | AsyncGenerator[Any, bytes] = b"",
158
157
  json: TJSON = None,
159
158
  follow_redirect: bool = True,
160
159
  timeout: float = 10.0,
@@ -214,9 +213,9 @@ class ASGITestClient:
214
213
  async def websocket(
215
214
  self,
216
215
  path: str,
217
- query: Union[str, dict, None] = None,
218
- headers: Optional[dict] = None,
219
- cookies: Optional[dict] = None,
216
+ query: str | dict | None = None,
217
+ headers: dict | None = None,
218
+ cookies: dict | None = None,
220
219
  ):
221
220
  """Connect to a websocket."""
222
221
  pipe = Pipe()
@@ -250,9 +249,9 @@ class ASGITestClient:
250
249
  def build_scope(
251
250
  self,
252
251
  path: str,
253
- headers: Union[dict, CIMultiDict, None] = None,
254
- query: Union[str, dict, None] = None,
255
- cookies: Optional[dict] = None,
252
+ headers: dict | CIMultiDict | None = None,
253
+ query: str | dict | None = None,
254
+ cookies: dict | None = None,
256
255
  **scope,
257
256
  ) -> TASGIScope:
258
257
  """Prepare a request scope."""
@@ -282,7 +281,7 @@ class ASGITestClient:
282
281
  "query_string": url.raw_query_string.encode(),
283
282
  "raw_path": url.raw_path.encode(),
284
283
  "root_path": "",
285
- "scheme": scope.get("type") == "http" and self.base_url.scheme or "ws",
284
+ "scheme": (scope.get("type") == "http" and self.base_url.scheme) or "ws",
286
285
  "headers": [
287
286
  (key.lower().encode(BASE_ENCODING), str(val).encode(BASE_ENCODING))
288
287
  for key, val in (headers or {}).items()
@@ -321,11 +320,11 @@ def encode_multipart(data: dict) -> tuple[bytes, str]:
321
320
 
322
321
  class Pipe:
323
322
  __slots__ = (
324
- "delay",
325
323
  "app_is_closed",
326
- "client_is_closed",
327
324
  "app_queue",
325
+ "client_is_closed",
328
326
  "client_queue",
327
+ "delay",
329
328
  )
330
329
 
331
330
  def __init__(self, delay: float = 1e-3):
@@ -366,7 +365,7 @@ class Pipe:
366
365
  await aio_sleep(self.delay)
367
366
  return self.app_queue.popleft()
368
367
 
369
- async def stream(self, data: Union[bytes, AsyncGenerator[Any, bytes]]):
368
+ async def stream(self, data: bytes | AsyncGenerator[Any, bytes]):
370
369
  if isinstance(data, bytes):
371
370
  return await self.send_to_app(
372
371
  {"type": "http.request", "body": data, "more_body": False},
@@ -392,7 +391,7 @@ async def manage_lifespan(app, timeout: float = 3e-2):
392
391
  async with aio_spawn(safe_spawn) as task:
393
392
  await pipe.send_to_app({"type": "lifespan.startup"})
394
393
 
395
- with suppress(TimeoutError, asyncio.TimeoutError): # python 39, 310
394
+ with suppress(TimeoutError, asyncio.TimeoutError): # python 310
396
395
  async with aio_timeout(timeout):
397
396
  msg = await pipe.receive_from_client()
398
397
  if msg["type"] == "lifespan.startup.failed":
@@ -401,6 +400,6 @@ async def manage_lifespan(app, timeout: float = 3e-2):
401
400
  yield
402
401
 
403
402
  await pipe.send_to_app({"type": "lifespan.shutdown"})
404
- with suppress(TimeoutError, asyncio.TimeoutError): # python 39, 310
403
+ with suppress(TimeoutError, asyncio.TimeoutError): # python 310
405
404
  async with aio_timeout(timeout):
406
405
  await pipe.receive_from_client()
asgi_tools/view.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- from typing import TYPE_CHECKING, Final, Optional
4
+ from typing import TYPE_CHECKING, Final
5
5
 
6
6
  if TYPE_CHECKING:
7
7
  from collections.abc import Awaitable
@@ -57,7 +57,7 @@ class HTTPView:
57
57
  return self(request, **opts)
58
58
 
59
59
  @classmethod
60
- def __route__(cls, router: Router, *paths: str, methods: Optional[TMethods] = None, **params):
60
+ def __route__(cls, router: Router, *paths: str, methods: TMethods | None = None, **params):
61
61
  """Bind the class view to the given router."""
62
62
  view_methods = dict(inspect.getmembers(cls, inspect.isfunction))
63
63
  methods = methods or [m for m in HTTP_METHODS if m.lower() in view_methods]