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/main.py CHANGED
@@ -5,136 +5,76 @@ import sys
5
5
  import types
6
6
  from collections.abc import Callable
7
7
  from logging.config import dictConfig
8
- from multiprocessing import Manager
9
8
  from pathlib import Path
10
- from threading import Thread
9
+
10
+ import orjson as json
11
11
 
12
12
  import panther.logging
13
13
  from panther import status
14
14
  from panther._load_configs import *
15
- from panther._utils import clean_traceback_message, http_response, is_function_async, reformat_code, \
16
- check_class_type_endpoint, check_function_type_endpoint
17
- from panther.background_tasks import background_tasks
15
+ from panther._utils import clean_traceback_message, reformat_code, check_class_type_endpoint, check_function_type_endpoint
18
16
  from panther.cli.utils import print_info
19
17
  from panther.configs import config
20
- from panther.exceptions import APIException, PantherException
18
+ from panther.events import Event
19
+ from panther.exceptions import APIError, PantherError
21
20
  from panther.monitoring import Monitoring
22
21
  from panther.request import Request
23
22
  from panther.response import Response
24
- from panther.routings import collect_path_variables, find_endpoint
23
+ from panther.routings import find_endpoint
25
24
 
26
25
  dictConfig(panther.logging.LOGGING)
27
26
  logger = logging.getLogger('panther')
28
27
 
29
28
 
30
29
  class Panther:
31
- def __init__(self, name: str, configs=None, urls: dict | None = None, startup: Callable = None, shutdown: Callable = None):
30
+ def __init__(self, name: str, configs: str | None = None, urls: dict | None = None):
32
31
  self._configs_module_name = configs
33
32
  self._urls = urls
34
- self._startup = startup
35
- self._shutdown = shutdown
36
33
 
37
- config['base_dir'] = Path(name).resolve().parent
34
+ config.BASE_DIR = Path(name).resolve().parent
38
35
 
39
36
  try:
40
37
  self.load_configs()
41
- if config['auto_reformat']:
42
- reformat_code(base_dir=config['base_dir'])
38
+ if config.AUTO_REFORMAT:
39
+ reformat_code(base_dir=config.BASE_DIR)
43
40
  except Exception as e: # noqa: BLE001
44
- if isinstance(e, PantherException):
45
- logger.error(e.args[0])
46
- else:
47
- logger.error(clean_traceback_message(e))
41
+ logger.error(e.args[0] if isinstance(e, PantherError) else clean_traceback_message(e))
48
42
  sys.exit()
49
43
 
50
- # Monitoring
51
- self.monitoring = Monitoring(is_active=config['monitoring'])
52
-
53
44
  # Print Info
54
45
  print_info(config)
55
46
 
56
47
  def load_configs(self) -> None:
57
-
58
48
  # Check & Read The Configs File
59
49
  self._configs_module = load_configs_module(self._configs_module_name)
60
50
 
61
- # Put Variables In "config" (Careful about the ordering)
62
- config['secret_key'] = load_secret_key(self._configs_module)
63
- config['monitoring'] = load_monitoring(self._configs_module)
64
- config['log_queries'] = load_log_queries(self._configs_module)
65
- config['background_tasks'] = load_background_tasks(self._configs_module)
66
- config['throttling'] = load_throttling(self._configs_module)
67
- config['default_cache_exp'] = load_default_cache_exp(self._configs_module)
68
- config['pantherdb_encryption'] = load_pantherdb_encryption(self._configs_module)
69
- middlewares = load_middlewares(self._configs_module)
70
- config['http_middlewares'] = middlewares['http']
71
- config['ws_middlewares'] = middlewares['ws']
72
- config['reversed_http_middlewares'] = middlewares['http'][::-1]
73
- config['reversed_ws_middlewares'] = middlewares['ws'][::-1]
74
- config['user_model'] = load_user_model(self._configs_module)
75
- config['authentication'] = load_authentication_class(self._configs_module)
76
- config['jwt_config'] = load_jwt_config(self._configs_module)
77
- config['startup'] = load_startup(self._configs_module)
78
- config['shutdown'] = load_shutdown(self._configs_module)
79
- config['auto_reformat'] = load_auto_reformat(self._configs_module)
80
- config['models'] = collect_all_models()
81
-
82
- # Initialize Background Tasks
83
- if config['background_tasks']:
84
- background_tasks.initialize()
85
-
86
- # Load URLs should be one of the last calls in load_configs,
87
- # because it will read all files and loads them.
88
- config['flat_urls'], config['urls'] = load_urls(self._configs_module, urls=self._urls)
89
- config['urls']['_panel'] = load_panel_urls()
90
-
91
- self._create_ws_connections_instance()
92
-
93
- def _create_ws_connections_instance(self):
94
- from panther.base_websocket import Websocket, WebsocketConnections
95
-
96
- # Check do we have ws endpoint
97
- for endpoint in config['flat_urls'].values():
98
- if not isinstance(endpoint, types.FunctionType) and issubclass(endpoint, Websocket):
99
- config['has_ws'] = True
100
- break
101
- else:
102
- config['has_ws'] = False
103
-
104
- # Create websocket connections instance
105
- if config['has_ws']:
106
- # Websocket Redis Connection
107
- for middleware in config['http_middlewares']:
108
- if middleware.__class__.__name__ == 'RedisMiddleware':
109
- self.ws_redis_connection = middleware.redis_connection_for_ws()
110
- break
111
- else:
112
- self.ws_redis_connection = None
113
-
114
- # Don't create Manager() if we are going to use Redis for PubSub
115
- manager = None if self.ws_redis_connection else Manager()
116
- config['websocket_connections'] = WebsocketConnections(manager=manager)
51
+ load_redis(self._configs_module)
52
+ load_startup(self._configs_module)
53
+ load_shutdown(self._configs_module)
54
+ load_timezone(self._configs_module)
55
+ load_database(self._configs_module)
56
+ load_secret_key(self._configs_module)
57
+ load_monitoring(self._configs_module)
58
+ load_throttling(self._configs_module)
59
+ load_user_model(self._configs_module)
60
+ load_log_queries(self._configs_module)
61
+ load_middlewares(self._configs_module)
62
+ load_auto_reformat(self._configs_module)
63
+ load_background_tasks(self._configs_module)
64
+ load_default_cache_exp(self._configs_module)
65
+ load_authentication_class(self._configs_module)
66
+ load_urls(self._configs_module, urls=self._urls)
67
+ load_websocket_connections()
117
68
 
118
69
  async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
119
- """
120
- 1.
121
- await func(scope, receive, send)
122
- 2.
123
- async with asyncio.TaskGroup() as tg:
124
- tg.create_task(func(scope, receive, send))
125
- 3.
126
- async with anyio.create_task_group() as task_group:
127
- task_group.start_soon(func, scope, receive, send)
128
- await anyio.to_thread.run_sync(func, scope, receive, send)
129
- 4.
130
- with ProcessPoolExecutor() as e:
131
- e.submit(func, scope, receive, send)
132
- """
133
70
  if scope['type'] == 'lifespan':
134
71
  message = await receive()
135
- if message["type"] == "lifespan.startup":
136
- await self.handle_ws_listener()
137
- await self.handle_startup()
72
+ if message["type"] == 'lifespan.startup':
73
+ await config.WEBSOCKET_CONNECTIONS.start()
74
+ await Event.run_startups()
75
+ elif message["type"] == 'lifespan.shutdown':
76
+ # It's not happening :\, so handle the shutdowns in __del__ ...
77
+ pass
138
78
  return
139
79
 
140
80
  func = self.handle_http if scope['type'] == 'http' else self.handle_ws
@@ -144,67 +84,75 @@ class Panther:
144
84
  from panther.websocket import GenericWebsocket, Websocket
145
85
 
146
86
  # Monitoring
147
- monitoring = Monitoring(is_active=config['monitoring'], is_ws=True)
87
+ monitoring = Monitoring(is_ws=True)
148
88
 
149
89
  # Create Temp Connection
150
90
  temp_connection = Websocket(scope=scope, receive=receive, send=send)
151
91
  await monitoring.before(request=temp_connection)
92
+ temp_connection._monitoring = monitoring
152
93
 
153
94
  # Find Endpoint
154
95
  endpoint, found_path = find_endpoint(path=temp_connection.path)
155
96
  if endpoint is None:
156
- await monitoring.after('Rejected')
157
- return await temp_connection.close(status.WS_1000_NORMAL_CLOSURE)
97
+ logger.debug(f'Path `{temp_connection.path}` not found')
98
+ return await temp_connection.close()
158
99
 
159
100
  # Check Endpoint Type
160
101
  if not issubclass(endpoint, GenericWebsocket):
161
- logger.critical(f'You may have forgotten to inherit from GenericWebsocket on the {endpoint.__name__}()')
162
- await monitoring.after('Rejected')
163
- return await temp_connection.close(status.WS_1014_BAD_GATEWAY)
164
-
165
- # Collect Path Variables
166
- path_variables: dict = collect_path_variables(request_path=temp_connection.path, found_path=found_path)
102
+ logger.critical(f'You may have forgotten to inherit from `GenericWebsocket` on the `{endpoint.__name__}()`')
103
+ return await temp_connection.close()
167
104
 
168
105
  # Create The Connection
169
106
  del temp_connection
170
107
  connection = endpoint(scope=scope, receive=receive, send=send)
171
- connection.set_path_variables(path_variables=path_variables)
108
+ connection._monitoring = monitoring
109
+
110
+ # Collect Path Variables
111
+ connection.collect_path_variables(found_path=found_path)
112
+
113
+ middlewares = [middleware(**data) for middleware, data in config.WS_MIDDLEWARES]
172
114
 
173
- # Call 'Before' Middlewares
174
- if await self._run_ws_middlewares_before_listen(connection=connection):
175
- # Only Listen() If Middlewares Didn't Raise Anything
176
- await config['websocket_connections'].new_connection(connection=connection)
177
- await monitoring.after('Accepted')
178
- await connection.listen()
115
+ # Call Middlewares .before()
116
+ await self._run_ws_middlewares_before_listen(connection=connection, middlewares=middlewares)
179
117
 
180
- # Call 'After' Middlewares
181
- await self._run_ws_middlewares_after_listen(connection=connection)
118
+ # Listen The Connection
119
+ await config.WEBSOCKET_CONNECTIONS.listen(connection=connection)
182
120
 
183
- # Done
184
- await monitoring.after('Closed')
185
- return None
121
+ # Call Middlewares .after()
122
+ middlewares.reverse()
123
+ await self._run_ws_middlewares_after_listen(connection=connection, middlewares=middlewares)
186
124
 
187
125
  @classmethod
188
- async def _run_ws_middlewares_before_listen(cls, *, connection) -> bool:
189
- for middleware in config['ws_middlewares']:
190
- try:
191
- connection = await middleware.before(request=connection)
192
- except APIException:
193
- await connection.close()
194
- return False
195
- return True
126
+ async def _run_ws_middlewares_before_listen(cls, *, connection, middlewares):
127
+ try:
128
+ for middleware in middlewares:
129
+ new_connection = await middleware.before(request=connection)
130
+ if new_connection is None:
131
+ logger.critical(
132
+ f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
133
+ await connection.close()
134
+ connection = new_connection
135
+ except APIError as e:
136
+ connection.log(e.detail)
137
+ await connection.close()
196
138
 
197
139
  @classmethod
198
- async def _run_ws_middlewares_after_listen(cls, *, connection):
199
- for middleware in config['reversed_ws_middlewares']:
200
- with contextlib.suppress(APIException):
201
- await middleware.after(response=connection)
140
+ async def _run_ws_middlewares_after_listen(cls, *, connection, middlewares):
141
+ for middleware in middlewares:
142
+ with contextlib.suppress(APIError):
143
+ connection = await middleware.after(response=connection)
144
+ if connection is None:
145
+ logger.critical(
146
+ f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
147
+ break
202
148
 
203
149
  async def handle_http(self, scope: dict, receive: Callable, send: Callable) -> None:
150
+ # Monitoring
151
+ monitoring = Monitoring()
152
+
204
153
  request = Request(scope=scope, receive=receive, send=send)
205
154
 
206
- # Monitoring
207
- await self.monitoring.before(request=request)
155
+ await monitoring.before(request=request)
208
156
 
209
157
  # Read Request Payload
210
158
  await request.read_body()
@@ -212,7 +160,7 @@ class Panther:
212
160
  # Find Endpoint
213
161
  _endpoint, found_path = find_endpoint(path=request.path)
214
162
  if _endpoint is None:
215
- return await self._raise(send, status_code=status.HTTP_404_NOT_FOUND)
163
+ return await self._raise(send, monitoring=monitoring, status_code=status.HTTP_404_NOT_FOUND)
216
164
 
217
165
  # Check Endpoint Type
218
166
  try:
@@ -221,87 +169,62 @@ class Panther:
221
169
  else:
222
170
  endpoint = check_class_type_endpoint(endpoint=_endpoint)
223
171
  except TypeError:
224
- return await self._raise(send, status_code=status.HTTP_501_NOT_IMPLEMENTED)
172
+ return await self._raise(send, monitoring=monitoring, status_code=status.HTTP_501_NOT_IMPLEMENTED)
225
173
 
226
174
  # Collect Path Variables
227
- path_variables: dict = collect_path_variables(request_path=request.path, found_path=found_path)
175
+ request.collect_path_variables(found_path=found_path)
228
176
 
229
- try: # They Both(middleware.before() & _endpoint()) Have The Same Exception (APIException)
230
- # Call 'Before' Middlewares
231
- for middleware in config['http_middlewares']:
177
+ middlewares = [middleware(**data) for middleware, data in config.HTTP_MIDDLEWARES]
178
+ try: # They Both(middleware.before() & _endpoint()) Have The Same Exception (APIError)
179
+ # Call Middlewares .before()
180
+ for middleware in middlewares:
232
181
  request = await middleware.before(request=request)
182
+ if request is None:
183
+ logger.critical(
184
+ f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
185
+ return await self._raise(send, monitoring=monitoring)
233
186
 
234
187
  # Call Endpoint
235
- response = await endpoint(request=request, **path_variables)
188
+ response = await endpoint(request=request)
236
189
 
237
- except APIException as e:
190
+ except APIError as e:
238
191
  response = self._handle_exceptions(e)
239
192
 
240
193
  except Exception as e: # noqa: BLE001
241
- # Every unhandled exception in Panther or code will catch here
194
+ # All unhandled exceptions are caught here
242
195
  exception = clean_traceback_message(exception=e)
243
196
  logger.critical(exception)
244
- return await self._raise(send, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
197
+ return await self._raise(send, monitoring=monitoring)
245
198
 
246
- # Call 'After' Middleware
247
- for middleware in config['reversed_http_middlewares']:
199
+ # Call Middlewares .after()
200
+ middlewares.reverse()
201
+ for middleware in middlewares:
248
202
  try:
249
203
  response = await middleware.after(response=response)
250
- except APIException as e: # noqa: PERF203
204
+ if response is None:
205
+ logger.critical(
206
+ f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
207
+ return await self._raise(send, monitoring=monitoring)
208
+ except APIError as e: # noqa: PERF203
251
209
  response = self._handle_exceptions(e)
252
210
 
253
- await http_response(
254
- send,
255
- status_code=response.status_code,
256
- monitoring=self.monitoring,
257
- headers=response.headers,
258
- body=response.body,
259
- )
260
-
261
- async def handle_ws_listener(self):
262
- # Start Websocket Listener (Redis/ Queue)
263
- if config['has_ws']:
264
- Thread(
265
- target=config['websocket_connections'],
266
- daemon=True,
267
- args=(self.ws_redis_connection,),
268
- ).start()
269
-
270
- async def handle_startup(self):
271
- if startup := config['startup'] or self._startup:
272
- if is_function_async(startup):
273
- await startup()
274
- else:
275
- startup()
276
-
277
- def handle_shutdown(self):
278
- if shutdown := config['shutdown'] or self._shutdown:
279
- if is_function_async(shutdown):
280
- try:
281
- asyncio.run(shutdown())
282
- except ModuleNotFoundError:
283
- # Error: import of asyncio halted; None in sys.modules
284
- # And as I figured it out, it only happens when we running with
285
- # gunicorn and Uvicorn workers (-k uvicorn.workers.UvicornWorker)
286
- pass
287
- else:
288
- shutdown()
211
+ await response.send(send, receive, monitoring=monitoring)
289
212
 
290
213
  def __del__(self):
291
- self.handle_shutdown()
214
+ Event.run_shutdowns()
292
215
 
293
216
  @classmethod
294
- def _handle_exceptions(cls, e: APIException, /) -> Response:
217
+ def _handle_exceptions(cls, e: APIError, /) -> Response:
295
218
  return Response(
296
219
  data=e.detail if isinstance(e.detail, dict) else {'detail': e.detail},
297
220
  status_code=e.status_code,
298
221
  )
299
222
 
300
- async def _raise(self, send, *, status_code: int):
301
- await http_response(
302
- send,
303
- headers={'content-type': 'application/json'},
304
- status_code=status_code,
305
- monitoring=self.monitoring,
306
- exception=True,
307
- )
223
+ @classmethod
224
+ async def _raise(cls, send, *, monitoring, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
225
+ headers = [[b'Content-Type', b'application/json']]
226
+ body = json.dumps({'detail': status.status_text[status_code]})
227
+ await monitoring.after(status_code)
228
+ await send({'type': 'http.response.start', 'status': status_code, 'headers': headers})
229
+ await send({'type': 'http.response.body', 'body': body, 'more_body': False})
230
+
@@ -4,6 +4,7 @@ from panther.websocket import GenericWebsocket
4
4
 
5
5
 
6
6
  class BaseMiddleware:
7
+ """Used in both http & ws requests"""
7
8
  async def before(self, request: Request | GenericWebsocket):
8
9
  raise NotImplementedError
9
10
 
@@ -12,6 +13,7 @@ class BaseMiddleware:
12
13
 
13
14
 
14
15
  class HTTPMiddleware(BaseMiddleware):
16
+ """Used only in http requests"""
15
17
  async def before(self, request: Request):
16
18
  return request
17
19
 
@@ -20,6 +22,7 @@ class HTTPMiddleware(BaseMiddleware):
20
22
 
21
23
 
22
24
  class WebsocketMiddleware(BaseMiddleware):
25
+ """Used only in ws requests"""
23
26
  async def before(self, request: GenericWebsocket):
24
27
  return request
25
28
 
panther/monitoring.py CHANGED
@@ -3,7 +3,7 @@ from time import perf_counter
3
3
  from typing import Literal
4
4
 
5
5
  from panther.base_request import BaseRequest
6
-
6
+ from panther.configs import config
7
7
 
8
8
  logger = logging.getLogger('monitoring')
9
9
 
@@ -13,12 +13,11 @@ class Monitoring:
13
13
  Create Log Message Like Below:
14
14
  date time | method | path | ip:port | response_time [ms, s] | status
15
15
  """
16
- def __init__(self, is_active: bool, is_ws: bool = False):
17
- self.is_active = is_active
16
+ def __init__(self, is_ws: bool = False):
18
17
  self.is_ws = is_ws
19
18
 
20
19
  async def before(self, request: BaseRequest):
21
- if self.is_active:
20
+ if config.MONITORING:
22
21
  ip, port = request.client
23
22
 
24
23
  if self.is_ws:
@@ -30,7 +29,7 @@ class Monitoring:
30
29
  self.start_time = perf_counter()
31
30
 
32
31
  async def after(self, status: int | Literal['Accepted', 'Rejected', 'Closed'], /):
33
- if self.is_active:
32
+ if config.MONITORING:
34
33
  response_time = perf_counter() - self.start_time
35
34
  time_unit = ' s'
36
35
 
@@ -38,4 +37,8 @@ class Monitoring:
38
37
  response_time = response_time * 1_000
39
38
  time_unit = 'ms'
40
39
 
40
+ elif response_time >= 10:
41
+ response_time = response_time / 60
42
+ time_unit = ' m'
43
+
41
44
  logger.info(f'{self.log} | {round(response_time, 4)} {time_unit} | {status}')
panther/pagination.py ADDED
@@ -0,0 +1,48 @@
1
+ from panther.db.cursor import Cursor
2
+ from pantherdb import Cursor as PantherDBCursor
3
+
4
+
5
+ class Pagination:
6
+ """
7
+ Request URL:
8
+ example.com/users?limit=10&skip=0
9
+ Response Data:
10
+ {
11
+ 'count': 10,
12
+ 'next': '?limit=10&skip=10',
13
+ 'previous': None,
14
+ results: [...]
15
+ }
16
+ """
17
+ DEFAULT_LIMIT = 20
18
+ DEFAULT_SKIP = 0
19
+
20
+ def __init__(self, query_params: dict, cursor: Cursor | PantherDBCursor):
21
+ self.limit = self.get_limit(query_params=query_params)
22
+ self.skip = self.get_skip(query_params=query_params)
23
+ self.cursor = cursor
24
+
25
+ def get_limit(self, query_params: dict) -> int:
26
+ return int(query_params.get('limit', self.DEFAULT_LIMIT))
27
+
28
+ def get_skip(self, query_params: dict) -> int:
29
+ return int(query_params.get('skip', self.DEFAULT_SKIP))
30
+
31
+ def build_next_params(self):
32
+ next_skip = self.skip + self.limit
33
+ return f'?limit={self.limit}&skip={next_skip}'
34
+
35
+ def build_previous_params(self):
36
+ previous_skip = max(self.skip - self.limit, 0)
37
+ return f'?limit={self.limit}&skip={previous_skip}'
38
+
39
+ async def paginate(self):
40
+ count = await self.cursor.cls.count(self.cursor.filter)
41
+ has_next = not bool(self.limit + self.skip >= count)
42
+
43
+ return {
44
+ 'count': count,
45
+ 'next': self.build_next_params() if has_next else None,
46
+ 'previous': self.build_previous_params() if self.skip else None,
47
+ 'results': self.cursor.skip(skip=self.skip).limit(limit=self.limit)
48
+ }
panther/panel/apis.py CHANGED
@@ -1,23 +1,31 @@
1
+ import contextlib
2
+
1
3
  from panther import status
2
4
  from panther.app import API
3
5
  from panther.configs import config
6
+ from panther.db.connections import db
7
+ from panther.db.connections import redis
4
8
  from panther.panel.utils import get_model_fields
5
9
  from panther.request import Request
6
10
  from panther.response import Response
7
11
 
12
+ with contextlib.suppress(ImportError):
13
+ import pymongo
14
+ from pymongo.errors import PyMongoError
15
+
8
16
 
9
17
  @API(methods=['GET'])
10
18
  async def models_api():
11
19
  return [{
12
- 'name': m['name'],
13
- 'module': m['module'],
20
+ 'name': model.__name__,
21
+ 'module': model.__module__,
14
22
  'index': i
15
- } for i, m in enumerate(config['models'])]
23
+ } for i, model in enumerate(config.MODELS)]
16
24
 
17
25
 
18
26
  @API(methods=['GET', 'POST'])
19
27
  async def documents_api(request: Request, index: int):
20
- model = config['models'][index]['class']
28
+ model = config.MODELS[index]
21
29
 
22
30
  if request.method == 'POST':
23
31
  validated_data = API.validate_input(model=model, request=request)
@@ -37,7 +45,7 @@ async def documents_api(request: Request, index: int):
37
45
 
38
46
  @API(methods=['PUT', 'DELETE', 'GET'])
39
47
  async def single_document_api(request: Request, index: int, document_id: int | str):
40
- model = config['models'][index]['class']
48
+ model = config.MODELS[index]
41
49
 
42
50
  if document := model.find_one(id=document_id):
43
51
  if request.method == 'PUT':
@@ -54,3 +62,22 @@ async def single_document_api(request: Request, index: int, document_id: int | s
54
62
 
55
63
  else:
56
64
  return Response(status_code=status.HTTP_404_NOT_FOUND)
65
+
66
+
67
+ @API()
68
+ async def healthcheck_api():
69
+ checks = []
70
+
71
+ # Database
72
+ if config.QUERY_ENGINE.__name__ == 'BaseMongoDBQuery':
73
+ with pymongo.timeout(3):
74
+ try:
75
+ ping = db.session.command('ping').get('ok') == 1.0
76
+ checks.append(ping)
77
+ except PyMongoError:
78
+ checks.append(False)
79
+ # Redis
80
+ if redis.is_connected:
81
+ checks.append(await redis.ping())
82
+
83
+ return Response(all(checks))
panther/panel/urls.py CHANGED
@@ -1,7 +1,8 @@
1
- from panther.panel.apis import documents_api, models_api, single_document_api
1
+ from panther.panel.apis import documents_api, models_api, single_document_api, healthcheck_api
2
2
 
3
3
  urls = {
4
4
  '': models_api,
5
5
  '<index>/': documents_api,
6
6
  '<index>/<document_id>/': single_document_api,
7
+ 'health': healthcheck_api,
7
8
  }
panther/permissions.py CHANGED
@@ -3,11 +3,11 @@ from panther.request import Request
3
3
 
4
4
  class BasePermission:
5
5
  @classmethod
6
- def authorization(cls, request: Request) -> bool:
6
+ async def authorization(cls, request: Request) -> bool:
7
7
  return True
8
8
 
9
9
 
10
10
  class AdminPermission(BasePermission):
11
11
  @classmethod
12
- def authorization(cls, request: Request) -> bool:
13
- return request.user and hasattr(request.user, 'is_admin') and request.user.is_admin
12
+ async def authorization(cls, request: Request) -> bool:
13
+ return request.user and getattr(request.user, 'is_admin', False)
panther/request.py CHANGED
@@ -1,16 +1,20 @@
1
1
  import logging
2
- from typing import Literal
2
+ from typing import Literal, Callable
3
3
 
4
4
  import orjson as json
5
5
 
6
6
  from panther._utils import read_multipart_form_data
7
7
  from panther.base_request import BaseRequest
8
8
 
9
-
10
9
  logger = logging.getLogger('panther')
11
10
 
12
11
 
13
12
  class Request(BaseRequest):
13
+ def __init__(self, scope: dict, receive: Callable, send: Callable):
14
+ self._data = ...
15
+ self.validated_data = None # It's been set in API.validate_input()
16
+ super().__init__(scope=scope, receive=receive, send=send)
17
+
14
18
  @property
15
19
  def method(self) -> Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']:
16
20
  return self.scope['method']
@@ -30,17 +34,6 @@ class Request(BaseRequest):
30
34
  self._data = self.__body
31
35
  return self._data
32
36
 
33
- def set_validated_data(self, validated_data) -> None:
34
- self._validated_data = validated_data
35
-
36
- @property
37
- def validated_data(self):
38
- """
39
- Return The Validated Data
40
- It has been set on API.validate_input() while request is happening.
41
- """
42
- return getattr(self, '_validated_data', None)
43
-
44
37
  async def read_body(self) -> None:
45
38
  """Read the entire body from an incoming ASGI message."""
46
39
  self.__body = b''