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,556 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import logging
7
+ import re
8
+ import signal
9
+ import time
10
+ import uuid
11
+ from typing import Any
12
+
13
+ from redis.exceptions import NoScriptError, RedisError
14
+
15
+ from swiftpy.core.config import ConfigurationError
16
+ from swiftpy.core.container import get_container
17
+ from swiftpy.database.pool import DatabasePool
18
+ from swiftpy.queue.dispatcher import QueueDispatcher
19
+ from swiftpy.queue.job import Job, JobSerializationError
20
+ from swiftpy.support.keys import validate_signing_key
21
+
22
+ logger = logging.getLogger("swiftpy.queue")
23
+
24
+ _QUEUE_NAME_REGEX = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
25
+
26
+ # Atomic Lua script to migrate due delayed jobs into the active queue list
27
+ _PROMOTE_DELAYED_LUA = """
28
+ local due = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 50)
29
+ for i, job in ipairs(due) do
30
+ redis.call('zrem', KEYS[1], job)
31
+ redis.call('rpush', KEYS[2], job)
32
+ end
33
+ return #due
34
+ """
35
+
36
+
37
+ class QueueWorker:
38
+ """
39
+ Queue worker process responsible for consuming jobs from Redis queues,
40
+ migrating scheduled delayed jobs, managing backoff retries, and recording
41
+ failed jobs into the Dead Letter Queue.
42
+
43
+ Queue delivery operates with at-least-once semantics; job handlers must be idempotent.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ queues: str | list[str] = "default",
49
+ dispatcher: QueueDispatcher | None = None,
50
+ sleep: float = 1.0,
51
+ max_jobs: int | None = None,
52
+ stop_when_empty: bool = False,
53
+ timeout: float | None = None,
54
+ worker_id: str | None = None,
55
+ concurrency: int = 1,
56
+ promote_interval: float = 0.1,
57
+ allow_unsigned: bool | None = None,
58
+ ) -> None:
59
+ if isinstance(queues, str):
60
+ raw_queues = [q.strip() for q in queues.split(",") if q.strip()]
61
+ else:
62
+ raw_queues = list(queues) if queues else []
63
+
64
+ if not raw_queues:
65
+ raise ValueError("Invalid queue name: queue name cannot be empty")
66
+
67
+ for q in raw_queues:
68
+ if not _QUEUE_NAME_REGEX.fullmatch(q):
69
+ raise ValueError(
70
+ f"Invalid queue name '{q}': must match ^[A-Za-z0-9_.-]{{1,64}}$"
71
+ )
72
+ self.queues = raw_queues
73
+
74
+ self._owns_dispatcher = dispatcher is None
75
+ self.dispatcher: QueueDispatcher = (
76
+ dispatcher if dispatcher is not None else QueueDispatcher()
77
+ )
78
+ self.sleep = max(0.01, float(sleep))
79
+ self.max_jobs = max_jobs
80
+ self.stop_when_empty = stop_when_empty
81
+ self.timeout = timeout
82
+ self.worker_id = worker_id or f"worker-{uuid.uuid4().hex[:8]}"
83
+ self.concurrency = max(1, int(concurrency))
84
+ self.promote_interval = max(0.01, float(promote_interval))
85
+ self.allow_unsigned = allow_unsigned
86
+ self._semaphore = asyncio.Semaphore(self.concurrency)
87
+ self._active_tasks: set[asyncio.Task[Any]] = set()
88
+ self._original_handlers: dict[signal.Signals, Any] = {}
89
+
90
+ self._should_stop: bool = False
91
+ self._is_working: bool = False
92
+ self.jobs_processed: int = 0
93
+ self.jobs_failed: int = 0
94
+ self._promote_script_sha: str | None = None
95
+ self._last_promoted_at: float = 0.0
96
+
97
+ def stop(self) -> None:
98
+ """Signal the worker process to gracefully stop."""
99
+ logger.info("Worker %s received stop signal", self.worker_id)
100
+ self._should_stop = True
101
+
102
+ def _setup_signal_handlers(self) -> None:
103
+ """Register SIGINT and SIGTERM handlers for graceful shutdown."""
104
+ try:
105
+ loop = asyncio.get_running_loop()
106
+ for sig in (signal.SIGINT, signal.SIGTERM):
107
+ try:
108
+ self._original_handlers[sig] = signal.getsignal(sig)
109
+ except (ValueError, AttributeError):
110
+ pass
111
+ try:
112
+ loop.add_signal_handler(sig, self.stop)
113
+ except (NotImplementedError, RuntimeError):
114
+ signal.signal(sig, lambda s, f: self.stop())
115
+ except (RuntimeError, ValueError):
116
+ for sig in (signal.SIGINT, signal.SIGTERM):
117
+ try:
118
+ self._original_handlers[sig] = signal.getsignal(sig)
119
+ except (ValueError, AttributeError):
120
+ pass
121
+ signal.signal(sig, lambda s, f: self.stop())
122
+
123
+ def _restore_signal_handlers(self) -> None:
124
+ """Restore original signal handlers on worker exit."""
125
+ for sig, handler in self._original_handlers.items():
126
+ try:
127
+ loop = asyncio.get_running_loop()
128
+ loop.remove_signal_handler(sig)
129
+ except (NotImplementedError, RuntimeError, ValueError):
130
+ pass
131
+ try:
132
+ if handler is not None:
133
+ signal.signal(sig, handler)
134
+ except (ValueError, AttributeError):
135
+ pass
136
+ self._original_handlers.clear()
137
+
138
+ async def _promote_delayed_jobs(self, queue: str) -> int:
139
+ """
140
+ Atomically move scheduled jobs with score <= now from delayed ZSET to active queue list.
141
+ Uses SHA-cached EVALSHA for efficiency.
142
+ """
143
+ if not hasattr(self.dispatcher, "get_client"):
144
+ return 0
145
+ client = await self.dispatcher.get_client()
146
+ delayed_key = self.dispatcher._delayed_key(queue)
147
+ queue_key = self.dispatcher._queue_key(queue)
148
+ now_str = str(time.time())
149
+
150
+ try:
151
+ if self._promote_script_sha is None:
152
+ try:
153
+ self._promote_script_sha = await client.script_load(_PROMOTE_DELAYED_LUA)
154
+ except (RedisError, ConnectionError, OSError):
155
+ logger.debug("Could not pre-load promote script SHA", exc_info=True)
156
+
157
+ if self._promote_script_sha is not None:
158
+ try:
159
+ res = await client.evalsha(
160
+ self._promote_script_sha,
161
+ 2,
162
+ delayed_key,
163
+ queue_key,
164
+ now_str,
165
+ )
166
+ except NoScriptError:
167
+ self._promote_script_sha = await client.script_load(_PROMOTE_DELAYED_LUA)
168
+ res = await client.evalsha(
169
+ self._promote_script_sha,
170
+ 2,
171
+ delayed_key,
172
+ queue_key,
173
+ now_str,
174
+ )
175
+ else:
176
+ res = await client.eval(
177
+ _PROMOTE_DELAYED_LUA,
178
+ 2,
179
+ delayed_key,
180
+ queue_key,
181
+ now_str,
182
+ )
183
+ return int(res) if res is not None else 0
184
+ except (TimeoutError, RedisError, ConnectionError, OSError) as exc:
185
+ logger.warning(
186
+ "Error promoting delayed jobs for queue '%s': %s", queue, exc
187
+ )
188
+ return 0
189
+
190
+ async def _pop_next_job(self) -> tuple[str, str] | None:
191
+ """
192
+ Check all monitored queues in order, throttled delayed-job promotion & lease reaping,
193
+ and atomically pop & lease the first available job payload.
194
+ """
195
+ now = time.monotonic()
196
+ if (
197
+ self._last_promoted_at <= 0.0
198
+ or (now - self._last_promoted_at) >= self.promote_interval
199
+ ):
200
+ self._last_promoted_at = now
201
+ for queue in self.queues:
202
+ await self._promote_delayed_jobs(queue)
203
+ await self.dispatcher.reap_expired_leases(queue)
204
+
205
+ for queue in self.queues:
206
+ lease_duration = (self.timeout + 30.0) if self.timeout else 60.0
207
+ raw_payload = await self.dispatcher.pop_and_lease(
208
+ queue,
209
+ self.worker_id,
210
+ lease_duration=lease_duration,
211
+ )
212
+ if raw_payload is not None:
213
+ return queue, raw_payload
214
+ return None
215
+
216
+ async def _all_queues_empty(self) -> bool:
217
+ """
218
+ Check whether all monitored active queues, delayed queues, and in-flight processing lists have zero jobs.
219
+ """
220
+ for queue in self.queues:
221
+ active_size = await self.dispatcher.size(queue)
222
+ if active_size > 0:
223
+ return False
224
+ delayed_size = await self.dispatcher.delayed_size(queue)
225
+ if delayed_size > 0:
226
+ return False
227
+ proc_size = await self.dispatcher.processing_size(queue)
228
+ if proc_size > 0:
229
+ return False
230
+ return True
231
+
232
+ async def process_job(self, queue: str, raw_payload: str) -> bool:
233
+ """
234
+ Deserialize and process a single queued job payload.
235
+ Returns True if handled, False on failure.
236
+ """
237
+ # 1. Parse JSON envelope
238
+ try:
239
+ data = json.loads(raw_payload)
240
+ if not isinstance(data, dict):
241
+ raise JobSerializationError(
242
+ f"Invalid job envelope: expected dict, got {type(data).__name__}"
243
+ )
244
+ except (
245
+ json.JSONDecodeError,
246
+ UnicodeDecodeError,
247
+ TypeError,
248
+ ValueError,
249
+ JobSerializationError,
250
+ ) as exc:
251
+ logger.error(
252
+ "Worker %s encountered unparseable JSON payload: %s",
253
+ self.worker_id,
254
+ exc,
255
+ )
256
+ await self.dispatcher.push_failed(
257
+ job=None,
258
+ exception=exc,
259
+ queue=queue,
260
+ payload={"raw": raw_payload},
261
+ )
262
+ await self.dispatcher.ack(queue, self.worker_id, raw_payload)
263
+ self.jobs_failed += 1
264
+ return False
265
+
266
+ # 2. Reconstruct Job instance
267
+ try:
268
+ job = Job.deserialize(data, allow_unsigned=self.allow_unsigned)
269
+ except (
270
+ JobSerializationError,
271
+ TypeError,
272
+ ValueError,
273
+ KeyError,
274
+ AttributeError,
275
+ ) as exc:
276
+ logger.error(
277
+ "Worker %s failed to deserialize job envelope: %s",
278
+ self.worker_id,
279
+ exc,
280
+ )
281
+ await self.dispatcher.push_failed(
282
+ job=None,
283
+ exception=exc,
284
+ queue=queue,
285
+ payload=data,
286
+ )
287
+ await self.dispatcher.ack(queue, self.worker_id, raw_payload)
288
+ self.jobs_failed += 1
289
+ return False
290
+
291
+ # 3. Increment attempt count
292
+ job.attempts += 1
293
+ effective_timeout = job.timeout or self.timeout
294
+
295
+ # 4. Execute Job.handle()
296
+ logger.debug(
297
+ "Worker %s executing job %s [%s] attempt %d/%d on queue '%s'",
298
+ self.worker_id,
299
+ job.job_id,
300
+ job.__class__.__name__,
301
+ job.attempts,
302
+ job.tries,
303
+ queue,
304
+ )
305
+
306
+ try:
307
+ if effective_timeout is not None and effective_timeout > 0:
308
+ await asyncio.wait_for(job.handle(), timeout=effective_timeout)
309
+ else:
310
+ await job.handle()
311
+ except asyncio.CancelledError:
312
+ # Worker task was cancelled mid-execution: preserve job and attempts by re-queuing at head
313
+ logger.warning(
314
+ "Worker %s cancelled while processing job %s; re-queueing",
315
+ self.worker_id,
316
+ job.job_id,
317
+ )
318
+ requeue_payload = json.dumps(
319
+ job.serialize(allow_unsigned=self.allow_unsigned),
320
+ ensure_ascii=False,
321
+ )
322
+ await self.dispatcher.release(
323
+ queue,
324
+ self.worker_id,
325
+ raw_payload,
326
+ delay=0,
327
+ new_payload=requeue_payload,
328
+ )
329
+ raise
330
+ except Exception as exc: # noqa: BLE001 - user job handler can raise any standard Exception
331
+ await self._handle_job_failure(job, queue, raw_payload, data, exc)
332
+ return False
333
+ else:
334
+ logger.info(
335
+ "Worker %s completed job %s [%s]",
336
+ self.worker_id,
337
+ job.job_id,
338
+ job.__class__.__name__,
339
+ )
340
+ await self.dispatcher.ack(queue, self.worker_id, raw_payload)
341
+ self.jobs_processed += 1
342
+ return True
343
+
344
+ async def _handle_job_failure(
345
+ self,
346
+ job: Job,
347
+ queue: str,
348
+ raw_payload: str,
349
+ data: dict[str, Any],
350
+ exc: BaseException,
351
+ ) -> None:
352
+ """
353
+ Handle job failure: retry with backoff if attempts < tries, or move to DLQ.
354
+ """
355
+ logger.warning(
356
+ "Job %s [%s] failed on attempt %d/%d: %s",
357
+ job.job_id,
358
+ job.__class__.__name__,
359
+ job.attempts,
360
+ job.tries,
361
+ exc,
362
+ )
363
+
364
+ if job.attempts < job.tries:
365
+ # Retry with backoff
366
+ delay = job.get_backoff_delay()
367
+ logger.info(
368
+ "Retrying job %s [%s] in %.2fs (attempt %d/%d)",
369
+ job.job_id,
370
+ job.__class__.__name__,
371
+ delay,
372
+ job.attempts + 1,
373
+ job.tries,
374
+ )
375
+ requeue_payload = json.dumps(
376
+ job.serialize(allow_unsigned=self.allow_unsigned),
377
+ ensure_ascii=False,
378
+ )
379
+ await self.dispatcher.release(
380
+ queue,
381
+ self.worker_id,
382
+ raw_payload,
383
+ delay=delay,
384
+ new_payload=requeue_payload,
385
+ )
386
+ else:
387
+ # Retries exhausted -> Dead Letter Queue
388
+ logger.error(
389
+ "Job %s [%s] exhausted all %d attempts. Moving to Dead Letter Queue.",
390
+ job.job_id,
391
+ job.__class__.__name__,
392
+ job.tries,
393
+ )
394
+ try:
395
+ res = job.failed(exc)
396
+ if inspect.isawaitable(res):
397
+ await res
398
+ except Exception:
399
+ logger.exception(
400
+ "Error executing failed() hook on job %s",
401
+ job.job_id,
402
+ )
403
+
404
+ await self.dispatcher.push_failed(
405
+ job=job,
406
+ exception=exc,
407
+ queue=queue,
408
+ payload=data,
409
+ )
410
+ await self.dispatcher.ack(queue, self.worker_id, raw_payload)
411
+ self.jobs_failed += 1
412
+
413
+ async def _run_job_with_semaphore(self, queue: str, raw_payload: str) -> None:
414
+ """Run job inside concurrency semaphore."""
415
+ try:
416
+ await self.process_job(queue, raw_payload)
417
+ finally:
418
+ self._semaphore.release()
419
+
420
+ async def work(self) -> None:
421
+ """
422
+ Run the worker loop pulling and executing jobs.
423
+ """
424
+ effective_key = Job.get_app_key()
425
+ unsigned_allowed = (
426
+ self.allow_unsigned
427
+ if self.allow_unsigned is not None
428
+ else Job.get_allow_unsigned()
429
+ )
430
+ if effective_key is None or not str(effective_key).strip():
431
+ if not unsigned_allowed:
432
+ raise ConfigurationError(
433
+ "APP_KEY (>=32 bytes) is required to run QueueWorker. "
434
+ "Configure APP_KEY or pass allow_unsigned=True for local tests."
435
+ )
436
+ else:
437
+ validate_signing_key(str(effective_key), purpose="APP_KEY")
438
+
439
+ self._setup_signal_handlers()
440
+ self._is_working = True
441
+
442
+ # Initialize DatabasePool if available in the application container
443
+ try:
444
+ container = get_container()
445
+ if container.has(DatabasePool):
446
+ pool = container.resolve(DatabasePool)
447
+ if not getattr(pool, "is_initialized", False):
448
+ await pool.init()
449
+ except RuntimeError:
450
+ pass
451
+
452
+ logger.info(
453
+ "Worker %s started monitoring queues: %s (concurrency=%d)",
454
+ self.worker_id,
455
+ ", ".join(self.queues),
456
+ self.concurrency,
457
+ )
458
+
459
+ try:
460
+ while not self._should_stop:
461
+ # Check maximum job limit including in-flight tasks
462
+ if self.max_jobs is not None:
463
+ completed = self.jobs_processed + self.jobs_failed
464
+ if completed >= self.max_jobs:
465
+ logger.info(
466
+ "Worker %s reached max_jobs limit (%d)",
467
+ self.worker_id,
468
+ self.max_jobs,
469
+ )
470
+ break
471
+ if completed + len(self._active_tasks) >= self.max_jobs:
472
+ await asyncio.wait(
473
+ self._active_tasks,
474
+ return_when=asyncio.FIRST_COMPLETED,
475
+ )
476
+ continue
477
+
478
+ acquired = False
479
+ if self.concurrency > 1:
480
+ await self._semaphore.acquire()
481
+ acquired = True
482
+ try:
483
+ job_data = await self._pop_next_job()
484
+ except BaseException:
485
+ if acquired:
486
+ self._semaphore.release()
487
+ raise
488
+
489
+ if job_data is not None:
490
+ queue, raw_payload = job_data
491
+ if self.concurrency > 1:
492
+ try:
493
+ task = asyncio.create_task(
494
+ self._run_job_with_semaphore(queue, raw_payload)
495
+ )
496
+ except BaseException:
497
+ if acquired:
498
+ self._semaphore.release()
499
+ raise
500
+ self._active_tasks.add(task)
501
+
502
+ def _on_done(t: asyncio.Task[Any]) -> None:
503
+ self._active_tasks.discard(t)
504
+ if not t.cancelled() and t.exception() is not None:
505
+ logger.exception(
506
+ "Queue worker task failed",
507
+ exc_info=t.exception(),
508
+ )
509
+
510
+ task.add_done_callback(_on_done)
511
+ else:
512
+ try:
513
+ await self.process_job(queue, raw_payload)
514
+ except Exception:
515
+ logger.exception(
516
+ "Worker %s encountered unexpected error processing job on queue '%s'",
517
+ self.worker_id,
518
+ queue,
519
+ )
520
+ continue
521
+
522
+ if acquired:
523
+ self._semaphore.release()
524
+
525
+ # No active job available
526
+ if self.stop_when_empty and await self._all_queues_empty():
527
+ if self._active_tasks:
528
+ await asyncio.wait(
529
+ self._active_tasks,
530
+ return_when=asyncio.FIRST_COMPLETED,
531
+ )
532
+ continue
533
+ logger.info(
534
+ "Worker %s stopping because queues are empty",
535
+ self.worker_id,
536
+ )
537
+ break
538
+
539
+ await asyncio.sleep(self.sleep)
540
+ finally:
541
+ self._is_working = False
542
+ if self._active_tasks:
543
+ await asyncio.gather(*self._active_tasks, return_exceptions=True)
544
+ self._restore_signal_handlers()
545
+ if self._owns_dispatcher:
546
+ await self.dispatcher.close()
547
+ logger.info(
548
+ "Worker %s stopped. (Processed: %d, Failed: %d)",
549
+ self.worker_id,
550
+ self.jobs_processed,
551
+ self.jobs_failed,
552
+ )
553
+
554
+ async def run(self) -> None:
555
+ """Alias for work()."""
556
+ await self.work()
@@ -0,0 +1,12 @@
1
+ """
2
+ SwiftPY Support Utilities.
3
+
4
+ Cross-cutting helper functions and configuration resolvers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from swiftpy.support.redis_config import build_redis_url, resolve_redis_params
10
+ from swiftpy.support.strings import pluralize, snake_case
11
+
12
+ __all__ = ["build_redis_url", "pluralize", "resolve_redis_params", "snake_case"]
@@ -0,0 +1,108 @@
1
+ """
2
+ Application cryptographic key decoding and entropy validation helpers.
3
+
4
+ Provides unified parsing of ``base64:`` prefixed keys and raw secrets across
5
+ subsystems (Queue, Events, Filesystem, Application startup) so every consumer
6
+ derives identical key material and enforces minimum entropy.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+
14
+ from swiftpy.core.config import Config, ConfigurationError, env
15
+
16
+ MIN_SIGNING_KEY_BYTES = 32
17
+
18
+
19
+ def decode_signing_key(raw_key: str) -> bytes:
20
+ """Decode a raw or ``base64:`` prefixed signing key into bytes."""
21
+ if not isinstance(raw_key, str) or not raw_key.strip():
22
+ raise ConfigurationError("Signing key must be a non-empty string.")
23
+
24
+ if raw_key.startswith("base64:"):
25
+ encoded = raw_key[len("base64:") :].strip()
26
+ if not encoded:
27
+ raise ConfigurationError("Malformed base64 signing key: empty payload.")
28
+ try:
29
+ return base64.b64decode(encoded.encode("ascii"), validate=True)
30
+ except (ValueError, binascii.Error, UnicodeEncodeError) as exc:
31
+ raise ConfigurationError(
32
+ f"Malformed base64 application signing key: {exc}"
33
+ ) from exc
34
+
35
+ return raw_key.encode("utf-8")
36
+
37
+
38
+ def validate_signing_key(
39
+ raw_key: str | None,
40
+ *,
41
+ min_bytes: int = MIN_SIGNING_KEY_BYTES,
42
+ purpose: str = "APP_KEY",
43
+ ) -> bytes:
44
+ """Decode and validate that a signing key meets minimum byte entropy."""
45
+ if raw_key is None or not isinstance(raw_key, str) or not raw_key.strip():
46
+ raise ConfigurationError(
47
+ f"{purpose} is required and must be configured (at least {min_bytes} bytes)."
48
+ )
49
+
50
+ decoded = decode_signing_key(raw_key)
51
+ if len(decoded) < min_bytes:
52
+ raise ConfigurationError(
53
+ f"{purpose} must decode to at least {min_bytes} bytes (got {len(decoded)} bytes)."
54
+ )
55
+ return decoded
56
+
57
+
58
+ def resolve_configured_app_key(config: Config | None = None) -> str | None:
59
+ """Resolve ``app.key`` / ``key`` from Config (when declared) or ``env('APP_KEY')``."""
60
+ cfg = config
61
+ if cfg is None:
62
+ try:
63
+ cfg = Config.get_instance()
64
+ except (ConfigurationError, RuntimeError):
65
+ cfg = None
66
+
67
+ if cfg is not None and (cfg.has("app.key") or cfg.has("key")):
68
+ raw = cfg.get("app.key") if cfg.has("app.key") else cfg.get("key")
69
+ if isinstance(raw, str) and raw.strip():
70
+ return raw
71
+ return None
72
+
73
+ env_key = env("APP_KEY")
74
+ if env_key is not None and str(env_key).strip():
75
+ return str(env_key)
76
+ return None
77
+
78
+
79
+ def resolve_configured_previous_keys(config: Config | None = None) -> list[str]:
80
+ """Resolve retired keys for signature rotation from Config or environment."""
81
+ cfg = config
82
+ if cfg is None:
83
+ try:
84
+ cfg = Config.get_instance()
85
+ except (ConfigurationError, RuntimeError):
86
+ cfg = None
87
+
88
+ if cfg is not None and cfg.has("app.previous_keys"):
89
+ val = cfg.get("app.previous_keys")
90
+ if isinstance(val, (list, tuple)):
91
+ return [str(k) for k in val if str(k).strip()]
92
+ if isinstance(val, str) and val.strip():
93
+ return [k.strip() for k in val.split(",") if k.strip()]
94
+ return []
95
+
96
+ env_prev = env("APP_PREVIOUS_KEYS")
97
+ if env_prev:
98
+ return [k.strip() for k in str(env_prev).split(",") if k.strip()]
99
+ return []
100
+
101
+
102
+ __all__ = [
103
+ "MIN_SIGNING_KEY_BYTES",
104
+ "decode_signing_key",
105
+ "resolve_configured_app_key",
106
+ "resolve_configured_previous_keys",
107
+ "validate_signing_key",
108
+ ]