oplnk-python-utils 1.2.6__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.
- {oplnk_python_utils-1.2.6/oplnk_python_utils.egg-info → oplnk_python_utils-1.3.0}/PKG-INFO +1 -1
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/__init__.py +1 -1
- oplnk_python_utils-1.3.0/oplnk_python_utils/cache/cache_utils.py +447 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/query/query_builder.py +90 -20
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0/oplnk_python_utils.egg-info}/PKG-INFO +1 -1
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/setup.py +1 -1
- oplnk_python_utils-1.2.6/oplnk_python_utils/cache/cache_utils.py +0 -278
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/LICENSE +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/MANIFEST.in +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/README.md +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/cache/__init__.py +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/__init__.py +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/data.py +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/parsing.py +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/serialization.py +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/query/__init__.py +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/reports/__init__.py +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/SOURCES.txt +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/not-zip-safe +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/requires.txt +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/top_level.txt +0 -0
- {oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/setup.cfg +0 -0
|
@@ -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}")
|
{oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/query/query_builder.py
RENAMED
|
@@ -927,7 +927,9 @@ class QueryBuilder:
|
|
|
927
927
|
|
|
928
928
|
return {"data": data_result, "meta": meta_result}
|
|
929
929
|
|
|
930
|
-
def get_reserved_field_lookup(
|
|
930
|
+
def get_reserved_field_lookup(
|
|
931
|
+
self, field_name: str, populate_options: Optional[Dict[str, Any]] = None
|
|
932
|
+
) -> List[Dict[str, Any]]:
|
|
931
933
|
"""
|
|
932
934
|
Generate lookup pipeline for reserved audit fields (creation_user, modification_user).
|
|
933
935
|
|
|
@@ -936,6 +938,8 @@ class QueryBuilder:
|
|
|
936
938
|
|
|
937
939
|
Args:
|
|
938
940
|
field_name: Name of the reserved field (e.g., "creation_user")
|
|
941
|
+
populate_options: Optional dict with populate options:
|
|
942
|
+
- fields: List of fields to include in projection (e.g., ['full_name'])
|
|
939
943
|
|
|
940
944
|
Returns:
|
|
941
945
|
List of aggregation stages for the lookup, or None if not a reserved field
|
|
@@ -945,6 +949,21 @@ class QueryBuilder:
|
|
|
945
949
|
|
|
946
950
|
field_config = RESERVED_AUDIT_FIELDS[field_name]
|
|
947
951
|
|
|
952
|
+
# Build projection based on populate_options
|
|
953
|
+
if populate_options and isinstance(populate_options, dict):
|
|
954
|
+
fields = populate_options.get("fields", [])
|
|
955
|
+
if fields and isinstance(fields, list):
|
|
956
|
+
# Include specified fields + always include _id
|
|
957
|
+
project_stage = {"_id": 1}
|
|
958
|
+
for field in fields:
|
|
959
|
+
project_stage[field] = 1
|
|
960
|
+
else:
|
|
961
|
+
# Default exclusion projection from config
|
|
962
|
+
project_stage = field_config["project_exclude"]
|
|
963
|
+
else:
|
|
964
|
+
# Default exclusion projection from config
|
|
965
|
+
project_stage = field_config["project_exclude"]
|
|
966
|
+
|
|
948
967
|
lookup_pipeline = [
|
|
949
968
|
{
|
|
950
969
|
"$lookup": {
|
|
@@ -956,7 +975,7 @@ class QueryBuilder:
|
|
|
956
975
|
"$expr": {"$eq": ["$_id", {"$toObjectId": "$$userId"}]}
|
|
957
976
|
}
|
|
958
977
|
},
|
|
959
|
-
{"$project":
|
|
978
|
+
{"$project": project_stage},
|
|
960
979
|
{"$addFields": {"_id": {"$toString": "$_id"}}},
|
|
961
980
|
],
|
|
962
981
|
"as": field_config["alias"],
|
|
@@ -972,8 +991,23 @@ class QueryBuilder:
|
|
|
972
991
|
|
|
973
992
|
return lookup_pipeline
|
|
974
993
|
|
|
975
|
-
def get_lookup_relation(
|
|
976
|
-
|
|
994
|
+
def get_lookup_relation(
|
|
995
|
+
self,
|
|
996
|
+
relation_field: List[str],
|
|
997
|
+
populate_options: Optional[Dict[str, Any]] = None,
|
|
998
|
+
) -> tuple:
|
|
999
|
+
"""
|
|
1000
|
+
Get lookup relation configuration for a specific relation field.
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
relation_field: Relation configuration list [collection, field_name, prefix, ...]
|
|
1004
|
+
populate_options: Optional dict with populate options:
|
|
1005
|
+
- fields: List of fields to include in projection (e.g., ['name', 'code'])
|
|
1006
|
+
- filters: Dict of filters to apply to the lookup (future feature)
|
|
1007
|
+
|
|
1008
|
+
Returns:
|
|
1009
|
+
Tuple of (lookup_pipeline, collection_name)
|
|
1010
|
+
"""
|
|
977
1011
|
_prefix = self.collection_prefix + "_"
|
|
978
1012
|
if len(relation_field) > 2 and relation_field[2]:
|
|
979
1013
|
_prefix = relation_field[2] + "_"
|
|
@@ -1003,22 +1037,50 @@ class QueryBuilder:
|
|
|
1003
1037
|
else:
|
|
1004
1038
|
let_value = {"lid": f"${relation_field_name}"}
|
|
1005
1039
|
|
|
1040
|
+
# Build the pipeline for the lookup
|
|
1041
|
+
lookup_pipeline_stages = [
|
|
1042
|
+
{"$addFields": {"_id": {"$toString": "$_id"}}},
|
|
1043
|
+
match_stage,
|
|
1044
|
+
]
|
|
1045
|
+
|
|
1046
|
+
# Build projection based on populate_options
|
|
1047
|
+
if populate_options and isinstance(populate_options, dict):
|
|
1048
|
+
fields = populate_options.get("fields", [])
|
|
1049
|
+
if fields and isinstance(fields, list):
|
|
1050
|
+
# Include specified fields + always include _id
|
|
1051
|
+
project_stage = {"_id": 1}
|
|
1052
|
+
for field in fields:
|
|
1053
|
+
project_stage[field] = 1
|
|
1054
|
+
lookup_pipeline_stages.append({"$project": project_stage})
|
|
1055
|
+
else:
|
|
1056
|
+
# Default exclusion projection
|
|
1057
|
+
lookup_pipeline_stages.append(
|
|
1058
|
+
{
|
|
1059
|
+
"$project": {
|
|
1060
|
+
"creation_user": 0,
|
|
1061
|
+
"creation_date": 0,
|
|
1062
|
+
"disable": 0,
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
)
|
|
1066
|
+
else:
|
|
1067
|
+
# Default exclusion projection
|
|
1068
|
+
lookup_pipeline_stages.append(
|
|
1069
|
+
{
|
|
1070
|
+
"$project": {
|
|
1071
|
+
"creation_user": 0,
|
|
1072
|
+
"creation_date": 0,
|
|
1073
|
+
"disable": 0,
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1006
1078
|
lookup_relation = [
|
|
1007
1079
|
{
|
|
1008
1080
|
"$lookup": {
|
|
1009
1081
|
"from": f"{_prefix}{collection_name_relation}",
|
|
1010
1082
|
"let": let_value,
|
|
1011
|
-
"pipeline":
|
|
1012
|
-
{"$addFields": {"_id": {"$toString": "$_id"}}},
|
|
1013
|
-
match_stage,
|
|
1014
|
-
{
|
|
1015
|
-
"$project": {
|
|
1016
|
-
"creation_user": 0,
|
|
1017
|
-
"creation_date": 0,
|
|
1018
|
-
"disable": 0,
|
|
1019
|
-
}
|
|
1020
|
-
},
|
|
1021
|
-
],
|
|
1083
|
+
"pipeline": lookup_pipeline_stages,
|
|
1022
1084
|
"as": _as,
|
|
1023
1085
|
},
|
|
1024
1086
|
},
|
|
@@ -1067,9 +1129,16 @@ class QueryBuilder:
|
|
|
1067
1129
|
for query_relation in query_relations.keys():
|
|
1068
1130
|
# Check if it's a reserved field first
|
|
1069
1131
|
if query_relation in RESERVED_AUDIT_FIELDS:
|
|
1070
|
-
# Handle both True and dict with options (options are currently ignored)
|
|
1071
1132
|
if query_relations[query_relation]:
|
|
1072
|
-
|
|
1133
|
+
# Pass options if it's a dict, otherwise None
|
|
1134
|
+
populate_opts = (
|
|
1135
|
+
query_relations[query_relation]
|
|
1136
|
+
if isinstance(query_relations[query_relation], dict)
|
|
1137
|
+
else None
|
|
1138
|
+
)
|
|
1139
|
+
reserved_lookup = self.get_reserved_field_lookup(
|
|
1140
|
+
query_relation, populate_opts
|
|
1141
|
+
)
|
|
1073
1142
|
if reserved_lookup:
|
|
1074
1143
|
query_lookup_relations.extend(reserved_lookup)
|
|
1075
1144
|
continue
|
|
@@ -1091,9 +1160,10 @@ class QueryBuilder:
|
|
|
1091
1160
|
]
|
|
1092
1161
|
|
|
1093
1162
|
if isinstance(query_relations[query_relation], dict):
|
|
1094
|
-
#
|
|
1095
|
-
|
|
1096
|
-
|
|
1163
|
+
# Pass populate options (fields, filters, etc.) to the lookup
|
|
1164
|
+
lookup_relation = self.get_lookup_relation(
|
|
1165
|
+
relation, query_relations[query_relation]
|
|
1166
|
+
)
|
|
1097
1167
|
query_lookup_relations = [
|
|
1098
1168
|
*query_lookup_relations,
|
|
1099
1169
|
*lookup_relation[0],
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="oplnk-python-utils",
|
|
5
|
-
version="1.
|
|
5
|
+
version="1.3.0",
|
|
6
6
|
author="OpenLink SpA",
|
|
7
7
|
author_email="contacto@openlink.cl",
|
|
8
8
|
description="Reusable utilities for caching, data manipulation, parsing, and MongoDB query building",
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Cache utilities - Generic caching functions that can be used across projects
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
|
-
from typing import Callable, Any, Optional, Union
|
|
8
|
-
from fastapi.encoders import jsonable_encoder
|
|
9
|
-
from bson.objectid import ObjectId
|
|
10
|
-
from fastapi import Request
|
|
11
|
-
import functools
|
|
12
|
-
|
|
13
|
-
# Import core utilities
|
|
14
|
-
from ..core.data import normalize
|
|
15
|
-
from ..core.serialization import datetime_serializer
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def generate_cache_id(params: dict) -> str:
|
|
19
|
-
"""
|
|
20
|
-
Utility to generate a cache key hash from a dictionary.
|
|
21
|
-
Creates a consistent hash based on normalized dictionary content.
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
params: Dictionary of parameters to hash
|
|
25
|
-
|
|
26
|
-
Returns:
|
|
27
|
-
Cache key string
|
|
28
|
-
"""
|
|
29
|
-
normalized = normalize(params)
|
|
30
|
-
encoded_data = jsonable_encoder(normalized, custom_encoder={ObjectId: str})
|
|
31
|
-
serialized = json.dumps(encoded_data, separators=(",", ":"), ensure_ascii=True)
|
|
32
|
-
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def get_or_set_cache(
|
|
36
|
-
redis_client,
|
|
37
|
-
cache_key: str,
|
|
38
|
-
fetch_func: Callable,
|
|
39
|
-
clear_cache: Union[str, bool] = False,
|
|
40
|
-
ttl: Optional[int] = None,
|
|
41
|
-
serializer: Optional[Callable] = None,
|
|
42
|
-
) -> Any:
|
|
43
|
-
"""
|
|
44
|
-
Retrieve data from cache if available, otherwise fetch, cache, and return it.
|
|
45
|
-
Optionally clear cache before fetching.
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
redis_client: Redis client instance
|
|
49
|
-
cache_key: Key to use for caching
|
|
50
|
-
fetch_func: Function to call if cache miss
|
|
51
|
-
clear_cache: Pattern to clear or boolean to clear specific key
|
|
52
|
-
ttl: Time to live in seconds
|
|
53
|
-
serializer: Function to serialize data before caching
|
|
54
|
-
|
|
55
|
-
Returns:
|
|
56
|
-
Cached or fetched data
|
|
57
|
-
"""
|
|
58
|
-
if clear_cache:
|
|
59
|
-
invalidate_cache_keys(
|
|
60
|
-
redis_client, clear_cache if isinstance(clear_cache, str) else cache_key
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
if redis_client.exists(cache_key):
|
|
64
|
-
return json.loads(redis_client.get(cache_key))
|
|
65
|
-
|
|
66
|
-
data = fetch_func()
|
|
67
|
-
if serializer:
|
|
68
|
-
data = serializer(data)
|
|
69
|
-
|
|
70
|
-
encoded_data = jsonable_encoder(data, custom_encoder={ObjectId: str})
|
|
71
|
-
redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl)
|
|
72
|
-
return encoded_data
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def invalidate_cache_keys(redis_client, pattern: str) -> int:
|
|
76
|
-
"""
|
|
77
|
-
Utility to invalidate cache keys matching a pattern.
|
|
78
|
-
|
|
79
|
-
Args:
|
|
80
|
-
redis_client: Redis client instance
|
|
81
|
-
pattern: Pattern to match keys for deletion
|
|
82
|
-
|
|
83
|
-
Returns:
|
|
84
|
-
Number of keys deleted
|
|
85
|
-
"""
|
|
86
|
-
cursor = 0
|
|
87
|
-
batch_size = 1000
|
|
88
|
-
total_deleted = 0
|
|
89
|
-
|
|
90
|
-
while True:
|
|
91
|
-
cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=batch_size)
|
|
92
|
-
if keys:
|
|
93
|
-
redis_client.unlink(*keys)
|
|
94
|
-
total_deleted += len(keys)
|
|
95
|
-
if cursor == 0:
|
|
96
|
-
break
|
|
97
|
-
|
|
98
|
-
return total_deleted
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
async def build_cache_key(
|
|
102
|
-
request: Request, api_name: str, collection: str, endpoint_type: str
|
|
103
|
-
) -> str:
|
|
104
|
-
"""
|
|
105
|
-
Builds a cache key based on the request, api name, collection, and endpoint type.
|
|
106
|
-
|
|
107
|
-
Incorporates HTTP method, URL path, path parameters and query parameters to
|
|
108
|
-
avoid collisions between routes that differ only by path params.
|
|
109
|
-
|
|
110
|
-
Args:
|
|
111
|
-
request: FastAPI request object
|
|
112
|
-
api_name: Name of the API
|
|
113
|
-
collection: Name of the collection
|
|
114
|
-
endpoint_type: Type of endpoint
|
|
115
|
-
|
|
116
|
-
Returns:
|
|
117
|
-
Cache key string
|
|
118
|
-
"""
|
|
119
|
-
# Ensure plain dicts for hashing
|
|
120
|
-
query_params_dict = dict(request.query_params) if request.query_params else {}
|
|
121
|
-
path_params_dict = (
|
|
122
|
-
dict(request.path_params) if getattr(request, "path_params", None) else {}
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
key_material = {
|
|
126
|
-
"method": request.method,
|
|
127
|
-
"path": request.url.path,
|
|
128
|
-
"path_params": path_params_dict,
|
|
129
|
-
"query": query_params_dict,
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
# Include request body for methods where it affects the response identity
|
|
133
|
-
if request.method in {"POST", "PUT"}:
|
|
134
|
-
content_type = (request.headers.get("content-type") or "").split(";")[0].lower()
|
|
135
|
-
body_hash: Optional[str] = None
|
|
136
|
-
if content_type == "application/json":
|
|
137
|
-
try:
|
|
138
|
-
json_body = await request.json()
|
|
139
|
-
normalized_body = normalize(json_body)
|
|
140
|
-
encoded_body = jsonable_encoder(
|
|
141
|
-
normalized_body, custom_encoder={ObjectId: str}
|
|
142
|
-
)
|
|
143
|
-
key_material["body"] = encoded_body
|
|
144
|
-
except Exception:
|
|
145
|
-
body_bytes = await request.body()
|
|
146
|
-
if body_bytes:
|
|
147
|
-
body_hash = hashlib.sha256(body_bytes).hexdigest()
|
|
148
|
-
else:
|
|
149
|
-
body_bytes = await request.body()
|
|
150
|
-
if body_bytes:
|
|
151
|
-
body_hash = hashlib.sha256(body_bytes).hexdigest()
|
|
152
|
-
|
|
153
|
-
if body_hash:
|
|
154
|
-
key_material["body_hash"] = body_hash
|
|
155
|
-
|
|
156
|
-
hashed = generate_cache_id(key_material)
|
|
157
|
-
return f"{api_name}:{collection}:{endpoint_type}:{hashed}"
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def cache_response(
|
|
161
|
-
redis_client,
|
|
162
|
-
api_name: str,
|
|
163
|
-
collection: str,
|
|
164
|
-
endpoint_type: str,
|
|
165
|
-
tags: list[str],
|
|
166
|
-
ttl: int | None = None,
|
|
167
|
-
use_cache: bool = True,
|
|
168
|
-
):
|
|
169
|
-
"""
|
|
170
|
-
Decorator to cache the response of an endpoint.
|
|
171
|
-
|
|
172
|
-
Args:
|
|
173
|
-
redis_client: Redis client instance
|
|
174
|
-
api_name: Name of the API
|
|
175
|
-
collection: Name of the collection
|
|
176
|
-
endpoint_type: Type of endpoint
|
|
177
|
-
tags: List of tags to use for caching
|
|
178
|
-
ttl: Time to live in seconds
|
|
179
|
-
use_cache: Whether to use cache
|
|
180
|
-
|
|
181
|
-
Returns:
|
|
182
|
-
Decorator function
|
|
183
|
-
"""
|
|
184
|
-
|
|
185
|
-
def decorator(func: Callable):
|
|
186
|
-
@functools.wraps(func)
|
|
187
|
-
async def wrapper(*args, **kwargs):
|
|
188
|
-
if not use_cache:
|
|
189
|
-
return await func(*args, **kwargs)
|
|
190
|
-
|
|
191
|
-
# Detectar el Request
|
|
192
|
-
request: Optional[Request] = kwargs.get("request")
|
|
193
|
-
if request is None:
|
|
194
|
-
for arg in args:
|
|
195
|
-
if isinstance(arg, Request):
|
|
196
|
-
request = arg
|
|
197
|
-
break
|
|
198
|
-
if request is None:
|
|
199
|
-
raise ValueError("Request object not found in endpoint parameters.")
|
|
200
|
-
|
|
201
|
-
query_params = request.query_params
|
|
202
|
-
clear_cache_value = query_params.get("clear_cache")
|
|
203
|
-
if isinstance(clear_cache_value, str):
|
|
204
|
-
clear_cache_flag = clear_cache_value.lower() in {
|
|
205
|
-
"1",
|
|
206
|
-
"true",
|
|
207
|
-
"yes",
|
|
208
|
-
"y",
|
|
209
|
-
}
|
|
210
|
-
else:
|
|
211
|
-
clear_cache_flag = bool(clear_cache_value)
|
|
212
|
-
if clear_cache_flag:
|
|
213
|
-
invalidate_tag(redis_client, f"{collection}")
|
|
214
|
-
|
|
215
|
-
# Construir clave
|
|
216
|
-
cache_key = await build_cache_key(
|
|
217
|
-
request, api_name, collection, endpoint_type
|
|
218
|
-
)
|
|
219
|
-
if redis_client.exists(cache_key):
|
|
220
|
-
return json.loads(redis_client.get(cache_key))
|
|
221
|
-
|
|
222
|
-
response = await func(*args, **kwargs)
|
|
223
|
-
encoded_data = jsonable_encoder(response, custom_encoder={ObjectId: str})
|
|
224
|
-
redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl)
|
|
225
|
-
for tag in tags:
|
|
226
|
-
redis_client.sadd(f"tag:{tag}", cache_key)
|
|
227
|
-
|
|
228
|
-
return encoded_data
|
|
229
|
-
|
|
230
|
-
return wrapper
|
|
231
|
-
|
|
232
|
-
return decorator
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def cache_factory(redis_client, api_name: str, collection: str):
|
|
236
|
-
"""
|
|
237
|
-
Factory to create cache decorators for different endpoint types.
|
|
238
|
-
|
|
239
|
-
Args:
|
|
240
|
-
redis_client: Redis client instance
|
|
241
|
-
api_name: Name of the API
|
|
242
|
-
collection: Name of the collection
|
|
243
|
-
|
|
244
|
-
Returns:
|
|
245
|
-
Decorator function
|
|
246
|
-
"""
|
|
247
|
-
|
|
248
|
-
def wrapper(
|
|
249
|
-
endpoint_type: str,
|
|
250
|
-
tags: list[str],
|
|
251
|
-
ttl: int | None = None,
|
|
252
|
-
use_cache: bool = True,
|
|
253
|
-
):
|
|
254
|
-
return cache_response(
|
|
255
|
-
redis_client=redis_client,
|
|
256
|
-
api_name=api_name,
|
|
257
|
-
collection=collection,
|
|
258
|
-
endpoint_type=endpoint_type,
|
|
259
|
-
tags=tags,
|
|
260
|
-
ttl=ttl,
|
|
261
|
-
use_cache=use_cache,
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
return wrapper
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def invalidate_tag(redis_client, tag: str):
|
|
268
|
-
"""
|
|
269
|
-
Utility to invalidate cache keys associated with a tag.
|
|
270
|
-
|
|
271
|
-
Args:
|
|
272
|
-
redis_client: Redis client instance
|
|
273
|
-
tag: Tag to invalidate
|
|
274
|
-
"""
|
|
275
|
-
keys = redis_client.smembers(f"tag:{tag}")
|
|
276
|
-
for key in keys:
|
|
277
|
-
redis_client.delete(key)
|
|
278
|
-
redis_client.delete(f"tag:{tag}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
{oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils/reports/__init__.py
RENAMED
|
File without changes
|
{oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/not-zip-safe
RENAMED
|
File without changes
|
{oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/requires.txt
RENAMED
|
File without changes
|
{oplnk_python_utils-1.2.6 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|