oplnk-python-utils 1.2.5__tar.gz → 1.3.0__tar.gz

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 (23) hide show
  1. {oplnk_python_utils-1.2.5/oplnk_python_utils.egg-info → oplnk_python_utils-1.3.0}/PKG-INFO +1 -1
  2. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/__init__.py +1 -1
  3. oplnk_python_utils-1.3.0/oplnk_python_utils/cache/cache_utils.py +447 -0
  4. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/query/query_builder.py +1523 -1452
  5. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0/oplnk_python_utils.egg-info}/PKG-INFO +1 -1
  6. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/setup.py +1 -1
  7. oplnk_python_utils-1.2.5/oplnk_python_utils/cache/cache_utils.py +0 -278
  8. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/LICENSE +0 -0
  9. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/MANIFEST.in +0 -0
  10. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/README.md +0 -0
  11. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/cache/__init__.py +0 -0
  12. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/__init__.py +0 -0
  13. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/data.py +0 -0
  14. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/parsing.py +0 -0
  15. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/serialization.py +0 -0
  16. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/query/__init__.py +0 -0
  17. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/reports/__init__.py +0 -0
  18. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/SOURCES.txt +0 -0
  19. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
  20. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/not-zip-safe +0 -0
  21. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/requires.txt +0 -0
  22. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/top_level.txt +0 -0
  23. {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oplnk-python-utils
3
- Version: 1.2.5
3
+ Version: 1.3.0
4
4
  Summary: Reusable utilities for caching, data manipulation, parsing, and MongoDB query building
5
5
  Home-page: https://github.com/openlinkspa/oplnk-python-utils
6
6
  Author: OpenLink SpA
@@ -7,7 +7,7 @@ from . import core
7
7
  from . import cache
8
8
  from . import query
9
9
 
10
- __version__ = "1.2.5"
10
+ __version__ = "1.3.0"
11
11
  __author__ = "OpenLink SpA"
12
12
 
13
13
  __all__ = ["core", "cache", "query", "reports", "__version__"]
@@ -0,0 +1,447 @@
1
+ """
2
+ Cache utilities - Generic caching functions that can be used across projects
3
+ """
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import time
9
+ import asyncio
10
+ from typing import Callable, Any, Optional, Union
11
+ from fastapi.encoders import jsonable_encoder
12
+ from bson.objectid import ObjectId
13
+ from fastapi import Request
14
+ import functools
15
+ from redis.exceptions import (
16
+ ConnectionError as RedisConnectionError,
17
+ TimeoutError as RedisTimeoutError,
18
+ )
19
+
20
+ # Import core utilities
21
+ from ..core.data import normalize
22
+ from ..core.serialization import datetime_serializer
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def generate_cache_id(params: dict) -> str:
28
+ """
29
+ Utility to generate a cache key hash from a dictionary.
30
+ Creates a consistent hash based on normalized dictionary content.
31
+
32
+ Args:
33
+ params: Dictionary of parameters to hash
34
+
35
+ Returns:
36
+ Cache key string
37
+ """
38
+ normalized = normalize(params)
39
+ encoded_data = jsonable_encoder(normalized, custom_encoder={ObjectId: str})
40
+ serialized = json.dumps(encoded_data, separators=(",", ":"), ensure_ascii=True)
41
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
42
+
43
+
44
+ def get_or_set_cache(
45
+ redis_client,
46
+ cache_key: str,
47
+ fetch_func: Callable,
48
+ clear_cache: Union[str, bool] = False,
49
+ ttl: Optional[int] = None,
50
+ serializer: Optional[Callable] = None,
51
+ max_retries: int = 3,
52
+ retry_delay: float = 0.1,
53
+ ) -> Any:
54
+ """
55
+ Retrieve data from cache if available, otherwise fetch, cache, and return it.
56
+ Optionally clear cache before fetching.
57
+
58
+ Args:
59
+ redis_client: Redis client instance
60
+ cache_key: Key to use for caching
61
+ fetch_func: Function to call if cache miss
62
+ clear_cache: Pattern to clear or boolean to clear specific key
63
+ ttl: Time to live in seconds
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
67
+
68
+ Returns:
69
+ Cached or fetched data
70
+ """
71
+ # Intentar limpiar cache si se solicita
72
+ if clear_cache:
73
+ invalidate_cache_keys(
74
+ redis_client, clear_cache if isinstance(clear_cache, str) else cache_key
75
+ )
76
+
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}")
89
+
90
+ # Cache miss o error: obtener datos frescos
91
+ data = fetch_func()
92
+ if serializer:
93
+ data = serializer(data)
94
+
95
+ encoded_data = jsonable_encoder(data, custom_encoder={ObjectId: str})
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
+
104
+ return encoded_data
105
+
106
+
107
+ def invalidate_cache_keys(redis_client, pattern: str) -> int:
108
+ """
109
+ Utility to invalidate cache keys matching a pattern.
110
+
111
+ Args:
112
+ redis_client: Redis client instance
113
+ pattern: Pattern to match keys for deletion
114
+
115
+ Returns:
116
+ Number of keys deleted (0 if Redis is unavailable)
117
+ """
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
132
+
133
+ return total_deleted
134
+ except (RedisConnectionError, RedisTimeoutError) as e:
135
+ logger.warning(f"Redis connection error during cache invalidation: {e}")
136
+ return 0
137
+
138
+
139
+ async def build_cache_key(
140
+ request: Request, api_name: str, collection: str, endpoint_type: str
141
+ ) -> str:
142
+ """
143
+ Builds a cache key based on the request, api name, collection, and endpoint type.
144
+
145
+ Incorporates HTTP method, URL path, path parameters and query parameters to
146
+ avoid collisions between routes that differ only by path params.
147
+
148
+ Args:
149
+ request: FastAPI request object
150
+ api_name: Name of the API
151
+ collection: Name of the collection
152
+ endpoint_type: Type of endpoint
153
+
154
+ Returns:
155
+ Cache key string
156
+ """
157
+ # Ensure plain dicts for hashing
158
+ query_params_dict = dict(request.query_params) if request.query_params else {}
159
+ path_params_dict = (
160
+ dict(request.path_params) if getattr(request, "path_params", None) else {}
161
+ )
162
+
163
+ key_material = {
164
+ "method": request.method,
165
+ "path": request.url.path,
166
+ "path_params": path_params_dict,
167
+ "query": query_params_dict,
168
+ }
169
+
170
+ # Include request body for methods where it affects the response identity
171
+ if request.method in {"POST", "PUT"}:
172
+ content_type = (request.headers.get("content-type") or "").split(";")[0].lower()
173
+ body_hash: Optional[str] = None
174
+ if content_type == "application/json":
175
+ try:
176
+ json_body = await request.json()
177
+ normalized_body = normalize(json_body)
178
+ encoded_body = jsonable_encoder(
179
+ normalized_body, custom_encoder={ObjectId: str}
180
+ )
181
+ key_material["body"] = encoded_body
182
+ except Exception:
183
+ body_bytes = await request.body()
184
+ if body_bytes:
185
+ body_hash = hashlib.sha256(body_bytes).hexdigest()
186
+ else:
187
+ body_bytes = await request.body()
188
+ if body_bytes:
189
+ body_hash = hashlib.sha256(body_bytes).hexdigest()
190
+
191
+ if body_hash:
192
+ key_material["body_hash"] = body_hash
193
+
194
+ hashed = generate_cache_id(key_material)
195
+ return f"{api_name}:{collection}:{endpoint_type}:{hashed}"
196
+
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
+
263
+ def cache_response(
264
+ redis_client,
265
+ api_name: str,
266
+ collection: str,
267
+ endpoint_type: str,
268
+ tags: list[str],
269
+ ttl: int | None = None,
270
+ use_cache: bool = True,
271
+ max_retries: int = 3,
272
+ ):
273
+ """
274
+ Decorator to cache the response of an endpoint.
275
+
276
+ Args:
277
+ redis_client: Redis client instance (sync or async)
278
+ api_name: Name of the API
279
+ collection: Name of the collection
280
+ endpoint_type: Type of endpoint
281
+ tags: List of tags to use for caching
282
+ ttl: Time to live in seconds
283
+ use_cache: Whether to use cache
284
+ max_retries: Maximum number of retry attempts for Redis operations
285
+
286
+ Returns:
287
+ Decorator function
288
+ """
289
+ is_async = _is_async_client(redis_client)
290
+
291
+ def decorator(func: Callable):
292
+ @functools.wraps(func)
293
+ async def wrapper(*args, **kwargs):
294
+ if not use_cache:
295
+ return await func(*args, **kwargs)
296
+
297
+ # Detectar el Request
298
+ request: Optional[Request] = kwargs.get("request")
299
+ if request is None:
300
+ for arg in args:
301
+ if isinstance(arg, Request):
302
+ request = arg
303
+ break
304
+ if request is None:
305
+ raise ValueError("Request object not found in endpoint parameters.")
306
+
307
+ query_params = request.query_params
308
+ clear_cache_value = query_params.get("clear_cache")
309
+ if isinstance(clear_cache_value, str):
310
+ clear_cache_flag = clear_cache_value.lower() in {
311
+ "1",
312
+ "true",
313
+ "yes",
314
+ "y",
315
+ }
316
+ else:
317
+ clear_cache_flag = bool(clear_cache_value)
318
+
319
+ # Intentar limpiar cache si se solicita
320
+ if clear_cache_flag:
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}")
325
+
326
+ # Construir clave
327
+ cache_key = await build_cache_key(
328
+ request, api_name, collection, endpoint_type
329
+ )
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
363
+ response = await func(*args, **kwargs)
364
+ encoded_data = jsonable_encoder(response, custom_encoder={ObjectId: str})
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
+ )
393
+
394
+ return encoded_data
395
+
396
+ return wrapper
397
+
398
+ return decorator
399
+
400
+
401
+ def cache_factory(redis_client, api_name: str, collection: str):
402
+ """
403
+ Factory to create cache decorators for different endpoint types.
404
+
405
+ Args:
406
+ redis_client: Redis client instance
407
+ api_name: Name of the API
408
+ collection: Name of the collection
409
+
410
+ Returns:
411
+ Decorator function
412
+ """
413
+
414
+ def wrapper(
415
+ endpoint_type: str,
416
+ tags: list[str],
417
+ ttl: int | None = None,
418
+ use_cache: bool = True,
419
+ ):
420
+ return cache_response(
421
+ redis_client=redis_client,
422
+ api_name=api_name,
423
+ collection=collection,
424
+ endpoint_type=endpoint_type,
425
+ tags=tags,
426
+ ttl=ttl,
427
+ use_cache=use_cache,
428
+ )
429
+
430
+ return wrapper
431
+
432
+
433
+ def invalidate_tag(redis_client, tag: str):
434
+ """
435
+ Utility to invalidate cache keys associated with a tag.
436
+
437
+ Args:
438
+ redis_client: Redis client instance
439
+ tag: Tag to invalidate
440
+ """
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}")