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.
Files changed (52) hide show
  1. panther/__init__.py +1 -1
  2. panther/_load_configs.py +168 -171
  3. panther/_utils.py +26 -49
  4. panther/app.py +85 -105
  5. panther/authentications.py +86 -55
  6. panther/background_tasks.py +25 -14
  7. panther/base_request.py +38 -14
  8. panther/base_websocket.py +172 -94
  9. panther/caching.py +60 -25
  10. panther/cli/create_command.py +20 -10
  11. panther/cli/monitor_command.py +63 -37
  12. panther/cli/template.py +40 -20
  13. panther/cli/utils.py +32 -18
  14. panther/configs.py +65 -58
  15. panther/db/connections.py +139 -0
  16. panther/db/cursor.py +43 -0
  17. panther/db/models.py +64 -29
  18. panther/db/queries/__init__.py +1 -1
  19. panther/db/queries/base_queries.py +127 -0
  20. panther/db/queries/mongodb_queries.py +77 -38
  21. panther/db/queries/pantherdb_queries.py +59 -30
  22. panther/db/queries/queries.py +232 -117
  23. panther/db/utils.py +17 -18
  24. panther/events.py +44 -0
  25. panther/exceptions.py +26 -12
  26. panther/file_handler.py +2 -2
  27. panther/generics.py +163 -0
  28. panther/logging.py +7 -2
  29. panther/main.py +111 -188
  30. panther/middlewares/base.py +3 -0
  31. panther/monitoring.py +8 -5
  32. panther/pagination.py +48 -0
  33. panther/panel/apis.py +32 -5
  34. panther/panel/urls.py +2 -1
  35. panther/permissions.py +3 -3
  36. panther/request.py +6 -13
  37. panther/response.py +114 -34
  38. panther/routings.py +83 -66
  39. panther/serializer.py +214 -33
  40. panther/test.py +31 -21
  41. panther/utils.py +28 -16
  42. panther/websocket.py +7 -4
  43. {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/METADATA +93 -71
  44. panther-4.0.0.dist-info/RECORD +57 -0
  45. {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/WHEEL +1 -1
  46. panther/db/connection.py +0 -92
  47. panther/middlewares/db.py +0 -18
  48. panther/middlewares/redis.py +0 -47
  49. panther-3.8.2.dist-info/RECORD +0 -54
  50. {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/LICENSE +0 -0
  51. {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/entry_points.txt +0 -0
  52. {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['log_queries'] is False:
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 = args[0].__name__ if hasattr(args[0], '__name__') else args[0].__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['query_engine'] is None:
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 int
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: bson.ObjectId | str) -> bson.ObjectId:
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 PantherException(Exception):
4
+ class PantherError(Exception):
5
5
  pass
6
6
 
7
7
 
8
- class DBException(Exception):
8
+ class DatabaseError(Exception):
9
9
  pass
10
10
 
11
11
 
12
- class APIException(Exception):
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__(self, detail=None, status_code=None):
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 MethodNotAllowed(APIException):
22
- detail = 'Method Not Allowed'
23
- status_code = status.HTTP_405_METHOD_NOT_ALLOWED
25
+ class BadRequestAPIError(APIError):
26
+ detail = 'Bad Request'
27
+ status_code = status.HTTP_400_BAD_REQUEST
24
28
 
25
29
 
26
- class AuthenticationException(APIException):
30
+ class AuthenticationAPIError(APIError):
27
31
  detail = 'Authentication Error'
28
32
  status_code = status.HTTP_401_UNAUTHORIZED
29
33
 
30
34
 
31
- class AuthorizationException(APIException):
35
+ class AuthorizationAPIError(APIError):
32
36
  detail = 'Permission Denied'
33
37
  status_code = status.HTTP_403_FORBIDDEN
34
38
 
35
39
 
36
- class JsonDecodeException(APIException):
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 ThrottlingException(APIException):
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 InvalidPathVariableException(APIException):
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 APIException
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 APIException(detail=msg, status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE)
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['base_dir'] / 'logs'
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
  }