panther 3.9.0__py3-none-any.whl → 4.0.1__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 +171 -94
- panther/caching.py +60 -25
- panther/cli/create_command.py +20 -10
- panther/cli/monitor_command.py +63 -37
- panther/cli/template.py +38 -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 +112 -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 +131 -25
- panther/test.py +31 -21
- panther/utils.py +28 -16
- panther/websocket.py +7 -4
- {panther-3.9.0.dist-info → panther-4.0.1.dist-info}/METADATA +93 -71
- panther-4.0.1.dist-info/RECORD +57 -0
- {panther-3.9.0.dist-info → panther-4.0.1.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.9.0.dist-info/RECORD +0 -54
- {panther-3.9.0.dist-info → panther-4.0.1.dist-info}/LICENSE +0 -0
- {panther-3.9.0.dist-info → panther-4.0.1.dist-info}/entry_points.txt +0 -0
- {panther-3.9.0.dist-info → panther-4.0.1.dist-info}/top_level.txt +0 -0
panther/main.py
CHANGED
@@ -5,136 +5,77 @@ 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
|
-
|
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,
|
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.
|
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
|
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
|
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
|
34
|
+
config.BASE_DIR = Path(name).resolve().parent
|
38
35
|
|
39
36
|
try:
|
40
37
|
self.load_configs()
|
41
|
-
if config
|
42
|
-
reformat_code(base_dir=config
|
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,
|
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
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
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"] ==
|
136
|
-
|
137
|
-
|
72
|
+
if message["type"] == 'lifespan.startup':
|
73
|
+
if config.HAS_WS:
|
74
|
+
await config.WEBSOCKET_CONNECTIONS.start()
|
75
|
+
await Event.run_startups()
|
76
|
+
elif message["type"] == 'lifespan.shutdown':
|
77
|
+
# It's not happening :\, so handle the shutdowns in __del__ ...
|
78
|
+
pass
|
138
79
|
return
|
139
80
|
|
140
81
|
func = self.handle_http if scope['type'] == 'http' else self.handle_ws
|
@@ -144,67 +85,75 @@ class Panther:
|
|
144
85
|
from panther.websocket import GenericWebsocket, Websocket
|
145
86
|
|
146
87
|
# Monitoring
|
147
|
-
monitoring = Monitoring(
|
88
|
+
monitoring = Monitoring(is_ws=True)
|
148
89
|
|
149
90
|
# Create Temp Connection
|
150
91
|
temp_connection = Websocket(scope=scope, receive=receive, send=send)
|
151
92
|
await monitoring.before(request=temp_connection)
|
93
|
+
temp_connection._monitoring = monitoring
|
152
94
|
|
153
95
|
# Find Endpoint
|
154
96
|
endpoint, found_path = find_endpoint(path=temp_connection.path)
|
155
97
|
if endpoint is None:
|
156
|
-
|
157
|
-
return await temp_connection.close(
|
98
|
+
logger.debug(f'Path `{temp_connection.path}` not found')
|
99
|
+
return await temp_connection.close()
|
158
100
|
|
159
101
|
# Check Endpoint Type
|
160
102
|
if not issubclass(endpoint, GenericWebsocket):
|
161
|
-
logger.critical(f'You may have forgotten to inherit from GenericWebsocket on the {endpoint.__name__}()')
|
162
|
-
await
|
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)
|
103
|
+
logger.critical(f'You may have forgotten to inherit from `GenericWebsocket` on the `{endpoint.__name__}()`')
|
104
|
+
return await temp_connection.close()
|
167
105
|
|
168
106
|
# Create The Connection
|
169
107
|
del temp_connection
|
170
108
|
connection = endpoint(scope=scope, receive=receive, send=send)
|
171
|
-
connection.
|
109
|
+
connection._monitoring = monitoring
|
110
|
+
|
111
|
+
# Collect Path Variables
|
112
|
+
connection.collect_path_variables(found_path=found_path)
|
113
|
+
|
114
|
+
middlewares = [middleware(**data) for middleware, data in config.WS_MIDDLEWARES]
|
172
115
|
|
173
|
-
# Call
|
174
|
-
|
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()
|
116
|
+
# Call Middlewares .before()
|
117
|
+
await self._run_ws_middlewares_before_listen(connection=connection, middlewares=middlewares)
|
179
118
|
|
180
|
-
#
|
181
|
-
await
|
119
|
+
# Listen The Connection
|
120
|
+
await config.WEBSOCKET_CONNECTIONS.listen(connection=connection)
|
182
121
|
|
183
|
-
#
|
184
|
-
|
185
|
-
|
122
|
+
# Call Middlewares .after()
|
123
|
+
middlewares.reverse()
|
124
|
+
await self._run_ws_middlewares_after_listen(connection=connection, middlewares=middlewares)
|
186
125
|
|
187
126
|
@classmethod
|
188
|
-
async def _run_ws_middlewares_before_listen(cls, *, connection)
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
127
|
+
async def _run_ws_middlewares_before_listen(cls, *, connection, middlewares):
|
128
|
+
try:
|
129
|
+
for middleware in middlewares:
|
130
|
+
new_connection = await middleware.before(request=connection)
|
131
|
+
if new_connection is None:
|
132
|
+
logger.critical(
|
133
|
+
f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
|
134
|
+
await connection.close()
|
135
|
+
connection = new_connection
|
136
|
+
except APIError as e:
|
137
|
+
connection.log(e.detail)
|
138
|
+
await connection.close()
|
196
139
|
|
197
140
|
@classmethod
|
198
|
-
async def _run_ws_middlewares_after_listen(cls, *, connection):
|
199
|
-
for middleware in
|
200
|
-
with contextlib.suppress(
|
201
|
-
await middleware.after(response=connection)
|
141
|
+
async def _run_ws_middlewares_after_listen(cls, *, connection, middlewares):
|
142
|
+
for middleware in middlewares:
|
143
|
+
with contextlib.suppress(APIError):
|
144
|
+
connection = await middleware.after(response=connection)
|
145
|
+
if connection is None:
|
146
|
+
logger.critical(
|
147
|
+
f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
|
148
|
+
break
|
202
149
|
|
203
150
|
async def handle_http(self, scope: dict, receive: Callable, send: Callable) -> None:
|
151
|
+
# Monitoring
|
152
|
+
monitoring = Monitoring()
|
153
|
+
|
204
154
|
request = Request(scope=scope, receive=receive, send=send)
|
205
155
|
|
206
|
-
|
207
|
-
await self.monitoring.before(request=request)
|
156
|
+
await monitoring.before(request=request)
|
208
157
|
|
209
158
|
# Read Request Payload
|
210
159
|
await request.read_body()
|
@@ -212,7 +161,7 @@ class Panther:
|
|
212
161
|
# Find Endpoint
|
213
162
|
_endpoint, found_path = find_endpoint(path=request.path)
|
214
163
|
if _endpoint is None:
|
215
|
-
return await self._raise(send, status_code=status.HTTP_404_NOT_FOUND)
|
164
|
+
return await self._raise(send, monitoring=monitoring, status_code=status.HTTP_404_NOT_FOUND)
|
216
165
|
|
217
166
|
# Check Endpoint Type
|
218
167
|
try:
|
@@ -221,87 +170,62 @@ class Panther:
|
|
221
170
|
else:
|
222
171
|
endpoint = check_class_type_endpoint(endpoint=_endpoint)
|
223
172
|
except TypeError:
|
224
|
-
return await self._raise(send, status_code=status.HTTP_501_NOT_IMPLEMENTED)
|
173
|
+
return await self._raise(send, monitoring=monitoring, status_code=status.HTTP_501_NOT_IMPLEMENTED)
|
225
174
|
|
226
175
|
# Collect Path Variables
|
227
|
-
|
176
|
+
request.collect_path_variables(found_path=found_path)
|
228
177
|
|
229
|
-
|
230
|
-
|
231
|
-
|
178
|
+
middlewares = [middleware(**data) for middleware, data in config.HTTP_MIDDLEWARES]
|
179
|
+
try: # They Both(middleware.before() & _endpoint()) Have The Same Exception (APIError)
|
180
|
+
# Call Middlewares .before()
|
181
|
+
for middleware in middlewares:
|
232
182
|
request = await middleware.before(request=request)
|
183
|
+
if request is None:
|
184
|
+
logger.critical(
|
185
|
+
f'Make sure to return the `request` at the end of `{middleware.__class__.__name__}.before()`')
|
186
|
+
return await self._raise(send, monitoring=monitoring)
|
233
187
|
|
234
188
|
# Call Endpoint
|
235
|
-
response = await endpoint(request=request
|
189
|
+
response = await endpoint(request=request)
|
236
190
|
|
237
|
-
except
|
191
|
+
except APIError as e:
|
238
192
|
response = self._handle_exceptions(e)
|
239
193
|
|
240
194
|
except Exception as e: # noqa: BLE001
|
241
|
-
#
|
195
|
+
# All unhandled exceptions are caught here
|
242
196
|
exception = clean_traceback_message(exception=e)
|
243
197
|
logger.critical(exception)
|
244
|
-
return await self._raise(send,
|
198
|
+
return await self._raise(send, monitoring=monitoring)
|
245
199
|
|
246
|
-
# Call
|
247
|
-
|
200
|
+
# Call Middlewares .after()
|
201
|
+
middlewares.reverse()
|
202
|
+
for middleware in middlewares:
|
248
203
|
try:
|
249
204
|
response = await middleware.after(response=response)
|
250
|
-
|
205
|
+
if response is None:
|
206
|
+
logger.critical(
|
207
|
+
f'Make sure to return the `response` at the end of `{middleware.__class__.__name__}.after()`')
|
208
|
+
return await self._raise(send, monitoring=monitoring)
|
209
|
+
except APIError as e: # noqa: PERF203
|
251
210
|
response = self._handle_exceptions(e)
|
252
211
|
|
253
|
-
await
|
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()
|
212
|
+
await response.send(send, receive, monitoring=monitoring)
|
289
213
|
|
290
214
|
def __del__(self):
|
291
|
-
|
215
|
+
Event.run_shutdowns()
|
292
216
|
|
293
217
|
@classmethod
|
294
|
-
def _handle_exceptions(cls, e:
|
218
|
+
def _handle_exceptions(cls, e: APIError, /) -> Response:
|
295
219
|
return Response(
|
296
220
|
data=e.detail if isinstance(e.detail, dict) else {'detail': e.detail},
|
297
221
|
status_code=e.status_code,
|
298
222
|
)
|
299
223
|
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
224
|
+
@classmethod
|
225
|
+
async def _raise(cls, send, *, monitoring, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
|
226
|
+
headers = [[b'Content-Type', b'application/json']]
|
227
|
+
body = json.dumps({'detail': status.status_text[status_code]})
|
228
|
+
await monitoring.after(status_code)
|
229
|
+
await send({'type': 'http.response.start', 'status': status_code, 'headers': headers})
|
230
|
+
await send({'type': 'http.response.body', 'body': body, 'more_body': False})
|
231
|
+
|
panther/middlewares/base.py
CHANGED
@@ -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,
|
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
|
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
|
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':
|
13
|
-
'module':
|
20
|
+
'name': model.__name__,
|
21
|
+
'module': model.__module__,
|
14
22
|
'index': i
|
15
|
-
} for i,
|
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[
|
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[
|
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
|
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''
|