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,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
|