panther 3.8.2__py3-none-any.whl → 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- panther/__init__.py +1 -1
- panther/_load_configs.py +168 -171
- panther/_utils.py +26 -49
- panther/app.py +85 -105
- panther/authentications.py +86 -55
- panther/background_tasks.py +25 -14
- panther/base_request.py +38 -14
- panther/base_websocket.py +172 -94
- panther/caching.py +60 -25
- panther/cli/create_command.py +20 -10
- panther/cli/monitor_command.py +63 -37
- panther/cli/template.py +40 -20
- panther/cli/utils.py +32 -18
- panther/configs.py +65 -58
- panther/db/connections.py +139 -0
- panther/db/cursor.py +43 -0
- panther/db/models.py +64 -29
- panther/db/queries/__init__.py +1 -1
- panther/db/queries/base_queries.py +127 -0
- panther/db/queries/mongodb_queries.py +77 -38
- panther/db/queries/pantherdb_queries.py +59 -30
- panther/db/queries/queries.py +232 -117
- panther/db/utils.py +17 -18
- panther/events.py +44 -0
- panther/exceptions.py +26 -12
- panther/file_handler.py +2 -2
- panther/generics.py +163 -0
- panther/logging.py +7 -2
- panther/main.py +111 -188
- panther/middlewares/base.py +3 -0
- panther/monitoring.py +8 -5
- panther/pagination.py +48 -0
- panther/panel/apis.py +32 -5
- panther/panel/urls.py +2 -1
- panther/permissions.py +3 -3
- panther/request.py +6 -13
- panther/response.py +114 -34
- panther/routings.py +83 -66
- panther/serializer.py +214 -33
- panther/test.py +31 -21
- panther/utils.py +28 -16
- panther/websocket.py +7 -4
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/METADATA +93 -71
- panther-4.0.0.dist-info/RECORD +57 -0
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/WHEEL +1 -1
- panther/db/connection.py +0 -92
- panther/middlewares/db.py +0 -18
- panther/middlewares/redis.py +0 -47
- panther-3.8.2.dist-info/RECORD +0 -54
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/LICENSE +0 -0
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/entry_points.txt +0 -0
- {panther-3.8.2.dist-info → panther-4.0.0.dist-info}/top_level.txt +0 -0
panther/base_request.py
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
from collections import namedtuple
|
2
2
|
from collections.abc import Callable
|
3
3
|
|
4
|
+
from panther.db import Model
|
5
|
+
from panther.exceptions import InvalidPathVariableAPIError
|
6
|
+
|
4
7
|
|
5
8
|
class Headers:
|
6
9
|
accept: str
|
@@ -31,14 +34,12 @@ class Headers:
|
|
31
34
|
def __getattr__(self, item: str):
|
32
35
|
if result := self.__pythonic_headers.get(item):
|
33
36
|
return result
|
34
|
-
|
35
|
-
return self.__headers.get(item)
|
37
|
+
return self.__headers.get(item)
|
36
38
|
|
37
39
|
def __getitem__(self, item: str):
|
38
40
|
if result := self.__headers.get(item):
|
39
41
|
return result
|
40
|
-
|
41
|
-
return self.__pythonic_headers.get(item)
|
42
|
+
return self.__pythonic_headers.get(item)
|
42
43
|
|
43
44
|
def __str__(self):
|
44
45
|
items = ', '.join(f'{k}={v}' for k, v in self.__headers.items())
|
@@ -51,7 +52,7 @@ class Headers:
|
|
51
52
|
return self.__headers
|
52
53
|
|
53
54
|
|
54
|
-
Address = namedtuple('
|
55
|
+
Address = namedtuple('Address', ['ip', 'port'])
|
55
56
|
|
56
57
|
|
57
58
|
class BaseRequest:
|
@@ -59,11 +60,10 @@ class BaseRequest:
|
|
59
60
|
self.scope = scope
|
60
61
|
self.asgi_send = send
|
61
62
|
self.asgi_receive = receive
|
62
|
-
self._data = ...
|
63
|
-
self._validated_data = None
|
64
|
-
self._user = None
|
65
63
|
self._headers: Headers | None = None
|
66
64
|
self._params: dict | None = None
|
65
|
+
self.user: Model | None = None
|
66
|
+
self.path_variables: dict | None = None
|
67
67
|
|
68
68
|
@property
|
69
69
|
def headers(self) -> Headers:
|
@@ -103,9 +103,33 @@ class BaseRequest:
|
|
103
103
|
def scheme(self) -> str:
|
104
104
|
return self.scope['scheme']
|
105
105
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
106
|
+
def collect_path_variables(self, found_path: str):
|
107
|
+
self.path_variables = {
|
108
|
+
variable.strip('< >'): value
|
109
|
+
for variable, value in zip(
|
110
|
+
found_path.strip('/').split('/'),
|
111
|
+
self.path.strip('/').split('/')
|
112
|
+
)
|
113
|
+
if variable.startswith('<')
|
114
|
+
}
|
115
|
+
|
116
|
+
def clean_parameters(self, func: Callable) -> dict:
|
117
|
+
kwargs = self.path_variables.copy()
|
118
|
+
|
119
|
+
for variable_name, variable_type in func.__annotations__.items():
|
120
|
+
# Put Request/ Websocket In kwargs (If User Wants It)
|
121
|
+
if issubclass(variable_type, BaseRequest):
|
122
|
+
kwargs[variable_name] = self
|
123
|
+
|
124
|
+
elif variable_name in kwargs:
|
125
|
+
# Cast To Boolean
|
126
|
+
if variable_type is bool:
|
127
|
+
kwargs[variable_name] = kwargs[variable_name].lower() not in ['false', '0']
|
128
|
+
|
129
|
+
# Cast To Int
|
130
|
+
elif variable_type is int:
|
131
|
+
try:
|
132
|
+
kwargs[variable_name] = int(kwargs[variable_name])
|
133
|
+
except ValueError:
|
134
|
+
raise InvalidPathVariableAPIError(value=kwargs[variable_name], variable_type=variable_type)
|
135
|
+
return kwargs
|
panther/base_websocket.py
CHANGED
@@ -1,28 +1,28 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import asyncio
|
4
|
-
import contextlib
|
5
4
|
import logging
|
6
|
-
from multiprocessing import
|
5
|
+
from multiprocessing.managers import SyncManager
|
7
6
|
from typing import TYPE_CHECKING, Literal
|
8
7
|
|
9
8
|
import orjson as json
|
10
9
|
|
11
10
|
from panther import status
|
12
|
-
from panther._utils import generate_ws_connection_id
|
13
11
|
from panther.base_request import BaseRequest
|
14
12
|
from panther.configs import config
|
15
|
-
from panther.db.
|
16
|
-
from panther.
|
13
|
+
from panther.db.connections import redis
|
14
|
+
from panther.exceptions import AuthenticationAPIError, InvalidPathVariableAPIError
|
15
|
+
from panther.monitoring import Monitoring
|
16
|
+
from panther.utils import Singleton, ULID
|
17
17
|
|
18
18
|
if TYPE_CHECKING:
|
19
|
-
from redis import Redis
|
19
|
+
from redis.asyncio import Redis
|
20
20
|
|
21
21
|
logger = logging.getLogger('panther')
|
22
22
|
|
23
23
|
|
24
24
|
class PubSub:
|
25
|
-
def __init__(self, manager):
|
25
|
+
def __init__(self, manager: SyncManager):
|
26
26
|
self._manager = manager
|
27
27
|
self._subscribers = self._manager.list()
|
28
28
|
|
@@ -37,17 +37,29 @@ class PubSub:
|
|
37
37
|
|
38
38
|
|
39
39
|
class WebsocketConnections(Singleton):
|
40
|
-
def __init__(self,
|
40
|
+
def __init__(self, pubsub_connection: Redis | SyncManager):
|
41
41
|
self.connections = {}
|
42
42
|
self.connections_count = 0
|
43
|
-
self.
|
43
|
+
self.pubsub_connection = pubsub_connection
|
44
44
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
45
|
+
if isinstance(self.pubsub_connection, SyncManager):
|
46
|
+
self.pubsub = PubSub(manager=self.pubsub_connection)
|
47
|
+
|
48
|
+
async def __call__(self):
|
49
|
+
if isinstance(self.pubsub_connection, SyncManager):
|
50
|
+
# We don't have redis connection, so use the `multiprocessing.Manager`
|
51
|
+
self.pubsub: PubSub
|
52
|
+
queue = self.pubsub.subscribe()
|
53
|
+
logger.info("Subscribed to 'websocket_connections' queue")
|
54
|
+
while True:
|
55
|
+
received_message = queue.get()
|
56
|
+
await self._handle_received_message(received_message=received_message)
|
57
|
+
else:
|
58
|
+
# We have a redis connection, so use it for pubsub
|
59
|
+
self.pubsub = self.pubsub_connection.pubsub()
|
60
|
+
await self.pubsub.subscribe('websocket_connections')
|
49
61
|
logger.info("Subscribed to 'websocket_connections' channel")
|
50
|
-
for channel_data in
|
62
|
+
async for channel_data in self.pubsub.listen():
|
51
63
|
match channel_data['type']:
|
52
64
|
# Subscribed
|
53
65
|
case 'subscribe':
|
@@ -56,19 +68,12 @@ class WebsocketConnections(Singleton):
|
|
56
68
|
# Message Received
|
57
69
|
case 'message':
|
58
70
|
loaded_data = json.loads(channel_data['data'].decode())
|
59
|
-
self._handle_received_message(received_message=loaded_data)
|
71
|
+
await self._handle_received_message(received_message=loaded_data)
|
60
72
|
|
61
73
|
case unknown_type:
|
62
|
-
logger.
|
63
|
-
else:
|
64
|
-
self.pubsub = PubSub(manager=self.manager)
|
65
|
-
queue = self.pubsub.subscribe()
|
66
|
-
logger.info("Subscribed to 'websocket_connections' queue")
|
67
|
-
while True:
|
68
|
-
received_message = queue.get()
|
69
|
-
self._handle_received_message(received_message=received_message)
|
74
|
+
logger.error(f'Unknown Channel Type: {unknown_type}')
|
70
75
|
|
71
|
-
def _handle_received_message(self, received_message):
|
76
|
+
async def _handle_received_message(self, received_message):
|
72
77
|
if (
|
73
78
|
isinstance(received_message, dict)
|
74
79
|
and (connection_id := received_message.get('connection_id'))
|
@@ -79,71 +84,156 @@ class WebsocketConnections(Singleton):
|
|
79
84
|
# Check Action of WS
|
80
85
|
match received_message['action']:
|
81
86
|
case 'send':
|
82
|
-
|
87
|
+
await self.connections[connection_id].send(data=received_message['data'])
|
83
88
|
case 'close':
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
))
|
89
|
-
# We are trying to disconnect the connection between a thread and a user
|
90
|
-
# from another thread, it's working, but we have to find another solution for it
|
91
|
-
#
|
92
|
-
# Error:
|
93
|
-
# Task <Task pending coro=<Websocket.close()>> got Future
|
94
|
-
# <Task pending coro=<WebSocketCommonProtocol.transfer_data()>>
|
95
|
-
# attached to a different loop
|
89
|
+
await self.connections[connection_id].close(
|
90
|
+
code=received_message['data']['code'],
|
91
|
+
reason=received_message['data']['reason']
|
92
|
+
)
|
96
93
|
case unknown_action:
|
97
|
-
logger.
|
94
|
+
logger.error(f'Unknown Message Action: {unknown_action}')
|
98
95
|
|
99
|
-
def publish(self, connection_id: str, action: Literal['send', 'close'], data: any):
|
96
|
+
async def publish(self, connection_id: str, action: Literal['send', 'close'], data: any):
|
100
97
|
publish_data = {'connection_id': connection_id, 'action': action, 'data': data}
|
101
98
|
|
102
99
|
if redis.is_connected:
|
103
|
-
redis.publish('websocket_connections', json.dumps(publish_data))
|
100
|
+
await redis.publish('websocket_connections', json.dumps(publish_data))
|
104
101
|
else:
|
105
102
|
self.pubsub.publish(publish_data)
|
106
103
|
|
107
|
-
async def
|
108
|
-
|
109
|
-
if not
|
110
|
-
|
111
|
-
await connection.close()
|
104
|
+
async def listen(self, connection: Websocket) -> None:
|
105
|
+
# 1. Authentication
|
106
|
+
if not connection.is_rejected:
|
107
|
+
await self.handle_authentication(connection=connection)
|
112
108
|
|
113
|
-
|
114
|
-
|
109
|
+
# 2. Permissions
|
110
|
+
if not connection.is_rejected:
|
111
|
+
await self.handle_permissions(connection=connection)
|
115
112
|
|
116
|
-
|
117
|
-
|
113
|
+
if connection.is_rejected:
|
114
|
+
# Connection is rejected so don't continue the flow ...
|
115
|
+
return None
|
118
116
|
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
117
|
+
# 3. Put PathVariables and Request(If User Wants It) In kwargs
|
118
|
+
try:
|
119
|
+
kwargs = connection.clean_parameters(connection.connect)
|
120
|
+
except InvalidPathVariableAPIError as e:
|
121
|
+
connection.log(e.detail)
|
122
|
+
return await connection.close()
|
123
123
|
|
124
|
+
# 4. Connect To Endpoint
|
125
|
+
await connection.connect(**kwargs)
|
124
126
|
|
125
|
-
|
126
|
-
|
127
|
+
# 5. Check Connection
|
128
|
+
if not connection.is_connected and not connection.is_rejected:
|
129
|
+
# User didn't call the `self.accept()` or `self.close()` so we `close()` the connection (reject)
|
130
|
+
return await connection.close()
|
127
131
|
|
128
|
-
|
129
|
-
|
130
|
-
await self.accept()
|
132
|
+
# 6. Listen Connection
|
133
|
+
await self.listen_connection(connection=connection)
|
131
134
|
|
132
|
-
async def
|
133
|
-
|
134
|
-
|
135
|
+
async def listen_connection(self, connection: Websocket):
|
136
|
+
while True:
|
137
|
+
response = await connection.asgi_receive()
|
138
|
+
if response['type'] == 'websocket.connect':
|
139
|
+
continue
|
140
|
+
|
141
|
+
if response['type'] == 'websocket.disconnect':
|
142
|
+
# Connect has to be closed by the client
|
143
|
+
await self.connection_closed(connection=connection)
|
144
|
+
break
|
135
145
|
|
146
|
+
if 'text' in response:
|
147
|
+
await connection.receive(data=response['text'])
|
148
|
+
else:
|
149
|
+
await connection.receive(data=response['bytes'])
|
150
|
+
|
151
|
+
async def connection_accepted(self, connection: Websocket) -> None:
|
136
152
|
# Generate ConnectionID
|
137
|
-
|
138
|
-
|
139
|
-
|
153
|
+
connection._connection_id = ULID.new()
|
154
|
+
|
155
|
+
# Save Connection
|
156
|
+
self.connections[connection.connection_id] = connection
|
140
157
|
|
141
|
-
#
|
142
|
-
|
158
|
+
# Logs
|
159
|
+
await connection.monitoring.after('Accepted')
|
160
|
+
connection.log(f'Accepted {connection.connection_id}')
|
161
|
+
|
162
|
+
async def connection_closed(self, connection: Websocket, from_server: bool = False) -> None:
|
163
|
+
if connection.is_connected:
|
164
|
+
del self.connections[connection.connection_id]
|
165
|
+
await connection.monitoring.after('Closed')
|
166
|
+
connection.log(f'Closed {connection.connection_id}')
|
167
|
+
connection._connection_id = ''
|
168
|
+
|
169
|
+
elif connection.is_rejected is False and from_server is True:
|
170
|
+
await connection.monitoring.after('Rejected')
|
171
|
+
connection.log('Rejected')
|
172
|
+
connection._is_rejected = True
|
173
|
+
|
174
|
+
async def start(self):
|
175
|
+
"""
|
176
|
+
Start Websocket Listener (Redis/ Queue)
|
177
|
+
|
178
|
+
Cause of --preload in gunicorn we have to keep this function here,
|
179
|
+
and we can't move it to __init__ of Panther
|
180
|
+
|
181
|
+
* Each process should start this listener for itself,
|
182
|
+
but they have same Manager()
|
183
|
+
"""
|
184
|
+
|
185
|
+
if config.HAS_WS:
|
186
|
+
# Schedule the async function to run in the background,
|
187
|
+
# We don't need to await for this task
|
188
|
+
asyncio.create_task(self())
|
189
|
+
|
190
|
+
@classmethod
|
191
|
+
async def handle_authentication(cls, connection: Websocket):
|
192
|
+
"""Return True if connection is closed, False otherwise."""
|
193
|
+
if connection.auth:
|
194
|
+
if not config.WS_AUTHENTICATION:
|
195
|
+
logger.critical('`WS_AUTHENTICATION` has not been set in configs')
|
196
|
+
await connection.close()
|
197
|
+
else:
|
198
|
+
try:
|
199
|
+
connection.user = await config.WS_AUTHENTICATION.authentication(connection)
|
200
|
+
except AuthenticationAPIError as e:
|
201
|
+
connection.log(e.detail)
|
202
|
+
await connection.close()
|
203
|
+
|
204
|
+
@classmethod
|
205
|
+
async def handle_permissions(cls, connection: Websocket):
|
206
|
+
"""Return True if connection is closed, False otherwise."""
|
207
|
+
for perm in connection.permissions:
|
208
|
+
if type(perm.authorization).__name__ != 'method':
|
209
|
+
logger.critical(f'{perm.__name__}.authorization should be "classmethod"')
|
210
|
+
await connection.close()
|
211
|
+
elif await perm.authorization(connection) is False:
|
212
|
+
connection.log('Permission Denied')
|
213
|
+
await connection.close()
|
214
|
+
|
215
|
+
|
216
|
+
class Websocket(BaseRequest):
|
217
|
+
auth: bool = False
|
218
|
+
permissions: list = []
|
219
|
+
_connection_id: str = ''
|
220
|
+
_is_rejected: bool = False
|
221
|
+
_monitoring: Monitoring
|
222
|
+
|
223
|
+
def __init_subclass__(cls, **kwargs):
|
224
|
+
if cls.__module__ != 'panther.websocket':
|
225
|
+
config.HAS_WS = True
|
226
|
+
|
227
|
+
async def connect(self, **kwargs) -> None:
|
228
|
+
pass
|
143
229
|
|
144
230
|
async def receive(self, data: str | bytes) -> None:
|
145
231
|
pass
|
146
232
|
|
233
|
+
async def accept(self, subprotocol: str | None = None, headers: dict | None = None) -> None:
|
234
|
+
await self.asgi_send({'type': 'websocket.accept', 'subprotocol': subprotocol, 'headers': headers or {}})
|
235
|
+
await config.WEBSOCKET_CONNECTIONS.connection_accepted(connection=self)
|
236
|
+
|
147
237
|
async def send(self, data: any = None) -> None:
|
148
238
|
logger.debug(f'Sending WS Message to {self.connection_id}')
|
149
239
|
if data:
|
@@ -161,38 +251,26 @@ class Websocket(BaseRequest):
|
|
161
251
|
await self.asgi_send({'type': 'websocket.send', 'bytes': bytes_data})
|
162
252
|
|
163
253
|
async def close(self, code: int = status.WS_1000_NORMAL_CLOSURE, reason: str = '') -> None:
|
164
|
-
logger.debug(f'Closing WS Connection {self.connection_id}')
|
165
|
-
self.is_connected = False
|
166
|
-
config['websocket_connections'].remove_connection(self)
|
167
254
|
await self.asgi_send({'type': 'websocket.close', 'code': code, 'reason': reason})
|
255
|
+
await config.WEBSOCKET_CONNECTIONS.connection_closed(connection=self, from_server=True)
|
168
256
|
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
if response['type'] == 'websocket.disconnect':
|
176
|
-
break
|
177
|
-
|
178
|
-
if 'text' in response:
|
179
|
-
await self.receive(data=response['text'])
|
180
|
-
else:
|
181
|
-
await self.receive(data=response['bytes'])
|
182
|
-
|
183
|
-
def set_path_variables(self, path_variables: dict) -> None:
|
184
|
-
self._path_variables = path_variables
|
257
|
+
@property
|
258
|
+
def connection_id(self) -> str:
|
259
|
+
if self.is_connected:
|
260
|
+
return self._connection_id
|
261
|
+
logger.error('You should first `self.accept()` the connection then use the `self.connection_id`')
|
185
262
|
|
186
263
|
@property
|
187
|
-
def
|
188
|
-
return
|
264
|
+
def is_connected(self) -> bool:
|
265
|
+
return bool(self._connection_id)
|
189
266
|
|
190
|
-
|
191
|
-
|
267
|
+
@property
|
268
|
+
def is_rejected(self) -> bool:
|
269
|
+
return self._is_rejected
|
192
270
|
|
193
271
|
@property
|
194
|
-
def
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
272
|
+
def monitoring(self) -> Monitoring:
|
273
|
+
return self._monitoring
|
274
|
+
|
275
|
+
def log(self, message: str):
|
276
|
+
logger.debug(f'WS {self.path} --> {message}')
|
panther/caching.py
CHANGED
@@ -6,54 +6,56 @@ from types import NoneType
|
|
6
6
|
import orjson as json
|
7
7
|
|
8
8
|
from panther.configs import config
|
9
|
-
from panther.db.
|
9
|
+
from panther.db.connections import redis
|
10
10
|
from panther.request import Request
|
11
11
|
from panther.response import Response, ResponseDataTypes
|
12
|
+
from panther.throttling import throttling_storage
|
12
13
|
from panther.utils import generate_hash_value_from_string, round_datetime
|
13
14
|
|
14
15
|
logger = logging.getLogger('panther')
|
15
16
|
|
16
17
|
caches = {}
|
17
|
-
CachedResponse = namedtuple('
|
18
|
+
CachedResponse = namedtuple('CachedResponse', ['data', 'status_code'])
|
18
19
|
|
19
20
|
|
20
|
-
def
|
21
|
+
def api_cache_key(request: Request, cache_exp_time: timedelta | None = None) -> str:
|
21
22
|
client = request.user and request.user.id or request.client.ip
|
22
23
|
query_params_hash = generate_hash_value_from_string(request.scope['query_string'].decode('utf-8'))
|
23
|
-
|
24
|
+
key = f'{client}-{request.path}-{query_params_hash}-{request.validated_data}'
|
24
25
|
|
25
|
-
|
26
|
-
def local_cache_key(*, request: Request, cache_exp_time: timedelta | None = None) -> str:
|
27
|
-
key = cache_key(request)
|
28
26
|
if cache_exp_time:
|
29
27
|
time = round_datetime(datetime.now(), cache_exp_time)
|
30
28
|
return f'{time}-{key}'
|
31
|
-
else:
|
32
|
-
return key
|
33
29
|
|
30
|
+
return key
|
34
31
|
|
35
|
-
|
32
|
+
|
33
|
+
def throttling_cache_key(request: Request, duration: timedelta) -> str:
|
34
|
+
client = request.user and request.user.id or request.client.ip
|
35
|
+
time = round_datetime(datetime.now(), duration)
|
36
|
+
return f'{time}-{client}-{request.path}'
|
37
|
+
|
38
|
+
|
39
|
+
async def get_response_from_cache(*, request: Request, cache_exp_time: timedelta) -> CachedResponse | None:
|
36
40
|
"""
|
37
41
|
If redis.is_connected:
|
38
42
|
Get Cached Data From Redis
|
39
43
|
else:
|
40
44
|
Get Cached Data From Memory
|
41
45
|
"""
|
42
|
-
if redis.is_connected:
|
43
|
-
key =
|
44
|
-
data = (redis.get(key) or b'{}').decode()
|
46
|
+
if redis.is_connected:
|
47
|
+
key = api_cache_key(request=request)
|
48
|
+
data = (await redis.get(key) or b'{}').decode()
|
45
49
|
if cached_value := json.loads(data):
|
46
50
|
return CachedResponse(*cached_value)
|
47
51
|
|
48
52
|
else:
|
49
|
-
key =
|
53
|
+
key = api_cache_key(request=request, cache_exp_time=cache_exp_time)
|
50
54
|
if cached_value := caches.get(key):
|
51
55
|
return CachedResponse(*cached_value)
|
52
56
|
|
53
|
-
return None
|
54
|
-
|
55
57
|
|
56
|
-
def
|
58
|
+
async def set_response_in_cache(*, request: Request, response: Response, cache_exp_time: timedelta | int) -> None:
|
57
59
|
"""
|
58
60
|
If redis.is_connected:
|
59
61
|
Cache The Data In Redis
|
@@ -63,10 +65,10 @@ def set_cache_response(*, request: Request, response: Response, cache_exp_time:
|
|
63
65
|
|
64
66
|
cache_data: tuple[ResponseDataTypes, int] = (response.data, response.status_code)
|
65
67
|
|
66
|
-
if redis.is_connected:
|
67
|
-
key =
|
68
|
+
if redis.is_connected:
|
69
|
+
key = api_cache_key(request=request)
|
68
70
|
|
69
|
-
cache_exp_time = cache_exp_time or config
|
71
|
+
cache_exp_time = cache_exp_time or config.DEFAULT_CACHE_EXP
|
70
72
|
cache_data: bytes = json.dumps(cache_data)
|
71
73
|
|
72
74
|
if not isinstance(cache_exp_time, timedelta | int | NoneType):
|
@@ -76,15 +78,48 @@ def set_cache_response(*, request: Request, response: Response, cache_exp_time:
|
|
76
78
|
if cache_exp_time is None:
|
77
79
|
logger.warning(
|
78
80
|
'your response are going to cache in redis forever '
|
79
|
-
'** set DEFAULT_CACHE_EXP in configs or
|
81
|
+
'** set DEFAULT_CACHE_EXP in `configs` or set the `cache_exp_time` in `@API.get()` to prevent this **'
|
80
82
|
)
|
81
|
-
redis.set(key, cache_data)
|
83
|
+
await redis.set(key, cache_data)
|
82
84
|
else:
|
83
|
-
redis.set(key, cache_data, ex=cache_exp_time)
|
85
|
+
await redis.set(key, cache_data, ex=cache_exp_time)
|
84
86
|
|
85
87
|
else:
|
86
|
-
key =
|
88
|
+
key = api_cache_key(request=request, cache_exp_time=cache_exp_time)
|
87
89
|
caches[key] = cache_data
|
88
90
|
|
89
91
|
if cache_exp_time:
|
90
|
-
logger.info('
|
92
|
+
logger.info('`cache_exp_time` is not very accurate when `redis` is not connected.')
|
93
|
+
|
94
|
+
|
95
|
+
async def get_throttling_from_cache(request: Request, duration: timedelta) -> int:
|
96
|
+
"""
|
97
|
+
If redis.is_connected:
|
98
|
+
Get Cached Data From Redis
|
99
|
+
else:
|
100
|
+
Get Cached Data From Memory
|
101
|
+
"""
|
102
|
+
key = throttling_cache_key(request=request, duration=duration)
|
103
|
+
|
104
|
+
if redis.is_connected:
|
105
|
+
data = (await redis.get(key) or b'0').decode()
|
106
|
+
return json.loads(data)
|
107
|
+
|
108
|
+
else:
|
109
|
+
return throttling_storage[key]
|
110
|
+
|
111
|
+
|
112
|
+
async def increment_throttling_in_cache(request: Request, duration: timedelta) -> None:
|
113
|
+
"""
|
114
|
+
If redis.is_connected:
|
115
|
+
Increment The Data In Redis
|
116
|
+
else:
|
117
|
+
Increment The Data In Memory
|
118
|
+
"""
|
119
|
+
key = throttling_cache_key(request=request, duration=duration)
|
120
|
+
|
121
|
+
if redis.is_connected:
|
122
|
+
await redis.incrby(key, amount=1)
|
123
|
+
|
124
|
+
else:
|
125
|
+
throttling_storage[key] += 1
|