swiftpy-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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()