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
@@ -1,20 +1,29 @@
|
|
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
|
5
9
|
|
6
10
|
from panther.db.connections import db
|
7
11
|
from panther.db.cursor import Cursor
|
12
|
+
from panther.db.models import Model
|
8
13
|
from panther.db.queries.base_queries import BaseQuery
|
9
14
|
from panther.db.utils import prepare_id_for_query
|
15
|
+
from panther.exceptions import DatabaseError
|
10
16
|
|
11
17
|
try:
|
12
18
|
from bson.codec_options import CodecOptions
|
19
|
+
from pymongo.results import InsertOneResult, InsertManyResult
|
13
20
|
except ImportError:
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
21
|
+
# MongoDB-related libraries are not required by default.
|
22
|
+
# If the user intends to use MongoDB, they must install the required dependencies explicitly.
|
23
|
+
# This will be enforced in `panther.db.connections.MongoDBConnection.init`.
|
17
24
|
CodecOptions = type('CodecOptions', (), {})
|
25
|
+
InsertOneResult = type('InsertOneResult', (), {})
|
26
|
+
InsertManyResult = type('InsertManyResult', (), {})
|
18
27
|
|
19
28
|
if version_info >= (3, 11):
|
20
29
|
from typing import Self
|
@@ -24,6 +33,32 @@ else:
|
|
24
33
|
Self = TypeVar('Self', bound='BaseMongoDBQuery')
|
25
34
|
|
26
35
|
|
36
|
+
def get_annotation_type(annotation: Any) -> type | None:
|
37
|
+
"""
|
38
|
+
Extracts the underlying, non-optional type from a type annotation.
|
39
|
+
Handles basic types, Pydantic BaseModels, lists, and unions (optionals).
|
40
|
+
Returns None if no single underlying type can be determined (e.g., for list[NoneType]).
|
41
|
+
Raises DatabaseError for unsupported annotations.
|
42
|
+
"""
|
43
|
+
origin = get_origin(annotation)
|
44
|
+
|
45
|
+
# Handle list[T] and Union[T, None] (T | None or typing.Union[T, None])
|
46
|
+
if origin is list or origin is types.UnionType or origin is Union:
|
47
|
+
# Extracts the first non-None type from a tuple of type arguments.
|
48
|
+
for arg in get_args(annotation):
|
49
|
+
if arg is not type(None):
|
50
|
+
return arg
|
51
|
+
return None
|
52
|
+
|
53
|
+
# Handle basic types (str, int, bool, dict) and Pydantic BaseModel subclasses
|
54
|
+
if isinstance(annotation, type) and (
|
55
|
+
annotation in (str, int, bool, dict) or issubclass(annotation, BaseModel)
|
56
|
+
):
|
57
|
+
return annotation
|
58
|
+
|
59
|
+
raise DatabaseError(f'Panther does not support {annotation} as a field type for unwrapping.')
|
60
|
+
|
61
|
+
|
27
62
|
class BaseMongoDBQuery(BaseQuery):
|
28
63
|
@classmethod
|
29
64
|
def _merge(cls, *args, is_mongo: bool = True) -> dict:
|
@@ -34,11 +69,126 @@ class BaseMongoDBQuery(BaseQuery):
|
|
34
69
|
# def collection(cls):
|
35
70
|
# return db.session.get_collection(name=cls.__name__, codec_options=CodecOptions(document_class=cls))
|
36
71
|
|
72
|
+
@classmethod
|
73
|
+
async def _create_list(cls, field_type: type, value: Any) -> Any:
|
74
|
+
# `field_type` is the expected type of items in the list (e.g., int, Model, list[str])
|
75
|
+
# `value` is a single item from the input list that needs processing.
|
76
|
+
|
77
|
+
# Handles list[list[int]], list[dict[str,int]] etc.
|
78
|
+
if isinstance(field_type, (types.GenericAlias, typing._GenericAlias)):
|
79
|
+
element_type = get_annotation_type(field_type) # Unwrap further (e.g. list[str] -> str)
|
80
|
+
if element_type is None:
|
81
|
+
raise DatabaseError(f"Cannot determine element type for generic list item: {field_type}")
|
82
|
+
if not isinstance(value, list): # Or check if iterable, matching the structure
|
83
|
+
raise DatabaseError(f"Expected a list for nested generic type {field_type}, got {type(value)}")
|
84
|
+
return [await cls._create_list(field_type=element_type, value=item) for item in value]
|
85
|
+
|
86
|
+
# Make sure Model condition is before BaseModel.
|
87
|
+
if isinstance(field_type, type) and issubclass(field_type, Model):
|
88
|
+
# `value` is assumed to be an ID for the Model instance.
|
89
|
+
return await field_type.first(id=value)
|
90
|
+
|
91
|
+
if isinstance(field_type, type) and issubclass(field_type, BaseModel):
|
92
|
+
if not isinstance(value, dict):
|
93
|
+
raise DatabaseError(f"Expected a dictionary for BaseModel {field_type.__name__}, got {type(value)}")
|
94
|
+
|
95
|
+
return {
|
96
|
+
field_name: await cls._create_field(model=field_type, field_name=field_name, value=value[field_name])
|
97
|
+
for field_name in value
|
98
|
+
}
|
99
|
+
|
100
|
+
# Base case: value is a primitive type (str, int, etc.)
|
101
|
+
return value
|
102
|
+
|
103
|
+
@classmethod
|
104
|
+
async def _create_field(cls, model: type, field_name: str, value: Any) -> Any:
|
105
|
+
# Handle primary key field directly
|
106
|
+
if field_name == '_id':
|
107
|
+
return value
|
108
|
+
|
109
|
+
if field_name not in model.model_fields:
|
110
|
+
# Field from input data is not defined in the model.
|
111
|
+
# Pydantic's `extra` config on the model will handle this upon instantiation.
|
112
|
+
return value
|
113
|
+
|
114
|
+
field_annotation = model.model_fields[field_name].annotation
|
115
|
+
unwrapped_type = get_annotation_type(field_annotation)
|
116
|
+
|
117
|
+
if unwrapped_type is None:
|
118
|
+
raise DatabaseError(
|
119
|
+
f"Could not determine a valid underlying type for field '{field_name}' "
|
120
|
+
f"with annotation {field_annotation} in model {model.__name__}."
|
121
|
+
)
|
122
|
+
|
123
|
+
if get_origin(field_annotation) is list:
|
124
|
+
# Or check for general iterables if applicable
|
125
|
+
if not isinstance(value, list):
|
126
|
+
raise DatabaseError(
|
127
|
+
f"Field '{field_name}' expects a list, got {type(value)} for model {model.__name__}")
|
128
|
+
return [await cls._create_list(field_type=unwrapped_type, value=item) for item in value]
|
129
|
+
|
130
|
+
if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, Model):
|
131
|
+
if obj := await unwrapped_type.first(id=value):
|
132
|
+
return obj.model_dump(by_alias=True)
|
133
|
+
return None
|
134
|
+
|
135
|
+
if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, BaseModel):
|
136
|
+
if not isinstance(value, dict):
|
137
|
+
raise DatabaseError(
|
138
|
+
f"Field '{field_name}' expects a dictionary for BaseModel {unwrapped_type.__name__}, "
|
139
|
+
f"got {type(value)} in model {model.__name__}"
|
140
|
+
)
|
141
|
+
return {
|
142
|
+
nested_field_name: await cls._create_field(
|
143
|
+
model=unwrapped_type,
|
144
|
+
field_name=nested_field_name,
|
145
|
+
value=value[nested_field_name],
|
146
|
+
)
|
147
|
+
for nested_field_name in unwrapped_type.model_fields if nested_field_name in value
|
148
|
+
}
|
149
|
+
|
150
|
+
return value
|
151
|
+
|
152
|
+
@classmethod
|
153
|
+
async def _create_model_instance(cls, document: dict) -> Self:
|
154
|
+
"""Prepares document and creates an instance of the model."""
|
155
|
+
processed_document = {
|
156
|
+
field_name: await cls._create_field(model=cls, field_name=field_name, value=field_value)
|
157
|
+
for field_name, field_value in document.items()
|
158
|
+
}
|
159
|
+
try:
|
160
|
+
return cls(**processed_document)
|
161
|
+
except ValidationError as validation_error:
|
162
|
+
error = cls._clean_error_message(validation_error=validation_error)
|
163
|
+
raise DatabaseError(error) from validation_error
|
164
|
+
|
165
|
+
@classmethod
|
166
|
+
def clean_value(cls, field: str | None, value: Any) -> dict[str, Any] | list[Any]:
|
167
|
+
match value:
|
168
|
+
case None:
|
169
|
+
return None
|
170
|
+
case Model() as model:
|
171
|
+
if model.id is None:
|
172
|
+
raise DatabaseError(f'Model instance{" in " + field if field else ""} has no ID.')
|
173
|
+
# We save full object because user didn't specify the type.
|
174
|
+
return model._id
|
175
|
+
case BaseModel() as model:
|
176
|
+
return {
|
177
|
+
field_name: cls.clean_value(field=field_name, value=getattr(model, field_name))
|
178
|
+
for field_name in model.model_fields
|
179
|
+
}
|
180
|
+
case dict() as d:
|
181
|
+
return {k: cls.clean_value(field=k, value=v) for k, v in d.items()}
|
182
|
+
case list() as l:
|
183
|
+
return [cls.clean_value(field=None, value=item) for item in l]
|
184
|
+
|
185
|
+
return value
|
186
|
+
|
37
187
|
# # # # # Find # # # # #
|
38
188
|
@classmethod
|
39
189
|
async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
40
190
|
if document := await db.session[cls.__name__].find_one(cls._merge(_filter, kwargs)):
|
41
|
-
return cls._create_model_instance(document=document)
|
191
|
+
return await cls._create_model_instance(document=document)
|
42
192
|
return None
|
43
193
|
|
44
194
|
@classmethod
|
@@ -48,14 +198,14 @@ class BaseMongoDBQuery(BaseQuery):
|
|
48
198
|
@classmethod
|
49
199
|
async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
50
200
|
cursor = await cls.find(_filter, **kwargs)
|
51
|
-
for result in cursor.sort('_id', 1).limit(-1):
|
201
|
+
async for result in cursor.sort('_id', 1).limit(-1):
|
52
202
|
return result
|
53
203
|
return None
|
54
204
|
|
55
205
|
@classmethod
|
56
206
|
async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None:
|
57
207
|
cursor = await cls.find(_filter, **kwargs)
|
58
|
-
for result in cursor.sort('_id', -1).limit(-1):
|
208
|
+
async for result in cursor.sort('_id', -1).limit(-1):
|
59
209
|
return result
|
60
210
|
return None
|
61
211
|
|
@@ -73,18 +223,32 @@ class BaseMongoDBQuery(BaseQuery):
|
|
73
223
|
async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self:
|
74
224
|
document = cls._merge(_document, kwargs)
|
75
225
|
cls._validate_data(data=document)
|
76
|
-
|
77
|
-
|
78
|
-
|
226
|
+
final_document = {
|
227
|
+
field: cls.clean_value(field=field, value=value)
|
228
|
+
for field, value in document.items()
|
229
|
+
}
|
230
|
+
result = await cls._create_model_instance(document=final_document)
|
231
|
+
insert_one_result: InsertOneResult = await db.session[cls.__name__].insert_one(final_document)
|
232
|
+
result.id = insert_one_result.inserted_id
|
233
|
+
return result
|
79
234
|
|
80
235
|
@classmethod
|
81
236
|
async def insert_many(cls, documents: Iterable[dict]) -> list[Self]:
|
237
|
+
final_documents = []
|
238
|
+
results = []
|
82
239
|
for document in documents:
|
83
240
|
prepare_id_for_query(document, is_mongo=True)
|
84
241
|
cls._validate_data(data=document)
|
85
|
-
|
86
|
-
|
87
|
-
|
242
|
+
cleaned_document = {
|
243
|
+
field: cls.clean_value(field=field, value=value)
|
244
|
+
for field, value in document.items()
|
245
|
+
}
|
246
|
+
final_documents.append(cleaned_document)
|
247
|
+
results.append(await cls._create_model_instance(document=cleaned_document))
|
248
|
+
insert_many_result: InsertManyResult = await db.session[cls.__name__].insert_many(final_documents)
|
249
|
+
for obj, _id in zip(results, insert_many_result.inserted_ids):
|
250
|
+
obj.id = _id
|
251
|
+
return results
|
88
252
|
|
89
253
|
# # # # # Delete # # # # #
|
90
254
|
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
|
|