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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|