swiftpy-framework 0.1.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.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,300 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ import time
6
+ from collections.abc import Awaitable, Sequence
7
+ from typing import Any, Protocol
8
+
9
+ import redis.asyncio as aioredis
10
+
11
+ from swiftpy.auth.jwt_issuer import JWTIssuer
12
+ from swiftpy.core.config import Config
13
+ from swiftpy.core.container import Container, get_container
14
+ from swiftpy.support.redis_config import build_redis_url
15
+
16
+
17
+ class _BlacklistPipeline(Protocol):
18
+ def exists(self, *names: str) -> Any: ...
19
+ def get(self, name: str, /) -> Any: ...
20
+ def execute(self) -> Awaitable[list[Any]]: ...
21
+
22
+
23
+ class _BlacklistRedisClient(Protocol):
24
+ def pipeline(self) -> _BlacklistPipeline: ...
25
+ def set(
26
+ self, name: str, value: str, /, ex: int | None = None
27
+ ) -> Awaitable[bool | str | bytes | None]: ...
28
+ def scan(
29
+ self,
30
+ cursor: int = 0,
31
+ match: str | None = None,
32
+ count: int | None = None,
33
+ ) -> Awaitable[tuple[int, Sequence[bytes | str]]]: ...
34
+ def delete(self, *names: str) -> Awaitable[int]: ...
35
+ def aclose(self) -> Awaitable[None]: ...
36
+
37
+
38
+ def _validate_positive_ttl(ttl: int | None) -> int | None:
39
+ """Validate explicit blacklist TTL overrides so <= 0 never silently extends to 7 days."""
40
+ if ttl is None:
41
+ return None
42
+ if isinstance(ttl, bool) or not isinstance(ttl, int) or ttl <= 0:
43
+ raise ValueError("ttl must be a positive integer")
44
+ return ttl
45
+
46
+
47
+ def is_token_blacklist_enabled(config: Config | None = None) -> bool:
48
+ """Return whether JWT token blacklist revocation checking is enabled."""
49
+ cfg = config
50
+ if cfg is None:
51
+ try:
52
+ cfg = Config.get_instance()
53
+ except (RuntimeError, ValueError):
54
+ return True
55
+
56
+ for key in (
57
+ "auth.guards.jwt.blacklist_enabled",
58
+ "auth.jwt.blacklist_enabled",
59
+ "auth.blacklist_enabled",
60
+ ):
61
+ val = cfg.get(key)
62
+ if val is not None:
63
+ if isinstance(val, bool):
64
+ return val
65
+ if isinstance(val, str):
66
+ return val.strip().lower() not in ("false", "0", "no", "off")
67
+ return bool(val)
68
+ return True
69
+
70
+
71
+ class TokenBlacklist:
72
+ """
73
+ Redis-backed token revocation list for JWT logout and invalidation (Decision D-1, P2-A3).
74
+
75
+ Keys are stored with a TTL capped at min(exp - now, max_token_lifetime),
76
+ ensuring Redis automatically reclaims memory once the token would have
77
+ expired normally, while preventing attacker-controlled memory exhaustion.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ redis_client: _BlacklistRedisClient | None = None,
83
+ redis_url: str | None = None,
84
+ prefix: str = "jwt_blacklist:",
85
+ default_ttl: int = 604800, # 7 days
86
+ max_token_lifetime: int = 604800, # 7 days
87
+ issuer: JWTIssuer | None = None,
88
+ config: Config | None = None,
89
+ container: Container | None = None,
90
+ ) -> None:
91
+ self._client: _BlacklistRedisClient | None = redis_client
92
+ self._redis_url = redis_url
93
+ self.prefix = prefix
94
+ self.default_ttl = default_ttl
95
+ self.max_token_lifetime = max_token_lifetime
96
+ self._issuer = issuer
97
+ self._config = config
98
+ self._container = container
99
+ self._owns_client = redis_client is None
100
+ self._lock = asyncio.Lock()
101
+ self._local_cache: dict[tuple[str, str], float] = {}
102
+ self._local_cache_ttl: float = 2.0
103
+
104
+ async def get_client(self) -> _BlacklistRedisClient:
105
+ """Lazily initialize and return the async Redis client safely under lock."""
106
+ if self._client is None:
107
+ async with self._lock:
108
+ if self._client is None:
109
+ url = self._redis_url
110
+ if not url:
111
+ cfg = self._config
112
+ if cfg is None:
113
+ try:
114
+ cfg = Config.get_instance()
115
+ except (RuntimeError, ValueError):
116
+ cfg = Config()
117
+ url = build_redis_url(cfg, section="auth")
118
+
119
+ created: _BlacklistRedisClient = aioredis.from_url(
120
+ url, decode_responses=True
121
+ )
122
+ self._client = created
123
+ self._owns_client = True
124
+
125
+ assert self._client is not None
126
+ return self._client
127
+
128
+ async def revoke(self, jti: str, ttl: int | None = None) -> None:
129
+ """
130
+ Revoke a token by its unique JWT ID (jti) with an optional TTL in seconds.
131
+ """
132
+ if not jti:
133
+ raise ValueError("JWT ID (jti) cannot be empty")
134
+
135
+ validated_ttl = _validate_positive_ttl(ttl)
136
+ client = await self.get_client()
137
+ key = f"{self.prefix}{jti}"
138
+ raw_ttl = validated_ttl if validated_ttl is not None else self.default_ttl
139
+ expiration = min(raw_ttl, self.max_token_lifetime)
140
+
141
+ self._local_cache.clear()
142
+ await client.set(key, "revoked", ex=expiration)
143
+
144
+ async def revoke_token(
145
+ self,
146
+ token: str,
147
+ issuer: JWTIssuer | None = None,
148
+ ) -> None:
149
+ """
150
+ Revoke an issued JWT by verifying its signature and calculating the remaining TTL.
151
+ Cryptographically verifies token signature before revoking to prevent
152
+ attacker-chosen TTL / unbounded memory DoS (P2-A3).
153
+ """
154
+ active_issuer = issuer or self._issuer
155
+ if (
156
+ active_issuer is None
157
+ and self._container is not None
158
+ and self._container.has(JWTIssuer)
159
+ ):
160
+ active_issuer = self._container.resolve(JWTIssuer)
161
+ if active_issuer is None:
162
+ try:
163
+ container = get_container()
164
+ if container.has(JWTIssuer):
165
+ active_issuer = container.resolve(JWTIssuer)
166
+ except RuntimeError:
167
+ pass
168
+
169
+ if active_issuer is None:
170
+ raise ValueError(
171
+ "JWTIssuer is required to cryptographically verify token signature before revocation."
172
+ )
173
+
174
+ # Cryptographically verify signature; exp and future nbf are handled below so future-active tokens can be pre-emptively revoked
175
+ payload = active_issuer.decode_token(token, verify_exp=False, verify_nbf=False)
176
+
177
+ jti = payload.get("jti")
178
+ if not jti:
179
+ raise ValueError("Token does not contain a 'jti' claim to revoke")
180
+
181
+ exp = payload.get("exp")
182
+ ttl: int
183
+ if (
184
+ exp is not None
185
+ and not isinstance(exp, bool)
186
+ and isinstance(exp, (int, float))
187
+ and math.isfinite(exp)
188
+ ):
189
+ remaining = int(exp - time.time())
190
+ if remaining <= 0:
191
+ # Token has already expired; naturally rejected by exp check, skip Redis allocation
192
+ return
193
+ ttl = min(remaining, self.max_token_lifetime)
194
+ else:
195
+ ttl = min(self.default_ttl, self.max_token_lifetime)
196
+
197
+ await self.revoke(jti, ttl=ttl)
198
+
199
+ async def revoke_all_for_subject(
200
+ self,
201
+ subject: str | int,
202
+ ttl: int | None = None,
203
+ ) -> None:
204
+ """
205
+ Revoke all tokens issued for a given subject (user_id) at or before the current timestamp.
206
+ Used for logout-all, password reset, or account compromise (P2-A3).
207
+ """
208
+ if subject is None or str(subject) == "":
209
+ raise ValueError("Subject cannot be empty")
210
+
211
+ validated_ttl = _validate_positive_ttl(ttl)
212
+ client = await self.get_client()
213
+ sub_key = f"{self.prefix}sub:{subject}"
214
+ raw_ttl = (
215
+ validated_ttl if validated_ttl is not None else self.max_token_lifetime
216
+ )
217
+ expiration = min(raw_ttl, self.max_token_lifetime)
218
+ now = int(time.time())
219
+ self._local_cache.clear()
220
+ await client.set(sub_key, str(now), ex=expiration)
221
+
222
+ async def is_revoked(
223
+ self,
224
+ jti: str,
225
+ subject: str | int | None = None,
226
+ issued_at: int | None = None,
227
+ ) -> bool:
228
+ """
229
+ Determine whether a JWT ID (jti) or subject has been revoked.
230
+ O(1) check against individual jti and per-subject not_before timestamp.
231
+ """
232
+ if not jti and not subject:
233
+ return False
234
+
235
+ cache_key = (jti or "", str(subject) if subject is not None else "")
236
+ now = time.monotonic()
237
+ cached_ts = self._local_cache.get(cache_key)
238
+ if cached_ts is not None and (now - cached_ts) < self._local_cache_ttl:
239
+ return False
240
+
241
+ client = await self.get_client()
242
+
243
+ pipe = client.pipeline()
244
+ has_jti = bool(jti)
245
+ has_sub = subject is not None
246
+
247
+ if has_jti:
248
+ pipe.exists(f"{self.prefix}{jti}")
249
+ if has_sub:
250
+ pipe.get(f"{self.prefix}sub:{subject}")
251
+
252
+ results = await pipe.execute()
253
+
254
+ res_idx = 0
255
+ if has_jti:
256
+ if results[res_idx]:
257
+ return True
258
+ res_idx += 1
259
+
260
+ if has_sub:
261
+ sub_cutoff = results[res_idx]
262
+ if sub_cutoff is not None:
263
+ cutoff_ts = int(sub_cutoff)
264
+ if issued_at is None or issued_at <= cutoff_ts:
265
+ return True
266
+
267
+ if len(self._local_cache) > 10000:
268
+ self._local_cache.clear()
269
+ self._local_cache[cache_key] = now
270
+ return False
271
+
272
+ async def clear(self) -> None:
273
+ """
274
+ Remove all blacklisted tokens matching this prefix (primarily for tests/maintenance).
275
+ """
276
+ self._local_cache.clear()
277
+ client = await self.get_client()
278
+ match_pattern = f"{self.prefix}*"
279
+ cursor = 0
280
+ keys_to_delete: list[str] = []
281
+
282
+ while True:
283
+ cursor, keys = await client.scan(
284
+ cursor=cursor, match=match_pattern, count=100
285
+ )
286
+ if keys:
287
+ keys_to_delete.extend(
288
+ k.decode("utf-8") if isinstance(k, bytes) else str(k) for k in keys
289
+ )
290
+ if cursor == 0:
291
+ break
292
+
293
+ if keys_to_delete:
294
+ await client.delete(*keys_to_delete)
295
+
296
+ async def close(self) -> None:
297
+ """Close the underlying Redis client connection pool."""
298
+ if self._client is not None and self._owns_client:
299
+ await self._client.aclose()
300
+ self._client = None
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.cache.cache import Cache, CacheManager
4
+ from swiftpy.cache.driver import CacheDriver
5
+ from swiftpy.cache.drivers.memory import MemoryDriver
6
+ from swiftpy.cache.drivers.redis import RedisDriver
7
+
8
+ __all__ = [
9
+ "Cache",
10
+ "CacheDriver",
11
+ "CacheManager",
12
+ "MemoryDriver",
13
+ "RedisDriver",
14
+ ]
swiftpy/cache/cache.py ADDED
@@ -0,0 +1,251 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any
5
+
6
+ import redis.asyncio as aioredis
7
+
8
+ from swiftpy.cache.driver import CacheDriver
9
+ from swiftpy.cache.drivers.memory import MemoryDriver
10
+ from swiftpy.cache.drivers.redis import RedisDriver
11
+ from swiftpy.core.config import Config
12
+ from swiftpy.support.redis_config import resolve_redis_params
13
+
14
+
15
+ class Cache:
16
+ """
17
+ Cache manager for SwiftPY applications.
18
+
19
+ Provides a unified interface across caching backends (Redis, in-memory)
20
+ with driver management, configuration integration, and stampede-safe caching.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ config: Config | None = None,
26
+ default_driver: str | None = None,
27
+ ) -> None:
28
+ if config is None:
29
+ try:
30
+ self.config = Config.get_instance()
31
+ except RuntimeError:
32
+ self.config = Config()
33
+ else:
34
+ self.config = config
35
+
36
+ self._default_driver_name = default_driver
37
+ self._drivers: dict[str, CacheDriver] = {}
38
+ self._custom_creators: dict[str, Callable[..., CacheDriver]] = {}
39
+ self._shared_pools: dict[tuple[Any, ...], aioredis.ConnectionPool] = {}
40
+
41
+ @property
42
+ def default_driver_name(self) -> str:
43
+ if self._default_driver_name:
44
+ return self._default_driver_name
45
+ return str(self.config.get("cache.default", "redis"))
46
+
47
+ def extend(
48
+ self,
49
+ driver_name: str,
50
+ factory: Callable[..., CacheDriver],
51
+ ) -> None:
52
+ """Register a custom cache driver creator."""
53
+ self._custom_creators[driver_name] = factory
54
+
55
+ def driver(self, name: str | None = None) -> CacheDriver:
56
+ """
57
+ Resolve a cache driver instance by name, or return the default driver.
58
+ """
59
+ driver_name = name or self.default_driver_name
60
+
61
+ if driver_name in self._drivers:
62
+ return self._drivers[driver_name]
63
+
64
+ driver_instance = self._resolve_driver(driver_name)
65
+ self._drivers[driver_name] = driver_instance
66
+ return driver_instance
67
+
68
+ def store(self, name: str | None = None) -> CacheDriver:
69
+ """Alias for driver()."""
70
+ return self.driver(name)
71
+
72
+ def _build_store_prefix(self, store_config: dict[str, Any]) -> str:
73
+ """Resolve cache store prefix consistently including optional app.name namespace."""
74
+ app_name = str(self.config.get("app.name", "")).strip()
75
+ raw_val = (
76
+ store_config["prefix"]
77
+ if "prefix" in store_config
78
+ else self.config.get("cache.prefix", "swiftpy_cache:")
79
+ )
80
+ raw_prefix = str(raw_val) if raw_val is not None else "swiftpy_cache:"
81
+ if (
82
+ app_name
83
+ and not raw_prefix.startswith(f"{app_name}:")
84
+ and not raw_prefix.startswith(f"{app_name}_")
85
+ ):
86
+ return f"{app_name}:{raw_prefix}"
87
+ return raw_prefix
88
+
89
+ def _resolve_store_ttl(self, store_config: dict[str, Any]) -> float | None:
90
+ """Resolve default TTL while distinguishing explicit None or 0.0 values."""
91
+ default_ttl = (
92
+ store_config["ttl"]
93
+ if "ttl" in store_config
94
+ else self.config.get("cache.default_ttl", None)
95
+ )
96
+ return float(default_ttl) if default_ttl is not None else None
97
+
98
+ def _resolve_driver(self, name: str) -> CacheDriver:
99
+ if name in self._custom_creators:
100
+ creator = self._custom_creators[name]
101
+ return creator()
102
+
103
+ stores = self.config.get("cache.stores", {})
104
+ store_config: dict[str, Any] = {}
105
+ if isinstance(stores, dict) and name in stores:
106
+ raw = stores[name]
107
+ if isinstance(raw, dict):
108
+ store_config = raw
109
+
110
+ driver_type = store_config.get("driver", name)
111
+
112
+ if driver_type == "memory":
113
+ prefix = self._build_store_prefix(store_config)
114
+ ttl_val = self._resolve_store_ttl(store_config)
115
+ raw_max_items = (
116
+ store_config["max_items"]
117
+ if "max_items" in store_config
118
+ else self.config.get("cache.max_items", 1000)
119
+ )
120
+ max_items = int(raw_max_items) if raw_max_items is not None else None
121
+ return MemoryDriver(prefix=prefix, default_ttl=ttl_val, max_items=max_items)
122
+
123
+ if driver_type == "redis":
124
+ prefix = self._build_store_prefix(store_config)
125
+ ttl_val = self._resolve_store_ttl(store_config)
126
+ host = store_config.get("host")
127
+ port = store_config.get("port")
128
+ password = store_config.get("password")
129
+ database = store_config.get("database", 0)
130
+ redis_url = store_config.get(
131
+ "url", self.config.get("cache.redis_url") or self.config.get("cache.url")
132
+ )
133
+ max_connections = int(
134
+ store_config.get(
135
+ "max_connections", self.config.get("cache.max_connections", 50)
136
+ )
137
+ )
138
+ timeout = float(
139
+ store_config.get("timeout", self.config.get("cache.timeout", 30.0))
140
+ )
141
+
142
+ pool_key = (
143
+ redis_url,
144
+ str(host) if host is not None else None,
145
+ int(port) if port is not None else None,
146
+ str(password) if password is not None else None,
147
+ int(database) if database is not None else 0,
148
+ max_connections,
149
+ timeout,
150
+ )
151
+
152
+ if pool_key not in self._shared_pools:
153
+ if redis_url:
154
+ pool = aioredis.ConnectionPool.from_url(
155
+ redis_url,
156
+ decode_responses=True,
157
+ max_connections=max_connections,
158
+ timeout=timeout,
159
+ )
160
+ else:
161
+ params = resolve_redis_params(
162
+ section="cache",
163
+ host=str(host) if host is not None else None,
164
+ port=int(port) if port is not None else None,
165
+ password=str(password) if password is not None else None,
166
+ database=int(database) if database is not None else 0,
167
+ )
168
+ pool = aioredis.BlockingConnectionPool(
169
+ host=params["host"],
170
+ port=params["port"],
171
+ db=params["database"],
172
+ password=params["password"] or None,
173
+ decode_responses=True,
174
+ max_connections=max_connections,
175
+ timeout=timeout,
176
+ )
177
+ self._shared_pools[pool_key] = pool
178
+
179
+ return RedisDriver(
180
+ connection_pool=self._shared_pools[pool_key],
181
+ prefix=prefix,
182
+ default_ttl=ttl_val,
183
+ max_connections=max_connections,
184
+ timeout=timeout,
185
+ )
186
+
187
+ raise ValueError(f"Unsupported cache driver: '{name}'")
188
+
189
+ async def get(self, key: str, default: Any = None) -> Any:
190
+ return await self.driver().get(key, default)
191
+
192
+ async def set(
193
+ self,
194
+ key: str,
195
+ value: Any,
196
+ ttl: float | None = None,
197
+ ) -> bool:
198
+ return await self.driver().set(key, value, ttl)
199
+
200
+ async def remember(
201
+ self,
202
+ key: str,
203
+ ttl: float | None,
204
+ callback: Callable[[], Any] | Callable[[], Awaitable[Any]],
205
+ lock_timeout: float = 10.0,
206
+ ) -> Any:
207
+ return await self.driver().remember(
208
+ key, ttl, callback, lock_timeout=lock_timeout
209
+ )
210
+
211
+ async def forget(self, key: str) -> bool:
212
+ return await self.driver().forget(key)
213
+
214
+ async def has(self, key: str) -> bool:
215
+ return await self.driver().has(key)
216
+
217
+ async def flush(self) -> bool:
218
+ return await self.driver().flush()
219
+
220
+ async def pull(self, key: str, default: Any = None) -> Any:
221
+ return await self.driver().pull(key, default)
222
+
223
+ async def add(
224
+ self,
225
+ key: str,
226
+ value: Any,
227
+ ttl: float | None = None,
228
+ ) -> bool:
229
+ return await self.driver().add(key, value, ttl)
230
+
231
+ async def increment(
232
+ self, key: str, value: int = 1, ttl: float | None = None
233
+ ) -> int:
234
+ return await self.driver().increment(key, value, ttl=ttl)
235
+
236
+ async def decrement(
237
+ self, key: str, value: int = 1, ttl: float | None = None
238
+ ) -> int:
239
+ return await self.driver().decrement(key, value, ttl=ttl)
240
+
241
+ async def close(self) -> None:
242
+ for drv in self._drivers.values():
243
+ await drv.close()
244
+ self._drivers.clear()
245
+ for pool in self._shared_pools.values():
246
+ await pool.disconnect()
247
+ self._shared_pools.clear()
248
+
249
+
250
+ # CacheManager is an alias for Cache for consistency across naming styles
251
+ CacheManager = Cache