panther 3.9.0__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 +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 +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 +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.0.dist-info}/METADATA +93 -71
- panther-4.0.0.dist-info/RECORD +57 -0
- {panther-3.9.0.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.9.0.dist-info/RECORD +0 -54
- {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/LICENSE +0 -0
- {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/entry_points.txt +0 -0
- {panther-3.9.0.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
|
-
|
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
|
-
await
|
137
|
-
await
|
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(
|
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
|
-
|
157
|
-
return await temp_connection.close(
|
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
|
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.
|
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
|
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()
|
115
|
+
# Call Middlewares .before()
|
116
|
+
await self._run_ws_middlewares_before_listen(connection=connection, middlewares=middlewares)
|
179
117
|
|
180
|
-
#
|
181
|
-
await
|
118
|
+
# Listen The Connection
|
119
|
+
await config.WEBSOCKET_CONNECTIONS.listen(connection=connection)
|
182
120
|
|
183
|
-
#
|
184
|
-
|
185
|
-
|
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)
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
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
|
200
|
-
with contextlib.suppress(
|
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
|
-
|
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
|
-
|
175
|
+
request.collect_path_variables(found_path=found_path)
|
228
176
|
|
229
|
-
|
230
|
-
|
231
|
-
|
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
|
188
|
+
response = await endpoint(request=request)
|
236
189
|
|
237
|
-
except
|
190
|
+
except APIError as e:
|
238
191
|
response = self._handle_exceptions(e)
|
239
192
|
|
240
193
|
except Exception as e: # noqa: BLE001
|
241
|
-
#
|
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,
|
197
|
+
return await self._raise(send, monitoring=monitoring)
|
245
198
|
|
246
|
-
# Call
|
247
|
-
|
199
|
+
# Call Middlewares .after()
|
200
|
+
middlewares.reverse()
|
201
|
+
for middleware in middlewares:
|
248
202
|
try:
|
249
203
|
response = await middleware.after(response=response)
|
250
|
-
|
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
|
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
|
-
|
214
|
+
Event.run_shutdowns()
|
292
215
|
|
293
216
|
@classmethod
|
294
|
-
def _handle_exceptions(cls, e:
|
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
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
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
|
+
|
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''
|