panther 4.3.7__py3-none-any.whl → 5.0.0b2__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 +78 -64
- panther/_utils.py +1 -1
- panther/app.py +126 -60
- panther/authentications.py +26 -9
- panther/base_request.py +27 -2
- panther/base_websocket.py +26 -27
- panther/cli/create_command.py +1 -0
- panther/cli/main.py +19 -27
- panther/cli/monitor_command.py +8 -4
- panther/cli/template.py +11 -6
- panther/cli/utils.py +3 -2
- panther/configs.py +7 -9
- panther/db/cursor.py +23 -7
- panther/db/models.py +26 -19
- panther/db/queries/base_queries.py +1 -1
- panther/db/queries/mongodb_queries.py +177 -13
- panther/db/queries/pantherdb_queries.py +5 -5
- panther/db/queries/queries.py +1 -1
- panther/events.py +10 -4
- panther/exceptions.py +24 -2
- panther/generics.py +2 -2
- panther/main.py +90 -117
- panther/middlewares/__init__.py +1 -1
- panther/middlewares/base.py +15 -19
- panther/middlewares/monitoring.py +42 -0
- panther/openapi/__init__.py +1 -0
- panther/openapi/templates/openapi.html +27 -0
- panther/openapi/urls.py +5 -0
- panther/openapi/utils.py +167 -0
- panther/openapi/views.py +101 -0
- panther/pagination.py +1 -1
- panther/panel/middlewares.py +10 -0
- panther/panel/templates/base.html +14 -0
- panther/panel/templates/create.html +21 -0
- panther/panel/templates/create.js +1270 -0
- panther/panel/templates/detail.html +55 -0
- panther/panel/templates/home.html +9 -0
- panther/panel/templates/home.js +30 -0
- panther/panel/templates/login.html +47 -0
- panther/panel/templates/sidebar.html +13 -0
- panther/panel/templates/table.html +73 -0
- panther/panel/templates/table.js +339 -0
- panther/panel/urls.py +10 -5
- panther/panel/utils.py +98 -0
- panther/panel/views.py +143 -0
- panther/request.py +3 -0
- panther/response.py +91 -53
- panther/routings.py +7 -2
- panther/serializer.py +1 -1
- panther/utils.py +34 -26
- panther/websocket.py +3 -0
- {panther-4.3.7.dist-info → panther-5.0.0b2.dist-info}/METADATA +19 -17
- panther-5.0.0b2.dist-info/RECORD +75 -0
- {panther-4.3.7.dist-info → panther-5.0.0b2.dist-info}/WHEEL +1 -1
- panther-4.3.7.dist-info/RECORD +0 -57
- {panther-4.3.7.dist-info → panther-5.0.0b2.dist-info}/entry_points.txt +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b2.dist-info}/licenses/LICENSE +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b2.dist-info}/top_level.txt +0 -0
panther/panel/utils.py
CHANGED
@@ -1,6 +1,96 @@
|
|
1
|
+
from collections import defaultdict
|
2
|
+
|
3
|
+
from panther.configs import config
|
1
4
|
from panther.db.models import Model
|
2
5
|
|
3
6
|
|
7
|
+
def _ref_name(ref: str) -> str:
|
8
|
+
obj_name = ref.rsplit('/', maxsplit=1)[1]
|
9
|
+
return f'${obj_name}'
|
10
|
+
|
11
|
+
|
12
|
+
def clean_model_schema(schema: dict) -> dict:
|
13
|
+
"""
|
14
|
+
Example:
|
15
|
+
{
|
16
|
+
'title': 'Author',
|
17
|
+
'$': {
|
18
|
+
'Book': {
|
19
|
+
'title': 'Book',
|
20
|
+
'fields': {
|
21
|
+
'title': {'title': 'Title', 'type': ['string'], 'required': True},
|
22
|
+
'pages_count': {'title': 'Pages Count', 'type': ['integer'], 'required': True},
|
23
|
+
'readers': {'title': 'Readers', 'type': ['array', 'null'], 'items': '$Person', 'default': None, 'required': False},
|
24
|
+
'co_owner': {'type': ['$Person', 'null'], 'default': None, 'required': False}
|
25
|
+
}
|
26
|
+
},
|
27
|
+
'Parent': {
|
28
|
+
'title': 'Parent',
|
29
|
+
'fields': {
|
30
|
+
'name': {'title': 'Name', 'type': ['string'], 'required': True},
|
31
|
+
'age': {'title': 'Age', 'type': ['string'], 'required': True},
|
32
|
+
'has_child': {'title': 'Has Child', 'type': ['boolean'], 'required': True}
|
33
|
+
}
|
34
|
+
},
|
35
|
+
'Person': {
|
36
|
+
'title': 'Person',
|
37
|
+
'fields': {
|
38
|
+
'age': {'title': 'Age', 'type': ['integer'], 'required': True},
|
39
|
+
'real_name': {'title': 'Real Name', 'type': ['string'], 'required': True},
|
40
|
+
'parent': {'type': '$Parent', 'required': True},
|
41
|
+
'is_alive': {'title': 'Is Alive', 'type': ['boolean'], 'required': True},
|
42
|
+
'friends': {'title': 'Friends', 'type': ['array'], 'items': '$Person', 'required': True}
|
43
|
+
}
|
44
|
+
}
|
45
|
+
},
|
46
|
+
'fields': {
|
47
|
+
'_id': {'title': ' Id', 'type': ['string', 'null'], 'default': None, 'required': False},
|
48
|
+
'name': {'title': 'Name', 'type': ['string'], 'required': True},
|
49
|
+
'person': {'type': ['$Person', 'null'], 'default': None, 'required': False},
|
50
|
+
'books': {'title': 'Books', 'type': ['array'], 'items': '$Book', 'required': True},
|
51
|
+
'is_male': {'title': 'Is Male', 'type': ['boolean', 'null'], 'required': True}
|
52
|
+
}
|
53
|
+
}
|
54
|
+
"""
|
55
|
+
|
56
|
+
result = defaultdict(dict)
|
57
|
+
result['title'] = schema['title']
|
58
|
+
if '$defs' in schema:
|
59
|
+
for sk, sv in schema['$defs'].items():
|
60
|
+
result['$'][sk] = clean_model_schema(sv)
|
61
|
+
|
62
|
+
for k, v in schema['properties'].items():
|
63
|
+
result['fields'][k] = {}
|
64
|
+
if 'title' in v:
|
65
|
+
result['fields'][k]['title'] = v['title']
|
66
|
+
|
67
|
+
if 'type' in v:
|
68
|
+
result['fields'][k]['type'] = [v['type']]
|
69
|
+
|
70
|
+
if 'anyOf' in v:
|
71
|
+
result['fields'][k]['type'] = [i['type'] if 'type' in i else _ref_name(i['$ref']) for i in v['anyOf']]
|
72
|
+
if 'array' in result['fields'][k]['type']:
|
73
|
+
# One of them was array, so add the `items` field
|
74
|
+
for t in v['anyOf']:
|
75
|
+
if 'items' in t:
|
76
|
+
result['fields'][k]['items'] = _ref_name(t['items']['$ref'])
|
77
|
+
|
78
|
+
if 'default' in v:
|
79
|
+
result['fields'][k]['default'] = v['default']
|
80
|
+
|
81
|
+
if '$ref' in v: # For obj
|
82
|
+
result['fields'][k]['type'] = _ref_name(v['$ref'])
|
83
|
+
|
84
|
+
if 'items' in v: # For array
|
85
|
+
result['fields'][k]['items'] = _ref_name(v['items']['$ref'])
|
86
|
+
|
87
|
+
result['fields'][k]['required'] = k in schema.get('required', [])
|
88
|
+
|
89
|
+
# Cast it to have a more clear stdout
|
90
|
+
return dict(result)
|
91
|
+
|
92
|
+
|
93
|
+
# TODO: Remove this
|
4
94
|
def get_model_fields(model):
|
5
95
|
result = {}
|
6
96
|
|
@@ -15,3 +105,11 @@ def get_model_fields(model):
|
|
15
105
|
else:
|
16
106
|
result[k] = getattr(v.annotation, '__name__', str(v.annotation))
|
17
107
|
return result
|
108
|
+
|
109
|
+
|
110
|
+
def get_models():
|
111
|
+
return [{
|
112
|
+
'index': i,
|
113
|
+
'name': model.__name__,
|
114
|
+
'module': model.__module__,
|
115
|
+
} for i, model in enumerate(config.MODELS)]
|
panther/panel/views.py
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
import logging
|
2
|
+
|
3
|
+
from panther import status
|
4
|
+
from panther.app import API, GenericAPI
|
5
|
+
from panther.authentications import JWTAuthentication, CookieJWTAuthentication
|
6
|
+
from panther.configs import config
|
7
|
+
from panther.db.models import BaseUser
|
8
|
+
from panther.exceptions import RedirectAPIError, AuthenticationAPIError
|
9
|
+
from panther.panel.middlewares import RedirectToSlashMiddleware
|
10
|
+
from panther.panel.utils import get_models, clean_model_schema
|
11
|
+
from panther.permissions import BasePermission
|
12
|
+
from panther.request import Request
|
13
|
+
from panther.response import TemplateResponse, Response, Cookie, RedirectResponse
|
14
|
+
|
15
|
+
logger = logging.getLogger('panther')
|
16
|
+
|
17
|
+
|
18
|
+
class AdminPanelPermission(BasePermission):
|
19
|
+
@classmethod
|
20
|
+
async def authorization(cls, request: Request) -> bool:
|
21
|
+
try: # We don't want to set AUTHENTICATION class, so we have to use permission classes
|
22
|
+
await CookieJWTAuthentication.authentication(request=request)
|
23
|
+
return True
|
24
|
+
except AuthenticationAPIError:
|
25
|
+
raise RedirectAPIError(url=f'login?redirect_to={request.path}')
|
26
|
+
|
27
|
+
|
28
|
+
class LoginView(GenericAPI):
|
29
|
+
middlewares = [RedirectToSlashMiddleware]
|
30
|
+
|
31
|
+
def get(self, request: Request):
|
32
|
+
return TemplateResponse(name='login.html')
|
33
|
+
|
34
|
+
async def post(self, request: Request):
|
35
|
+
user: BaseUser = await config.USER_MODEL.find_one({config.USER_MODEL.USERNAME_FIELD: request.data['username']})
|
36
|
+
if user is None:
|
37
|
+
logger.debug('User not found.')
|
38
|
+
return TemplateResponse(
|
39
|
+
name='login.html',
|
40
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
41
|
+
context={'error': 'Authentication Error'},
|
42
|
+
)
|
43
|
+
if user.check_password(password=request.data['password']) is False:
|
44
|
+
logger.debug('Password is incorrect.')
|
45
|
+
return TemplateResponse(
|
46
|
+
name='login.html',
|
47
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
48
|
+
context={'error': 'Authentication Error'},
|
49
|
+
)
|
50
|
+
tokens = JWTAuthentication.login(user.id)
|
51
|
+
return RedirectResponse(
|
52
|
+
url=request.query_params.get('redirect_to', '..'),
|
53
|
+
status_code=status.HTTP_302_FOUND,
|
54
|
+
set_cookies=[
|
55
|
+
Cookie(
|
56
|
+
key='access_token',
|
57
|
+
value=tokens['access_token'],
|
58
|
+
max_age=config.JWT_CONFIG.life_time
|
59
|
+
),
|
60
|
+
Cookie(
|
61
|
+
key='refresh_token',
|
62
|
+
value=tokens['refresh_token'],
|
63
|
+
max_age=config.JWT_CONFIG.refresh_life_time
|
64
|
+
)
|
65
|
+
]
|
66
|
+
)
|
67
|
+
|
68
|
+
|
69
|
+
class HomeView(GenericAPI):
|
70
|
+
permissions = [AdminPanelPermission]
|
71
|
+
|
72
|
+
def get(self):
|
73
|
+
return TemplateResponse(name='home.html', context={'tables': get_models()})
|
74
|
+
|
75
|
+
|
76
|
+
class TableView(GenericAPI):
|
77
|
+
permissions = [AdminPanelPermission]
|
78
|
+
middlewares = [RedirectToSlashMiddleware]
|
79
|
+
|
80
|
+
async def get(self, request: Request, index: int):
|
81
|
+
model = config.MODELS[index]
|
82
|
+
if data := await model.find():
|
83
|
+
data = data
|
84
|
+
else:
|
85
|
+
data = []
|
86
|
+
|
87
|
+
return TemplateResponse(
|
88
|
+
name='table.html',
|
89
|
+
context={
|
90
|
+
'fields': clean_model_schema(model.schema()),
|
91
|
+
'tables': get_models(),
|
92
|
+
'records': Response.prepare_data(data),
|
93
|
+
}
|
94
|
+
)
|
95
|
+
|
96
|
+
|
97
|
+
class CreateView(GenericAPI):
|
98
|
+
permissions = [AdminPanelPermission]
|
99
|
+
middlewares = [RedirectToSlashMiddleware]
|
100
|
+
|
101
|
+
async def get(self, request: Request, index: int):
|
102
|
+
model = config.MODELS[index]
|
103
|
+
return TemplateResponse(
|
104
|
+
name='create.html',
|
105
|
+
context={
|
106
|
+
'fields': clean_model_schema(model.schema()),
|
107
|
+
'tables': get_models(),
|
108
|
+
}
|
109
|
+
)
|
110
|
+
|
111
|
+
async def post(self, request: Request, index: int):
|
112
|
+
model = config.MODELS[index]
|
113
|
+
validated_data = API.validate_input(model=model, request=request)
|
114
|
+
instance = await model.insert_one(validated_data.model_dump())
|
115
|
+
if issubclass(model, BaseUser):
|
116
|
+
await instance.set_password(password=instance.password)
|
117
|
+
return instance
|
118
|
+
|
119
|
+
|
120
|
+
class DetailView(GenericAPI):
|
121
|
+
permissions = [AdminPanelPermission]
|
122
|
+
middlewares = [RedirectToSlashMiddleware]
|
123
|
+
|
124
|
+
async def get(self, index: int, document_id: str):
|
125
|
+
model = config.MODELS[index]
|
126
|
+
obj = await model.find_one_or_raise(id=document_id)
|
127
|
+
return TemplateResponse(
|
128
|
+
name='detail.html',
|
129
|
+
context={
|
130
|
+
'fields': clean_model_schema(model.schema()),
|
131
|
+
'data': obj.model_dump()
|
132
|
+
}
|
133
|
+
)
|
134
|
+
|
135
|
+
async def put(self, request: Request, index: int, document_id: str):
|
136
|
+
model = config.MODELS[index]
|
137
|
+
validated_data = API.validate_input(model=model, request=request)
|
138
|
+
return await model.update_one({'id': document_id}, validated_data.model_dump())
|
139
|
+
|
140
|
+
async def delete(self, index: int, document_id: str):
|
141
|
+
model = config.MODELS[index]
|
142
|
+
await model.delete_one(id=document_id)
|
143
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
panther/request.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
import logging
|
2
2
|
from typing import Literal, Callable
|
3
|
+
from urllib.parse import parse_qsl
|
3
4
|
|
4
5
|
import orjson as json
|
5
6
|
|
@@ -26,6 +27,8 @@ class Request(BaseRequest):
|
|
26
27
|
match (self.headers.content_type or '').split('; boundary='):
|
27
28
|
case ['' | 'application/json']:
|
28
29
|
self._data = json.loads(self.__body or b'{}')
|
30
|
+
case ['application/x-www-form-urlencoded']:
|
31
|
+
self._data = {k.decode(): v.decode() for k, v in parse_qsl(self.__body)}
|
29
32
|
case ['multipart/form-data', boundary]:
|
30
33
|
self._data = read_multipart_form_data(boundary=boundary, body=self.__body)
|
31
34
|
case [unknown]:
|
panther/response.py
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
import asyncio
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from http import cookies
|
2
4
|
from sys import version_info
|
3
5
|
from types import NoneType
|
4
|
-
from typing import Generator, AsyncGenerator, Any, Type
|
6
|
+
from typing import Generator, AsyncGenerator, Any, Type, Literal
|
5
7
|
|
6
8
|
if version_info >= (3, 11):
|
7
9
|
from typing import LiteralString
|
@@ -10,7 +12,6 @@ else:
|
|
10
12
|
|
11
13
|
LiteralString = TypeVar('LiteralString')
|
12
14
|
|
13
|
-
|
14
15
|
import orjson as json
|
15
16
|
from pydantic import BaseModel
|
16
17
|
|
@@ -19,7 +20,6 @@ from panther.configs import config
|
|
19
20
|
from panther._utils import to_async_generator
|
20
21
|
from panther.db.cursor import Cursor
|
21
22
|
from pantherdb import Cursor as PantherDBCursor
|
22
|
-
from panther.monitoring import Monitoring
|
23
23
|
from panther.pagination import Pagination
|
24
24
|
|
25
25
|
ResponseDataTypes = list | tuple | set | Cursor | PantherDBCursor | dict | int | float | str | bool | bytes | NoneType | Type[BaseModel]
|
@@ -27,30 +27,75 @@ IterableDataTypes = list | tuple | set | Cursor | PantherDBCursor
|
|
27
27
|
StreamingDataTypes = Generator | AsyncGenerator
|
28
28
|
|
29
29
|
|
30
|
+
@dataclass(slots=True)
|
31
|
+
class Cookie:
|
32
|
+
"""
|
33
|
+
path: [Optional] Indicates the path that must exist in the requested URL for the browser to send the Cookie header.
|
34
|
+
Default is `/`
|
35
|
+
domain: [Optional] Defines the host to which the cookie will be sent.
|
36
|
+
Default is the host of the current document URL, not including subdomains.
|
37
|
+
max_age: [Optional] Indicates the number of seconds until the cookie expires.
|
38
|
+
A zero or negative number will expire the cookie immediately.
|
39
|
+
secure: [Optional] Indicates that the cookie is sent to the server
|
40
|
+
only when a request is made with the https: scheme (except on localhost)
|
41
|
+
httponly: [Optional] Forbids JavaScript from accessing the cookie,
|
42
|
+
for example, through the `Document.cookie` property.
|
43
|
+
samesite: [Optional] Controls whether a cookie is sent with cross-site requests or not,
|
44
|
+
`lax` is the default behavior if not specified.
|
45
|
+
expires: [Deprecated] In HTTP version 1.1, `expires` was deprecated and replaced with the easier-to-use `max-age`
|
46
|
+
"""
|
47
|
+
key: str
|
48
|
+
value: str
|
49
|
+
domain: str = None
|
50
|
+
max_age: int = None
|
51
|
+
secure: bool = False
|
52
|
+
httponly: bool = False
|
53
|
+
samesite: Literal['none', 'lax', 'strict'] = 'lax'
|
54
|
+
path: str = '/'
|
55
|
+
|
56
|
+
|
30
57
|
class Response:
|
31
58
|
content_type = 'application/json'
|
32
59
|
|
33
60
|
def __init__(
|
34
61
|
self,
|
35
62
|
data: ResponseDataTypes = None,
|
36
|
-
headers: dict | None = None,
|
37
63
|
status_code: int = status.HTTP_200_OK,
|
64
|
+
headers: dict | None = None,
|
38
65
|
pagination: Pagination | None = None,
|
66
|
+
set_cookies: list[Cookie] | None = None
|
39
67
|
):
|
40
68
|
"""
|
41
69
|
:param data: should be an instance of ResponseDataTypes
|
42
|
-
:param headers: should be dict of headers
|
43
70
|
:param status_code: should be int
|
44
|
-
:param
|
71
|
+
:param headers: should be dict of headers
|
72
|
+
:param pagination: an instance of Pagination or None
|
45
73
|
The `pagination.template()` method will be used
|
74
|
+
:param set_cookies: list of cookies you want to set on the client
|
75
|
+
Set the `max_age` to `0` if you want to delete a cookie
|
46
76
|
"""
|
47
|
-
|
77
|
+
headers = headers or {}
|
48
78
|
self.pagination: Pagination | None = pagination
|
49
79
|
if isinstance(data, Cursor):
|
50
80
|
data = list(data)
|
51
81
|
self.initial_data = data
|
52
82
|
self.data = self.prepare_data(data=data)
|
53
83
|
self.status_code = self.check_status_code(status_code=status_code)
|
84
|
+
self.cookies = None
|
85
|
+
if set_cookies:
|
86
|
+
c = cookies.SimpleCookie()
|
87
|
+
for cookie in set_cookies:
|
88
|
+
c[cookie.key] = cookie.value
|
89
|
+
c[cookie.key]['path'] = cookie.path
|
90
|
+
c[cookie.key]['secure'] = cookie.secure
|
91
|
+
c[cookie.key]['httponly'] = cookie.httponly
|
92
|
+
c[cookie.key]['samesite'] = cookie.samesite
|
93
|
+
if cookie.domain is not None:
|
94
|
+
c[cookie.key]['domain'] = cookie.domain
|
95
|
+
if cookie.max_age is not None:
|
96
|
+
c[cookie.key]['max-age'] = cookie.max_age
|
97
|
+
self.cookies = [(b'Set-Cookie', cookie.OutputString().encode()) for cookie in c.values()]
|
98
|
+
self.headers = headers
|
54
99
|
|
55
100
|
@property
|
56
101
|
def body(self) -> bytes:
|
@@ -70,26 +115,30 @@ class Response:
|
|
70
115
|
} | self._headers
|
71
116
|
|
72
117
|
@property
|
73
|
-
def bytes_headers(self) -> list[
|
74
|
-
|
118
|
+
def bytes_headers(self) -> list[tuple[bytes]]:
|
119
|
+
result = [(k.encode(), str(v).encode()) for k, v in (self.headers or {}).items()]
|
120
|
+
if self.cookies:
|
121
|
+
result.extend(self.cookies)
|
122
|
+
return result
|
75
123
|
|
76
124
|
@headers.setter
|
77
125
|
def headers(self, headers: dict):
|
78
126
|
self._headers = headers
|
79
127
|
|
80
|
-
|
128
|
+
@classmethod
|
129
|
+
def prepare_data(cls, data: Any):
|
81
130
|
"""Make sure the response data is only ResponseDataTypes or Iterable of ResponseDataTypes"""
|
82
131
|
if isinstance(data, (int | float | str | bool | bytes | NoneType)):
|
83
132
|
return data
|
84
133
|
|
85
134
|
elif isinstance(data, dict):
|
86
|
-
return {key:
|
135
|
+
return {key: cls.prepare_data(value) for key, value in data.items()}
|
87
136
|
|
88
137
|
elif issubclass(type(data), BaseModel):
|
89
138
|
return data.model_dump()
|
90
139
|
|
91
140
|
elif isinstance(data, IterableDataTypes):
|
92
|
-
return [
|
141
|
+
return [cls.prepare_data(d) for d in data]
|
93
142
|
|
94
143
|
else:
|
95
144
|
msg = f'Invalid Response Type: {type(data)}'
|
@@ -102,51 +151,15 @@ class Response:
|
|
102
151
|
raise TypeError(error)
|
103
152
|
return status_code
|
104
153
|
|
105
|
-
async def apply_output_model(self, output_model: Type[BaseModel]):
|
106
|
-
"""This method is called in API.__call__"""
|
107
|
-
|
108
|
-
# Dict
|
109
|
-
if isinstance(self.data, dict):
|
110
|
-
# Apply `validation_alias` (id -> _id)
|
111
|
-
for field_name, field in output_model.model_fields.items():
|
112
|
-
if field.validation_alias and field_name in self.data:
|
113
|
-
self.data[field.validation_alias] = self.data.pop(field_name)
|
114
|
-
output = output_model(**self.data)
|
115
|
-
if hasattr(output_model, 'prepare_response'):
|
116
|
-
return await output.prepare_response(instance=self.initial_data, data=output.model_dump())
|
117
|
-
return output.model_dump()
|
118
|
-
|
119
|
-
# Iterable
|
120
|
-
results = []
|
121
|
-
if isinstance(self.data, IterableDataTypes):
|
122
|
-
for i, d in enumerate(self.data):
|
123
|
-
# Apply `validation_alias` (id -> _id)
|
124
|
-
for field_name, field in output_model.model_fields.items():
|
125
|
-
if field.validation_alias and field_name in d:
|
126
|
-
d[field.validation_alias] = d.pop(field_name)
|
127
|
-
|
128
|
-
output = output_model(**d)
|
129
|
-
if hasattr(output_model, 'prepare_response'):
|
130
|
-
result = await output.prepare_response(instance=self.initial_data[i], data=output.model_dump())
|
131
|
-
else:
|
132
|
-
result = output.model_dump()
|
133
|
-
results.append(result)
|
134
|
-
return results
|
135
|
-
|
136
|
-
# Str | Bool | Bytes
|
137
|
-
msg = 'Type of Response data is not match with `output_model`.\n*hint: You may want to remove `output_model`'
|
138
|
-
raise TypeError(msg)
|
139
|
-
|
140
154
|
async def send_headers(self, send, /):
|
141
155
|
await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers})
|
142
156
|
|
143
157
|
async def send_body(self, send, receive, /):
|
144
158
|
await send({'type': 'http.response.body', 'body': self.body, 'more_body': False})
|
145
159
|
|
146
|
-
async def send(self, send, receive,
|
160
|
+
async def send(self, send, receive, /):
|
147
161
|
await self.send_headers(send)
|
148
162
|
await self.send_body(send, receive)
|
149
|
-
await monitoring.after(self.status_code)
|
150
163
|
|
151
164
|
def __str__(self):
|
152
165
|
if len(data := str(self.data)) > 30:
|
@@ -228,23 +241,31 @@ class PlainTextResponse(Response):
|
|
228
241
|
|
229
242
|
|
230
243
|
class TemplateResponse(HTMLResponse):
|
244
|
+
"""
|
245
|
+
You may want to declare `TEMPLATES_DIR` in your configs
|
246
|
+
|
247
|
+
Example:
|
248
|
+
TEMPLATES_DIR = 'templates/'
|
249
|
+
or
|
250
|
+
TEMPLATES_DIR = '.'
|
251
|
+
"""
|
231
252
|
def __init__(
|
232
253
|
self,
|
233
254
|
source: str | LiteralString | NoneType = None,
|
234
|
-
|
255
|
+
name: str | NoneType = None,
|
235
256
|
context: dict | NoneType = None,
|
236
257
|
headers: dict | NoneType = None,
|
237
258
|
status_code: int = status.HTTP_200_OK,
|
238
259
|
):
|
239
260
|
"""
|
240
261
|
:param source: should be a string
|
241
|
-
:param
|
262
|
+
:param name: name of the template file (should be with its extension, e.g. index.html)
|
242
263
|
:param context: should be dict of items
|
243
264
|
:param headers: should be dict of headers
|
244
265
|
:param status_code: should be int
|
245
266
|
"""
|
246
|
-
if
|
247
|
-
template = config.JINJA_ENVIRONMENT.get_template(name=
|
267
|
+
if name:
|
268
|
+
template = config.JINJA_ENVIRONMENT.get_template(name=name)
|
248
269
|
else:
|
249
270
|
template = config.JINJA_ENVIRONMENT.from_string(source=source)
|
250
271
|
super().__init__(
|
@@ -252,3 +273,20 @@ class TemplateResponse(HTMLResponse):
|
|
252
273
|
headers=headers,
|
253
274
|
status_code=status_code,
|
254
275
|
)
|
276
|
+
|
277
|
+
|
278
|
+
class RedirectResponse(Response):
|
279
|
+
def __init__(
|
280
|
+
self,
|
281
|
+
url: str,
|
282
|
+
headers: dict | None = None,
|
283
|
+
status_code: int = status.HTTP_307_TEMPORARY_REDIRECT,
|
284
|
+
set_cookies: list[Cookie] | None = None
|
285
|
+
):
|
286
|
+
headers = headers or {}
|
287
|
+
headers['Location'] = url
|
288
|
+
super().__init__(
|
289
|
+
headers=headers,
|
290
|
+
status_code=status_code,
|
291
|
+
set_cookies=set_cookies,
|
292
|
+
)
|
panther/routings.py
CHANGED
@@ -18,6 +18,11 @@ def _flattening_urls(data: dict | Callable, url: str = ''):
|
|
18
18
|
url = f'{url}/'
|
19
19
|
|
20
20
|
if isinstance(data, dict):
|
21
|
+
if data == {}:
|
22
|
+
# User didn't define any endpoint,
|
23
|
+
# So we just reserve this path so won't be used in path variables.
|
24
|
+
yield url.removeprefix('/'), {}
|
25
|
+
|
21
26
|
for k, v in data.items():
|
22
27
|
yield from _flattening_urls(v, f'{url}{k}')
|
23
28
|
else:
|
@@ -144,8 +149,8 @@ def find_endpoint(path: str) -> tuple[Callable | None, str]:
|
|
144
149
|
return found, '/'.join(found_path)
|
145
150
|
|
146
151
|
# `found` is dict
|
147
|
-
if isinstance(found, dict)
|
148
|
-
if callable(endpoint):
|
152
|
+
if isinstance(found, dict):
|
153
|
+
if (endpoint := found.get('')) and callable(endpoint):
|
149
154
|
found_path.append(part)
|
150
155
|
return endpoint, '/'.join(found_path)
|
151
156
|
else:
|
panther/serializer.py
CHANGED
@@ -195,7 +195,7 @@ class ModelSerializer(metaclass=MetaModelSerializer):
|
|
195
195
|
https://pantherpy.github.io/serializer/#style-2-model-serializer
|
196
196
|
Example:
|
197
197
|
class PersonSerializer(ModelSerializer):
|
198
|
-
class
|
198
|
+
class Config:
|
199
199
|
model = Person
|
200
200
|
fields = '*'
|
201
201
|
exclude = ['created_date'] # Optional
|
panther/utils.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1
|
+
import asyncio
|
1
2
|
import base64
|
2
3
|
import hashlib
|
3
4
|
import logging
|
4
5
|
import os
|
5
|
-
import
|
6
|
-
from datetime import datetime, timedelta, timezone
|
6
|
+
from datetime import datetime, timedelta
|
7
7
|
from pathlib import Path
|
8
|
+
from threading import Thread
|
8
9
|
from typing import ClassVar
|
9
10
|
|
10
11
|
import pytz
|
@@ -38,7 +39,10 @@ def load_env(env_file: str | Path, /) -> dict[str, str]:
|
|
38
39
|
key, value = striped_line.split('=', 1)
|
39
40
|
key = key.strip()
|
40
41
|
value = value.strip().strip('"\'')
|
41
|
-
|
42
|
+
if (boolean_value := value.lower()) in ['true', 'false']:
|
43
|
+
variables[key] = bool(boolean_value == 'true')
|
44
|
+
else:
|
45
|
+
variables[key] = value
|
42
46
|
|
43
47
|
# Load them as system environment variable
|
44
48
|
os.environ[key] = value
|
@@ -101,27 +105,31 @@ def scrypt(password: str, salt: bytes, digest: bool = False) -> str | bytes:
|
|
101
105
|
return derived_key
|
102
106
|
|
103
107
|
|
104
|
-
class ULID:
|
105
|
-
"""https://github.com/ulid/spec"""
|
106
|
-
|
107
|
-
crockford_base32_characters = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
108
|
-
|
109
|
-
@classmethod
|
110
|
-
def new(cls):
|
111
|
-
current_timestamp = int(datetime.now(timezone.utc).timestamp() * 1000)
|
112
|
-
epoch_bits = '{0:050b}'.format(current_timestamp)
|
113
|
-
random_bits = '{0:080b}'.format(secrets.randbits(80))
|
114
|
-
bits = epoch_bits + random_bits
|
115
|
-
return cls._generate(bits)
|
116
|
-
|
117
|
-
@classmethod
|
118
|
-
def _generate(cls, bits: str) -> str:
|
119
|
-
return ''.join(
|
120
|
-
cls.crockford_base32_characters[int(bits[i: i + 5], base=2)]
|
121
|
-
for i in range(0, 130, 5)
|
122
|
-
)
|
123
|
-
|
124
|
-
|
125
108
|
def timezone_now():
|
126
|
-
tz
|
127
|
-
|
109
|
+
return datetime.now(tz=pytz.timezone(config.TIMEZONE))
|
110
|
+
|
111
|
+
|
112
|
+
def run_coroutine(coroutine):
|
113
|
+
try:
|
114
|
+
# Check if there's an event loop already running in this thread
|
115
|
+
asyncio.get_running_loop()
|
116
|
+
except RuntimeError:
|
117
|
+
# No event loop is running in this thread — safe to use asyncio.run
|
118
|
+
return asyncio.run(coroutine)
|
119
|
+
|
120
|
+
# Since we cannot block a running event loop with run_until_complete,
|
121
|
+
# we execute the coroutine in a separate thread with its own event loop.
|
122
|
+
result = []
|
123
|
+
|
124
|
+
def run_in_thread():
|
125
|
+
new_loop = asyncio.new_event_loop()
|
126
|
+
asyncio.set_event_loop(new_loop)
|
127
|
+
try:
|
128
|
+
result.append(new_loop.run_until_complete(coroutine))
|
129
|
+
finally:
|
130
|
+
new_loop.close()
|
131
|
+
|
132
|
+
thread = Thread(target=run_in_thread)
|
133
|
+
thread.start()
|
134
|
+
thread.join()
|
135
|
+
return result[0]
|
panther/websocket.py
CHANGED
@@ -9,6 +9,9 @@ class GenericWebsocket(Websocket):
|
|
9
9
|
auth: bool = False
|
10
10
|
permissions: list = []
|
11
11
|
|
12
|
+
def __init__(self, parent):
|
13
|
+
self.__dict__ = parent.__dict__.copy()
|
14
|
+
|
12
15
|
async def connect(self, **kwargs):
|
13
16
|
"""
|
14
17
|
Check your conditions then `accept()` or `close()` the connection
|