panther 5.0.0b2__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.
- panther/__init__.py +1 -1
- panther/_load_configs.py +46 -37
- panther/_utils.py +49 -34
- panther/app.py +96 -97
- panther/authentications.py +97 -50
- panther/background_tasks.py +98 -124
- panther/base_request.py +16 -10
- panther/base_websocket.py +8 -8
- panther/caching.py +16 -80
- panther/cli/create_command.py +17 -16
- panther/cli/main.py +1 -1
- panther/cli/monitor_command.py +11 -6
- panther/cli/run_command.py +5 -71
- panther/cli/template.py +7 -7
- panther/cli/utils.py +58 -69
- panther/configs.py +70 -72
- panther/db/connections.py +18 -24
- panther/db/cursor.py +0 -1
- panther/db/models.py +24 -8
- panther/db/queries/base_queries.py +2 -5
- panther/db/queries/mongodb_queries.py +17 -20
- panther/db/queries/pantherdb_queries.py +1 -1
- panther/db/queries/queries.py +26 -8
- panther/db/utils.py +1 -1
- panther/events.py +25 -14
- panther/exceptions.py +2 -7
- panther/file_handler.py +1 -1
- panther/generics.py +11 -8
- panther/logging.py +2 -1
- panther/main.py +12 -13
- panther/middlewares/cors.py +67 -0
- panther/middlewares/monitoring.py +5 -3
- panther/openapi/urls.py +2 -2
- panther/openapi/utils.py +3 -3
- panther/openapi/views.py +20 -37
- panther/pagination.py +4 -2
- panther/panel/apis.py +2 -7
- panther/panel/urls.py +2 -6
- panther/panel/utils.py +9 -5
- panther/panel/views.py +17 -23
- panther/permissions.py +2 -1
- panther/request.py +2 -1
- panther/response.py +53 -47
- panther/routings.py +12 -12
- panther/serializer.py +19 -20
- panther/test.py +73 -58
- panther/throttling.py +68 -3
- panther/utils.py +5 -11
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/METADATA +1 -1
- panther-5.0.0b4.dist-info/RECORD +75 -0
- panther/monitoring.py +0 -34
- panther-5.0.0b2.dist-info/RECORD +0 -75
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/WHEEL +0 -0
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/entry_points.txt +0 -0
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/licenses/LICENSE +0 -0
- {panther-5.0.0b2.dist-info → panther-5.0.0b4.dist-info}/top_level.txt +0 -0
panther/permissions.py
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
from panther.request import Request
|
2
|
+
from panther.websocket import Websocket
|
2
3
|
|
3
4
|
|
4
5
|
class BasePermission:
|
5
6
|
@classmethod
|
6
|
-
async def authorization(cls, request: Request) -> bool:
|
7
|
+
async def authorization(cls, request: Request | Websocket) -> bool:
|
7
8
|
return True
|
8
9
|
|
9
10
|
|
panther/request.py
CHANGED
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
|
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 =
|
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
|
111
|
-
|
112
|
-
|
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
|
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
|
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
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
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
|
214
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
41
|
+
if endpoint is None:
|
41
42
|
raise PantherError(f"URL Can't Point To None. ('{url}' -> None)")
|
42
|
-
|
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 = ''
|
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
|
-
|
190
|
-
|
191
|
-
|
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
|
5
|
-
from pydantic.fields import
|
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
|
-
|
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
|
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
|
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
|
-
|
188
|
-
|
189
|
-
|
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
|
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
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
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
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
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
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
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
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
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
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
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
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
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
|