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,421 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import logging
7
+ import math
8
+ import time
9
+ import uuid
10
+ from abc import ABC, abstractmethod
11
+ from collections.abc import Awaitable, Callable
12
+ from datetime import date, datetime
13
+ from datetime import time as dt_time
14
+ from decimal import Decimal
15
+ from enum import Enum
16
+ from typing import Any
17
+ from uuid import UUID
18
+
19
+ import redis.asyncio as aioredis
20
+ from redis.exceptions import RedisError, ResponseError
21
+
22
+ from swiftpy.cache.driver import _SENTINEL, CacheDriver
23
+ from swiftpy.support.redis_config import resolve_redis_params
24
+
25
+ logger = logging.getLogger("swiftpy.cache")
26
+
27
+ _INCR_WITH_TTL_LUA = """
28
+ local existed = redis.call('exists', KEYS[1])
29
+ local current = redis.call('incrby', KEYS[1], ARGV[1])
30
+ local ttl = tonumber(ARGV[2])
31
+ if existed == 0 and ttl and ttl > 0 then
32
+ redis.call('pexpire', KEYS[1], math.max(1, math.floor(ttl * 1000)))
33
+ end
34
+ return current
35
+ """
36
+
37
+
38
+ class CacheSerializer(ABC):
39
+ """Abstract interface for pluggable cache serializers."""
40
+
41
+ @abstractmethod
42
+ def serialize(self, value: Any) -> str:
43
+ """Convert a Python value into a string representation for caching."""
44
+ ...
45
+
46
+ @abstractmethod
47
+ def deserialize(self, raw: str) -> Any:
48
+ """Reconstruct a Python value from its cached string representation."""
49
+ ...
50
+
51
+
52
+ def _cache_json_default(obj: Any) -> Any:
53
+ if isinstance(obj, (bytes, bytearray, memoryview)):
54
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
55
+ if isinstance(obj, datetime):
56
+ return {"__swiftpy_type__": "datetime", "val": obj.isoformat()}
57
+ if isinstance(obj, date):
58
+ return {"__swiftpy_type__": "date", "val": obj.isoformat()}
59
+ if isinstance(obj, dt_time):
60
+ return {"__swiftpy_type__": "time", "val": obj.isoformat()}
61
+ if isinstance(obj, UUID):
62
+ return {"__swiftpy_type__": "uuid", "val": str(obj)}
63
+ if isinstance(obj, Decimal):
64
+ return {"__swiftpy_type__": "decimal", "val": str(obj)}
65
+ if isinstance(obj, Enum):
66
+ return obj.value
67
+ if hasattr(obj, "to_dict") and callable(obj.to_dict):
68
+ raw_dict = obj.to_dict()
69
+ if isinstance(raw_dict, dict):
70
+ hidden = set(getattr(obj, "__hidden__", ()) or ())
71
+ if hidden:
72
+ return {k: v for k, v in raw_dict.items() if k not in hidden}
73
+ return raw_dict
74
+ return raw_dict
75
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
76
+
77
+
78
+ def _cache_json_object_hook(dct: dict[str, Any]) -> Any:
79
+ if "__swiftpy_type__" in dct and "val" in dct and len(dct) == 2:
80
+ t = dct["__swiftpy_type__"]
81
+ v = dct["val"]
82
+ if t == "datetime":
83
+ return datetime.fromisoformat(v)
84
+ if t == "date":
85
+ return date.fromisoformat(v)
86
+ if t == "time":
87
+ return dt_time.fromisoformat(v)
88
+ if t == "uuid":
89
+ return UUID(v)
90
+ if t == "decimal":
91
+ return Decimal(v)
92
+ return dct
93
+
94
+
95
+ class JsonCacheSerializer(CacheSerializer):
96
+ """Default JSON serializer supporting datetime, UUID, Decimal, and models."""
97
+
98
+ def serialize(self, value: Any) -> str:
99
+ return json.dumps(
100
+ value,
101
+ default=_cache_json_default,
102
+ ensure_ascii=False,
103
+ separators=(",", ":"),
104
+ )
105
+
106
+ def deserialize(self, raw: str) -> Any:
107
+ return json.loads(raw, object_hook=_cache_json_object_hook)
108
+
109
+
110
+ class RedisDriver(CacheDriver):
111
+ """
112
+ Redis cache driver utilizing redis.asyncio connection pooling.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ redis_client: aioredis.Redis | None = None,
118
+ connection_pool: aioredis.ConnectionPool | None = None,
119
+ redis_url: str | None = None,
120
+ host: str | None = None,
121
+ port: int | None = None,
122
+ password: str | None = None,
123
+ database: int = 0,
124
+ prefix: str = "swiftpy_cache:",
125
+ default_ttl: float | None = None,
126
+ max_connections: int | None = None,
127
+ timeout: float = 30.0,
128
+ serializer: CacheSerializer | None = None,
129
+ ) -> None:
130
+ super().__init__()
131
+ self._client = redis_client
132
+ self._pool = connection_pool
133
+ self._redis_url = redis_url
134
+ self.host = host
135
+ self.port = port
136
+ self.password = password
137
+ self.database = database
138
+ self.prefix = prefix
139
+ self.default_ttl = default_ttl
140
+ self.serializer = (
141
+ serializer if serializer is not None else JsonCacheSerializer()
142
+ )
143
+ self.max_connections = max_connections
144
+ self.timeout = timeout
145
+ self._owns_client = redis_client is None
146
+ self._owns_pool = connection_pool is None and redis_client is None
147
+ self._client_lock = asyncio.Lock()
148
+
149
+ async def get_client(self) -> aioredis.Redis:
150
+ """Lazily initialize and return the async Redis client."""
151
+ if self._client is not None:
152
+ return self._client
153
+ async with self._client_lock:
154
+ if self._client is not None:
155
+ return self._client
156
+ if self._redis_url:
157
+ self._client = aioredis.from_url(
158
+ self._redis_url,
159
+ decode_responses=True,
160
+ )
161
+ else:
162
+ if self._pool is None:
163
+ params = resolve_redis_params(
164
+ section="cache",
165
+ host=self.host,
166
+ port=self.port,
167
+ password=self.password,
168
+ database=self.database,
169
+ )
170
+ max_conns = self.max_connections or 50
171
+
172
+ self._pool = aioredis.BlockingConnectionPool(
173
+ host=params["host"],
174
+ port=params["port"],
175
+ db=params["database"],
176
+ password=params["password"] or None,
177
+ decode_responses=True,
178
+ max_connections=max_conns,
179
+ timeout=self.timeout,
180
+ )
181
+ self._client = aioredis.Redis(
182
+ connection_pool=self._pool,
183
+ decode_responses=True,
184
+ )
185
+ self._owns_client = True
186
+ return self._client
187
+
188
+ def _prefixed_key(self, key: str) -> str:
189
+ return f"{self.prefix}{key}"
190
+
191
+ def _serialize(self, value: Any) -> str:
192
+ return self.serializer.serialize(value)
193
+
194
+ def _deserialize(self, raw: str | bytes | None, default: Any) -> Any:
195
+ if raw is None:
196
+ return default
197
+ if isinstance(raw, bytes):
198
+ raw = raw.decode("utf-8")
199
+ try:
200
+ return self.serializer.deserialize(raw)
201
+ except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError):
202
+ return raw
203
+
204
+ async def get(self, key: str, default: Any = None) -> Any:
205
+ client = await self.get_client()
206
+ raw = await client.get(self._prefixed_key(key))
207
+ return self._deserialize(raw, default)
208
+
209
+ async def set(
210
+ self,
211
+ key: str,
212
+ value: Any,
213
+ ttl: float | None = None,
214
+ ) -> bool:
215
+ client = await self.get_client()
216
+ full_key = self._prefixed_key(key)
217
+ effective_ttl = ttl if ttl is not None else self.default_ttl
218
+
219
+ if effective_ttl is not None and effective_ttl <= 0:
220
+ await client.delete(full_key)
221
+ return True
222
+
223
+ serialized = self._serialize(value)
224
+ if effective_ttl is not None:
225
+ if isinstance(effective_ttl, float) and not effective_ttl.is_integer():
226
+ await client.set(full_key, serialized, px=int(effective_ttl * 1000))
227
+ else:
228
+ await client.set(full_key, serialized, ex=int(effective_ttl))
229
+ else:
230
+ await client.set(full_key, serialized)
231
+
232
+ return True
233
+
234
+ async def forget(self, key: str) -> bool:
235
+ client = await self.get_client()
236
+ count = await client.delete(self._prefixed_key(key))
237
+ return bool(count > 0)
238
+
239
+ async def has(self, key: str) -> bool:
240
+ client = await self.get_client()
241
+ count = await client.exists(self._prefixed_key(key))
242
+ return bool(count > 0)
243
+
244
+ async def flush(self) -> bool:
245
+ client = await self.get_client()
246
+ pattern = f"{self.prefix}*"
247
+ batch: list[str] = []
248
+ async for key in client.scan_iter(match=pattern, count=500):
249
+ batch.append(key.decode("utf-8") if isinstance(key, bytes) else str(key))
250
+ if len(batch) >= 500:
251
+ await client.unlink(*batch)
252
+ batch.clear()
253
+
254
+ if batch:
255
+ await client.unlink(*batch)
256
+ return True
257
+
258
+ async def add(
259
+ self,
260
+ key: str,
261
+ value: Any,
262
+ ttl: float | None = None,
263
+ ) -> bool:
264
+ client = await self.get_client()
265
+ full_key = self._prefixed_key(key)
266
+ effective_ttl = ttl if ttl is not None else self.default_ttl
267
+
268
+ if effective_ttl is not None and effective_ttl <= 0:
269
+ exists = await client.exists(full_key)
270
+ return not bool(exists)
271
+
272
+ serialized = self._serialize(value)
273
+ if effective_ttl is not None:
274
+ if isinstance(effective_ttl, float) and not effective_ttl.is_integer():
275
+ res = await client.set(
276
+ full_key,
277
+ serialized,
278
+ px=int(effective_ttl * 1000),
279
+ nx=True,
280
+ )
281
+ else:
282
+ res = await client.set(
283
+ full_key,
284
+ serialized,
285
+ ex=int(effective_ttl),
286
+ nx=True,
287
+ )
288
+ else:
289
+ res = await client.set(full_key, serialized, nx=True)
290
+
291
+ return bool(res)
292
+
293
+ async def increment(
294
+ self, key: str, value: int = 1, ttl: float | None = None
295
+ ) -> int:
296
+ client = await self.get_client()
297
+ full_key = self._prefixed_key(key)
298
+ effective_ttl = ttl if ttl is not None else self.default_ttl
299
+ try:
300
+ if effective_ttl is not None and effective_ttl > 0:
301
+ res = await client.eval(
302
+ _INCR_WITH_TTL_LUA, 1, full_key, value, effective_ttl
303
+ )
304
+ return int(res)
305
+ new_val = await client.incrby(full_key, value)
306
+ return int(new_val)
307
+ except ResponseError as exc:
308
+ raise TypeError(
309
+ f"Cannot increment non-numeric cached value: {exc}"
310
+ ) from exc
311
+
312
+ async def decrement(
313
+ self, key: str, value: int = 1, ttl: float | None = None
314
+ ) -> int:
315
+ return await self.increment(key, -value, ttl)
316
+
317
+ async def remember(
318
+ self,
319
+ key: str,
320
+ ttl: float | None,
321
+ callback: Callable[[], Any] | Callable[[], Awaitable[Any]],
322
+ lock_timeout: float = 10.0,
323
+ ) -> Any:
324
+ """
325
+ Get an item from Redis, or execute callback with in-process coalescing and distributed lock protection.
326
+
327
+ Uses both per-key asyncio lock coalescing and Redis SET NX (millisecond PX expiration)
328
+ so concurrent requests across coroutines or processes invoke callback at most once.
329
+ Even if ``lock_timeout <= 0``, attempts a single non-blocking SET NX acquisition before falling back.
330
+ """
331
+ # Fast path
332
+ cached = await self.get(key, _SENTINEL)
333
+ if cached is not _SENTINEL:
334
+ return cached
335
+
336
+ async with self._acquire_key_lock(key, lock_timeout=lock_timeout) as local_acquired:
337
+ if not local_acquired:
338
+ logger.warning(
339
+ "Cache lock timed out waiting for key '%s'; computing value directly without caching",
340
+ key,
341
+ )
342
+ if inspect.iscoroutinefunction(callback):
343
+ return await callback()
344
+ res = callback()
345
+ return await res if inspect.isawaitable(res) else res
346
+
347
+ # Re-check after winning the in-process key lock
348
+ cached = await self.get(key, _SENTINEL)
349
+ if cached is not _SENTINEL:
350
+ return cached
351
+
352
+ client = await self.get_client()
353
+ lock_key = f"{self.prefix}lock:{key}"
354
+ token = uuid.uuid4().hex
355
+ lock_ttl_ms = (
356
+ max(1, math.ceil(float(lock_timeout) * 1000))
357
+ if lock_timeout > 0
358
+ else 1000
359
+ )
360
+ deadline = time.monotonic() + max(0.0, float(lock_timeout))
361
+
362
+ while True:
363
+ # Try to acquire distributed lock with millisecond precision
364
+ acquired = await client.set(
365
+ lock_key,
366
+ token,
367
+ px=lock_ttl_ms,
368
+ nx=True,
369
+ )
370
+ if acquired:
371
+ try:
372
+ # Double-checked locking
373
+ cached = await self.get(key, _SENTINEL)
374
+ if cached is not _SENTINEL:
375
+ return cached
376
+
377
+ if inspect.iscoroutinefunction(callback):
378
+ result = await callback()
379
+ else:
380
+ res = callback()
381
+ result = await res if inspect.isawaitable(res) else res
382
+
383
+ await self.set(key, result, ttl)
384
+ return result
385
+ finally:
386
+ lua_release = (
387
+ "if redis.call('get', KEYS[1]) == ARGV[1] then "
388
+ "return redis.call('del', KEYS[1]) "
389
+ "else return 0 end"
390
+ )
391
+ try:
392
+ await client.eval(lua_release, 1, lock_key, token)
393
+ except RedisError as exc:
394
+ logger.debug("Failed to release lock %s: %s", lock_key, exc)
395
+
396
+ remaining = deadline - time.monotonic()
397
+ if remaining <= 0:
398
+ break
399
+
400
+ await asyncio.sleep(min(0.02, remaining))
401
+ cached = await self.get(key, _SENTINEL)
402
+ if cached is not _SENTINEL:
403
+ return cached
404
+
405
+ # Fallback if timeout elapsed: compute directly without caching and log warning
406
+ logger.warning(
407
+ "Cache lock timed out waiting for key '%s'; computing value directly without caching",
408
+ key,
409
+ )
410
+ if inspect.iscoroutinefunction(callback):
411
+ return await callback()
412
+ res = callback()
413
+ return await res if inspect.isawaitable(res) else res
414
+
415
+ async def close(self) -> None:
416
+ if self._client is not None and self._owns_client:
417
+ await self._client.aclose()
418
+ self._client = None
419
+ if self._owns_pool and self._pool is not None:
420
+ await self._pool.disconnect()
421
+ self._pool = None
File without changes
swiftpy/console/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from swiftpy.console.commands.cache import CacheCommand
7
+ from swiftpy.console.commands.make import MakeCommand
8
+ from swiftpy.console.commands.migrate import MigrateCommand
9
+ from swiftpy.console.commands.new import NewCommand
10
+ from swiftpy.console.commands.queue import QueueCommand
11
+ from swiftpy.console.commands.seed import SeedCommand
12
+ from swiftpy.console.commands.start import StartCommand
13
+ from swiftpy.console.commands.storage import StorageCommand
14
+
15
+
16
+ class CLI:
17
+ """
18
+ SwiftPY command line entrypoint.
19
+
20
+ Responsible for:
21
+ - Parsing arguments
22
+ - Dispatching commands
23
+ - Delegating execution
24
+ """
25
+
26
+ def __init__(self) -> None:
27
+ self.parser = argparse.ArgumentParser(
28
+ prog="swiftpy",
29
+ description="SwiftPY Framework CLI",
30
+ )
31
+
32
+ self.subparsers = self.parser.add_subparsers(
33
+ dest="command",
34
+ required=True,
35
+ )
36
+
37
+ self._register_commands()
38
+
39
+ def _register_commands(self) -> None:
40
+ """Register all framework commands."""
41
+ NewCommand.register(self.subparsers)
42
+ StartCommand.register(self.subparsers)
43
+ MigrateCommand.register(self.subparsers)
44
+ SeedCommand.register(self.subparsers)
45
+ MakeCommand.register(self.subparsers)
46
+ QueueCommand.register(self.subparsers)
47
+ CacheCommand.register(self.subparsers)
48
+ StorageCommand.register(self.subparsers)
49
+
50
+ def run(self, args: list[str] | None = None) -> None:
51
+ """Parse arguments and dispatch."""
52
+ parsed_args = self.parser.parse_args(args)
53
+
54
+ handler = getattr(parsed_args, "_handler", None)
55
+
56
+ if handler is None:
57
+ self.parser.print_help()
58
+ return
59
+
60
+ handler(parsed_args)
61
+
62
+
63
+ def main() -> None:
64
+ CLI().run(sys.argv[1:])
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
File without changes
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from swiftpy.ai.prompts.loader import PromptLoader
9
+ from swiftpy.console.loader import load_application
10
+ from swiftpy.core.config import Config, ConfigurationError
11
+
12
+
13
+ def _paint(text: str, code: str) -> str:
14
+ """Format text with ANSI escape codes only if stdout is a terminal and NO_COLOR is unset."""
15
+ if sys.stdout.isatty() and "NO_COLOR" not in os.environ:
16
+ return f"\033[{code}m{text}\033[0m"
17
+ return text
18
+
19
+
20
+ class CacheCommand:
21
+ """Console commands for building and clearing precompiled application caches."""
22
+
23
+ @staticmethod
24
+ def register(
25
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
26
+ ) -> None:
27
+ # 1. `swiftpy cache:build`
28
+ build_parser = subparsers.add_parser(
29
+ "cache:build",
30
+ help="Precompile routes, configuration, and prompt templates into serialized cache files",
31
+ )
32
+ CacheCommand._add_build_arguments(build_parser)
33
+ build_parser.set_defaults(_handler=CacheCommand.handle_build)
34
+
35
+ # 2. `swiftpy cache:clear`
36
+ clear_parser = subparsers.add_parser(
37
+ "cache:clear",
38
+ help="Clear precompiled route, configuration, and prompt caches",
39
+ )
40
+ CacheCommand._add_clear_arguments(clear_parser)
41
+ clear_parser.set_defaults(_handler=CacheCommand.handle_clear)
42
+
43
+ # 3. `swiftpy cache` group with `build` and `clear` subcommands
44
+ cache_group_parser = subparsers.add_parser(
45
+ "cache",
46
+ help="Manage application caches",
47
+ )
48
+ cache_subparsers = cache_group_parser.add_subparsers(
49
+ dest="cache_subcommand",
50
+ required=True,
51
+ )
52
+
53
+ sub_build = cache_subparsers.add_parser(
54
+ "build",
55
+ help="Precompile routes, configuration, and prompt templates",
56
+ )
57
+ CacheCommand._add_build_arguments(sub_build)
58
+ sub_build.set_defaults(_handler=CacheCommand.handle_build)
59
+
60
+ sub_clear = cache_subparsers.add_parser(
61
+ "clear",
62
+ help="Clear precompiled caches",
63
+ )
64
+ CacheCommand._add_clear_arguments(sub_clear)
65
+ sub_clear.set_defaults(_handler=CacheCommand.handle_clear)
66
+
67
+ @staticmethod
68
+ def _add_build_arguments(parser: argparse.ArgumentParser) -> None:
69
+ parser.add_argument(
70
+ "--app",
71
+ default="main:app",
72
+ help="Application ASGI target (default: 'main:app')",
73
+ )
74
+ parser.add_argument(
75
+ "--cache-dir",
76
+ default="bootstrap/cache",
77
+ help="Cache directory path (default: 'bootstrap/cache')",
78
+ )
79
+
80
+ @staticmethod
81
+ def _add_clear_arguments(parser: argparse.ArgumentParser) -> None:
82
+ parser.add_argument(
83
+ "--cache-dir",
84
+ default="bootstrap/cache",
85
+ help="Cache directory path (default: 'bootstrap/cache')",
86
+ )
87
+
88
+ @staticmethod
89
+ def handle_build(args: argparse.Namespace) -> None:
90
+ cache_dir = Path(getattr(args, "cache_dir", "bootstrap/cache"))
91
+ cache_dir.mkdir(parents=True, exist_ok=True)
92
+ target_app = getattr(args, "app", "main:app")
93
+
94
+ # Clean prior cache to ensure clean compilation
95
+ for f in (
96
+ cache_dir / "config.json",
97
+ cache_dir / "prompts.json",
98
+ ):
99
+ if f.is_file():
100
+ f.unlink()
101
+
102
+ sys.stdout.write(
103
+ _paint(f"Building application cache into '{cache_dir}'...\n", "1;34")
104
+ )
105
+
106
+ # 1. Load application instance
107
+ try:
108
+ app = load_application(target_app)
109
+ except (
110
+ ImportError,
111
+ AttributeError,
112
+ TypeError,
113
+ ConfigurationError,
114
+ OSError,
115
+ ValueError,
116
+ RuntimeError,
117
+ SyntaxError,
118
+ ) as exc:
119
+ sys.stderr.write(
120
+ _paint(f"Error loading application '{target_app}': {exc}\n", "1;31")
121
+ )
122
+ raise SystemExit(1) from exc
123
+
124
+ # 2. Precompile Config
125
+ config_cache_file = cache_dir / "config.json"
126
+ app.config.dump_cache(cache_path=config_cache_file)
127
+ sys.stdout.write(
128
+ f" {_paint('✓', '1;32')} Configuration precompiled -> {config_cache_file}\n"
129
+ )
130
+
131
+ # 3. Precompile Prompts
132
+ prompts_cache_file = cache_dir / "prompts.json"
133
+ prompt_loader = PromptLoader.get_instance()
134
+ prompt_loader.dump_cache(cache_path=prompts_cache_file)
135
+ sys.stdout.write(
136
+ f" {_paint('✓', '1;32')} Prompt templates precompiled -> {prompts_cache_file}\n"
137
+ )
138
+
139
+ sys.stdout.write(
140
+ _paint(
141
+ "✨ Application cache built successfully for production deployment.\n",
142
+ "1;32",
143
+ )
144
+ )
145
+
146
+ @staticmethod
147
+ def handle_clear(args: argparse.Namespace) -> None:
148
+ cache_dir = Path(getattr(args, "cache_dir", "bootstrap/cache"))
149
+
150
+ config_file = cache_dir / "config.json"
151
+ prompts_file = cache_dir / "prompts.json"
152
+
153
+ Config.clear_cache(config_file)
154
+ PromptLoader.clear_compiled_cache(prompts_file)
155
+
156
+ sys.stdout.write(
157
+ _paint(
158
+ f"✨ Application cache cleared successfully from '{cache_dir}'.\n",
159
+ "1;32",
160
+ )
161
+ )
162
+
163
+
164
+ __all__ = ["CacheCommand"]