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,552 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from typing import Any, ClassVar, Self, cast
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
import redis.asyncio as aioredis
|
|
17
|
+
from redis.exceptions import RedisError
|
|
18
|
+
|
|
19
|
+
from swiftpy.core.config import ConfigurationError
|
|
20
|
+
from swiftpy.core.container import get_container
|
|
21
|
+
from swiftpy.events.dispatcher import EventDispatcher, EventHandler
|
|
22
|
+
from swiftpy.support.keys import (
|
|
23
|
+
decode_signing_key,
|
|
24
|
+
resolve_configured_app_key,
|
|
25
|
+
resolve_configured_previous_keys,
|
|
26
|
+
validate_signing_key,
|
|
27
|
+
)
|
|
28
|
+
from swiftpy.support.redis_config import resolve_redis_params
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("swiftpy.events")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RedisEventBus:
|
|
34
|
+
"""
|
|
35
|
+
Cross-process event delivery bus backed by Redis Pub/Sub.
|
|
36
|
+
Allows workers in different processes or containers to publish and listen for events.
|
|
37
|
+
|
|
38
|
+
Delivery Semantics:
|
|
39
|
+
Pub/sub messages operate with at-most-once delivery semantics.
|
|
40
|
+
If a subscriber is offline or disconnected when an event is published, the event is not retained.
|
|
41
|
+
|
|
42
|
+
Payload Contract:
|
|
43
|
+
- Local dispatch (local=True): Listeners in the current process receive the exact Python object.
|
|
44
|
+
- Remote dispatch: Payloads are serialized to JSON (converting datetimes, UUIDs, and models to dicts/strings).
|
|
45
|
+
Subscribers in remote processes receive the deserialized dicts/primitives.
|
|
46
|
+
- When APP_KEY is configured, envelopes are signed with HMAC-SHA256 to ensure authenticity.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_instance: ClassVar[RedisEventBus | None] = None
|
|
50
|
+
_app_key: ClassVar[str | None] = None
|
|
51
|
+
_previous_keys: ClassVar[list[str]] = []
|
|
52
|
+
_allow_unsigned: ClassVar[bool] = False
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def set_allow_unsigned(cls, enabled: bool) -> None:
|
|
56
|
+
"""Explicit opt-in to allow unsigned event bus messages in local tests."""
|
|
57
|
+
cls._allow_unsigned = bool(enabled)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def get_allow_unsigned(cls) -> bool:
|
|
61
|
+
"""Return whether unsigned event bus messages are explicitly opted in."""
|
|
62
|
+
return cls._allow_unsigned
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def set_app_key(cls, key: str | None) -> None:
|
|
66
|
+
"""Set the active application key for signing bus messages."""
|
|
67
|
+
cls._app_key = key
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def get_app_key(cls) -> str | None:
|
|
71
|
+
"""Return the active application key for signing bus messages."""
|
|
72
|
+
if cls._app_key is not None:
|
|
73
|
+
return cls._app_key
|
|
74
|
+
return resolve_configured_app_key()
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def set_previous_keys(cls, keys: list[str]) -> None:
|
|
78
|
+
"""Set previous keys for rotation verification."""
|
|
79
|
+
cls._previous_keys = list(keys)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def get_previous_keys(cls) -> list[str]:
|
|
83
|
+
"""Return previous keys for key rotation verification."""
|
|
84
|
+
if cls._previous_keys:
|
|
85
|
+
return cls._previous_keys
|
|
86
|
+
return resolve_configured_previous_keys()
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def _compute_signature(cls, envelope: dict[str, Any], key: str) -> str:
|
|
90
|
+
"""Compute an HMAC-SHA256 signature over the canonical representation of the envelope."""
|
|
91
|
+
canonical_fields = {k: v for k, v in envelope.items() if k != "signature"}
|
|
92
|
+
canonical_json = json.dumps(
|
|
93
|
+
canonical_fields,
|
|
94
|
+
sort_keys=True,
|
|
95
|
+
separators=(",", ":"),
|
|
96
|
+
ensure_ascii=False,
|
|
97
|
+
)
|
|
98
|
+
secret_bytes = decode_signing_key(key)
|
|
99
|
+
return hmac.new(
|
|
100
|
+
secret_bytes,
|
|
101
|
+
canonical_json.encode("utf-8"),
|
|
102
|
+
hashlib.sha256,
|
|
103
|
+
).hexdigest()
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
dispatcher: EventDispatcher | None = None,
|
|
108
|
+
redis_client: aioredis.Redis | None = None,
|
|
109
|
+
connection_pool: aioredis.ConnectionPool | None = None,
|
|
110
|
+
redis_url: str | None = None,
|
|
111
|
+
host: str | None = None,
|
|
112
|
+
port: int | None = None,
|
|
113
|
+
password: str | None = None,
|
|
114
|
+
database: int = 0,
|
|
115
|
+
channel_prefix: str = "swiftpy_events:",
|
|
116
|
+
ignore_self: bool = True,
|
|
117
|
+
max_connections: int | None = None,
|
|
118
|
+
timeout: float = 30.0,
|
|
119
|
+
max_concurrency: int = 50,
|
|
120
|
+
allow_unsigned: bool | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.bus_id: str = uuid.uuid4().hex
|
|
123
|
+
self.dispatcher: EventDispatcher = (
|
|
124
|
+
dispatcher if dispatcher is not None else EventDispatcher()
|
|
125
|
+
)
|
|
126
|
+
self._client: aioredis.Redis | None = redis_client
|
|
127
|
+
self._pool: aioredis.ConnectionPool | None = connection_pool
|
|
128
|
+
self._redis_url = redis_url
|
|
129
|
+
self.host = host
|
|
130
|
+
self.port = port
|
|
131
|
+
self.password = password
|
|
132
|
+
self.database = database
|
|
133
|
+
self.channel_prefix = channel_prefix
|
|
134
|
+
self.ignore_self = ignore_self
|
|
135
|
+
self.max_connections = max_connections or 20
|
|
136
|
+
self.timeout = timeout
|
|
137
|
+
self.max_concurrency = max_concurrency
|
|
138
|
+
self._allow_unsigned_override = allow_unsigned
|
|
139
|
+
self._owns_client = redis_client is None
|
|
140
|
+
self._owns_pool = connection_pool is None and redis_client is None
|
|
141
|
+
self._client_lock = asyncio.Lock()
|
|
142
|
+
|
|
143
|
+
self._running: bool = False
|
|
144
|
+
self._listener_task: asyncio.Task[None] | None = None
|
|
145
|
+
self._pubsub: aioredis.client.PubSub | None = None
|
|
146
|
+
self._ready_event: asyncio.Event = asyncio.Event()
|
|
147
|
+
self._dispatch_semaphore = asyncio.Semaphore(self.max_concurrency)
|
|
148
|
+
self._active_dispatch_tasks: set[asyncio.Task[Any]] = set()
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def get_instance(cls) -> RedisEventBus:
|
|
152
|
+
"""Retrieve the active application's RedisEventBus from the container, or fallback instance."""
|
|
153
|
+
try:
|
|
154
|
+
container = get_container()
|
|
155
|
+
if container.has(cls):
|
|
156
|
+
return container.resolve(cls)
|
|
157
|
+
disp = (
|
|
158
|
+
container.resolve(EventDispatcher)
|
|
159
|
+
if container.has(EventDispatcher)
|
|
160
|
+
else None
|
|
161
|
+
)
|
|
162
|
+
inst = cls(dispatcher=disp)
|
|
163
|
+
container.instance(cls, inst)
|
|
164
|
+
return inst
|
|
165
|
+
except RuntimeError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
if cls._instance is None:
|
|
169
|
+
cls._instance = cls()
|
|
170
|
+
return cls._instance
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def set_instance(cls, bus: RedisEventBus | None) -> None:
|
|
174
|
+
"""Override or reset the RedisEventBus instance in the active container and fallback."""
|
|
175
|
+
cls._instance = bus
|
|
176
|
+
try:
|
|
177
|
+
container = get_container()
|
|
178
|
+
if container.has(cls):
|
|
179
|
+
if bus is not None:
|
|
180
|
+
container.instance(cls, bus)
|
|
181
|
+
else:
|
|
182
|
+
container.singleton(
|
|
183
|
+
cls,
|
|
184
|
+
lambda c: cls(
|
|
185
|
+
dispatcher=c.resolve(EventDispatcher)
|
|
186
|
+
if c.has(EventDispatcher)
|
|
187
|
+
else None
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
except RuntimeError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
async def get_client(self) -> aioredis.Redis:
|
|
194
|
+
"""Lazily initialize and return the async Redis client."""
|
|
195
|
+
if self._client is not None:
|
|
196
|
+
return self._client
|
|
197
|
+
async with self._client_lock:
|
|
198
|
+
if self._client is not None:
|
|
199
|
+
return self._client
|
|
200
|
+
if self._redis_url:
|
|
201
|
+
self._client = aioredis.from_url(
|
|
202
|
+
self._redis_url,
|
|
203
|
+
decode_responses=True,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
if self._pool is None:
|
|
207
|
+
params = resolve_redis_params(
|
|
208
|
+
section="events",
|
|
209
|
+
host=self.host,
|
|
210
|
+
port=self.port,
|
|
211
|
+
password=self.password,
|
|
212
|
+
database=self.database if self.database != 0 else None,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self._pool = aioredis.BlockingConnectionPool(
|
|
216
|
+
host=params["host"],
|
|
217
|
+
port=params["port"],
|
|
218
|
+
db=params["database"],
|
|
219
|
+
password=params["password"] or None,
|
|
220
|
+
decode_responses=True,
|
|
221
|
+
max_connections=self.max_connections,
|
|
222
|
+
timeout=self.timeout,
|
|
223
|
+
)
|
|
224
|
+
self._client = aioredis.Redis(
|
|
225
|
+
connection_pool=self._pool,
|
|
226
|
+
decode_responses=True,
|
|
227
|
+
)
|
|
228
|
+
self._owns_client = True
|
|
229
|
+
return self._client
|
|
230
|
+
|
|
231
|
+
def subscribe(self, event: str, handler: EventHandler) -> None:
|
|
232
|
+
"""Subscribe a listener to events received by this worker."""
|
|
233
|
+
self.dispatcher.listen(event, handler)
|
|
234
|
+
|
|
235
|
+
def listen(self, event: str, handler: EventHandler) -> None:
|
|
236
|
+
"""Alias for subscribe."""
|
|
237
|
+
self.subscribe(event, handler)
|
|
238
|
+
|
|
239
|
+
def unsubscribe(self, event: str, handler: EventHandler) -> bool:
|
|
240
|
+
"""Unsubscribe a listener from events."""
|
|
241
|
+
return self.dispatcher.unsubscribe(event, handler)
|
|
242
|
+
|
|
243
|
+
def _serialize_value(self, val: Any, seen: set[int] | None = None) -> Any:
|
|
244
|
+
"""
|
|
245
|
+
Convert arbitrary objects to JSON-serializable structures.
|
|
246
|
+
Strictly raises TypeError on unsupported types (e.g. set) or circular references.
|
|
247
|
+
"""
|
|
248
|
+
if seen is None:
|
|
249
|
+
seen = set()
|
|
250
|
+
|
|
251
|
+
if val is None or isinstance(val, (int, float, str, bool)):
|
|
252
|
+
return val
|
|
253
|
+
if isinstance(val, (datetime, date)):
|
|
254
|
+
return val.isoformat()
|
|
255
|
+
if isinstance(val, UUID):
|
|
256
|
+
return str(val)
|
|
257
|
+
|
|
258
|
+
obj_id = id(val)
|
|
259
|
+
if obj_id in seen:
|
|
260
|
+
raise TypeError("Circular reference detected in event payload")
|
|
261
|
+
seen.add(obj_id)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
hidden = set(getattr(val, "__hidden__", ()) or ())
|
|
265
|
+
if hasattr(val, "to_dict") and callable(val.to_dict):
|
|
266
|
+
raw_dict = val.to_dict()
|
|
267
|
+
if hidden and isinstance(raw_dict, dict):
|
|
268
|
+
raw_dict = {k: v for k, v in raw_dict.items() if k not in hidden}
|
|
269
|
+
return self._serialize_value(raw_dict, seen)
|
|
270
|
+
if isinstance(val, (list, tuple)):
|
|
271
|
+
return [self._serialize_value(item, seen) for item in val]
|
|
272
|
+
if isinstance(val, dict):
|
|
273
|
+
return {str(k): self._serialize_value(v, seen) for k, v in val.items()}
|
|
274
|
+
if hasattr(val, "__dict__"):
|
|
275
|
+
return {
|
|
276
|
+
k: self._serialize_value(v, seen)
|
|
277
|
+
for k, v in val.__dict__.items()
|
|
278
|
+
if not k.startswith("_") and k not in hidden
|
|
279
|
+
}
|
|
280
|
+
finally:
|
|
281
|
+
seen.remove(obj_id)
|
|
282
|
+
|
|
283
|
+
raise TypeError(
|
|
284
|
+
f"Unsupported payload type for serialization: {type(val).__name__}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _resolve_validated_key(self) -> str | None:
|
|
288
|
+
"""Resolve and validate active signing key, failing closed unless explicitly opted into unsigned mode."""
|
|
289
|
+
key = self.get_app_key()
|
|
290
|
+
unsigned_allowed = (
|
|
291
|
+
self._allow_unsigned_override
|
|
292
|
+
if self._allow_unsigned_override is not None
|
|
293
|
+
else self.get_allow_unsigned()
|
|
294
|
+
)
|
|
295
|
+
if key is None or not str(key).strip():
|
|
296
|
+
if not unsigned_allowed:
|
|
297
|
+
raise ConfigurationError(
|
|
298
|
+
"APP_KEY (>=32 bytes) is required for RedisEventBus HMAC signing. "
|
|
299
|
+
"Configure APP_KEY or pass allow_unsigned=True for local tests."
|
|
300
|
+
)
|
|
301
|
+
return None
|
|
302
|
+
validate_signing_key(str(key), purpose="RedisEventBus APP_KEY")
|
|
303
|
+
return str(key)
|
|
304
|
+
|
|
305
|
+
async def publish(
|
|
306
|
+
self, event: str, payload: Any = None, *, local: bool = False
|
|
307
|
+
) -> int:
|
|
308
|
+
"""
|
|
309
|
+
Publish an event across processes via Redis Pub/Sub.
|
|
310
|
+
Optionally dispatches locally in the current process if local=True.
|
|
311
|
+
Returns the number of subscribers that received the message.
|
|
312
|
+
"""
|
|
313
|
+
key = self._resolve_validated_key()
|
|
314
|
+
serialized_payload = self._serialize_value(payload)
|
|
315
|
+
envelope: dict[str, Any] = {
|
|
316
|
+
"event": event,
|
|
317
|
+
"payload": serialized_payload,
|
|
318
|
+
"sender_id": self.bus_id,
|
|
319
|
+
"timestamp": time.time(),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if key:
|
|
323
|
+
envelope["signature"] = self._compute_signature(envelope, key)
|
|
324
|
+
|
|
325
|
+
channel = f"{self.channel_prefix}{event}"
|
|
326
|
+
raw_message = json.dumps(envelope)
|
|
327
|
+
|
|
328
|
+
client = await self.get_client()
|
|
329
|
+
subscribers_count = await client.publish(channel, raw_message)
|
|
330
|
+
|
|
331
|
+
if local:
|
|
332
|
+
await self.dispatcher.dispatch(event, payload)
|
|
333
|
+
|
|
334
|
+
return subscribers_count
|
|
335
|
+
|
|
336
|
+
async def start_listening(self) -> asyncio.Task[None]:
|
|
337
|
+
"""
|
|
338
|
+
Start the background worker task that consumes incoming pub/sub messages
|
|
339
|
+
and dispatches them to registered listeners.
|
|
340
|
+
"""
|
|
341
|
+
if self._listener_task is not None and not self._listener_task.done():
|
|
342
|
+
return self._listener_task
|
|
343
|
+
|
|
344
|
+
self._resolve_validated_key()
|
|
345
|
+
|
|
346
|
+
self._running = True
|
|
347
|
+
self._ready_event.clear()
|
|
348
|
+
client = await self.get_client()
|
|
349
|
+
self._pubsub = client.pubsub()
|
|
350
|
+
|
|
351
|
+
pattern = f"{self.channel_prefix}*"
|
|
352
|
+
await self._pubsub.psubscribe(pattern)
|
|
353
|
+
self._ready_event.set()
|
|
354
|
+
|
|
355
|
+
self._listener_task = asyncio.create_task(
|
|
356
|
+
self._listener_loop(self._pubsub, pattern)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _on_listener_done(t: asyncio.Task[None]) -> None:
|
|
360
|
+
if not t.cancelled() and t.exception() is not None:
|
|
361
|
+
logger.exception(
|
|
362
|
+
"RedisEventBus listener task crashed", exc_info=t.exception()
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
self._listener_task.add_done_callback(_on_listener_done)
|
|
366
|
+
return self._listener_task
|
|
367
|
+
|
|
368
|
+
async def wait_until_ready(self, timeout: float = 5.0) -> None:
|
|
369
|
+
"""Wait until the pub/sub connection and pattern subscription are established."""
|
|
370
|
+
await asyncio.wait_for(self._ready_event.wait(), timeout=timeout)
|
|
371
|
+
|
|
372
|
+
async def _close_pubsub(self, pubsub: aioredis.client.PubSub) -> None:
|
|
373
|
+
"""Close pubsub connection cleanly using aclose."""
|
|
374
|
+
aclose_fn = cast(Callable[[], Awaitable[None]], pubsub.aclose)
|
|
375
|
+
await aclose_fn()
|
|
376
|
+
|
|
377
|
+
async def _listener_loop(
|
|
378
|
+
self, pubsub: aioredis.client.PubSub, pattern: str
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Background loop reading Redis pub/sub messages with backoff and reconnection."""
|
|
381
|
+
backoff = 0.1
|
|
382
|
+
last_error_log = 0.0
|
|
383
|
+
try:
|
|
384
|
+
while self._running:
|
|
385
|
+
try:
|
|
386
|
+
message = await pubsub.get_message(
|
|
387
|
+
ignore_subscribe_messages=True, timeout=0.5
|
|
388
|
+
)
|
|
389
|
+
backoff = 0.1
|
|
390
|
+
except (RedisError, OSError) as exc:
|
|
391
|
+
if not self._running:
|
|
392
|
+
break
|
|
393
|
+
now = time.monotonic()
|
|
394
|
+
if now - last_error_log > 10.0:
|
|
395
|
+
logger.warning(
|
|
396
|
+
"Redis bus connection error: %s (backing off)", exc
|
|
397
|
+
)
|
|
398
|
+
last_error_log = now
|
|
399
|
+
jitter = 0.8 + 0.4 * random.random()
|
|
400
|
+
await asyncio.sleep(backoff * jitter)
|
|
401
|
+
backoff = min(30.0, backoff * 2.0)
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if message is None:
|
|
405
|
+
await asyncio.sleep(0.01)
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
msg_type = message.get("type")
|
|
409
|
+
if msg_type in ("pmessage", "message"):
|
|
410
|
+
data = message.get("data")
|
|
411
|
+
if isinstance(data, (str, bytes)):
|
|
412
|
+
await self._handle_raw_message(data)
|
|
413
|
+
finally:
|
|
414
|
+
try:
|
|
415
|
+
await pubsub.punsubscribe(pattern)
|
|
416
|
+
await self._close_pubsub(pubsub)
|
|
417
|
+
except (RedisError, OSError) as exc:
|
|
418
|
+
logger.debug("Error closing pubsub subscriber: %s", exc)
|
|
419
|
+
|
|
420
|
+
async def _handle_raw_message(self, data: str | bytes) -> None:
|
|
421
|
+
"""Deserialize message envelope, verify signature, and route event to dispatcher concurrently."""
|
|
422
|
+
try:
|
|
423
|
+
if isinstance(data, bytes):
|
|
424
|
+
text = data.decode("utf-8")
|
|
425
|
+
else:
|
|
426
|
+
text = data
|
|
427
|
+
envelope = json.loads(text)
|
|
428
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
429
|
+
logger.warning("Failed to decode Redis bus message: %s", exc)
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
if not isinstance(envelope, dict):
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# HMAC signature verification (fail closed unless allow_unsigned is explicitly opted in)
|
|
436
|
+
try:
|
|
437
|
+
key = self._resolve_validated_key()
|
|
438
|
+
except ConfigurationError as exc:
|
|
439
|
+
logger.error(
|
|
440
|
+
"Dropping Redis bus message for event '%s' due to invalid APP_KEY config: %s",
|
|
441
|
+
envelope.get("event"),
|
|
442
|
+
exc,
|
|
443
|
+
)
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
if key:
|
|
447
|
+
sig = envelope.get("signature")
|
|
448
|
+
if not sig or not isinstance(sig, str):
|
|
449
|
+
logger.warning(
|
|
450
|
+
"Dropping unsigned Redis bus message for event '%s'",
|
|
451
|
+
envelope.get("event"),
|
|
452
|
+
)
|
|
453
|
+
return
|
|
454
|
+
expected_sig = self._compute_signature(envelope, key)
|
|
455
|
+
if not hmac.compare_digest(sig, expected_sig):
|
|
456
|
+
matched = False
|
|
457
|
+
for prev_key in self.get_previous_keys():
|
|
458
|
+
if hmac.compare_digest(
|
|
459
|
+
sig, self._compute_signature(envelope, prev_key)
|
|
460
|
+
):
|
|
461
|
+
matched = True
|
|
462
|
+
break
|
|
463
|
+
if not matched:
|
|
464
|
+
logger.warning(
|
|
465
|
+
"Dropping tampered Redis bus message for event '%s'",
|
|
466
|
+
envelope.get("event"),
|
|
467
|
+
)
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
sender_id = envelope.get("sender_id")
|
|
471
|
+
if self.ignore_self and sender_id == self.bus_id:
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
event = envelope.get("event")
|
|
475
|
+
payload = envelope.get("payload")
|
|
476
|
+
|
|
477
|
+
if isinstance(event, str):
|
|
478
|
+
await self._dispatch_semaphore.acquire()
|
|
479
|
+
|
|
480
|
+
async def _dispatch_task(evt: str, pay: Any) -> None:
|
|
481
|
+
try:
|
|
482
|
+
await self.dispatcher.dispatch(evt, pay)
|
|
483
|
+
except Exception:
|
|
484
|
+
logger.exception(
|
|
485
|
+
"Error executing listener for cross-process event '%s'",
|
|
486
|
+
evt,
|
|
487
|
+
)
|
|
488
|
+
finally:
|
|
489
|
+
self._dispatch_semaphore.release()
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
task = asyncio.create_task(_dispatch_task(event, payload))
|
|
493
|
+
except BaseException:
|
|
494
|
+
self._dispatch_semaphore.release()
|
|
495
|
+
raise
|
|
496
|
+
|
|
497
|
+
self._active_dispatch_tasks.add(task)
|
|
498
|
+
|
|
499
|
+
def _on_task_done(t: asyncio.Task[Any]) -> None:
|
|
500
|
+
self._active_dispatch_tasks.discard(t)
|
|
501
|
+
if not t.cancelled() and t.exception() is not None:
|
|
502
|
+
logger.exception(
|
|
503
|
+
"Event dispatch task failed", exc_info=t.exception()
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
task.add_done_callback(_on_task_done)
|
|
507
|
+
|
|
508
|
+
async def stop_listening(self) -> None:
|
|
509
|
+
"""Stop background message consumer and release subscriptions."""
|
|
510
|
+
self._running = False
|
|
511
|
+
self._ready_event.clear()
|
|
512
|
+
|
|
513
|
+
if self._listener_task is not None:
|
|
514
|
+
self._listener_task.cancel()
|
|
515
|
+
try:
|
|
516
|
+
await self._listener_task
|
|
517
|
+
except asyncio.CancelledError:
|
|
518
|
+
pass
|
|
519
|
+
except (RedisError, OSError, RuntimeError) as exc:
|
|
520
|
+
logger.debug("Error during listener task shutdown: %s", exc)
|
|
521
|
+
self._listener_task = None
|
|
522
|
+
|
|
523
|
+
if self._pubsub is not None:
|
|
524
|
+
try:
|
|
525
|
+
await self._close_pubsub(self._pubsub)
|
|
526
|
+
except (RedisError, OSError) as exc:
|
|
527
|
+
logger.debug("Error closing pubsub: %s", exc)
|
|
528
|
+
self._pubsub = None
|
|
529
|
+
|
|
530
|
+
if self._active_dispatch_tasks:
|
|
531
|
+
await asyncio.gather(*self._active_dispatch_tasks, return_exceptions=True)
|
|
532
|
+
self._active_dispatch_tasks.clear()
|
|
533
|
+
|
|
534
|
+
async def close(self) -> None:
|
|
535
|
+
"""Stop listening and close Redis connections."""
|
|
536
|
+
await self.stop_listening()
|
|
537
|
+
|
|
538
|
+
if self._owns_client and self._client is not None:
|
|
539
|
+
await self._client.aclose()
|
|
540
|
+
self._client = None
|
|
541
|
+
|
|
542
|
+
if self._owns_pool and self._pool is not None:
|
|
543
|
+
await self._pool.disconnect()
|
|
544
|
+
self._pool = None
|
|
545
|
+
|
|
546
|
+
async def __aenter__(self) -> Self:
|
|
547
|
+
await self.start_listening()
|
|
548
|
+
await self.wait_until_ready()
|
|
549
|
+
return self
|
|
550
|
+
|
|
551
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
552
|
+
await self.close()
|