panther 3.8.2__py3-none-any.whl → 4.0.0__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 +168 -171
- panther/_utils.py +26 -49
- panther/app.py +85 -105
- panther/authentications.py +86 -55
- panther/background_tasks.py +25 -14
- panther/base_request.py +38 -14
- panther/base_websocket.py +172 -94
- panther/caching.py +60 -25
- panther/cli/create_command.py +20 -10
- panther/cli/monitor_command.py +63 -37
- panther/cli/template.py +40 -20
- panther/cli/utils.py +32 -18
- panther/configs.py +65 -58
- panther/db/connections.py +139 -0
- panther/db/cursor.py +43 -0
- panther/db/models.py +64 -29
- panther/db/queries/__init__.py +1 -1
- panther/db/queries/base_queries.py +127 -0
- panther/db/queries/mongodb_queries.py +77 -38
- panther/db/queries/pantherdb_queries.py +59 -30
- panther/db/queries/queries.py +232 -117
- panther/db/utils.py +17 -18
- panther/events.py +44 -0
- panther/exceptions.py +26 -12
- panther/file_handler.py +2 -2
- panther/generics.py +163 -0
- panther/logging.py +7 -2
- panther/main.py +111 -188
- panther/middlewares/base.py +3 -0
- panther/monitoring.py +8 -5
- panther/pagination.py +48 -0
- panther/panel/apis.py +32 -5
- panther/panel/urls.py +2 -1
- panther/permissions.py +3 -3
- panther/request.py +6 -13
- panther/response.py +114 -34
- panther/routings.py +83 -66
- panther/serializer.py +214 -33
- panther/test.py +31 -21
- panther/utils.py +28 -16
- panther/websocket.py +7 -4
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/METADATA +93 -71
- panther-4.0.0.dist-info/RECORD +57 -0
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/WHEEL +1 -1
- panther/db/connection.py +0 -92
- panther/middlewares/db.py +0 -18
- panther/middlewares/redis.py +0 -47
- panther-3.8.2.dist-info/RECORD +0 -54
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/LICENSE +0 -0
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/entry_points.txt +0 -0
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/top_level.txt +0 -0
panther/db/utils.py
CHANGED
@@ -1,35 +1,38 @@
|
|
1
1
|
import logging
|
2
|
-
import operator
|
3
|
-
from functools import reduce
|
4
2
|
from time import perf_counter
|
5
3
|
|
6
|
-
import bson
|
7
|
-
|
8
4
|
from panther.configs import config
|
9
5
|
|
6
|
+
try:
|
7
|
+
# Only required if user wants to use mongodb
|
8
|
+
import bson
|
9
|
+
except ImportError:
|
10
|
+
pass
|
10
11
|
|
11
12
|
logger = logging.getLogger('query')
|
12
13
|
|
13
14
|
|
14
15
|
def log_query(func):
|
15
|
-
def log(*args, **kwargs):
|
16
|
-
if config
|
17
|
-
return func(*args, **kwargs)
|
16
|
+
async def log(*args, **kwargs):
|
17
|
+
if config.LOG_QUERIES is False:
|
18
|
+
return await func(*args, **kwargs)
|
18
19
|
start = perf_counter()
|
19
|
-
response = func(*args, **kwargs)
|
20
|
+
response = await func(*args, **kwargs)
|
20
21
|
end = perf_counter()
|
21
|
-
class_name =
|
22
|
+
class_name = getattr(args[0], '__name__', args[0].__class__.__name__)
|
22
23
|
logger.info(f'\033[1mQuery -->\033[0m {class_name}.{func.__name__}() --> {(end - start) * 1_000:.2} ms')
|
23
24
|
return response
|
25
|
+
|
24
26
|
return log
|
25
27
|
|
26
28
|
|
27
29
|
def check_connection(func):
|
28
|
-
def wrapper(*args, **kwargs):
|
29
|
-
if config
|
30
|
+
async def wrapper(*args, **kwargs):
|
31
|
+
if config.QUERY_ENGINE is None:
|
30
32
|
msg = "You don't have active database connection, Check your middlewares"
|
31
33
|
raise NotImplementedError(msg)
|
32
|
-
return func(*args, **kwargs)
|
34
|
+
return await func(*args, **kwargs)
|
35
|
+
|
33
36
|
return wrapper
|
34
37
|
|
35
38
|
|
@@ -41,11 +44,11 @@ def prepare_id_for_query(*args, is_mongo: bool = False):
|
|
41
44
|
d['_id'] = d.pop('id')
|
42
45
|
|
43
46
|
if '_id' in d:
|
44
|
-
_converter = _convert_to_object_id if is_mongo else
|
47
|
+
_converter = _convert_to_object_id if is_mongo else str
|
45
48
|
d['_id'] = _converter(d['_id'])
|
46
49
|
|
47
50
|
|
48
|
-
def _convert_to_object_id(_id
|
51
|
+
def _convert_to_object_id(_id):
|
49
52
|
if isinstance(_id, bson.ObjectId):
|
50
53
|
return _id
|
51
54
|
try:
|
@@ -53,7 +56,3 @@ def _convert_to_object_id(_id: bson.ObjectId | str) -> bson.ObjectId:
|
|
53
56
|
except bson.objectid.InvalidId:
|
54
57
|
msg = f'id={_id} is invalid bson.ObjectId'
|
55
58
|
raise bson.errors.InvalidId(msg)
|
56
|
-
|
57
|
-
|
58
|
-
def merge_dicts(*args) -> dict:
|
59
|
-
return reduce(operator.ior, filter(None, args), {})
|
panther/events.py
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
import asyncio
|
2
|
+
|
3
|
+
from panther._utils import is_function_async
|
4
|
+
from panther.configs import config
|
5
|
+
|
6
|
+
|
7
|
+
class Event:
|
8
|
+
@staticmethod
|
9
|
+
def startup(func):
|
10
|
+
config.STARTUPS.append(func)
|
11
|
+
|
12
|
+
def wrapper():
|
13
|
+
return func()
|
14
|
+
return wrapper
|
15
|
+
|
16
|
+
@staticmethod
|
17
|
+
def shutdown(func):
|
18
|
+
config.SHUTDOWNS.append(func)
|
19
|
+
|
20
|
+
def wrapper():
|
21
|
+
return func()
|
22
|
+
return wrapper
|
23
|
+
|
24
|
+
@staticmethod
|
25
|
+
async def run_startups():
|
26
|
+
for func in config.STARTUPS:
|
27
|
+
if is_function_async(func):
|
28
|
+
await func()
|
29
|
+
else:
|
30
|
+
func()
|
31
|
+
|
32
|
+
@staticmethod
|
33
|
+
def run_shutdowns():
|
34
|
+
for func in config.SHUTDOWNS:
|
35
|
+
if is_function_async(func):
|
36
|
+
try:
|
37
|
+
asyncio.run(func())
|
38
|
+
except ModuleNotFoundError:
|
39
|
+
# Error: import of asyncio halted; None in sys.modules
|
40
|
+
# And as I figured it out, it only happens when we are running with
|
41
|
+
# gunicorn and Uvicorn workers (-k uvicorn.workers.UvicornWorker)
|
42
|
+
pass
|
43
|
+
else:
|
44
|
+
func()
|
panther/exceptions.py
CHANGED
@@ -1,49 +1,63 @@
|
|
1
1
|
from panther import status
|
2
2
|
|
3
3
|
|
4
|
-
class
|
4
|
+
class PantherError(Exception):
|
5
5
|
pass
|
6
6
|
|
7
7
|
|
8
|
-
class
|
8
|
+
class DatabaseError(Exception):
|
9
9
|
pass
|
10
10
|
|
11
11
|
|
12
|
-
class
|
12
|
+
class APIError(Exception):
|
13
13
|
detail: str | dict | list = 'Internal Server Error'
|
14
14
|
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
15
15
|
|
16
|
-
def __init__(
|
16
|
+
def __init__(
|
17
|
+
self,
|
18
|
+
detail: str | dict | list = None,
|
19
|
+
status_code: int = None
|
20
|
+
):
|
17
21
|
self.detail = detail or self.detail
|
18
22
|
self.status_code = status_code or self.status_code
|
19
23
|
|
20
24
|
|
21
|
-
class
|
22
|
-
detail = '
|
23
|
-
status_code = status.
|
25
|
+
class BadRequestAPIError(APIError):
|
26
|
+
detail = 'Bad Request'
|
27
|
+
status_code = status.HTTP_400_BAD_REQUEST
|
24
28
|
|
25
29
|
|
26
|
-
class
|
30
|
+
class AuthenticationAPIError(APIError):
|
27
31
|
detail = 'Authentication Error'
|
28
32
|
status_code = status.HTTP_401_UNAUTHORIZED
|
29
33
|
|
30
34
|
|
31
|
-
class
|
35
|
+
class AuthorizationAPIError(APIError):
|
32
36
|
detail = 'Permission Denied'
|
33
37
|
status_code = status.HTTP_403_FORBIDDEN
|
34
38
|
|
35
39
|
|
36
|
-
class
|
40
|
+
class NotFoundAPIError(APIError):
|
41
|
+
detail = 'Not Found'
|
42
|
+
status_code = status.HTTP_404_NOT_FOUND
|
43
|
+
|
44
|
+
|
45
|
+
class MethodNotAllowedAPIError(APIError):
|
46
|
+
detail = 'Method Not Allowed'
|
47
|
+
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
|
48
|
+
|
49
|
+
|
50
|
+
class JSONDecodeAPIError(APIError):
|
37
51
|
detail = 'JSON Decode Error'
|
38
52
|
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
39
53
|
|
40
54
|
|
41
|
-
class
|
55
|
+
class ThrottlingAPIError(APIError):
|
42
56
|
detail = 'Too Many Request'
|
43
57
|
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
44
58
|
|
45
59
|
|
46
|
-
class
|
60
|
+
class InvalidPathVariableAPIError(APIError):
|
47
61
|
def __init__(self, value: str, variable_type: type):
|
48
62
|
detail = f"Path variable '{value}' should be '{variable_type.__name__}'"
|
49
63
|
super().__init__(detail=detail, status_code=status.HTTP_400_BAD_REQUEST)
|
panther/file_handler.py
CHANGED
@@ -3,7 +3,7 @@ from functools import cached_property
|
|
3
3
|
from panther import status
|
4
4
|
from pydantic import BaseModel, field_validator
|
5
5
|
|
6
|
-
from panther.exceptions import
|
6
|
+
from panther.exceptions import APIError
|
7
7
|
|
8
8
|
|
9
9
|
class File(BaseModel):
|
@@ -27,5 +27,5 @@ class Image(File):
|
|
27
27
|
def validate_content_type(cls, content_type: str) -> str:
|
28
28
|
if not content_type.startswith('image/'):
|
29
29
|
msg = f"{content_type} is not a valid image 'content_type'"
|
30
|
-
raise
|
30
|
+
raise APIError(detail=msg, status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE)
|
31
31
|
return content_type
|
panther/generics.py
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
import contextlib
|
2
|
+
import logging
|
3
|
+
|
4
|
+
from pantherdb import Cursor as PantherDBCursor
|
5
|
+
|
6
|
+
from panther import status
|
7
|
+
from panther.app import GenericAPI
|
8
|
+
from panther.configs import config
|
9
|
+
from panther.db import Model
|
10
|
+
from panther.db.cursor import Cursor
|
11
|
+
from panther.exceptions import APIError
|
12
|
+
from panther.pagination import Pagination
|
13
|
+
from panther.request import Request
|
14
|
+
from panther.response import Response
|
15
|
+
from panther.serializer import ModelSerializer
|
16
|
+
|
17
|
+
with contextlib.suppress(ImportError):
|
18
|
+
# Only required if user wants to use mongodb
|
19
|
+
import bson
|
20
|
+
|
21
|
+
logger = logging.getLogger('panther')
|
22
|
+
|
23
|
+
|
24
|
+
class ObjectRequired:
|
25
|
+
def _check_object(self, instance):
|
26
|
+
if issubclass(type(instance), Model) is False:
|
27
|
+
logger.critical(f'`{self.__class__.__name__}.object()` should return instance of a Model --> `find_one()`')
|
28
|
+
raise APIError
|
29
|
+
|
30
|
+
async def object(self, request: Request, **kwargs) -> Model:
|
31
|
+
"""
|
32
|
+
Used in `RetrieveAPI`, `UpdateAPI`, `DeleteAPI`
|
33
|
+
"""
|
34
|
+
logger.error(f'`object()` method is not implemented in {self.__class__} .')
|
35
|
+
raise APIError(status_code=status.HTTP_501_NOT_IMPLEMENTED)
|
36
|
+
|
37
|
+
|
38
|
+
class ObjectsRequired:
|
39
|
+
def _check_objects(self, cursor):
|
40
|
+
if isinstance(cursor, (Cursor, PantherDBCursor)) is False:
|
41
|
+
logger.critical(f'`{self.__class__.__name__}.objects()` should return a Cursor --> `find()`')
|
42
|
+
raise APIError
|
43
|
+
|
44
|
+
async def objects(self, request: Request, **kwargs) -> Cursor | PantherDBCursor:
|
45
|
+
"""
|
46
|
+
Used in `ListAPI`
|
47
|
+
Should return `.find()`
|
48
|
+
"""
|
49
|
+
logger.error(f'`objects()` method is not implemented in {self.__class__} .')
|
50
|
+
raise APIError(status_code=status.HTTP_501_NOT_IMPLEMENTED)
|
51
|
+
|
52
|
+
|
53
|
+
class RetrieveAPI(GenericAPI, ObjectRequired):
|
54
|
+
async def get(self, request: Request, **kwargs):
|
55
|
+
instance = await self.object(request=request, **kwargs)
|
56
|
+
self._check_object(instance)
|
57
|
+
|
58
|
+
return Response(data=instance, status_code=status.HTTP_200_OK)
|
59
|
+
|
60
|
+
|
61
|
+
class ListAPI(GenericAPI, ObjectsRequired):
|
62
|
+
sort_fields: list[str]
|
63
|
+
search_fields: list[str]
|
64
|
+
filter_fields: list[str]
|
65
|
+
pagination: type[Pagination]
|
66
|
+
|
67
|
+
async def get(self, request: Request, **kwargs):
|
68
|
+
cursor = await self.objects(request=request, **kwargs)
|
69
|
+
self._check_objects(cursor)
|
70
|
+
|
71
|
+
query = {}
|
72
|
+
query |= self.process_filters(query_params=request.query_params, cursor=cursor)
|
73
|
+
query |= self.process_search(query_params=request.query_params)
|
74
|
+
|
75
|
+
if query:
|
76
|
+
cursor = await cursor.cls.find(cursor.filter | query)
|
77
|
+
|
78
|
+
if sort := self.process_sort(query_params=request.query_params):
|
79
|
+
cursor = cursor.sort(sort)
|
80
|
+
|
81
|
+
if pagination := self.process_pagination(query_params=request.query_params, cursor=cursor):
|
82
|
+
cursor = await pagination.paginate()
|
83
|
+
|
84
|
+
return Response(data=cursor, status_code=status.HTTP_200_OK)
|
85
|
+
|
86
|
+
def process_filters(self, query_params: dict, cursor: Cursor | PantherDBCursor) -> dict:
|
87
|
+
_filter = {}
|
88
|
+
if hasattr(self, 'filter_fields'):
|
89
|
+
for field in self.filter_fields:
|
90
|
+
if field in query_params:
|
91
|
+
if config.DATABASE.__class__.__name__ == 'MongoDBConnection':
|
92
|
+
with contextlib.suppress(Exception):
|
93
|
+
if cursor.cls.model_fields[field].metadata[0].func.__name__ == 'validate_object_id':
|
94
|
+
_filter[field] = bson.ObjectId(query_params[field])
|
95
|
+
continue
|
96
|
+
_filter[field] = query_params[field]
|
97
|
+
return _filter
|
98
|
+
|
99
|
+
def process_search(self, query_params: dict) -> dict:
|
100
|
+
if hasattr(self, 'search_fields') and 'search' in query_params:
|
101
|
+
value = query_params['search']
|
102
|
+
if config.DATABASE.__class__.__name__ == 'MongoDBConnection':
|
103
|
+
if search := [{field: {'$regex': value}} for field in self.search_fields]:
|
104
|
+
return {'$or': search}
|
105
|
+
else:
|
106
|
+
logger.warning(f'`?search={value} does not work well while using `PantherDB` as Database')
|
107
|
+
return {field: value for field in self.search_fields}
|
108
|
+
return {}
|
109
|
+
|
110
|
+
def process_sort(self, query_params: dict) -> list:
|
111
|
+
if hasattr(self, 'sort_fields') and 'sort' in query_params:
|
112
|
+
return [
|
113
|
+
(field, -1 if param[0] == '-' else 1)
|
114
|
+
for field in self.sort_fields for param in query_params['sort'].split(',')
|
115
|
+
if field == param.removeprefix('-')
|
116
|
+
]
|
117
|
+
|
118
|
+
def process_pagination(self, query_params: dict, cursor: Cursor | PantherDBCursor) -> Pagination | None:
|
119
|
+
if hasattr(self, 'pagination'):
|
120
|
+
return self.pagination(query_params=query_params, cursor=cursor)
|
121
|
+
|
122
|
+
|
123
|
+
class CreateAPI(GenericAPI):
|
124
|
+
input_model: type[ModelSerializer]
|
125
|
+
|
126
|
+
async def post(self, request: Request, **kwargs):
|
127
|
+
instance = await request.validated_data.create(
|
128
|
+
validated_data=request.validated_data.model_dump()
|
129
|
+
)
|
130
|
+
return Response(data=instance, status_code=status.HTTP_201_CREATED)
|
131
|
+
|
132
|
+
|
133
|
+
class UpdateAPI(GenericAPI, ObjectRequired):
|
134
|
+
input_model: type[ModelSerializer]
|
135
|
+
|
136
|
+
async def put(self, request: Request, **kwargs):
|
137
|
+
instance = await self.object(request=request, **kwargs)
|
138
|
+
self._check_object(instance)
|
139
|
+
|
140
|
+
await request.validated_data.update(
|
141
|
+
instance=instance,
|
142
|
+
validated_data=request.validated_data.model_dump()
|
143
|
+
)
|
144
|
+
return Response(data=instance, status_code=status.HTTP_200_OK)
|
145
|
+
|
146
|
+
async def patch(self, request: Request, **kwargs):
|
147
|
+
instance = await self.object(request=request, **kwargs)
|
148
|
+
self._check_object(instance)
|
149
|
+
|
150
|
+
await request.validated_data.partial_update(
|
151
|
+
instance=instance,
|
152
|
+
validated_data=request.validated_data.model_dump(exclude_none=True)
|
153
|
+
)
|
154
|
+
return Response(data=instance, status_code=status.HTTP_200_OK)
|
155
|
+
|
156
|
+
|
157
|
+
class DeleteAPI(GenericAPI, ObjectRequired):
|
158
|
+
async def delete(self, request: Request, **kwargs):
|
159
|
+
instance = await self.object(request=request, **kwargs)
|
160
|
+
self._check_object(instance)
|
161
|
+
|
162
|
+
await instance.delete()
|
163
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
panther/logging.py
CHANGED
@@ -2,7 +2,7 @@ import logging
|
|
2
2
|
from pathlib import Path
|
3
3
|
from panther.configs import config
|
4
4
|
|
5
|
-
LOGS_DIR = config
|
5
|
+
LOGS_DIR = config.BASE_DIR / 'logs'
|
6
6
|
|
7
7
|
|
8
8
|
class FileHandler(logging.FileHandler):
|
@@ -63,6 +63,11 @@ LOGGING = {
|
|
63
63
|
'query': {
|
64
64
|
'handlers': ['default', 'query_file'],
|
65
65
|
'level': 'DEBUG',
|
66
|
-
}
|
66
|
+
},
|
67
|
+
'uvicorn.error': {
|
68
|
+
'handlers': ['default'],
|
69
|
+
'level': 'WARNING',
|
70
|
+
'propagate': False,
|
71
|
+
},
|
67
72
|
}
|
68
73
|
}
|