persistence-kit 3.6.0__tar.gz → 3.7.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 (99) hide show
  1. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/PKG-INFO +28 -2
  2. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/README.md +27 -1
  3. persistence_kit-3.7.0/persistence_kit/cache/__init__.py +15 -0
  4. persistence_kit-3.7.0/persistence_kit/cache/contracts.py +23 -0
  5. persistence_kit-3.7.0/persistence_kit/cache/factory.py +55 -0
  6. persistence_kit-3.7.0/persistence_kit/cache/memory.py +37 -0
  7. persistence_kit-3.7.0/persistence_kit/cache/mongo.py +55 -0
  8. persistence_kit-3.7.0/persistence_kit/cache/namespaced.py +31 -0
  9. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/__init__.py +2 -0
  10. persistence_kit-3.7.0/persistence_kit/restclient/caching.py +176 -0
  11. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/client.py +1 -0
  12. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/config.py +3 -0
  13. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/contracts.py +1 -0
  14. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/memory.py +1 -0
  15. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/provider.py +2 -0
  16. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/registry.py +19 -2
  17. persistence_kit-3.7.0/persistence_kit/settings/cache_settings.py +16 -0
  18. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/pyproject.toml +1 -1
  19. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/LICENSE +0 -0
  20. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/__init__.py +0 -0
  21. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/api/__init__.py +0 -0
  22. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/api/common.py +0 -0
  23. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/api/error_handlers.py +0 -0
  24. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/api/exceptions.py +0 -0
  25. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/api/rate_limit.py +0 -0
  26. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/api/route_loader.py +0 -0
  27. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/authenticated_user.py +0 -0
  28. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/bootstrap/__init__.py +0 -0
  29. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/bootstrap/configuration.py +0 -0
  30. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/bootstrap/seeders.py +0 -0
  31. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/bootstrap/startup.py +0 -0
  32. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/contracts/__init__.py +0 -0
  33. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/contracts/repository.py +0 -0
  34. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/contracts/view_repository.py +0 -0
  35. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/py.typed +0 -0
  36. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/__init__.py +0 -0
  37. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  38. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  39. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/filter_ops.py +0 -0
  40. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  41. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  42. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  43. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  44. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  45. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  46. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  47. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  48. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  49. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  50. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  51. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository_factory/__init__.py +0 -0
  52. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  53. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  54. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  55. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  56. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  57. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  58. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/resilience/__init__.py +0 -0
  59. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/resilience/circuit.py +0 -0
  60. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/aggregate.py +0 -0
  61. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/auth/__init__.py +0 -0
  62. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/auth/api_key.py +0 -0
  63. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/auth/base.py +0 -0
  64. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/auth/basic.py +0 -0
  65. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/auth/bearer.py +0 -0
  66. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/auth/login.py +0 -0
  67. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
  68. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/errors.py +0 -0
  69. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/factory.py +0 -0
  70. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/mapping.py +0 -0
  71. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/payload.py +0 -0
  72. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/populate.py +0 -0
  73. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/resolver.py +0 -0
  74. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/restclient/retry.py +0 -0
  75. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/__init__.py +0 -0
  76. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/factory.py +0 -0
  77. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/ports.py +0 -0
  78. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/providers/__init__.py +0 -0
  79. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  80. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  81. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/registration.py +0 -0
  82. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  83. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  84. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  85. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/settings/__init__.py +0 -0
  86. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/settings/app_settings.py +0 -0
  87. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/settings/constants.py +0 -0
  88. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/settings/parsers.py +0 -0
  89. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/settings/repo_settings.py +0 -0
  90. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/__init__.py +0 -0
  91. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/contracts.py +0 -0
  92. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/errors.py +0 -0
  93. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/factory.py +0 -0
  94. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/local.py +0 -0
  95. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/media.py +0 -0
  96. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/routes.py +0 -0
  97. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/storage/s3.py +0 -0
  98. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/utils/__init__.py +0 -0
  99. {persistence_kit-3.6.0 → persistence_kit-3.7.0}/persistence_kit/utils/upsert.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persistence-kit
3
- Version: 3.6.0
3
+ Version: 3.7.0
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -69,6 +69,11 @@ Author: Andres Felipe Serrano Barrios
69
69
  - `security/`: reusable identity provider contracts, Cognito/memory adapters, and JWT verifiers
70
70
  - `repository/`: concrete repository implementations by backend
71
71
  - `repository_factory/`: entity registry, repository creation, and populated view repository
72
+ - `resilience/`: retry policy and circuit breaker shared across the kit
73
+ - `restclient/`: generic REST/HTTP client (transport, pluggable auth, endpoint resolvers, DTO mapper) with optional response caching
74
+ - `cache/`: generic key-value cache with TTL (in-memory, Mongo, DynamoDB), selected by `CACHE_BACKEND`
75
+
76
+ See `docs/restclient_and_cache.md` for the REST client and cache.
72
77
 
73
78
  Recommended rule:
74
79
 
@@ -167,6 +172,22 @@ from persistence_kit.repository_factory import (
167
172
  provide_view_repo,
168
173
  set_registry_initializer,
169
174
  )
175
+ from persistence_kit.restclient import (
176
+ RestClient,
177
+ ServiceConfig,
178
+ register_rest_service,
179
+ get_rest_client,
180
+ provide_rest_client,
181
+ set_rest_registry_initializer,
182
+ decode,
183
+ )
184
+ from persistence_kit.cache import (
185
+ Cache,
186
+ InMemoryTTLCache,
187
+ get_cache,
188
+ CacheBackend,
189
+ CacheSettings,
190
+ )
170
191
  ```
171
192
 
172
193
  Use internal paths only for implementation details, for example:
@@ -273,7 +294,10 @@ the reusable FastAPI local export route.
273
294
 
274
295
  ## Supported Environment Variables
275
296
 
276
- - `REPO_DATABASE=memory|mongo|postgres`
297
+ - `REPO_DATABASE=memory|mongo|postgres|dynamodb`
298
+ - `CACHE_BACKEND=memory|mongo|dynamodb` (default `memory`; backend for `get_cache` / the REST client cache)
299
+ - `CACHE_NAMESPACE` (optional key prefix; isolates apps that share one cache, e.g. a common DynamoDB table)
300
+ - `REST_SERVICE_URLS` (JSON map `{"service": "base_url"}` to override registered REST base URLs)
277
301
  - `MONGO_DSN`
278
302
  - `MONGO_DB`
279
303
  - `POSTGRES_USER`
@@ -281,6 +305,8 @@ the reusable FastAPI local export route.
281
305
  - `POSTGRES_HOST`
282
306
  - `POSTGRES_PORT`
283
307
  - `POSTGRES_DB`
308
+ - `DYNAMODB_REGION`
309
+ - `DYNAMODB_TABLE_PREFIX`
284
310
 
285
311
  ## Local Development
286
312
 
@@ -25,6 +25,11 @@ Author: Andres Felipe Serrano Barrios
25
25
  - `security/`: reusable identity provider contracts, Cognito/memory adapters, and JWT verifiers
26
26
  - `repository/`: concrete repository implementations by backend
27
27
  - `repository_factory/`: entity registry, repository creation, and populated view repository
28
+ - `resilience/`: retry policy and circuit breaker shared across the kit
29
+ - `restclient/`: generic REST/HTTP client (transport, pluggable auth, endpoint resolvers, DTO mapper) with optional response caching
30
+ - `cache/`: generic key-value cache with TTL (in-memory, Mongo, DynamoDB), selected by `CACHE_BACKEND`
31
+
32
+ See `docs/restclient_and_cache.md` for the REST client and cache.
28
33
 
29
34
  Recommended rule:
30
35
 
@@ -123,6 +128,22 @@ from persistence_kit.repository_factory import (
123
128
  provide_view_repo,
124
129
  set_registry_initializer,
125
130
  )
131
+ from persistence_kit.restclient import (
132
+ RestClient,
133
+ ServiceConfig,
134
+ register_rest_service,
135
+ get_rest_client,
136
+ provide_rest_client,
137
+ set_rest_registry_initializer,
138
+ decode,
139
+ )
140
+ from persistence_kit.cache import (
141
+ Cache,
142
+ InMemoryTTLCache,
143
+ get_cache,
144
+ CacheBackend,
145
+ CacheSettings,
146
+ )
126
147
  ```
127
148
 
128
149
  Use internal paths only for implementation details, for example:
@@ -229,7 +250,10 @@ the reusable FastAPI local export route.
229
250
 
230
251
  ## Supported Environment Variables
231
252
 
232
- - `REPO_DATABASE=memory|mongo|postgres`
253
+ - `REPO_DATABASE=memory|mongo|postgres|dynamodb`
254
+ - `CACHE_BACKEND=memory|mongo|dynamodb` (default `memory`; backend for `get_cache` / the REST client cache)
255
+ - `CACHE_NAMESPACE` (optional key prefix; isolates apps that share one cache, e.g. a common DynamoDB table)
256
+ - `REST_SERVICE_URLS` (JSON map `{"service": "base_url"}` to override registered REST base URLs)
233
257
  - `MONGO_DSN`
234
258
  - `MONGO_DB`
235
259
  - `POSTGRES_USER`
@@ -237,6 +261,8 @@ the reusable FastAPI local export route.
237
261
  - `POSTGRES_HOST`
238
262
  - `POSTGRES_PORT`
239
263
  - `POSTGRES_DB`
264
+ - `DYNAMODB_REGION`
265
+ - `DYNAMODB_TABLE_PREFIX`
240
266
 
241
267
  ## Local Development
242
268
 
@@ -0,0 +1,15 @@
1
+ from persistence_kit.cache.contracts import Cache
2
+ from persistence_kit.cache.factory import get_cache, reset_cache
3
+ from persistence_kit.cache.memory import InMemoryTTLCache
4
+ from persistence_kit.cache.namespaced import NamespacedCache
5
+ from persistence_kit.settings.cache_settings import CacheBackend, CacheSettings
6
+
7
+ __all__ = [
8
+ "Cache",
9
+ "InMemoryTTLCache",
10
+ "NamespacedCache",
11
+ "get_cache",
12
+ "reset_cache",
13
+ "CacheBackend",
14
+ "CacheSettings",
15
+ ]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class Cache(Protocol):
8
+ """Almacen clave-valor con expiracion opcional (TTL).
9
+
10
+ Los backends (memoria, Mongo, Dynamo) son intercambiables; el valor debe ser
11
+ serializable a JSON para los backends persistentes.
12
+ """
13
+
14
+ async def get(self, key: str) -> Any | None: ...
15
+
16
+ async def set(self, key: str, value: Any, ttl_seconds: float | None = None) -> None: ...
17
+
18
+ async def delete(self, key: str) -> None: ...
19
+
20
+ async def clear(self, prefix: str = "") -> int:
21
+ """Borra todas las claves que empiezan con ``prefix`` (o todo si vacio).
22
+ Devuelve cuantas borro."""
23
+ ...
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+
5
+ from persistence_kit.cache.contracts import Cache
6
+ from persistence_kit.settings.cache_settings import CacheBackend, CacheSettings
7
+
8
+
9
+ @lru_cache
10
+ def _mongo_collection(dsn: str, dbname: str, name: str):
11
+ from motor.motor_asyncio import AsyncIOMotorClient
12
+
13
+ return AsyncIOMotorClient(dsn, uuidRepresentation="standard")[dbname][name]
14
+
15
+
16
+ @lru_cache
17
+ def _cache_cached(name: str, backend: CacheBackend) -> Cache:
18
+ if backend is CacheBackend.MEMORY:
19
+ from persistence_kit.cache.memory import InMemoryTTLCache
20
+
21
+ return InMemoryTTLCache()
22
+
23
+ if backend is CacheBackend.MONGO:
24
+ from persistence_kit.cache.mongo import MongoCache
25
+ from persistence_kit.settings.repo_settings import RepoSettings
26
+
27
+ repo = RepoSettings()
28
+ collection = _mongo_collection(repo.mongo_dsn, repo.mongo_db, f"cache_{name}")
29
+ return MongoCache(collection)
30
+
31
+ if backend is CacheBackend.DYNAMODB:
32
+ raise NotImplementedError("DynamoCache pendiente (paso 2 del plan de cache).")
33
+
34
+ raise ValueError(f"Cache backend no soportado: {backend}")
35
+
36
+
37
+ def get_cache(name: str = "default") -> Cache:
38
+ """Devuelve el cache para ``name``, con el backend elegido por el setting
39
+ ``CACHE_BACKEND`` (gemelo de ``get_repo`` con ``REPO_DATABASE``).
40
+
41
+ Si ``CACHE_NAMESPACE`` esta seteado, todas las claves se prefijan con el, para
42
+ aislar apps que comparten un mismo cache (p. ej. una tabla DynamoDB comun).
43
+ """
44
+ settings = CacheSettings()
45
+ cache = _cache_cached(name, settings.cache_backend)
46
+ if settings.cache_namespace:
47
+ from persistence_kit.cache.namespaced import NamespacedCache
48
+
49
+ return NamespacedCache(cache, settings.cache_namespace)
50
+ return cache
51
+
52
+
53
+ def reset_cache() -> None:
54
+ _cache_cached.cache_clear()
55
+ _mongo_collection.cache_clear()
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+
7
+ class InMemoryTTLCache:
8
+ """Cache en memoria del proceso con expiracion perezosa por TTL.
9
+
10
+ Es por-proceso: cada instancia/tarea tiene la suya y se pierde al reiniciar.
11
+ """
12
+
13
+ def __init__(self) -> None:
14
+ self._store: dict[str, tuple[Any, float | None]] = {}
15
+
16
+ async def get(self, key: str) -> Any | None:
17
+ entry = self._store.get(key)
18
+ if entry is None:
19
+ return None
20
+ value, expires_at = entry
21
+ if expires_at is not None and time.monotonic() >= expires_at:
22
+ self._store.pop(key, None)
23
+ return None
24
+ return value
25
+
26
+ async def set(self, key: str, value: Any, ttl_seconds: float | None = None) -> None:
27
+ expires_at = time.monotonic() + ttl_seconds if ttl_seconds else None
28
+ self._store[key] = (value, expires_at)
29
+
30
+ async def delete(self, key: str) -> None:
31
+ self._store.pop(key, None)
32
+
33
+ async def clear(self, prefix: str = "") -> int:
34
+ keys = [key for key in self._store if key.startswith(prefix)]
35
+ for key in keys:
36
+ self._store.pop(key, None)
37
+ return len(keys)
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any
6
+
7
+ from motor.motor_asyncio import AsyncIOMotorCollection
8
+
9
+
10
+ class MongoCache:
11
+ """Cache respaldado por una coleccion Mongo con indice TTL nativo sobre
12
+ ``expiresAt`` (el servidor borra los documentos expirados solo).
13
+
14
+ El monitor TTL de Mongo corre cada ~60s, por eso ``get`` valida la expiracion
15
+ ademas del indice, para no devolver un documento recien vencido.
16
+ """
17
+
18
+ def __init__(self, collection: AsyncIOMotorCollection) -> None:
19
+ self._col = collection
20
+ self._index_ready = False
21
+
22
+ async def _ensure_index(self) -> None:
23
+ if not self._index_ready:
24
+ await self._col.create_index("expiresAt", expireAfterSeconds=0)
25
+ self._index_ready = True
26
+
27
+ async def get(self, key: str) -> Any | None:
28
+ doc = await self._col.find_one({"_id": key})
29
+ if doc is None:
30
+ return None
31
+ expires_at = doc.get("expiresAt")
32
+ if expires_at is not None:
33
+ if expires_at.tzinfo is None:
34
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
35
+ if expires_at <= datetime.now(timezone.utc):
36
+ return None
37
+ return doc.get("value")
38
+
39
+ async def set(self, key: str, value: Any, ttl_seconds: float | None = None) -> None:
40
+ await self._ensure_index()
41
+ doc: dict[str, Any] = {"_id": key, "value": value}
42
+ if ttl_seconds:
43
+ doc["expiresAt"] = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
44
+ await self._col.replace_one({"_id": key}, doc, upsert=True)
45
+
46
+ async def delete(self, key: str) -> None:
47
+ await self._col.delete_one({"_id": key})
48
+
49
+ async def clear(self, prefix: str = "") -> int:
50
+ if prefix:
51
+ query = {"_id": {"$regex": f"^{re.escape(prefix)}"}}
52
+ else:
53
+ query = {}
54
+ result = await self._col.delete_many(query)
55
+ return result.deleted_count
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from persistence_kit.cache.contracts import Cache
6
+
7
+
8
+ class NamespacedCache:
9
+ """Prefija todas las claves con ``namespace:`` para aislar aplicaciones que
10
+ comparten un mismo cache.
11
+
12
+ Util cuando varias apps (p. ej. store_manager y siga) usan la misma tabla
13
+ DynamoDB o la misma coleccion Mongo: cada app pone su ``CACHE_NAMESPACE`` y
14
+ sus claves no colisionan.
15
+ """
16
+
17
+ def __init__(self, inner: Cache, namespace: str) -> None:
18
+ self._inner = inner
19
+ self._prefix = f"{namespace}:"
20
+
21
+ async def get(self, key: str) -> Any | None:
22
+ return await self._inner.get(self._prefix + key)
23
+
24
+ async def set(self, key: str, value: Any, ttl_seconds: float | None = None) -> None:
25
+ await self._inner.set(self._prefix + key, value, ttl_seconds)
26
+
27
+ async def delete(self, key: str) -> None:
28
+ await self._inner.delete(self._prefix + key)
29
+
30
+ async def clear(self, prefix: str = "") -> int:
31
+ return await self._inner.clear(self._prefix + prefix)
@@ -58,8 +58,10 @@ from persistence_kit.restclient.resolver import (
58
58
  StaticEndpointResolver,
59
59
  )
60
60
  from persistence_kit.restclient.retry import RetryPolicy
61
+ from persistence_kit.restclient.caching import CachingRestClient
61
62
 
62
63
  __all__ = [
64
+ "CachingRestClient",
63
65
  "RestClient",
64
66
  "RestRequest",
65
67
  "RestResponse",
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import json as _json
7
+ import logging
8
+ import time
9
+ from typing import Any, Awaitable, Callable, Mapping
10
+
11
+ from persistence_kit.cache.contracts import Cache
12
+ from persistence_kit.restclient.contracts import RestClient, RestResponse
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ OnChange = Callable[[str], None]
17
+
18
+
19
+ def _serialize(response: RestResponse) -> dict[str, Any]:
20
+ return {
21
+ "status_code": response.status_code,
22
+ "headers": dict(response.headers),
23
+ "content_b64": base64.b64encode(response.content).decode("ascii"),
24
+ "url": response.url,
25
+ }
26
+
27
+
28
+ def _deserialize(data: Mapping[str, Any]) -> RestResponse:
29
+ return RestResponse(
30
+ status_code=data["status_code"],
31
+ headers=dict(data["headers"]),
32
+ content=base64.b64decode(data["content_b64"]),
33
+ url=data["url"],
34
+ )
35
+
36
+
37
+ class CachingRestClient:
38
+ """Decora un RestClient con cache de respuestas idempotentes (GET) y
39
+ deteccion de cambios por hash de contenido.
40
+
41
+ La cacheabilidad se controla por la config del servicio
42
+ (``cache_ttl_seconds``) o por-llamada con el parametro ``cache_ttl`` de
43
+ ``request``: ``None`` usa el default del servicio, ``0`` no cachea esa
44
+ llamada (bypass), y un valor ``> 0`` cachea con ese TTL.
45
+
46
+ Cada entry guarda ``{response, hash, stored_at}``. Dentro del TTL fresco se
47
+ sirve del cache; dentro de la ventana ``stale-while-revalidate`` se sirve el
48
+ valor viejo Y se dispara una revalidacion en background (sin bloquear, sin
49
+ jobs): re-consulta, compara el hash y, si cambio, actualiza y llama
50
+ ``on_change(key)``.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ inner: RestClient,
56
+ cache: Cache,
57
+ *,
58
+ default_ttl_seconds: float | None = None,
59
+ default_swr_seconds: float = 0.0,
60
+ name: str = "",
61
+ on_change: OnChange | None = None,
62
+ cacheable_methods: tuple[str, ...] = ("GET",),
63
+ ) -> None:
64
+ self._inner = inner
65
+ self._cache = cache
66
+ self._default_ttl = default_ttl_seconds
67
+ self._default_swr = default_swr_seconds
68
+ self._name = name
69
+ self._on_change = on_change
70
+ self._methods = frozenset(method.upper() for method in cacheable_methods)
71
+ self._inflight: set[str] = set()
72
+
73
+ def _key(self, method: str, service: str, params: Mapping[str, Any] | None) -> str:
74
+ params_repr = _json.dumps(params or {}, sort_keys=True, default=str)
75
+ return f"{self._name}|{method}|{service}|{params_repr}"
76
+
77
+ async def request(
78
+ self,
79
+ method: str,
80
+ service: str,
81
+ *,
82
+ params: Mapping[str, Any] | None = None,
83
+ json: Any | None = None,
84
+ xml: Any | None = None,
85
+ content: bytes | None = None,
86
+ content_type: str | None = None,
87
+ soap_action: str | None = None,
88
+ headers: Mapping[str, str] | None = None,
89
+ cache_ttl: float | None = None,
90
+ ) -> RestResponse:
91
+ fresh = cache_ttl if cache_ttl is not None else self._default_ttl
92
+ swr = self._default_swr
93
+
94
+ cacheable = (
95
+ method.upper() in self._methods
96
+ and json is None
97
+ and xml is None
98
+ and content is None
99
+ and fresh is not None
100
+ and fresh > 0
101
+ )
102
+
103
+ async def _fetch() -> RestResponse:
104
+ return await self._inner.request(
105
+ method,
106
+ service,
107
+ params=params,
108
+ json=json,
109
+ xml=xml,
110
+ content=content,
111
+ content_type=content_type,
112
+ soap_action=soap_action,
113
+ headers=headers,
114
+ )
115
+
116
+ if not cacheable:
117
+ return await _fetch()
118
+
119
+ key = self._key(method.upper(), service, params)
120
+ entry = await self._cache.get(key)
121
+ now = time.time()
122
+
123
+ if entry is not None:
124
+ age = now - entry.get("stored_at", now)
125
+ if age < fresh:
126
+ return _deserialize(entry["response"])
127
+ if swr and age < fresh + swr:
128
+ self._schedule_revalidate(key, entry.get("hash"), _fetch, fresh, swr)
129
+ return _deserialize(entry["response"])
130
+
131
+ response = await _fetch()
132
+ if response.is_success:
133
+ await self._store(key, response, fresh, swr, now)
134
+ return response
135
+
136
+ async def _store(
137
+ self, key: str, response: RestResponse, fresh: float, swr: float, now: float
138
+ ) -> None:
139
+ value = {
140
+ "response": _serialize(response),
141
+ "hash": hashlib.sha256(response.content).hexdigest(),
142
+ "stored_at": now,
143
+ }
144
+ await self._cache.set(key, value, fresh + (swr or 0.0))
145
+
146
+ def _schedule_revalidate(
147
+ self,
148
+ key: str,
149
+ old_hash: str | None,
150
+ fetch: Callable[[], Awaitable[RestResponse]],
151
+ fresh: float,
152
+ swr: float,
153
+ ) -> None:
154
+ if key in self._inflight:
155
+ return
156
+ self._inflight.add(key)
157
+
158
+ async def _run() -> None:
159
+ try:
160
+ response = await fetch()
161
+ if response.is_success:
162
+ new_hash = hashlib.sha256(response.content).hexdigest()
163
+ await self._store(key, response, fresh, swr, time.time())
164
+ if new_hash != old_hash:
165
+ logger.info("cache: cambio detectado en %s", key)
166
+ if self._on_change is not None:
167
+ self._on_change(key)
168
+ except Exception:
169
+ logger.exception("cache: fallo revalidando %s", key)
170
+ finally:
171
+ self._inflight.discard(key)
172
+
173
+ asyncio.create_task(_run())
174
+
175
+ async def aclose(self) -> None:
176
+ await self._inner.aclose()
@@ -72,6 +72,7 @@ class HttpxRestClient(ModelMappingMixin):
72
72
  content_type: str | None = None,
73
73
  soap_action: str | None = None,
74
74
  headers: Mapping[str, str] | None = None,
75
+ cache_ttl: float | None = None,
75
76
  ) -> RestResponse:
76
77
  url = await self._resolver.resolve(service)
77
78
  request = prepare_request(
@@ -15,6 +15,9 @@ class ServiceConfig(BaseModel):
15
15
  raise_for_status: bool = True
16
16
  default_headers: dict[str, str] = Field(default_factory=dict)
17
17
  user_agent: str | None = None
18
+ cacheable: bool = False
19
+ cache_ttl_seconds: float | None = None
20
+ cache_stale_while_revalidate_seconds: float = 0.0
18
21
 
19
22
  @classmethod
20
23
  def from_settings(cls, settings: Any, **overrides: Any) -> "ServiceConfig":
@@ -94,6 +94,7 @@ class RestClient(Protocol):
94
94
  content_type: str | None = None,
95
95
  soap_action: str | None = None,
96
96
  headers: Mapping[str, str] | None = None,
97
+ cache_ttl: float | None = None,
97
98
  ) -> RestResponse: ...
98
99
 
99
100
  async def aclose(self) -> None: ...
@@ -82,6 +82,7 @@ class MemoryRestClient(ModelMappingMixin):
82
82
  content_type: str | None = None,
83
83
  soap_action: str | None = None,
84
84
  headers: Mapping[str, str] | None = None,
85
+ cache_ttl: float | None = None,
85
86
  ) -> RestResponse:
86
87
  url = await self._resolver.resolve(service) if self._resolver else service
87
88
  request = prepare_request(
@@ -46,6 +46,7 @@ def register_rest_service(
46
46
  config: ServiceConfig | None = None,
47
47
  retry_policy: RetryPolicy | None = None,
48
48
  circuit_breaker: CircuitBreaker | None = None,
49
+ on_cache_change: Callable[[str], None] | None = None,
49
50
  replace: bool = False,
50
51
  ) -> None:
51
52
  default_rest_registry().register(
@@ -56,6 +57,7 @@ def register_rest_service(
56
57
  config=config,
57
58
  retry_policy=retry_policy,
58
59
  circuit_breaker=circuit_breaker,
60
+ on_cache_change=on_cache_change,
59
61
  replace=replace,
60
62
  )
61
63
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Any
4
+ from typing import Any, Callable
5
5
 
6
6
  from persistence_kit.resilience import CircuitBreaker
7
7
  from persistence_kit.restclient.auth.base import NoAuth
@@ -24,6 +24,7 @@ class RegisteredService:
24
24
  authenticator: Authenticator
25
25
  retry_policy: RetryPolicy | None = None
26
26
  circuit_breaker: CircuitBreaker | None = None
27
+ on_cache_change: Callable[[str], None] | None = None
27
28
 
28
29
 
29
30
  class RestClientRegistry:
@@ -59,6 +60,7 @@ class RestClientRegistry:
59
60
  config: ServiceConfig | None = None,
60
61
  retry_policy: RetryPolicy | None = None,
61
62
  circuit_breaker: CircuitBreaker | None = None,
63
+ on_cache_change: Callable[[str], None] | None = None,
62
64
  replace: bool = False,
63
65
  ) -> "RestClientRegistry":
64
66
  if name in self._services and not replace:
@@ -70,6 +72,7 @@ class RestClientRegistry:
70
72
  authenticator=authenticator or NoAuth(),
71
73
  retry_policy=retry_policy,
72
74
  circuit_breaker=circuit_breaker,
75
+ on_cache_change=on_cache_change,
73
76
  )
74
77
  self._clients.pop(name, None)
75
78
  return self
@@ -90,13 +93,27 @@ class RestClientRegistry:
90
93
  "Instala con `persistence-kit[restclient]`."
91
94
  ) from exc
92
95
 
93
- client = HttpxRestClient(
96
+ client: RestClient = HttpxRestClient(
94
97
  config=service.config,
95
98
  resolver=service.resolver,
96
99
  authenticator=service.authenticator,
97
100
  retry_policy=service.retry_policy,
98
101
  circuit_breaker=service.circuit_breaker,
99
102
  )
103
+
104
+ if service.config.cache_ttl_seconds or service.config.cacheable:
105
+ from persistence_kit.cache.factory import get_cache
106
+ from persistence_kit.restclient.caching import CachingRestClient
107
+
108
+ client = CachingRestClient(
109
+ client,
110
+ get_cache("restclient"),
111
+ default_ttl_seconds=service.config.cache_ttl_seconds,
112
+ default_swr_seconds=service.config.cache_stale_while_revalidate_seconds,
113
+ name=name,
114
+ on_change=service.on_cache_change,
115
+ )
116
+
100
117
  self._clients[name] = client
101
118
  return client
102
119
 
@@ -0,0 +1,16 @@
1
+ from enum import Enum
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class CacheBackend(str, Enum):
7
+ MEMORY = "memory"
8
+ MONGO = "mongo"
9
+ DYNAMODB = "dynamodb"
10
+
11
+
12
+ class CacheSettings(BaseSettings):
13
+ cache_backend: CacheBackend = CacheBackend.MEMORY
14
+ cache_namespace: str = ""
15
+
16
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "persistence-kit"
3
- version = "3.6.0"
3
+ version = "3.7.0"
4
4
  description = "Reusable persistence and repository toolkit"
5
5
  authors = ["Andres Felipe Serrano Barrios <andresfserrano1@gmail.com>"]
6
6
  readme = "README.md"
File without changes