panther 4.3.7__py3-none-any.whl → 5.0.0b1__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 +172 -10
- 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 +80 -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.0b1.dist-info}/METADATA +19 -17
- panther-5.0.0b1.dist-info/RECORD +75 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/WHEEL +1 -1
- panther-4.3.7.dist-info/RECORD +0 -57
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/entry_points.txt +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/licenses/LICENSE +0 -0
- {panther-4.3.7.dist-info → panther-5.0.0b1.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,19 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import types
|
4
|
+
import typing
|
3
5
|
from sys import version_info
|
4
|
-
from typing import Iterable, Sequence
|
6
|
+
from typing import get_args, get_origin, Iterable, Sequence, Any, Union
|
7
|
+
|
8
|
+
from pydantic import BaseModel, ValidationError
|
9
|
+
from pymongo.results import InsertOneResult, InsertManyResult
|
5
10
|
|
6
11
|
from panther.db.connections import db
|
7
12
|
from panther.db.cursor import Cursor
|
13
|
+
from panther.db.models import Model
|
8
14
|
from panther.db.queries.base_queries import BaseQuery
|
9
15
|
from panther.db.utils import prepare_id_for_query
|
16
|
+
from panther.exceptions import DatabaseError
|
10
17
|
|
11
18
|
try:
|
12
19
|
from bson.codec_options import CodecOptions
|
@@ -24,6 +31,32 @@ else:
|
|
24
31
|
Self = TypeVar('Self', bound='BaseMongoDBQuery')
|
25
32
|
|
26
33
|
|
34
|
+
def get_annotation_type(annotation: Any) -> type | None:
|
35
|
+
"""
|
36
|
+
Extracts the underlying, non-optional type from a type annotation.
|
37
|
+
Handles basic types, Pydantic BaseModels, lists, and unions (optionals).
|
38
|
+
Returns None if no single underlying type can be determined (e.g., for list[NoneType]).
|
39
|
+
Raises DatabaseError for unsupported annotations.
|
40
|
+
"""
|
41
|
+
origin = get_origin(annotation)
|
42
|
+
|
43
|
+
# Handle list[T] and Union[T, None] (T | None or typing.Union[T, None])
|
44
|
+
if origin is list or origin is types.UnionType or origin is Union:
|
45
|
+
# Extracts the first non-None type from a tuple of type arguments.
|
46
|
+
for arg in get_args(annotation):
|
47
|
+
if arg is not type(None):
|
48
|
+
return arg
|
49
|
+
return None
|
50
|
+
|
51
|
+
# Handle basic types (str, int, bool, dict) and Pydantic BaseModel subclasses
|
52
|
+
if isinstance(annotation, type) and (
|
53
|
+
annotation in (str, int, bool, dict) or issubclass(annotation, BaseModel)
|
54
|
+
):
|
55
|
+
return annotation
|
56
|
+
|
57
|
+
raise DatabaseError(f'Panther does not support {annotation} as a field type for unwrapping.')
|
58
|
+
|
59
|
+
|
27
60
|
class BaseMongoDBQuery(BaseQuery):
|
28
61
|
@classmethod
|
29
62
|
def _merge(cls, *args, is_mongo: bool = True) -> dict:
|
@@ -34,11 +67,126 @@ class BaseMongoDBQuery(BaseQuery):
|
|
34
67
|
# def collection(cls):
|
35
68
|
# return db.session.get_collection(name=cls.__name__, codec_options=CodecOptions(document_class=cls))
|
36
69
|
|
70
|
+
@classmethod
|
71
|
+
async def _create_list(cls, field_type: type, value: Any) -> Any:
|
72
|
+
# `field_type` is the expected type of items in the list (e.g., int, Model, list[str])
|
73
|
+
# `value` is a single item from the input list that needs processing.
|
74
|
+
|
75
|
+
# Handles list[list[int]], list[dict[str,int]] etc.
|
76
|
+
if isinstance(field_type, (types.GenericAlias, typing._GenericAlias)):
|
77
|
+
element_type = get_annotation_type(field_type) # Unwrap further (e.g. list[str] -> str)
|
78
|
+
if element_type is None:
|
79
|
+
raise DatabaseError(f"Cannot determine element type for generic list item: {field_type}")
|
80
|
+
if not isinstance(value, list): # Or check if iterable, matching the structure
|
81
|
+
raise DatabaseError(f"Expected a list for nested generic type {field_type}, got {type(value)}")
|
82
|
+
return [await cls._create_list(field_type=element_type, value=item) for item in value]
|
83
|
+
|
84
|
+
# Make sure Model condition is before BaseModel.
|
85
|
+
if isinstance(field_type, type) and issubclass(field_type, Model):
|
86
|
+
# `value` is assumed to be an ID for the Model instance.
|
87
|
+
return await field_type.first(id=value)
|
88
|
+
|
89
|
+
if isinstance(field_type, type) and issubclass(field_type, BaseModel):
|
90
|
+
if not isinstance(value, dict):
|
91
|
+
raise DatabaseError(f"Expected a dictionary for BaseModel {field_type.__name__}, got {type(value)}")
|
92
|
+
|
93
|
+
return {
|
94
|
+
field_name: await cls._create_field(model=field_type, field_name=field_name, value=value[field_name])
|
95
|
+
for field_name in value
|
96
|
+
}
|
97
|
+
|
98
|
+
# Base case: value is a primitive type (str, int, etc.)
|
99
|
+
return value
|
100
|
+
|
101
|
+
@classmethod
|
102
|
+
async def _create_field(cls, model: type, field_name: str, value: Any) -> Any:
|
103
|
+
# Handle primary key field directly
|
104
|
+
if field_name == '_id':
|
105
|
+
return value
|
106
|
+
|
107
|
+
if field_name not in model.model_fields:
|
108
|
+
# Field from input data is not defined in the model.
|
109
|
+
# Pydantic's `extra` config on the model will handle this upon instantiation.
|
110
|
+
return value
|
111
|
+
|
112
|
+
field_annotation = model.model_fields[field_name].annotation
|
113
|
+
unwrapped_type = get_annotation_type(field_annotation)
|
114
|
+
|
115
|
+
if unwrapped_type is None:
|
116
|
+
raise DatabaseError(
|
117
|
+
f"Could not determine a valid underlying type for field '{field_name}' "
|
118
|
+
f"with annotation {field_annotation} in model {model.__name__}."
|
119
|
+
)
|
120
|
+
|
121
|
+
if get_origin(field_annotation) is list:
|
122
|
+
# Or check for general iterables if applicable
|
123
|
+
if not isinstance(value, list):
|
124
|
+
raise DatabaseError(
|
125
|
+
f"Field '{field_name}' expects a list, got {type(value)} for model {model.__name__}")
|
126
|
+
return [await cls._create_list(field_type=unwrapped_type, value=item) for item in value]
|
127
|
+
|
128
|
+
if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, Model):
|
129
|
+
if obj := await unwrapped_type.first(id=value):
|
130
|
+
return obj.model_dump(by_alias=True)
|
131
|
+
return None
|
132
|
+
|
133
|
+
if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, BaseModel):
|
134
|
+
if not isinstance(value, dict):
|
135
|
+
raise DatabaseError(
|
136
|
+
f"Field '{field_name}' expects a dictionary for BaseModel {unwrapped_type.__name__}, "
|
137
|
+
f"got {type(value)} in model {model.__name__}"
|
138
|
+
)
|
139
|
+
return {
|
140
|
+
nested_field_name: await cls._create_field(
|
141
|
+
model=unwrapped_type,
|
142
|
+
field_name=nested_field_name,
|
143
|
+
value=value[nested_field_name],
|
144
|
+
)
|
145
|
+
for nested_field_name in unwrapped_type.model_fields if nested_field_name in value
|
146
|
+
}
|
147
|
+
|
148
|
+
return value
|
149
|
+
|
150
|
+
@classmethod
|
151
|
+
async def _create_model_instance(cls, document: dict) -> Self:
|
152
|
+
"""Prepares document and creates an instance of the model."""
|
153
|
+
processed_document = {
|
154
|
+
field_name: await cls._create_field(model=cls, field_name=field_name, value=field_value)
|
155
|
+
for field_name, field_value in document.items()
|
156
|
+
}
|
157
|
+
try:
|
158
|
+
return cls(**processed_document)
|
159
|
+
except ValidationError as validation_error:
|
160
|
+
error = cls._clean_error_message(validation_error=validation_error)
|
161
|
+
raise DatabaseError(error) from validation_error
|
162
|
+
|
163
|
+
@classmethod
|
164
|
+
def clean_value(cls, field: str | None, value: Any) -> dict[str, Any] | list[Any]:
|
165
|
+
match value:
|
166
|
+
case None:
|
167
|
+
return None
|
168
|
+
case Model() as model:
|
169
|
+
if model.id is None:
|
170
|
+
raise DatabaseError(f'Model instance{" in " + field if field else ""} has no ID.')
|
171
|
+
# We save full object because user didn't specify the type.
|
172
|
+
return model._id
|
173
|
+
case BaseModel() as model:
|
174
|
+
return {
|
175
|
+
field_name: cls.clean_value(field=field_name, value=getattr(model, field_name))
|
176
|
+
for field_name in model.model_fields
|
177
|
+
}
|
178
|
+
case dict() as d:
|
179
|
+
return {k: cls.clean_value(field=k, value=v) for k, v in d.items()}
|
180
|
+
case list() as l:
|
181
|
+
return [cls.clean_value(field=None, value=item) for item in l]
|
182
|
+
|
183
|
+
return value
|
184
|
+
|
37
185
|
# # # # # Find # # # # #
|
38
186
|
@classmethod
|
39
187
|
async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
40
188
|
if document := await db.session[cls.__name__].find_one(cls._merge(_filter, kwargs)):
|
41
|
-
return cls._create_model_instance(document=document)
|
189
|
+
return await cls._create_model_instance(document=document)
|
42
190
|
return None
|
43
191
|
|
44
192
|
@classmethod
|
@@ -48,14 +196,14 @@ class BaseMongoDBQuery(BaseQuery):
|
|
48
196
|
@classmethod
|
49
197
|
async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
50
198
|
cursor = await cls.find(_filter, **kwargs)
|
51
|
-
for result in cursor.sort('_id', 1).limit(-1):
|
199
|
+
async for result in cursor.sort('_id', 1).limit(-1):
|
52
200
|
return result
|
53
201
|
return None
|
54
202
|
|
55
203
|
@classmethod
|
56
204
|
async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
57
205
|
cursor = await cls.find(_filter, **kwargs)
|
58
|
-
for result in cursor.sort('_id', -1).limit(-1):
|
206
|
+
async for result in cursor.sort('_id', -1).limit(-1):
|
59
207
|
return result
|
60
208
|
return None
|
61
209
|
|
@@ -73,18 +221,32 @@ class BaseMongoDBQuery(BaseQuery):
|
|
73
221
|
async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self:
|
74
222
|
document = cls._merge(_document, kwargs)
|
75
223
|
cls._validate_data(data=document)
|
76
|
-
|
77
|
-
|
78
|
-
|
224
|
+
final_document = {
|
225
|
+
field: cls.clean_value(field=field, value=value)
|
226
|
+
for field, value in document.items()
|
227
|
+
}
|
228
|
+
result = await cls._create_model_instance(document=final_document)
|
229
|
+
insert_one_result: InsertOneResult = await db.session[cls.__name__].insert_one(final_document)
|
230
|
+
result.id = insert_one_result.inserted_id
|
231
|
+
return result
|
79
232
|
|
80
233
|
@classmethod
|
81
234
|
async def insert_many(cls, documents: Iterable[dict]) -> list[Self]:
|
235
|
+
final_documents = []
|
236
|
+
results = []
|
82
237
|
for document in documents:
|
83
238
|
prepare_id_for_query(document, is_mongo=True)
|
84
239
|
cls._validate_data(data=document)
|
85
|
-
|
86
|
-
|
87
|
-
|
240
|
+
cleaned_document = {
|
241
|
+
field: cls.clean_value(field=field, value=value)
|
242
|
+
for field, value in document.items()
|
243
|
+
}
|
244
|
+
final_documents.append(cleaned_document)
|
245
|
+
results.append(await cls._create_model_instance(document=cleaned_document))
|
246
|
+
insert_many_result: InsertManyResult = await db.session[cls.__name__].insert_many(final_documents)
|
247
|
+
for obj, _id in zip(results, insert_many_result.inserted_ids):
|
248
|
+
obj.id = _id
|
249
|
+
return results
|
88
250
|
|
89
251
|
# # # # # Delete # # # # #
|
90
252
|
async def delete(self) -> None:
|
@@ -25,7 +25,7 @@ class BasePantherDBQuery(BaseQuery):
|
|
25
25
|
@classmethod
|
26
26
|
async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
27
27
|
if document := db.session.collection(cls.__name__).find_one(**cls._merge(_filter, kwargs)):
|
28
|
-
return cls._create_model_instance(document=document)
|
28
|
+
return await cls._create_model_instance(document=document)
|
29
29
|
return None
|
30
30
|
|
31
31
|
@classmethod
|
@@ -38,13 +38,13 @@ class BasePantherDBQuery(BaseQuery):
|
|
38
38
|
@classmethod
|
39
39
|
async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
40
40
|
if document := db.session.collection(cls.__name__).first(**cls._merge(_filter, kwargs)):
|
41
|
-
return cls._create_model_instance(document=document)
|
41
|
+
return await cls._create_model_instance(document=document)
|
42
42
|
return None
|
43
43
|
|
44
44
|
@classmethod
|
45
45
|
async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
46
46
|
if document := db.session.collection(cls.__name__).last(**cls._merge(_filter, kwargs)):
|
47
|
-
return cls._create_model_instance(document=document)
|
47
|
+
return await cls._create_model_instance(document=document)
|
48
48
|
return None
|
49
49
|
|
50
50
|
@classmethod
|
@@ -64,7 +64,7 @@ class BasePantherDBQuery(BaseQuery):
|
|
64
64
|
cls._validate_data(data=_document)
|
65
65
|
|
66
66
|
document = db.session.collection(cls.__name__).insert_one(**_document)
|
67
|
-
return cls._create_model_instance(document=document)
|
67
|
+
return await cls._create_model_instance(document=document)
|
68
68
|
|
69
69
|
@classmethod
|
70
70
|
async def insert_many(cls, documents: Iterable[dict]) -> list[Self]:
|
@@ -74,7 +74,7 @@ class BasePantherDBQuery(BaseQuery):
|
|
74
74
|
cls._validate_data(data=document)
|
75
75
|
inserted_document = db.session.collection(cls.__name__).insert_one(**document)
|
76
76
|
document['_id'] = inserted_document['_id']
|
77
|
-
result.append(cls._create_model_instance(document=document))
|
77
|
+
result.append(await cls._create_model_instance(document=document))
|
78
78
|
return result
|
79
79
|
|
80
80
|
# # # # # Delete # # # # #
|
panther/db/queries/queries.py
CHANGED
@@ -404,7 +404,7 @@ class Query(BaseQuery):
|
|
404
404
|
>>> await user.save()
|
405
405
|
"""
|
406
406
|
document = {
|
407
|
-
field: getattr(self, field).model_dump()
|
407
|
+
field: getattr(self, field).model_dump(by_alias=True)
|
408
408
|
if issubclass(type(getattr(self, field)), BaseModel)
|
409
409
|
else getattr(self, field)
|
410
410
|
for field in self.model_fields.keys() if field != 'request'
|
panther/events.py
CHANGED
@@ -1,8 +1,11 @@
|
|
1
1
|
import asyncio
|
2
|
+
import logging
|
2
3
|
|
3
4
|
from panther._utils import is_function_async
|
4
5
|
from panther.configs import config
|
5
6
|
|
7
|
+
logger = logging.getLogger('panther')
|
8
|
+
|
6
9
|
|
7
10
|
class Event:
|
8
11
|
@staticmethod
|
@@ -24,10 +27,13 @@ class Event:
|
|
24
27
|
@staticmethod
|
25
28
|
async def run_startups():
|
26
29
|
for func in config.STARTUPS:
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
30
|
+
try:
|
31
|
+
if is_function_async(func):
|
32
|
+
await func()
|
33
|
+
else:
|
34
|
+
func()
|
35
|
+
except Exception as e:
|
36
|
+
logger.error(f'{func.__name__}() startup event got error: {e}')
|
31
37
|
|
32
38
|
@staticmethod
|
33
39
|
def run_shutdowns():
|
panther/exceptions.py
CHANGED
@@ -9,17 +9,34 @@ class DatabaseError(Exception):
|
|
9
9
|
pass
|
10
10
|
|
11
11
|
|
12
|
-
class
|
12
|
+
class BaseError(Exception):
|
13
13
|
detail: str | dict | list = 'Internal Server Error'
|
14
14
|
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
15
15
|
|
16
16
|
def __init__(
|
17
17
|
self,
|
18
18
|
detail: str | dict | list = None,
|
19
|
-
status_code: int = None
|
19
|
+
status_code: int = None,
|
20
|
+
headers: dict = None
|
20
21
|
):
|
21
22
|
self.detail = detail or self.detail
|
22
23
|
self.status_code = status_code or self.status_code
|
24
|
+
self.headers = headers
|
25
|
+
|
26
|
+
|
27
|
+
class APIError(BaseError):
|
28
|
+
detail: str | dict | list = 'Internal Server Error'
|
29
|
+
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
30
|
+
|
31
|
+
|
32
|
+
class WebsocketError(BaseError):
|
33
|
+
detail: str | dict | list = 'Internal Error'
|
34
|
+
status_code: int = status.WS_1011_INTERNAL_ERROR
|
35
|
+
|
36
|
+
|
37
|
+
class RedirectAPIError(APIError):
|
38
|
+
def __init__(self, url: str, status_code: int = status.HTTP_302_FOUND):
|
39
|
+
super().__init__(headers={'Location': url}, status_code=status_code)
|
23
40
|
|
24
41
|
|
25
42
|
class BadRequestAPIError(APIError):
|
@@ -52,6 +69,11 @@ class JSONDecodeAPIError(APIError):
|
|
52
69
|
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
53
70
|
|
54
71
|
|
72
|
+
class UpgradeRequiredError(APIError):
|
73
|
+
detail = 'This service requires use of the WebSocket protocol.'
|
74
|
+
status_code = status.HTTP_426_UPGRADE_REQUIRED
|
75
|
+
|
76
|
+
|
55
77
|
class ThrottlingAPIError(APIError):
|
56
78
|
detail = 'Too Many Request'
|
57
79
|
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
panther/generics.py
CHANGED
@@ -146,7 +146,7 @@ class UpdateAPI(GenericAPI, ObjectRequired):
|
|
146
146
|
|
147
147
|
await request.validated_data.update(
|
148
148
|
instance=instance,
|
149
|
-
validated_data=request.validated_data.model_dump()
|
149
|
+
validated_data=request.validated_data.model_dump(by_alias=True)
|
150
150
|
)
|
151
151
|
return Response(data=instance, status_code=status.HTTP_200_OK)
|
152
152
|
|
@@ -156,7 +156,7 @@ class UpdateAPI(GenericAPI, ObjectRequired):
|
|
156
156
|
|
157
157
|
await request.validated_data.partial_update(
|
158
158
|
instance=instance,
|
159
|
-
validated_data=request.validated_data.model_dump(exclude_none=True)
|
159
|
+
validated_data=request.validated_data.model_dump(exclude_none=True, by_alias=True)
|
160
160
|
)
|
161
161
|
return Response(data=instance, status_code=status.HTTP_200_OK)
|
162
162
|
|
panther/main.py
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
import contextlib
|
2
1
|
import logging
|
3
2
|
import sys
|
4
3
|
import types
|
@@ -6,20 +5,20 @@ from collections.abc import Callable
|
|
6
5
|
from logging.config import dictConfig
|
7
6
|
from pathlib import Path
|
8
7
|
|
9
|
-
import orjson as json
|
10
|
-
|
11
8
|
import panther.logging
|
12
9
|
from panther import status
|
13
10
|
from panther._load_configs import *
|
14
11
|
from panther._utils import traceback_message, reformat_code
|
12
|
+
from panther.app import GenericAPI
|
13
|
+
from panther.base_websocket import Websocket
|
15
14
|
from panther.cli.utils import print_info
|
16
15
|
from panther.configs import config
|
17
16
|
from panther.events import Event
|
18
|
-
from panther.exceptions import APIError, PantherError
|
19
|
-
from panther.monitoring import Monitoring
|
17
|
+
from panther.exceptions import APIError, PantherError, NotFoundAPIError, BaseError, UpgradeRequiredError
|
20
18
|
from panther.request import Request
|
21
19
|
from panther.response import Response
|
22
20
|
from panther.routings import find_endpoint
|
21
|
+
from panther.websocket import GenericWebsocket
|
23
22
|
|
24
23
|
dictConfig(panther.logging.LOGGING)
|
25
24
|
logger = logging.getLogger('panther')
|
@@ -53,7 +52,6 @@ class Panther:
|
|
53
52
|
load_timezone(self._configs_module)
|
54
53
|
load_database(self._configs_module)
|
55
54
|
load_secret_key(self._configs_module)
|
56
|
-
load_monitoring(self._configs_module)
|
57
55
|
load_throttling(self._configs_module)
|
58
56
|
load_user_model(self._configs_module)
|
59
57
|
load_log_queries(self._configs_module)
|
@@ -62,8 +60,8 @@ class Panther:
|
|
62
60
|
load_auto_reformat(self._configs_module)
|
63
61
|
load_background_tasks(self._configs_module)
|
64
62
|
load_default_cache_exp(self._configs_module)
|
65
|
-
load_authentication_class(self._configs_module)
|
66
63
|
load_urls(self._configs_module, urls=self._urls)
|
64
|
+
load_authentication_class(self._configs_module)
|
67
65
|
load_websocket_connections()
|
68
66
|
|
69
67
|
check_endpoints_inheritance()
|
@@ -74,7 +72,11 @@ class Panther:
|
|
74
72
|
if message["type"] == 'lifespan.startup':
|
75
73
|
if config.HAS_WS:
|
76
74
|
await config.WEBSOCKET_CONNECTIONS.start()
|
77
|
-
|
75
|
+
try:
|
76
|
+
await Event.run_startups()
|
77
|
+
except Exception as e:
|
78
|
+
logger.error(e)
|
79
|
+
raise e
|
78
80
|
elif message["type"] == 'lifespan.shutdown':
|
79
81
|
# It's not happening :\, so handle the shutdowns in __del__ ...
|
80
82
|
pass
|
@@ -83,145 +85,106 @@ class Panther:
|
|
83
85
|
func = self.handle_http if scope['type'] == 'http' else self.handle_ws
|
84
86
|
await func(scope=scope, receive=receive, send=send)
|
85
87
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
# Monitoring
|
90
|
-
monitoring = Monitoring(is_ws=True)
|
91
|
-
|
92
|
-
# Create Temp Connection
|
93
|
-
temp_connection = Websocket(scope=scope, receive=receive, send=send)
|
94
|
-
await monitoring.before(request=temp_connection)
|
95
|
-
temp_connection._monitoring = monitoring
|
96
|
-
|
88
|
+
@classmethod
|
89
|
+
async def handle_ws_endpoint(cls, connection: Websocket):
|
97
90
|
# Find Endpoint
|
98
|
-
endpoint, found_path = find_endpoint(path=
|
91
|
+
endpoint, found_path = find_endpoint(path=connection.path)
|
99
92
|
if endpoint is None:
|
100
|
-
logger.debug(f'Path `{
|
101
|
-
|
93
|
+
logger.debug(f'Path `{connection.path}` not found')
|
94
|
+
await connection.close()
|
95
|
+
return connection
|
102
96
|
|
103
97
|
# Check Endpoint Type
|
104
98
|
if not issubclass(endpoint, GenericWebsocket):
|
105
|
-
logger.
|
106
|
-
|
99
|
+
logger.warning(f'{endpoint.__name__}() class is not a Websocket class.')
|
100
|
+
await connection.close()
|
101
|
+
return connection
|
107
102
|
|
108
103
|
# Create The Connection
|
109
|
-
|
110
|
-
connection
|
111
|
-
connection._monitoring = monitoring
|
104
|
+
final_connection = endpoint(parent=connection)
|
105
|
+
del connection
|
112
106
|
|
113
107
|
# Collect Path Variables
|
114
|
-
|
108
|
+
final_connection.collect_path_variables(found_path=found_path)
|
115
109
|
|
116
|
-
|
110
|
+
return await config.WEBSOCKET_CONNECTIONS.listen(connection=final_connection)
|
117
111
|
|
118
|
-
# Call Middlewares .before()
|
119
|
-
await self._run_ws_middlewares_before_listen(connection=connection, middlewares=middlewares)
|
120
112
|
|
121
|
-
|
122
|
-
|
113
|
+
async def handle_ws(self, scope: dict, receive: Callable, send: Callable) -> None:
|
114
|
+
# Create Temp Connection
|
115
|
+
connection = Websocket(scope=scope, receive=receive, send=send)
|
123
116
|
|
124
|
-
#
|
125
|
-
|
126
|
-
|
117
|
+
# Create Middlewares chain
|
118
|
+
chained_func = self.handle_ws_endpoint
|
119
|
+
for middleware in reversed(config.WS_MIDDLEWARES):
|
120
|
+
chained_func = middleware(dispatch=chained_func)
|
127
121
|
|
128
|
-
|
129
|
-
async def _run_ws_middlewares_before_listen(cls, *, connection, middlewares):
|
122
|
+
# Call Middlewares & Endpoint
|
130
123
|
try:
|
131
|
-
|
132
|
-
|
133
|
-
if new_connection is None:
|
134
|
-
logger.critical(
|
135
|
-
f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
|
136
|
-
await connection.close()
|
137
|
-
connection = new_connection
|
138
|
-
except APIError as e:
|
124
|
+
connection = await chained_func(connection=connection)
|
125
|
+
except BaseError as e:
|
139
126
|
connection.log(e.detail)
|
140
127
|
await connection.close()
|
128
|
+
except Exception as e:
|
129
|
+
logger.error(traceback_message(exception=e))
|
130
|
+
await connection.close()
|
141
131
|
|
142
|
-
@classmethod
|
143
|
-
async def _run_ws_middlewares_after_listen(cls, *, connection, middlewares):
|
144
|
-
for middleware in middlewares:
|
145
|
-
with contextlib.suppress(APIError):
|
146
|
-
connection = await middleware.after(response=connection)
|
147
|
-
if connection is None:
|
148
|
-
logger.critical(
|
149
|
-
f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
|
150
|
-
break
|
151
|
-
|
152
|
-
async def handle_http(self, scope: dict, receive: Callable, send: Callable) -> None:
|
153
|
-
# Monitoring
|
154
|
-
monitoring = Monitoring()
|
155
|
-
|
156
|
-
request = Request(scope=scope, receive=receive, send=send)
|
157
|
-
|
158
|
-
await monitoring.before(request=request)
|
159
|
-
|
160
|
-
# Read Request Payload
|
161
|
-
await request.read_body()
|
162
132
|
|
133
|
+
@classmethod
|
134
|
+
async def handle_http_endpoint(cls, request: Request) -> Response:
|
163
135
|
# Find Endpoint
|
164
136
|
endpoint, found_path = find_endpoint(path=request.path)
|
165
137
|
if endpoint is None:
|
166
|
-
|
138
|
+
raise NotFoundAPIError
|
167
139
|
|
168
140
|
# Collect Path Variables
|
169
141
|
request.collect_path_variables(found_path=found_path)
|
170
142
|
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
request = await middleware.before(request=request)
|
176
|
-
if request is None:
|
177
|
-
logger.critical(
|
178
|
-
f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
|
179
|
-
return await self._raise(send, monitoring=monitoring)
|
143
|
+
# Prepare the method
|
144
|
+
if not isinstance(endpoint, types.FunctionType):
|
145
|
+
if not issubclass(endpoint, GenericAPI):
|
146
|
+
raise UpgradeRequiredError
|
180
147
|
|
181
|
-
|
182
|
-
|
183
|
-
|
148
|
+
endpoint = endpoint().call_method
|
149
|
+
# Call Endpoint
|
150
|
+
return await endpoint(request=request)
|
151
|
+
|
152
|
+
async def handle_http(self, scope: dict, receive: Callable, send: Callable) -> None:
|
153
|
+
# Create `Request` and its body
|
154
|
+
request = Request(scope=scope, receive=receive, send=send)
|
155
|
+
await request.read_body()
|
184
156
|
|
185
|
-
|
186
|
-
|
157
|
+
# Create Middlewares chain
|
158
|
+
chained_func = self.handle_http_endpoint
|
159
|
+
for middleware in reversed(config.HTTP_MIDDLEWARES):
|
160
|
+
chained_func = middleware(dispatch=chained_func)
|
187
161
|
|
162
|
+
# Call Middlewares & Endpoint
|
163
|
+
try:
|
164
|
+
response = await chained_func(request=request)
|
165
|
+
if response is None:
|
166
|
+
logger.error('You forgot to return `response` on the `Middlewares.__call__()`')
|
167
|
+
response = Response(
|
168
|
+
data={'detail': 'Internal Server Error'},
|
169
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
170
|
+
)
|
171
|
+
# Handle `APIError` Exceptions
|
188
172
|
except APIError as e:
|
189
|
-
response =
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
|
205
|
-
return await self._raise(send, monitoring=monitoring)
|
206
|
-
except APIError as e: # noqa: PERF203
|
207
|
-
response = self._handle_exceptions(e)
|
208
|
-
|
209
|
-
await response.send(send, receive, monitoring=monitoring)
|
173
|
+
response = Response(
|
174
|
+
data=e.detail if isinstance(e.detail, dict) else {'detail': e.detail},
|
175
|
+
headers=e.headers,
|
176
|
+
status_code=e.status_code,
|
177
|
+
)
|
178
|
+
# Handle Unknown Exceptions
|
179
|
+
except Exception as e: # noqa: BLE001 - Blind Exception
|
180
|
+
logger.error(traceback_message(exception=e))
|
181
|
+
response = Response(
|
182
|
+
data={'detail': 'Internal Server Error'},
|
183
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
184
|
+
)
|
185
|
+
|
186
|
+
# Return Response
|
187
|
+
await response.send(send, receive)
|
210
188
|
|
211
189
|
def __del__(self):
|
212
190
|
Event.run_shutdowns()
|
213
|
-
|
214
|
-
@classmethod
|
215
|
-
def _handle_exceptions(cls, e: APIError, /) -> Response:
|
216
|
-
return Response(
|
217
|
-
data=e.detail if isinstance(e.detail, dict) else {'detail': e.detail},
|
218
|
-
status_code=e.status_code,
|
219
|
-
)
|
220
|
-
|
221
|
-
@classmethod
|
222
|
-
async def _raise(cls, send, *, monitoring, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
|
223
|
-
headers = [[b'Content-Type', b'application/json']]
|
224
|
-
body = json.dumps({'detail': status.status_text[status_code]})
|
225
|
-
await monitoring.after(status_code)
|
226
|
-
await send({'type': 'http.response.start', 'status': status_code, 'headers': headers})
|
227
|
-
await send({'type': 'http.response.body', 'body': body, 'more_body': False})
|
panther/middlewares/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
from panther.middlewares.base import
|
1
|
+
from panther.middlewares.base import HTTPMiddleware, WebsocketMiddleware # noqa: F401
|