panther 5.0.0b3__py3-none-any.whl → 5.0.0b5__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 +30 -24
- panther/db/cursor.py +3 -1
- panther/db/models.py +26 -10
- panther/db/queries/base_queries.py +4 -5
- panther/db/queries/mongodb_queries.py +21 -21
- 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 +74 -100
- 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 +13 -22
- panther/permissions.py +2 -1
- panther/request.py +2 -1
- panther/response.py +101 -94
- panther/routings.py +12 -12
- panther/serializer.py +20 -43
- panther/test.py +73 -58
- panther/throttling.py +68 -3
- panther/utils.py +5 -11
- panther-5.0.0b5.dist-info/METADATA +188 -0
- panther-5.0.0b5.dist-info/RECORD +75 -0
- panther/monitoring.py +0 -34
- panther-5.0.0b3.dist-info/METADATA +0 -223
- panther-5.0.0b3.dist-info/RECORD +0 -75
- {panther-5.0.0b3.dist-info → panther-5.0.0b5.dist-info}/WHEEL +0 -0
- {panther-5.0.0b3.dist-info → panther-5.0.0b5.dist-info}/entry_points.txt +0 -0
- {panther-5.0.0b3.dist-info → panther-5.0.0b5.dist-info}/licenses/LICENSE +0 -0
- {panther-5.0.0b3.dist-info → panther-5.0.0b5.dist-info}/top_level.txt +0 -0
panther/panel/views.py
CHANGED
@@ -4,22 +4,24 @@ from panther import status
|
|
4
4
|
from panther.app import API, GenericAPI
|
5
5
|
from panther.configs import config
|
6
6
|
from panther.db.models import BaseUser
|
7
|
-
from panther.exceptions import
|
7
|
+
from panther.exceptions import AuthenticationAPIError, RedirectAPIError
|
8
8
|
from panther.panel.middlewares import RedirectToSlashMiddleware
|
9
|
-
from panther.panel.utils import
|
9
|
+
from panther.panel.utils import clean_model_schema, get_models
|
10
10
|
from panther.permissions import BasePermission
|
11
11
|
from panther.request import Request
|
12
|
-
from panther.response import
|
12
|
+
from panther.response import Cookie, RedirectResponse, Response, TemplateResponse
|
13
13
|
|
14
14
|
logger = logging.getLogger('panther')
|
15
15
|
|
16
16
|
|
17
17
|
class AdminPanelPermission(BasePermission):
|
18
|
+
"""We didn't want to change AUTHENTICATION class of user, so we use permission class for this purpose."""
|
19
|
+
|
18
20
|
@classmethod
|
19
21
|
async def authorization(cls, request: Request) -> bool:
|
20
22
|
from panther.authentications import CookieJWTAuthentication
|
21
23
|
|
22
|
-
try:
|
24
|
+
try:
|
23
25
|
await CookieJWTAuthentication.authentication(request=request)
|
24
26
|
return True
|
25
27
|
except AuthenticationAPIError:
|
@@ -50,22 +52,14 @@ class LoginView(GenericAPI):
|
|
50
52
|
status_code=status.HTTP_400_BAD_REQUEST,
|
51
53
|
context={'error': 'Authentication Error'},
|
52
54
|
)
|
53
|
-
tokens = JWTAuthentication.login(user
|
55
|
+
tokens = await JWTAuthentication.login(user=user)
|
54
56
|
return RedirectResponse(
|
55
57
|
url=request.query_params.get('redirect_to', '..'),
|
56
58
|
status_code=status.HTTP_302_FOUND,
|
57
59
|
set_cookies=[
|
58
|
-
Cookie(
|
59
|
-
|
60
|
-
|
61
|
-
max_age=config.JWT_CONFIG.life_time
|
62
|
-
),
|
63
|
-
Cookie(
|
64
|
-
key='refresh_token',
|
65
|
-
value=tokens['refresh_token'],
|
66
|
-
max_age=config.JWT_CONFIG.refresh_life_time
|
67
|
-
)
|
68
|
-
]
|
60
|
+
Cookie(key='access_token', value=tokens['access_token'], max_age=config.JWT_CONFIG.life_time),
|
61
|
+
Cookie(key='refresh_token', value=tokens['refresh_token'], max_age=config.JWT_CONFIG.refresh_life_time),
|
62
|
+
],
|
69
63
|
)
|
70
64
|
|
71
65
|
|
@@ -93,7 +87,7 @@ class TableView(GenericAPI):
|
|
93
87
|
'fields': clean_model_schema(model.schema()),
|
94
88
|
'tables': get_models(),
|
95
89
|
'records': Response.prepare_data(data),
|
96
|
-
}
|
90
|
+
},
|
97
91
|
)
|
98
92
|
|
99
93
|
|
@@ -108,7 +102,7 @@ class CreateView(GenericAPI):
|
|
108
102
|
context={
|
109
103
|
'fields': clean_model_schema(model.schema()),
|
110
104
|
'tables': get_models(),
|
111
|
-
}
|
105
|
+
},
|
112
106
|
)
|
113
107
|
|
114
108
|
async def post(self, request: Request, index: int):
|
@@ -129,10 +123,7 @@ class DetailView(GenericAPI):
|
|
129
123
|
obj = await model.find_one_or_raise(id=document_id)
|
130
124
|
return TemplateResponse(
|
131
125
|
name='detail.html',
|
132
|
-
context={
|
133
|
-
'fields': clean_model_schema(model.schema()),
|
134
|
-
'data': obj.model_dump()
|
135
|
-
}
|
126
|
+
context={'fields': clean_model_schema(model.schema()), 'data': obj.model_dump()},
|
136
127
|
)
|
137
128
|
|
138
129
|
async def put(self, request: Request, index: int, document_id: str):
|
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,19 +82,20 @@ 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
|
75
|
-
Set the `
|
85
|
+
:param set_cookies: single cookie or list of cookies you want to set on the client.
|
86
|
+
Set the `max-age` to `0` if you want to delete a cookie.
|
76
87
|
"""
|
77
|
-
|
78
|
-
self.pagination: Pagination | None = pagination
|
79
|
-
if isinstance(data, Cursor):
|
88
|
+
if isinstance(data, (Cursor, PantherDBCursor)):
|
80
89
|
data = list(data)
|
81
|
-
self.
|
82
|
-
self.
|
83
|
-
self.
|
90
|
+
self.data = data
|
91
|
+
self.status_code = status_code
|
92
|
+
self.headers = {'Content-Type': self.content_type} | (headers or {})
|
93
|
+
self.pagination: Pagination | None = pagination
|
84
94
|
self.cookies = None
|
85
95
|
if set_cookies:
|
86
96
|
c = cookies.SimpleCookie()
|
97
|
+
if not isinstance(set_cookies, list):
|
98
|
+
set_cookies = [set_cookies]
|
87
99
|
for cookie in set_cookies:
|
88
100
|
c[cookie.key] = cookie.value
|
89
101
|
c[cookie.key]['path'] = cookie.path
|
@@ -95,78 +107,63 @@ class Response:
|
|
95
107
|
if cookie.max_age is not None:
|
96
108
|
c[cookie.key]['max-age'] = cookie.max_age
|
97
109
|
self.cookies = [(b'Set-Cookie', cookie.OutputString().encode()) for cookie in c.values()]
|
98
|
-
|
110
|
+
|
111
|
+
def __str__(self):
|
112
|
+
if len(data := str(self.data)) > 30:
|
113
|
+
data = f'{data:.27}...'
|
114
|
+
return f'Response(status_code={self.status_code}, data={data})'
|
115
|
+
|
116
|
+
__repr__ = __str__
|
99
117
|
|
100
118
|
@property
|
101
119
|
def body(self) -> bytes:
|
120
|
+
def default(obj: Any):
|
121
|
+
if isinstance(obj, BaseModel):
|
122
|
+
return obj.model_dump()
|
123
|
+
raise TypeError(f'Type {type(obj)} not serializable')
|
124
|
+
|
102
125
|
if isinstance(self.data, bytes):
|
103
126
|
return self.data
|
104
|
-
|
105
127
|
if self.data is None:
|
106
128
|
return b''
|
107
|
-
return json.dumps(self.data)
|
108
|
-
|
109
|
-
@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
|
129
|
+
return json.dumps(self.data, default=default)
|
116
130
|
|
117
131
|
@property
|
118
|
-
def bytes_headers(self) -> list[tuple[bytes]]:
|
119
|
-
|
132
|
+
def bytes_headers(self) -> list[tuple[bytes, bytes]]:
|
133
|
+
headers = {'Content-Length': len(self.body)} | self.headers
|
134
|
+
result = [(k.encode(), str(v).encode()) for k, v in headers.items()]
|
120
135
|
if self.cookies:
|
121
|
-
result
|
136
|
+
result += self.cookies
|
122
137
|
return result
|
123
138
|
|
124
|
-
|
125
|
-
def headers(self, headers: dict):
|
126
|
-
self._headers = headers
|
127
|
-
|
128
|
-
@classmethod
|
129
|
-
def prepare_data(cls, data: Any):
|
130
|
-
"""Make sure the response data is only ResponseDataTypes or Iterable of ResponseDataTypes"""
|
131
|
-
if isinstance(data, (int | float | str | bool | bytes | NoneType)):
|
132
|
-
return data
|
133
|
-
|
134
|
-
elif isinstance(data, dict):
|
135
|
-
return {key: cls.prepare_data(value) for key, value in data.items()}
|
136
|
-
|
137
|
-
elif issubclass(type(data), BaseModel):
|
138
|
-
return data.model_dump()
|
139
|
-
|
140
|
-
elif isinstance(data, IterableDataTypes):
|
141
|
-
return [cls.prepare_data(d) for d in data]
|
142
|
-
|
143
|
-
else:
|
144
|
-
msg = f'Invalid Response Type: {type(data)}'
|
145
|
-
raise TypeError(msg)
|
146
|
-
|
147
|
-
@classmethod
|
148
|
-
def check_status_code(cls, status_code: Any):
|
149
|
-
if not isinstance(status_code, int):
|
150
|
-
error = f'Response `status_code` Should Be `int`. (`{status_code}` is {type(status_code)})'
|
151
|
-
raise TypeError(error)
|
152
|
-
return status_code
|
153
|
-
|
154
|
-
async def send_headers(self, send, /):
|
139
|
+
async def send(self, send, receive):
|
155
140
|
await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers})
|
156
|
-
|
157
|
-
async def send_body(self, send, receive, /):
|
158
141
|
await send({'type': 'http.response.body', 'body': self.body, 'more_body': False})
|
159
142
|
|
160
|
-
async def
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
143
|
+
async def serialize_output(self, output_model: type[BaseModel]):
|
144
|
+
"""Serializes response data using the given output_model."""
|
145
|
+
|
146
|
+
async def handle_output(obj):
|
147
|
+
output = output_model(**obj) if isinstance(obj, dict) else output_model(**obj.model_dump())
|
148
|
+
if hasattr(output_model, 'to_response'):
|
149
|
+
return await output.to_response(instance=obj, data=output.model_dump())
|
150
|
+
return output.model_dump()
|
151
|
+
|
152
|
+
if isinstance(self.data, dict) or isinstance(self.data, BaseModel):
|
153
|
+
return await handle_output(self.data)
|
154
|
+
|
155
|
+
if isinstance(self.data, IterableDataTypes):
|
156
|
+
results = []
|
157
|
+
for d in self.data:
|
158
|
+
if isinstance(d, dict) or isinstance(d, BaseModel):
|
159
|
+
results.append(await handle_output(d))
|
160
|
+
else:
|
161
|
+
msg = 'Type of Response data is not match with `output_model`.\n*hint: You may want to remove `output_model`'
|
162
|
+
raise TypeError(msg)
|
163
|
+
return results
|
164
|
+
|
165
|
+
msg = 'Type of Response data is not match with `output_model`.\n*hint: You may want to remove `output_model`'
|
166
|
+
raise TypeError(msg)
|
170
167
|
|
171
168
|
|
172
169
|
class StreamingResponse(Response):
|
@@ -181,27 +178,21 @@ class StreamingResponse(Response):
|
|
181
178
|
if message['type'] == 'http.disconnect':
|
182
179
|
self.connection_closed = True
|
183
180
|
|
184
|
-
def prepare_data(self, data: any) -> AsyncGenerator:
|
185
|
-
if isinstance(data, AsyncGenerator):
|
186
|
-
return data
|
187
|
-
elif isinstance(data, Generator):
|
188
|
-
return to_async_generator(data)
|
189
|
-
msg = f'Invalid Response Type: {type(data)}'
|
190
|
-
raise TypeError(msg)
|
191
|
-
|
192
181
|
@property
|
193
|
-
def
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
@headers.setter
|
200
|
-
def headers(self, headers: dict):
|
201
|
-
self._headers = headers
|
182
|
+
def bytes_headers(self) -> list[tuple[bytes, bytes]]:
|
183
|
+
result = [(k.encode(), str(v).encode()) for k, v in self.headers.items()]
|
184
|
+
if self.cookies:
|
185
|
+
result += self.cookies
|
186
|
+
return result
|
202
187
|
|
203
188
|
@property
|
204
189
|
async def body(self) -> AsyncGenerator:
|
190
|
+
if not isinstance(self.data, (Generator, AsyncGenerator)):
|
191
|
+
raise TypeError(f'Type {type(self.data)} is not streamable, should be `Generator` or `AsyncGenerator`.')
|
192
|
+
|
193
|
+
if isinstance(self.data, Generator):
|
194
|
+
self.data = to_async_generator(self.data)
|
195
|
+
|
205
196
|
async for chunk in self.data:
|
206
197
|
if isinstance(chunk, bytes):
|
207
198
|
yield chunk
|
@@ -210,8 +201,11 @@ class StreamingResponse(Response):
|
|
210
201
|
else:
|
211
202
|
yield json.dumps(chunk)
|
212
203
|
|
213
|
-
async def
|
214
|
-
|
204
|
+
async def send(self, send, receive):
|
205
|
+
# Send Headers
|
206
|
+
await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers})
|
207
|
+
# Send Body as chunks
|
208
|
+
asyncio.create_task(self.listen_to_disconnection(receive=receive))
|
215
209
|
async for chunk in self.body:
|
216
210
|
if self.connection_closed:
|
217
211
|
break
|
@@ -242,13 +236,13 @@ class PlainTextResponse(Response):
|
|
242
236
|
|
243
237
|
class TemplateResponse(HTMLResponse):
|
244
238
|
"""
|
245
|
-
You may want to declare `TEMPLATES_DIR` in your configs
|
239
|
+
You may want to declare `TEMPLATES_DIR` in your configs, default is '.'
|
246
240
|
|
247
241
|
Example:
|
248
242
|
TEMPLATES_DIR = 'templates/'
|
249
|
-
|
250
|
-
TEMPLATES_DIR = '.'
|
243
|
+
|
251
244
|
"""
|
245
|
+
|
252
246
|
def __init__(
|
253
247
|
self,
|
254
248
|
source: str | LiteralString | NoneType = None,
|
@@ -265,7 +259,20 @@ class TemplateResponse(HTMLResponse):
|
|
265
259
|
:param status_code: should be int
|
266
260
|
"""
|
267
261
|
if name:
|
268
|
-
|
262
|
+
try:
|
263
|
+
template = config.JINJA_ENVIRONMENT.get_template(name=name)
|
264
|
+
except jinja2.exceptions.TemplateNotFound:
|
265
|
+
loaded_path = ' - '.join(
|
266
|
+
' - '.join(loader.searchpath)
|
267
|
+
for loader in config.JINJA_ENVIRONMENT.loader.loaders
|
268
|
+
if isinstance(loader, jinja2.loaders.FileSystemLoader)
|
269
|
+
)
|
270
|
+
error = (
|
271
|
+
f'`{name}` Template Not Found.\n'
|
272
|
+
f'* Make sure `TEMPLATES_DIR` in your configs is correct, Current is {loaded_path}'
|
273
|
+
)
|
274
|
+
logger.error(error)
|
275
|
+
raise APIError
|
269
276
|
else:
|
270
277
|
template = config.JINJA_ENVIRONMENT.from_string(source=source)
|
271
278
|
super().__init__(
|
@@ -281,7 +288,7 @@ class RedirectResponse(Response):
|
|
281
288
|
url: str,
|
282
289
|
headers: dict | None = None,
|
283
290
|
status_code: int = status.HTTP_307_TEMPORARY_REDIRECT,
|
284
|
-
set_cookies: list[Cookie] | None = None
|
291
|
+
set_cookies: list[Cookie] | None = None,
|
285
292
|
):
|
286
293
|
headers = headers or {}
|
287
294
|
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,30 +200,9 @@ 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
|
|
208
|
-
async def
|
209
|
-
"""
|
210
|
-
validated_data = ModelSerializer.model_dump()
|
211
|
-
"""
|
212
|
-
return await self.model.insert_one(validated_data)
|
213
|
-
|
214
|
-
async def update(self, instance: Model, validated_data: dict):
|
215
|
-
"""
|
216
|
-
instance = UpdateAPI.object()
|
217
|
-
validated_data = ModelSerializer.model_dump()
|
218
|
-
"""
|
219
|
-
await instance.update(validated_data)
|
220
|
-
return instance
|
221
|
-
|
222
|
-
async def partial_update(self, instance: Model, validated_data: dict):
|
223
|
-
"""
|
224
|
-
instance = UpdateAPI.object()
|
225
|
-
validated_data = ModelSerializer.model_dump(exclude_none=True)
|
226
|
-
"""
|
227
|
-
await instance.update(validated_data)
|
228
|
-
return instance
|
229
|
-
|
230
|
-
async def prepare_response(self, instance: Any, data: dict) -> dict:
|
207
|
+
async def to_response(self, instance: Any, data: dict) -> dict:
|
231
208
|
return data
|