panther 5.0.0b3__py3-none-any.whl → 5.0.0b4__py3-none-any.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.
Files changed (56) hide show
  1. panther/__init__.py +1 -1
  2. panther/_load_configs.py +46 -37
  3. panther/_utils.py +49 -34
  4. panther/app.py +96 -97
  5. panther/authentications.py +97 -50
  6. panther/background_tasks.py +98 -124
  7. panther/base_request.py +16 -10
  8. panther/base_websocket.py +8 -8
  9. panther/caching.py +16 -80
  10. panther/cli/create_command.py +17 -16
  11. panther/cli/main.py +1 -1
  12. panther/cli/monitor_command.py +11 -6
  13. panther/cli/run_command.py +5 -71
  14. panther/cli/template.py +7 -7
  15. panther/cli/utils.py +58 -69
  16. panther/configs.py +70 -72
  17. panther/db/connections.py +18 -24
  18. panther/db/cursor.py +0 -1
  19. panther/db/models.py +24 -8
  20. panther/db/queries/base_queries.py +2 -5
  21. panther/db/queries/mongodb_queries.py +17 -20
  22. panther/db/queries/pantherdb_queries.py +1 -1
  23. panther/db/queries/queries.py +26 -8
  24. panther/db/utils.py +1 -1
  25. panther/events.py +25 -14
  26. panther/exceptions.py +2 -7
  27. panther/file_handler.py +1 -1
  28. panther/generics.py +11 -8
  29. panther/logging.py +2 -1
  30. panther/main.py +12 -13
  31. panther/middlewares/cors.py +67 -0
  32. panther/middlewares/monitoring.py +5 -3
  33. panther/openapi/urls.py +2 -2
  34. panther/openapi/utils.py +3 -3
  35. panther/openapi/views.py +20 -37
  36. panther/pagination.py +4 -2
  37. panther/panel/apis.py +2 -7
  38. panther/panel/urls.py +2 -6
  39. panther/panel/utils.py +9 -5
  40. panther/panel/views.py +13 -22
  41. panther/permissions.py +2 -1
  42. panther/request.py +2 -1
  43. panther/response.py +53 -47
  44. panther/routings.py +12 -12
  45. panther/serializer.py +19 -20
  46. panther/test.py +73 -58
  47. panther/throttling.py +68 -3
  48. panther/utils.py +5 -11
  49. {panther-5.0.0b3.dist-info → panther-5.0.0b4.dist-info}/METADATA +1 -1
  50. panther-5.0.0b4.dist-info/RECORD +75 -0
  51. panther/monitoring.py +0 -34
  52. panther-5.0.0b3.dist-info/RECORD +0 -75
  53. {panther-5.0.0b3.dist-info → panther-5.0.0b4.dist-info}/WHEEL +0 -0
  54. {panther-5.0.0b3.dist-info → panther-5.0.0b4.dist-info}/entry_points.txt +0 -0
  55. {panther-5.0.0b3.dist-info → panther-5.0.0b4.dist-info}/licenses/LICENSE +0 -0
  56. {panther-5.0.0b3.dist-info → panther-5.0.0b4.dist-info}/top_level.txt +0 -0
panther/request.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import logging
2
- from typing import Literal, Callable
2
+ from collections.abc import Callable
3
+ from typing import Literal
3
4
  from urllib.parse import parse_qsl
4
5
 
5
6
  import orjson as json
panther/response.py CHANGED
@@ -1,9 +1,15 @@
1
1
  import asyncio
2
+ import logging
3
+ from collections.abc import AsyncGenerator, Generator
2
4
  from dataclasses import dataclass
3
5
  from http import cookies
4
6
  from sys import version_info
5
7
  from types import NoneType
6
- from typing import Generator, AsyncGenerator, Any, Type, Literal
8
+ from typing import Any, Literal
9
+
10
+ import jinja2
11
+
12
+ from panther.exceptions import APIError
7
13
 
8
14
  if version_info >= (3, 11):
9
15
  from typing import LiteralString
@@ -13,19 +19,23 @@ else:
13
19
  LiteralString = TypeVar('LiteralString')
14
20
 
15
21
  import orjson as json
22
+ from pantherdb import Cursor as PantherDBCursor
16
23
  from pydantic import BaseModel
17
24
 
18
25
  from panther import status
19
- from panther.configs import config
20
26
  from panther._utils import to_async_generator
27
+ from panther.configs import config
21
28
  from panther.db.cursor import Cursor
22
- from pantherdb import Cursor as PantherDBCursor
23
29
  from panther.pagination import Pagination
24
30
 
25
- ResponseDataTypes = list | tuple | set | Cursor | PantherDBCursor | dict | int | float | str | bool | bytes | NoneType | Type[BaseModel]
31
+ ResponseDataTypes = (
32
+ list | tuple | set | Cursor | PantherDBCursor | dict | int | float | str | bool | bytes | NoneType | type[BaseModel]
33
+ )
26
34
  IterableDataTypes = list | tuple | set | Cursor | PantherDBCursor
27
35
  StreamingDataTypes = Generator | AsyncGenerator
28
36
 
37
+ logger = logging.getLogger('panther')
38
+
29
39
 
30
40
  @dataclass(slots=True)
31
41
  class Cookie:
@@ -44,6 +54,7 @@ class Cookie:
44
54
  `lax` is the default behavior if not specified.
45
55
  expires: [Deprecated] In HTTP version 1.1, `expires` was deprecated and replaced with the easier-to-use `max-age`
46
56
  """
57
+
47
58
  key: str
48
59
  value: str
49
60
  domain: str = None
@@ -63,7 +74,7 @@ class Response:
63
74
  status_code: int = status.HTTP_200_OK,
64
75
  headers: dict | None = None,
65
76
  pagination: Pagination | None = None,
66
- set_cookies: list[Cookie] | None = None
77
+ set_cookies: Cookie | list[Cookie] | None = None,
67
78
  ):
68
79
  """
69
80
  :param data: should be an instance of ResponseDataTypes
@@ -71,10 +82,10 @@ class Response:
71
82
  :param headers: should be dict of headers
72
83
  :param pagination: an instance of Pagination or None
73
84
  The `pagination.template()` method will be used
74
- :param set_cookies: list of cookies you want to set on the client
85
+ :param set_cookies: single cookie or list of cookies you want to set on the client
75
86
  Set the `max_age` to `0` if you want to delete a cookie
76
87
  """
77
- headers = headers or {}
88
+ self.headers = {'Content-Type': self.content_type} | (headers or {})
78
89
  self.pagination: Pagination | None = pagination
79
90
  if isinstance(data, Cursor):
80
91
  data = list(data)
@@ -84,6 +95,8 @@ class Response:
84
95
  self.cookies = None
85
96
  if set_cookies:
86
97
  c = cookies.SimpleCookie()
98
+ if not isinstance(set_cookies, list):
99
+ set_cookies = [set_cookies]
87
100
  for cookie in set_cookies:
88
101
  c[cookie.key] = cookie.value
89
102
  c[cookie.key]['path'] = cookie.path
@@ -95,36 +108,23 @@ class Response:
95
108
  if cookie.max_age is not None:
96
109
  c[cookie.key]['max-age'] = cookie.max_age
97
110
  self.cookies = [(b'Set-Cookie', cookie.OutputString().encode()) for cookie in c.values()]
98
- self.headers = headers
99
111
 
100
112
  @property
101
113
  def body(self) -> bytes:
102
114
  if isinstance(self.data, bytes):
103
115
  return self.data
104
-
105
116
  if self.data is None:
106
117
  return b''
107
118
  return json.dumps(self.data)
108
119
 
109
120
  @property
110
- def headers(self) -> dict:
111
- return {
112
- 'Content-Type': self.content_type,
113
- 'Content-Length': len(self.body),
114
- 'Access-Control-Allow-Origin': '*',
115
- } | self._headers
116
-
117
- @property
118
- def bytes_headers(self) -> list[tuple[bytes]]:
119
- result = [(k.encode(), str(v).encode()) for k, v in (self.headers or {}).items()]
121
+ def bytes_headers(self) -> list[tuple[bytes, bytes]]:
122
+ headers = {'Content-Length': len(self.body)} | self.headers
123
+ result = [(k.encode(), str(v).encode()) for k, v in headers.items()]
120
124
  if self.cookies:
121
- result.extend(self.cookies)
125
+ result += self.cookies
122
126
  return result
123
127
 
124
- @headers.setter
125
- def headers(self, headers: dict):
126
- self._headers = headers
127
-
128
128
  @classmethod
129
129
  def prepare_data(cls, data: Any):
130
130
  """Make sure the response data is only ResponseDataTypes or Iterable of ResponseDataTypes"""
@@ -151,16 +151,10 @@ class Response:
151
151
  raise TypeError(error)
152
152
  return status_code
153
153
 
154
- async def send_headers(self, send, /):
154
+ async def send(self, send, receive):
155
155
  await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers})
156
-
157
- async def send_body(self, send, receive, /):
158
156
  await send({'type': 'http.response.body', 'body': self.body, 'more_body': False})
159
157
 
160
- async def send(self, send, receive, /):
161
- await self.send_headers(send)
162
- await self.send_body(send, receive)
163
-
164
158
  def __str__(self):
165
159
  if len(data := str(self.data)) > 30:
166
160
  data = f'{data:.27}...'
@@ -190,15 +184,11 @@ class StreamingResponse(Response):
190
184
  raise TypeError(msg)
191
185
 
192
186
  @property
193
- def headers(self) -> dict:
194
- return {
195
- 'Content-Type': self.content_type,
196
- 'Access-Control-Allow-Origin': '*',
197
- } | self._headers
198
-
199
- @headers.setter
200
- def headers(self, headers: dict):
201
- self._headers = headers
187
+ def bytes_headers(self) -> list[tuple[bytes, bytes]]:
188
+ result = [(k.encode(), str(v).encode()) for k, v in self.headers.items()]
189
+ if self.cookies:
190
+ result += self.cookies
191
+ return result
202
192
 
203
193
  @property
204
194
  async def body(self) -> AsyncGenerator:
@@ -210,8 +200,11 @@ class StreamingResponse(Response):
210
200
  else:
211
201
  yield json.dumps(chunk)
212
202
 
213
- async def send_body(self, send, receive, /):
214
- asyncio.create_task(self.listen_to_disconnection(receive))
203
+ async def send(self, send, receive):
204
+ # Send Headers
205
+ await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers})
206
+ # Send Body as chunks
207
+ asyncio.create_task(self.listen_to_disconnection(receive=receive))
215
208
  async for chunk in self.body:
216
209
  if self.connection_closed:
217
210
  break
@@ -242,13 +235,13 @@ class PlainTextResponse(Response):
242
235
 
243
236
  class TemplateResponse(HTMLResponse):
244
237
  """
245
- You may want to declare `TEMPLATES_DIR` in your configs
238
+ You may want to declare `TEMPLATES_DIR` in your configs, default is '.'
246
239
 
247
240
  Example:
248
241
  TEMPLATES_DIR = 'templates/'
249
- or
250
- TEMPLATES_DIR = '.'
242
+
251
243
  """
244
+
252
245
  def __init__(
253
246
  self,
254
247
  source: str | LiteralString | NoneType = None,
@@ -265,7 +258,20 @@ class TemplateResponse(HTMLResponse):
265
258
  :param status_code: should be int
266
259
  """
267
260
  if name:
268
- template = config.JINJA_ENVIRONMENT.get_template(name=name)
261
+ try:
262
+ template = config.JINJA_ENVIRONMENT.get_template(name=name)
263
+ except jinja2.exceptions.TemplateNotFound:
264
+ loaded_path = ' - '.join(
265
+ ' - '.join(loader.searchpath)
266
+ for loader in config.JINJA_ENVIRONMENT.loader.loaders
267
+ if isinstance(loader, jinja2.loaders.FileSystemLoader)
268
+ )
269
+ error = (
270
+ f'`{name}` Template Not Found.\n'
271
+ f'* Make sure `TEMPLATES_DIR` in your configs is correct, Current is {loaded_path}'
272
+ )
273
+ logger.error(error)
274
+ raise APIError
269
275
  else:
270
276
  template = config.JINJA_ENVIRONMENT.from_string(source=source)
271
277
  super().__init__(
@@ -281,7 +287,7 @@ class RedirectResponse(Response):
281
287
  url: str,
282
288
  headers: dict | None = None,
283
289
  status_code: int = status.HTTP_307_TEMPORARY_REDIRECT,
284
- set_cookies: list[Cookie] | None = None
290
+ set_cookies: list[Cookie] | None = None,
285
291
  ):
286
292
  headers = headers or {}
287
293
  headers['Location'] = url
panther/routings.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import re
2
+ import types
2
3
  from collections import Counter
3
4
  from collections.abc import Callable, Mapping, MutableMapping
4
5
  from copy import deepcopy
@@ -37,10 +38,12 @@ def _flattening_urls(data: dict | Callable, url: str = ''):
37
38
  def _is_url_endpoint_valid(url: str, endpoint: Callable):
38
39
  if endpoint is ...:
39
40
  raise PantherError(f"URL Can't Point To Ellipsis. ('{url}' -> ...)")
40
- elif endpoint is None:
41
+ if endpoint is None:
41
42
  raise PantherError(f"URL Can't Point To None. ('{url}' -> None)")
42
- elif url and not re.match(r'^[a-zA-Z<>0-9_/-]+$', url):
43
+ if url and not re.match(r'^[a-zA-Z<>0-9_/-]+$', url):
43
44
  raise PantherError(f"URL Is Not Valid. --> '{url}'")
45
+ elif isinstance(endpoint, types.ModuleType):
46
+ raise PantherError(f"URL Can't Point To Module. --> '{url}'")
44
47
 
45
48
 
46
49
  def finalize_urls(urls: dict) -> dict:
@@ -64,7 +67,7 @@ def finalize_urls(urls: dict) -> dict:
64
67
  return final_urls
65
68
 
66
69
 
67
- def check_urls_path_variables(urls: dict, path: str = '', ) -> None:
70
+ def check_urls_path_variables(urls: dict, path: str = '') -> None:
68
71
  middle_route_error = []
69
72
  last_route_error = []
70
73
  for key, value in urls.items():
@@ -79,13 +82,11 @@ def check_urls_path_variables(urls: dict, path: str = '', ) -> None:
79
82
 
80
83
  if len(middle_route_error) > 1:
81
84
  msg = '\n\t- ' + '\n\t- '.join(e for e in middle_route_error)
82
- raise PantherError(
83
- f"URLs can't have same-level path variables that point to a dict: {msg}")
85
+ raise PantherError(f"URLs can't have same-level path variables that point to a dict: {msg}")
84
86
 
85
87
  if len(last_route_error) > 1:
86
88
  msg = '\n\t- ' + '\n\t- '.join(e for e in last_route_error)
87
- raise PantherError(
88
- f"URLs can't have same-level path variables that point to an endpoint: {msg}")
89
+ raise PantherError(f"URLs can't have same-level path variables that point to an endpoint: {msg}")
89
90
 
90
91
 
91
92
  def _merge(destination: MutableMapping, *sources) -> MutableMapping:
@@ -185,10 +186,9 @@ def find_endpoint(path: str) -> tuple[Callable | None, str]:
185
186
  else:
186
187
  # `found` is None
187
188
  for key, value in urls.items():
188
- if key.startswith('<'):
189
- if isinstance(value, dict):
190
- found_path.append(key)
191
- urls = value
192
- break
189
+ if key.startswith('<') and isinstance(value, dict):
190
+ found_path.append(key)
191
+ urls = value
192
+ break
193
193
  else:
194
194
  return ENDPOINT_NOT_FOUND
panther/serializer.py CHANGED
@@ -1,8 +1,8 @@
1
1
  import typing
2
2
  from typing import Any
3
3
 
4
- from pydantic import create_model, BaseModel, ConfigDict
5
- from pydantic.fields import FieldInfo, Field
4
+ from pydantic import BaseModel, create_model
5
+ from pydantic.fields import Field, FieldInfo
6
6
  from pydantic_core._pydantic_core import PydanticUndefined
7
7
 
8
8
  from panther.db import Model
@@ -12,13 +12,7 @@ from panther.request import Request
12
12
  class MetaModelSerializer:
13
13
  KNOWN_CONFIGS = ['model', 'fields', 'exclude', 'required_fields', 'optional_fields']
14
14
 
15
- def __new__(
16
- cls,
17
- cls_name: str,
18
- bases: tuple[type[typing.Any], ...],
19
- namespace: dict[str, typing.Any],
20
- **kwargs
21
- ):
15
+ def __new__(cls, cls_name: str, bases: tuple[type[typing.Any], ...], namespace: dict[str, typing.Any], **kwargs):
22
16
  if cls_name == 'ModelSerializer':
23
17
  # Put `model` and `request` to the main class with `create_model()`
24
18
  namespace['__annotations__'].pop('model')
@@ -45,7 +39,7 @@ class MetaModelSerializer:
45
39
  __base__=(cls.model_serializer, BaseModel),
46
40
  model=(typing.ClassVar[type[BaseModel]], config.model),
47
41
  request=(Request, Field(None, exclude=True)),
48
- **field_definitions
42
+ **field_definitions,
49
43
  )
50
44
 
51
45
  @classmethod
@@ -108,12 +102,11 @@ class MetaModelSerializer:
108
102
  raise AttributeError(msg) from None
109
103
 
110
104
  # Check `required_fields` and `optional_fields` together
111
- if (
112
- (config.optional_fields == '*' and config.required_fields != []) or
113
- (config.required_fields == '*' and config.optional_fields != [])
105
+ if (config.optional_fields == '*' and config.required_fields != []) or (
106
+ config.required_fields == '*' and config.optional_fields != []
114
107
  ):
115
108
  msg = (
116
- f"`{cls_name}.Config.optional_fields` and "
109
+ f'`{cls_name}.Config.optional_fields` and '
117
110
  f"`{cls_name}.Config.required_fields` can't include same fields at the same time"
118
111
  )
119
112
  raise AttributeError(msg) from None
@@ -122,7 +115,7 @@ class MetaModelSerializer:
122
115
  if optional == required:
123
116
  msg = (
124
117
  f"`{optional}` can't be in `{cls_name}.Config.optional_fields` and "
125
- f"`{cls_name}.Config.required_fields` at the same time"
118
+ f'`{cls_name}.Config.required_fields` at the same time'
126
119
  )
127
120
  raise AttributeError(msg) from None
128
121
 
@@ -151,7 +144,7 @@ class MetaModelSerializer:
151
144
  for field_name in config.fields:
152
145
  field_definitions[field_name] = (
153
146
  config.model.model_fields[field_name].annotation,
154
- config.model.model_fields[field_name]
147
+ config.model.model_fields[field_name],
155
148
  )
156
149
 
157
150
  # Apply `exclude`
@@ -183,10 +176,15 @@ class MetaModelSerializer:
183
176
 
184
177
  @classmethod
185
178
  def collect_model_config(cls, config: typing.Callable, namespace: dict) -> dict:
186
- return {
187
- attr: getattr(config, attr) for attr in dir(config)
188
- if not attr.startswith('__') and attr not in cls.KNOWN_CONFIGS
189
- } | namespace.pop('model_config', {}) | {'arbitrary_types_allowed': True}
179
+ return (
180
+ {
181
+ attr: getattr(config, attr)
182
+ for attr in dir(config)
183
+ if not attr.startswith('__') and attr not in cls.KNOWN_CONFIGS
184
+ }
185
+ | namespace.pop('model_config', {})
186
+ | {'arbitrary_types_allowed': True}
187
+ )
190
188
 
191
189
 
192
190
  class ModelSerializer(metaclass=MetaModelSerializer):
@@ -202,6 +200,7 @@ class ModelSerializer(metaclass=MetaModelSerializer):
202
200
  required_fields = ['first_name', 'last_name'] # Optional
203
201
  optional_fields = ['age'] # Optional
204
202
  """
203
+
205
204
  model: type[BaseModel]
206
205
  request: Request
207
206
 
panther/test.py CHANGED
@@ -4,7 +4,7 @@ from typing import Literal
4
4
 
5
5
  import orjson as json
6
6
 
7
- from panther.response import Response, HTMLResponse, PlainTextResponse, StreamingResponse
7
+ from panther.response import HTMLResponse, PlainTextResponse, Response
8
8
 
9
9
  __all__ = ('APIClient', 'WebsocketClient')
10
10
 
@@ -28,12 +28,12 @@ class RequestClient:
28
28
  }
29
29
 
30
30
  async def request(
31
- self,
32
- path: str,
33
- method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
34
- payload: bytes | dict | None,
35
- headers: dict,
36
- query_params: dict,
31
+ self,
32
+ path: str,
33
+ method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
34
+ payload: bytes | dict | None,
35
+ headers: dict,
36
+ query_params: dict,
37
37
  ) -> Response:
38
38
  headers = [(k.encode(), str(v).encode()) for k, v in headers.items()]
39
39
  if not path.startswith('/'):
@@ -56,21 +56,24 @@ class RequestClient:
56
56
  send=self.send,
57
57
  )
58
58
  response_headers = {key.decode(): value.decode() for key, value in self.header['headers']}
59
+ cookies = [(key, value) for key, value in self.header['headers'] if key.decode() == 'Set-Cookie']
59
60
  if response_headers['Content-Type'] == 'text/html; charset=utf-8':
60
61
  data = self.response.decode()
61
- return HTMLResponse(data=data, status_code=self.header['status'], headers=response_headers)
62
+ response = HTMLResponse(data=data, status_code=self.header['status'], headers=response_headers)
62
63
 
63
64
  elif response_headers['Content-Type'] == 'text/plain; charset=utf-8':
64
65
  data = self.response.decode()
65
- return PlainTextResponse(data=data, status_code=self.header['status'], headers=response_headers)
66
+ response = PlainTextResponse(data=data, status_code=self.header['status'], headers=response_headers)
66
67
 
67
68
  elif response_headers['Content-Type'] == 'application/octet-stream':
68
69
  data = self.response.decode()
69
- return PlainTextResponse(data=data, status_code=self.header['status'], headers=response_headers)
70
+ response = PlainTextResponse(data=data, status_code=self.header['status'], headers=response_headers)
70
71
 
71
72
  else:
72
73
  data = json.loads(self.response or b'null')
73
- return Response(data=data, status_code=self.header['status'], headers=response_headers)
74
+ response = Response(data=data, status_code=self.header['status'], headers=response_headers)
75
+ response.cookies = cookies
76
+ return response
74
77
 
75
78
 
76
79
  class APIClient:
@@ -78,27 +81,41 @@ class APIClient:
78
81
  self._app = app
79
82
 
80
83
  async def _send_request(
81
- self,
82
- path: str,
83
- method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
84
- payload: dict | None,
85
- headers: dict,
86
- query_params: dict,
84
+ self,
85
+ path: str,
86
+ method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
87
+ payload: dict | None,
88
+ headers: dict,
89
+ query_params: dict,
87
90
  ) -> Response:
88
91
  request_client = RequestClient(app=self._app)
89
92
  return await request_client.request(
90
- path=path,
91
- method=method,
92
- payload=payload,
93
- headers=headers,
94
- query_params=query_params or {},
95
- )
93
+ path=path,
94
+ method=method,
95
+ payload=payload,
96
+ headers=headers,
97
+ query_params=query_params or {},
98
+ )
99
+
100
+ async def options(
101
+ self,
102
+ path: str,
103
+ headers: dict | None = None,
104
+ query_params: dict | None = None,
105
+ ) -> Response:
106
+ return await self._send_request(
107
+ path=path,
108
+ method='OPTIONS',
109
+ payload=None,
110
+ headers=headers or {},
111
+ query_params=query_params or {},
112
+ )
96
113
 
97
114
  async def get(
98
- self,
99
- path: str,
100
- headers: dict | None = None,
101
- query_params: dict | None = None,
115
+ self,
116
+ path: str,
117
+ headers: dict | None = None,
118
+ query_params: dict | None = None,
102
119
  ) -> Response:
103
120
  return await self._send_request(
104
121
  path=path,
@@ -109,12 +126,12 @@ class APIClient:
109
126
  )
110
127
 
111
128
  async def post(
112
- self,
113
- path: str,
114
- payload: bytes | dict | None = None,
115
- headers: dict | None = None,
116
- query_params: dict | None = None,
117
- content_type: str = 'application/json',
129
+ self,
130
+ path: str,
131
+ payload: bytes | dict | None = None,
132
+ headers: dict | None = None,
133
+ query_params: dict | None = None,
134
+ content_type: str = 'application/json',
118
135
  ) -> Response:
119
136
  headers = {'content-type': content_type} | (headers or {})
120
137
  return await self._send_request(
@@ -126,12 +143,12 @@ class APIClient:
126
143
  )
127
144
 
128
145
  async def put(
129
- self,
130
- path: str,
131
- payload: bytes | dict | None = None,
132
- headers: dict | None = None,
133
- query_params: dict | None = None,
134
- content_type: Literal['application/json', 'multipart/form-data'] = 'application/json',
146
+ self,
147
+ path: str,
148
+ payload: bytes | dict | None = None,
149
+ headers: dict | None = None,
150
+ query_params: dict | None = None,
151
+ content_type: Literal['application/json', 'multipart/form-data'] = 'application/json',
135
152
  ) -> Response:
136
153
  headers = {'content-type': content_type} | (headers or {})
137
154
  return await self._send_request(
@@ -143,12 +160,12 @@ class APIClient:
143
160
  )
144
161
 
145
162
  async def patch(
146
- self,
147
- path: str,
148
- payload: bytes | dict | None = None,
149
- headers: dict | None = None,
150
- query_params: dict | None = None,
151
- content_type: Literal['application/json', 'multipart/form-data'] = 'application/json',
163
+ self,
164
+ path: str,
165
+ payload: bytes | dict | None = None,
166
+ headers: dict | None = None,
167
+ query_params: dict | None = None,
168
+ content_type: Literal['application/json', 'multipart/form-data'] = 'application/json',
152
169
  ) -> Response:
153
170
  headers = {'content-type': content_type} | (headers or {})
154
171
  return await self._send_request(
@@ -160,10 +177,10 @@ class APIClient:
160
177
  )
161
178
 
162
179
  async def delete(
163
- self,
164
- path: str,
165
- headers: dict | None = None,
166
- query_params: dict | None = None,
180
+ self,
181
+ path: str,
182
+ headers: dict | None = None,
183
+ query_params: dict | None = None,
167
184
  ) -> Response:
168
185
  return await self._send_request(
169
186
  path=path,
@@ -183,15 +200,13 @@ class WebsocketClient:
183
200
  self.responses.append(data)
184
201
 
185
202
  async def receive(self):
186
- return {
187
- 'type': 'websocket.connect'
188
- }
203
+ return {'type': 'websocket.connect'}
189
204
 
190
205
  def connect(
191
- self,
192
- path: str,
193
- headers: dict | None = None,
194
- query_params: dict | None = None,
206
+ self,
207
+ path: str,
208
+ headers: dict | None = None,
209
+ query_params: dict | None = None,
195
210
  ):
196
211
  headers = [(k.encode(), str(v).encode()) for k, v in (headers or {}).items()]
197
212
  if not path.startswith('/'):
@@ -210,13 +225,13 @@ class WebsocketClient:
210
225
  'query_string': query_params.encode(),
211
226
  'headers': headers,
212
227
  'subprotocols': [],
213
- 'state': {}
228
+ 'state': {},
214
229
  }
215
230
  asyncio.run(
216
231
  self.app(
217
232
  scope=scope,
218
233
  receive=self.receive,
219
234
  send=self.send,
220
- )
235
+ ),
221
236
  )
222
237
  return self.responses