oplnk-python-utils 1.2.5__py3-none-any.whl → 1.3.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.
- oplnk_python_utils/__init__.py +1 -1
- oplnk_python_utils/cache/cache_utils.py +196 -27
- oplnk_python_utils/query/query_builder.py +1523 -1452
- {oplnk_python_utils-1.2.5.dist-info → oplnk_python_utils-1.3.0.dist-info}/METADATA +1 -1
- {oplnk_python_utils-1.2.5.dist-info → oplnk_python_utils-1.3.0.dist-info}/RECORD +8 -8
- {oplnk_python_utils-1.2.5.dist-info → oplnk_python_utils-1.3.0.dist-info}/WHEEL +0 -0
- {oplnk_python_utils-1.2.5.dist-info → oplnk_python_utils-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {oplnk_python_utils-1.2.5.dist-info → oplnk_python_utils-1.3.0.dist-info}/top_level.txt +0 -0
oplnk_python_utils/__init__.py
CHANGED
|
@@ -4,16 +4,25 @@ Cache utilities - Generic caching functions that can be used across projects
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
import asyncio
|
|
7
10
|
from typing import Callable, Any, Optional, Union
|
|
8
11
|
from fastapi.encoders import jsonable_encoder
|
|
9
12
|
from bson.objectid import ObjectId
|
|
10
13
|
from fastapi import Request
|
|
11
14
|
import functools
|
|
15
|
+
from redis.exceptions import (
|
|
16
|
+
ConnectionError as RedisConnectionError,
|
|
17
|
+
TimeoutError as RedisTimeoutError,
|
|
18
|
+
)
|
|
12
19
|
|
|
13
20
|
# Import core utilities
|
|
14
21
|
from ..core.data import normalize
|
|
15
22
|
from ..core.serialization import datetime_serializer
|
|
16
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
17
26
|
|
|
18
27
|
def generate_cache_id(params: dict) -> str:
|
|
19
28
|
"""
|
|
@@ -39,6 +48,8 @@ def get_or_set_cache(
|
|
|
39
48
|
clear_cache: Union[str, bool] = False,
|
|
40
49
|
ttl: Optional[int] = None,
|
|
41
50
|
serializer: Optional[Callable] = None,
|
|
51
|
+
max_retries: int = 3,
|
|
52
|
+
retry_delay: float = 0.1,
|
|
42
53
|
) -> Any:
|
|
43
54
|
"""
|
|
44
55
|
Retrieve data from cache if available, otherwise fetch, cache, and return it.
|
|
@@ -51,24 +62,45 @@ def get_or_set_cache(
|
|
|
51
62
|
clear_cache: Pattern to clear or boolean to clear specific key
|
|
52
63
|
ttl: Time to live in seconds
|
|
53
64
|
serializer: Function to serialize data before caching
|
|
65
|
+
max_retries: Maximum number of retry attempts for Redis operations
|
|
66
|
+
retry_delay: Delay between retry attempts in seconds
|
|
54
67
|
|
|
55
68
|
Returns:
|
|
56
69
|
Cached or fetched data
|
|
57
70
|
"""
|
|
71
|
+
# Intentar limpiar cache si se solicita
|
|
58
72
|
if clear_cache:
|
|
59
73
|
invalidate_cache_keys(
|
|
60
74
|
redis_client, clear_cache if isinstance(clear_cache, str) else cache_key
|
|
61
75
|
)
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
# Intentar obtener del cache
|
|
78
|
+
cached_data = _execute_redis_op(
|
|
79
|
+
lambda: redis_client.get(cache_key) if redis_client.exists(cache_key) else None,
|
|
80
|
+
max_retries=max_retries,
|
|
81
|
+
retry_delay=retry_delay,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if cached_data is not None:
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(cached_data)
|
|
87
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
88
|
+
logger.warning(f"Failed to decode cached data for key {cache_key}: {e}")
|
|
65
89
|
|
|
90
|
+
# Cache miss o error: obtener datos frescos
|
|
66
91
|
data = fetch_func()
|
|
67
92
|
if serializer:
|
|
68
93
|
data = serializer(data)
|
|
69
94
|
|
|
70
95
|
encoded_data = jsonable_encoder(data, custom_encoder={ObjectId: str})
|
|
71
|
-
|
|
96
|
+
|
|
97
|
+
# Intentar guardar en cache (falla silenciosamente si Redis no está disponible)
|
|
98
|
+
_execute_redis_op(
|
|
99
|
+
lambda: redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl),
|
|
100
|
+
max_retries=max_retries,
|
|
101
|
+
retry_delay=retry_delay,
|
|
102
|
+
)
|
|
103
|
+
|
|
72
104
|
return encoded_data
|
|
73
105
|
|
|
74
106
|
|
|
@@ -81,21 +113,27 @@ def invalidate_cache_keys(redis_client, pattern: str) -> int:
|
|
|
81
113
|
pattern: Pattern to match keys for deletion
|
|
82
114
|
|
|
83
115
|
Returns:
|
|
84
|
-
Number of keys deleted
|
|
116
|
+
Number of keys deleted (0 if Redis is unavailable)
|
|
85
117
|
"""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
try:
|
|
119
|
+
cursor = 0
|
|
120
|
+
batch_size = 1000
|
|
121
|
+
total_deleted = 0
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
cursor, keys = redis_client.scan(
|
|
125
|
+
cursor=cursor, match=pattern, count=batch_size
|
|
126
|
+
)
|
|
127
|
+
if keys:
|
|
128
|
+
redis_client.unlink(*keys)
|
|
129
|
+
total_deleted += len(keys)
|
|
130
|
+
if cursor == 0:
|
|
131
|
+
break
|
|
97
132
|
|
|
98
|
-
|
|
133
|
+
return total_deleted
|
|
134
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
135
|
+
logger.warning(f"Redis connection error during cache invalidation: {e}")
|
|
136
|
+
return 0
|
|
99
137
|
|
|
100
138
|
|
|
101
139
|
async def build_cache_key(
|
|
@@ -157,6 +195,71 @@ async def build_cache_key(
|
|
|
157
195
|
return f"{api_name}:{collection}:{endpoint_type}:{hashed}"
|
|
158
196
|
|
|
159
197
|
|
|
198
|
+
def _is_async_client(redis_client) -> bool:
|
|
199
|
+
"""
|
|
200
|
+
Detecta si el cliente Redis es asíncrono.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
redis_client: Cliente Redis a verificar
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True si es asíncrono, False si es síncrono
|
|
207
|
+
"""
|
|
208
|
+
return (
|
|
209
|
+
hasattr(redis_client, "__aenter__") or "async" in type(redis_client).__module__
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _execute_redis_op(
|
|
214
|
+
operation: Callable, max_retries: int = 3, retry_delay: float = 0.1
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""
|
|
217
|
+
Ejecuta operación Redis síncrona con reintentos y degradación elegante.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
operation: Función que ejecuta la operación Redis
|
|
221
|
+
max_retries: Número máximo de reintentos
|
|
222
|
+
retry_delay: Tiempo de espera entre reintentos en segundos
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Resultado de la operación o None si falla después de todos los reintentos
|
|
226
|
+
"""
|
|
227
|
+
for attempt in range(max_retries):
|
|
228
|
+
try:
|
|
229
|
+
return operation()
|
|
230
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
231
|
+
if attempt == max_retries - 1:
|
|
232
|
+
logger.warning(f"Redis unavailable after {max_retries} attempts: {e}")
|
|
233
|
+
return None
|
|
234
|
+
time.sleep(retry_delay)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def _execute_redis_op_async(
|
|
239
|
+
operation: Callable, max_retries: int = 3, retry_delay: float = 0.1
|
|
240
|
+
) -> Any:
|
|
241
|
+
"""
|
|
242
|
+
Ejecuta operación Redis asíncrona con reintentos y degradación elegante.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
operation: Función async que ejecuta la operación Redis
|
|
246
|
+
max_retries: Número máximo de reintentos
|
|
247
|
+
retry_delay: Tiempo de espera entre reintentos en segundos
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Resultado de la operación o None si falla después de todos los reintentos
|
|
251
|
+
"""
|
|
252
|
+
for attempt in range(max_retries):
|
|
253
|
+
try:
|
|
254
|
+
return await operation()
|
|
255
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
256
|
+
if attempt == max_retries - 1:
|
|
257
|
+
logger.warning(f"Redis unavailable after {max_retries} attempts: {e}")
|
|
258
|
+
return None
|
|
259
|
+
await asyncio.sleep(retry_delay)
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
160
263
|
def cache_response(
|
|
161
264
|
redis_client,
|
|
162
265
|
api_name: str,
|
|
@@ -165,22 +268,25 @@ def cache_response(
|
|
|
165
268
|
tags: list[str],
|
|
166
269
|
ttl: int | None = None,
|
|
167
270
|
use_cache: bool = True,
|
|
271
|
+
max_retries: int = 3,
|
|
168
272
|
):
|
|
169
273
|
"""
|
|
170
274
|
Decorator to cache the response of an endpoint.
|
|
171
275
|
|
|
172
276
|
Args:
|
|
173
|
-
redis_client: Redis client instance
|
|
277
|
+
redis_client: Redis client instance (sync or async)
|
|
174
278
|
api_name: Name of the API
|
|
175
279
|
collection: Name of the collection
|
|
176
280
|
endpoint_type: Type of endpoint
|
|
177
281
|
tags: List of tags to use for caching
|
|
178
282
|
ttl: Time to live in seconds
|
|
179
283
|
use_cache: Whether to use cache
|
|
284
|
+
max_retries: Maximum number of retry attempts for Redis operations
|
|
180
285
|
|
|
181
286
|
Returns:
|
|
182
287
|
Decorator function
|
|
183
288
|
"""
|
|
289
|
+
is_async = _is_async_client(redis_client)
|
|
184
290
|
|
|
185
291
|
def decorator(func: Callable):
|
|
186
292
|
@functools.wraps(func)
|
|
@@ -209,21 +315,81 @@ def cache_response(
|
|
|
209
315
|
}
|
|
210
316
|
else:
|
|
211
317
|
clear_cache_flag = bool(clear_cache_value)
|
|
318
|
+
|
|
319
|
+
# Intentar limpiar cache si se solicita
|
|
212
320
|
if clear_cache_flag:
|
|
213
|
-
|
|
321
|
+
try:
|
|
322
|
+
invalidate_tag(redis_client, f"{collection}")
|
|
323
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
324
|
+
logger.warning(f"Redis connection error during cache clear: {e}")
|
|
214
325
|
|
|
215
326
|
# Construir clave
|
|
216
327
|
cache_key = await build_cache_key(
|
|
217
328
|
request, api_name, collection, endpoint_type
|
|
218
329
|
)
|
|
219
|
-
if redis_client.exists(cache_key):
|
|
220
|
-
return json.loads(redis_client.get(cache_key))
|
|
221
330
|
|
|
331
|
+
# Intentar obtener del cache
|
|
332
|
+
cached_data = None
|
|
333
|
+
if is_async:
|
|
334
|
+
|
|
335
|
+
async def get_cached_data():
|
|
336
|
+
exists_result = await redis_client.exists(cache_key)
|
|
337
|
+
if exists_result:
|
|
338
|
+
return await redis_client.get(cache_key)
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
cached_data = await _execute_redis_op_async(
|
|
342
|
+
get_cached_data, max_retries=max_retries
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
cached_data = _execute_redis_op(
|
|
346
|
+
lambda: (
|
|
347
|
+
redis_client.get(cache_key)
|
|
348
|
+
if redis_client.exists(cache_key)
|
|
349
|
+
else None
|
|
350
|
+
),
|
|
351
|
+
max_retries=max_retries,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if cached_data is not None:
|
|
355
|
+
try:
|
|
356
|
+
return json.loads(cached_data)
|
|
357
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
358
|
+
logger.warning(
|
|
359
|
+
f"Failed to decode cached data for key {cache_key}: {e}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Cache miss o error: ejecutar función y cachear respuesta
|
|
222
363
|
response = await func(*args, **kwargs)
|
|
223
364
|
encoded_data = jsonable_encoder(response, custom_encoder={ObjectId: str})
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
365
|
+
|
|
366
|
+
# Intentar guardar en cache (falla silenciosamente si Redis no está disponible)
|
|
367
|
+
if is_async:
|
|
368
|
+
|
|
369
|
+
async def set_cache_data():
|
|
370
|
+
await redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl)
|
|
371
|
+
|
|
372
|
+
await _execute_redis_op_async(set_cache_data, max_retries=max_retries)
|
|
373
|
+
# Agregar tags
|
|
374
|
+
for tag in tags:
|
|
375
|
+
# Capturar el valor de tag en la closure usando parámetro por defecto
|
|
376
|
+
async def add_tag(tag_name=tag):
|
|
377
|
+
await redis_client.sadd(f"tag:{tag_name}", cache_key)
|
|
378
|
+
|
|
379
|
+
await _execute_redis_op_async(add_tag, max_retries=max_retries)
|
|
380
|
+
else:
|
|
381
|
+
_execute_redis_op(
|
|
382
|
+
lambda: redis_client.set(
|
|
383
|
+
cache_key, json.dumps(encoded_data), ex=ttl
|
|
384
|
+
),
|
|
385
|
+
max_retries=max_retries,
|
|
386
|
+
)
|
|
387
|
+
# Agregar tags
|
|
388
|
+
for tag in tags:
|
|
389
|
+
_execute_redis_op(
|
|
390
|
+
lambda t=tag: redis_client.sadd(f"tag:{t}", cache_key),
|
|
391
|
+
max_retries=max_retries,
|
|
392
|
+
)
|
|
227
393
|
|
|
228
394
|
return encoded_data
|
|
229
395
|
|
|
@@ -272,7 +438,10 @@ def invalidate_tag(redis_client, tag: str):
|
|
|
272
438
|
redis_client: Redis client instance
|
|
273
439
|
tag: Tag to invalidate
|
|
274
440
|
"""
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
441
|
+
try:
|
|
442
|
+
keys = redis_client.smembers(f"tag:{tag}")
|
|
443
|
+
for key in keys:
|
|
444
|
+
redis_client.delete(key)
|
|
445
|
+
redis_client.delete(f"tag:{tag}")
|
|
446
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
447
|
+
logger.warning(f"Redis connection error during tag invalidation: {e}")
|