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,813 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ import traceback
8
+ import uuid
9
+ from typing import Any, ClassVar, Self
10
+
11
+ import redis.asyncio as aioredis
12
+ from redis.exceptions import NoScriptError, RedisError
13
+
14
+ from swiftpy.core.config import Config, ConfigurationError
15
+ from swiftpy.core.container import get_container
16
+ from swiftpy.queue.job import Job, set_dispatch_handler
17
+ from swiftpy.support.keys import validate_signing_key
18
+ from swiftpy.support.redis_config import resolve_redis_params
19
+
20
+ logger = logging.getLogger("swiftpy.queue")
21
+
22
+ _POP_AND_LEASE_LUA = """
23
+ local function sanitize_utf8(s)
24
+ local n = #s
25
+ local has_non_ascii = false
26
+ for k = 1, n do
27
+ if string.byte(s, k) >= 128 then
28
+ has_non_ascii = true
29
+ break
30
+ end
31
+ end
32
+ if not has_non_ascii then
33
+ return s
34
+ end
35
+ local i = 1
36
+ local out = nil
37
+ local last = 1
38
+ local repl = string.char(239, 191, 189)
39
+ while i <= n do
40
+ local b1 = string.byte(s, i)
41
+ local seq_len = 0
42
+ if b1 < 128 then
43
+ seq_len = 1
44
+ elseif b1 >= 194 and b1 <= 223 and i + 1 <= n then
45
+ local b2 = string.byte(s, i + 1)
46
+ if b2 >= 128 and b2 <= 191 then
47
+ seq_len = 2
48
+ end
49
+ elseif b1 >= 224 and b1 <= 239 and i + 2 <= n then
50
+ local b2, b3 = string.byte(s, i + 1, i + 2)
51
+ if b2 >= 128 and b2 <= 191 and b3 >= 128 and b3 <= 191 then
52
+ if not (b1 == 224 and b2 < 160) and not (b1 == 237 and b2 > 159) then
53
+ seq_len = 3
54
+ end
55
+ end
56
+ elseif b1 >= 240 and b1 <= 244 and i + 3 <= n then
57
+ local b2, b3, b4 = string.byte(s, i + 1, i + 3)
58
+ if b2 >= 128 and b2 <= 191 and b3 >= 128 and b3 <= 191 and b4 >= 128 and b4 <= 191 then
59
+ if not (b1 == 240 and b2 < 144) and not (b1 == 244 and b2 > 143) then
60
+ seq_len = 4
61
+ end
62
+ end
63
+ end
64
+ if seq_len > 0 then
65
+ i = i + seq_len
66
+ else
67
+ if not out then
68
+ out = {}
69
+ end
70
+ if i > last then
71
+ out[#out + 1] = string.sub(s, last, i - 1)
72
+ end
73
+ out[#out + 1] = repl
74
+ i = i + 1
75
+ last = i
76
+ end
77
+ end
78
+ if not out then
79
+ return s
80
+ end
81
+ if last <= n then
82
+ out[#out + 1] = string.sub(s, last)
83
+ end
84
+ return table.concat(out)
85
+ end
86
+
87
+ local job = redis.call('lpop', KEYS[1])
88
+ if job then
89
+ job = sanitize_utf8(job)
90
+ redis.call('rpush', KEYS[2], job)
91
+ local member = ARGV[1] .. string.char(31) .. job
92
+ redis.call('zadd', KEYS[3], ARGV[2], member)
93
+ return job
94
+ end
95
+ return nil
96
+ """
97
+
98
+ _ACK_JOB_LUA = """
99
+ local removed = redis.call('lrem', KEYS[1], 1, ARGV[1])
100
+ local zremoved = redis.call('zrem', KEYS[2], ARGV[2])
101
+ if removed > 0 or zremoved > 0 then
102
+ return 1
103
+ end
104
+ return 0
105
+ """
106
+
107
+ _RELEASE_JOB_LUA = """
108
+ local removed = redis.call('lrem', KEYS[1], 1, ARGV[1])
109
+ local zremoved = redis.call('zrem', KEYS[2], ARGV[2])
110
+ if removed == 0 and zremoved == 0 then
111
+ return 0
112
+ end
113
+ local delay = tonumber(ARGV[4]) or 0
114
+ if delay > 0 then
115
+ local score = tonumber(ARGV[5]) + delay
116
+ redis.call('zadd', KEYS[3], score, ARGV[3])
117
+ else
118
+ redis.call('lpush', KEYS[3], ARGV[3])
119
+ end
120
+ return 1
121
+ """
122
+
123
+ _REAP_LEASES_LUA = """
124
+ local function update_top_attempts(raw_json, new_attempts)
125
+ local n = #raw_json
126
+ local i = 1
127
+ local depth = 0
128
+ local in_str = false
129
+ local escaped = false
130
+ while i <= n do
131
+ local b = string.byte(raw_json, i)
132
+ if in_str then
133
+ if escaped then
134
+ escaped = false
135
+ elseif b == 92 then
136
+ escaped = true
137
+ elseif b == 34 then
138
+ in_str = false
139
+ end
140
+ i = i + 1
141
+ else
142
+ if b == 34 then
143
+ local j = i + 1
144
+ local esc = false
145
+ while j <= n do
146
+ local c = string.byte(raw_json, j)
147
+ if esc then
148
+ esc = false
149
+ elseif c == 92 then
150
+ esc = true
151
+ elseif c == 34 then
152
+ break
153
+ end
154
+ j = j + 1
155
+ end
156
+ local key_name = string.sub(raw_json, i + 1, j - 1)
157
+ local k = j + 1
158
+ while k <= n do
159
+ local ws = string.byte(raw_json, k)
160
+ if ws == 32 or ws == 9 or ws == 10 or ws == 13 then
161
+ k = k + 1
162
+ else
163
+ break
164
+ end
165
+ end
166
+ if depth == 1 and key_name == 'attempts' and string.byte(raw_json, k) == 58 then
167
+ local v_start = k + 1
168
+ while v_start <= n do
169
+ local ws = string.byte(raw_json, v_start)
170
+ if ws == 32 or ws == 9 or ws == 10 or ws == 13 then
171
+ v_start = v_start + 1
172
+ else
173
+ break
174
+ end
175
+ end
176
+ local v_end = v_start
177
+ while v_end <= n do
178
+ local vc = string.byte(raw_json, v_end)
179
+ if vc == 44 or vc == 125 or vc == 32 or vc == 9 or vc == 10 or vc == 13 then
180
+ break
181
+ end
182
+ v_end = v_end + 1
183
+ end
184
+ return string.sub(raw_json, 1, v_start - 1) .. tostring(new_attempts) .. string.sub(raw_json, v_end)
185
+ end
186
+ i = j + 1
187
+ else
188
+ if b == 123 or b == 91 then
189
+ depth = depth + 1
190
+ elseif b == 125 or b == 93 then
191
+ depth = depth - 1
192
+ end
193
+ i = i + 1
194
+ end
195
+ end
196
+ end
197
+ local rev = string.reverse(raw_json)
198
+ local pos = string.find(rev, '}')
199
+ if pos then
200
+ local close_idx = n - pos + 1
201
+ local inner = string.sub(raw_json, 1, close_idx - 1)
202
+ if string.match(inner, '^{%s*$') then
203
+ return inner .. '"attempts":' .. tostring(new_attempts) .. string.sub(raw_json, close_idx)
204
+ end
205
+ return inner .. ',"attempts":' .. tostring(new_attempts) .. string.sub(raw_json, close_idx)
206
+ end
207
+ return raw_json
208
+ end
209
+
210
+ local expired = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]) or 50)
211
+ local max_failed = tonumber(ARGV[4]) or 0
212
+ local count = 0
213
+ for i, member in ipairs(expired) do
214
+ redis.call('zrem', KEYS[1], member)
215
+ local sep = string.find(member, string.char(31))
216
+ if sep then
217
+ local worker_id = string.sub(member, 1, sep - 1)
218
+ local raw_payload = string.sub(member, sep + 1)
219
+ local proc_key = ARGV[3] .. worker_id
220
+ redis.call('lrem', proc_key, 1, raw_payload)
221
+
222
+ local ok, data = pcall(cjson.decode, raw_payload)
223
+ if ok and type(data) == 'table' then
224
+ local attempts = (tonumber(data['attempts']) or 0) + 1
225
+ local tries = tonumber(data['tries']) or 3
226
+ local updated_raw = update_top_attempts(raw_payload, attempts)
227
+ if attempts >= tries then
228
+ local record_json = '{"job_id":' .. cjson.encode(tostring(data['job_id'] or 'unknown'))
229
+ .. ',"job_class":' .. cjson.encode(tostring(data['job_class'] or 'unknown'))
230
+ .. ',"queue":' .. cjson.encode(tostring(data['queue'] or 'default'))
231
+ .. ',"payload":' .. updated_raw
232
+ .. ',"exception":{"type":"LeaseExpiredError","message":'
233
+ .. cjson.encode('Job lease expired and max retry attempts (' .. tries .. ') reached')
234
+ .. ',"traceback":""},"failed_at":' .. ARGV[1]
235
+ .. ',"attempts":' .. attempts
236
+ .. ',"tries":' .. tries .. '}'
237
+ redis.call('rpush', KEYS[3], record_json)
238
+ if max_failed > 0 then
239
+ redis.call('ltrim', KEYS[3], -max_failed, -1)
240
+ end
241
+ else
242
+ redis.call('lpush', KEYS[2], updated_raw)
243
+ end
244
+ else
245
+ redis.call('lpush', KEYS[2], raw_payload)
246
+ end
247
+ count = count + 1
248
+ end
249
+ end
250
+ return count
251
+ """
252
+
253
+
254
+ def _default_queue_prefix() -> str:
255
+ try:
256
+ cfg = Config.get_instance()
257
+ val = cfg.get("queue.connections.redis.prefix") or cfg.get("queue.prefix")
258
+ if val and isinstance(val, str):
259
+ return val
260
+ except ConfigurationError:
261
+ logger.debug("Falling back to default queue prefix", exc_info=True)
262
+ return "swiftpy_queue:"
263
+
264
+
265
+ class QueueDispatcher:
266
+ """
267
+ Queue dispatcher responsible for pushing jobs onto Redis-backed queues,
268
+ managing delayed job scheduling, and handling dead letter queues.
269
+ """
270
+
271
+ _instance: ClassVar[QueueDispatcher | None] = None
272
+
273
+ def __init__(
274
+ self,
275
+ redis_client: aioredis.Redis | None = None,
276
+ connection_pool: aioredis.ConnectionPool | None = None,
277
+ redis_url: str | None = None,
278
+ host: str | None = None,
279
+ port: int | None = None,
280
+ password: str | None = None,
281
+ database: int | None = None,
282
+ prefix: str | None = None,
283
+ max_connections: int | None = None,
284
+ timeout: float = 30.0,
285
+ failed_max_size: int = 1000,
286
+ ) -> None:
287
+ self._client: aioredis.Redis | None = redis_client
288
+ self._pool: aioredis.ConnectionPool | None = connection_pool
289
+ self._redis_url = redis_url
290
+ self.host = host
291
+ self.port = port
292
+ self.password = password
293
+ self._explicit_database = database
294
+ self.database = database if database is not None else 0
295
+ self.prefix = prefix if prefix is not None else _default_queue_prefix()
296
+ self.max_connections = max_connections or 50
297
+ self.timeout = timeout
298
+ self.failed_max_size = max(0, int(failed_max_size))
299
+ self._owns_client = redis_client is None
300
+ self._owns_pool = connection_pool is None and redis_client is None
301
+ self._client_lock = asyncio.Lock()
302
+ self._script_shas: dict[str, str] = {}
303
+
304
+ @classmethod
305
+ def get_instance(cls) -> QueueDispatcher:
306
+ """Retrieve the active application's QueueDispatcher from the container, or fallback instance."""
307
+ try:
308
+ container = get_container()
309
+ if container.has(cls):
310
+ return container.resolve(cls)
311
+ inst = cls()
312
+ container.instance(cls, inst)
313
+ return inst
314
+ except RuntimeError:
315
+ pass
316
+
317
+ if cls._instance is None:
318
+ cls._instance = cls()
319
+ return cls._instance
320
+
321
+ @classmethod
322
+ def set_instance(cls, dispatcher: QueueDispatcher | None) -> None:
323
+ """Override or reset the QueueDispatcher instance in the active container and fallback."""
324
+ cls._instance = dispatcher
325
+ try:
326
+ container = get_container()
327
+ if container.has(cls):
328
+ if dispatcher is not None:
329
+ container.instance(cls, dispatcher)
330
+ else:
331
+ container.singleton(cls, lambda _: cls())
332
+ except RuntimeError:
333
+ pass
334
+
335
+ async def get_client(self) -> aioredis.Redis:
336
+ """Lazily initialize and return the async Redis client."""
337
+ if self._client is not None:
338
+ return self._client
339
+ async with self._client_lock:
340
+ if self._client is not None:
341
+ return self._client
342
+ if self._redis_url:
343
+ self._client = aioredis.from_url(
344
+ self._redis_url,
345
+ decode_responses=True,
346
+ encoding_errors="replace",
347
+ )
348
+ else:
349
+ if self._pool is None:
350
+ params = resolve_redis_params(
351
+ section="queue",
352
+ host=self.host,
353
+ port=self.port,
354
+ password=self.password,
355
+ database=self._explicit_database,
356
+ )
357
+
358
+ self._pool = aioredis.BlockingConnectionPool(
359
+ host=params["host"],
360
+ port=params["port"],
361
+ db=params["database"],
362
+ password=params["password"] or None,
363
+ decode_responses=True,
364
+ encoding_errors="replace",
365
+ max_connections=self.max_connections,
366
+ timeout=self.timeout,
367
+ )
368
+ self._client = aioredis.Redis(
369
+ connection_pool=self._pool,
370
+ decode_responses=True,
371
+ encoding_errors="replace",
372
+ )
373
+ self._owns_client = True
374
+ return self._client
375
+
376
+ def _queue_key(self, queue: str) -> str:
377
+ return f"{self.prefix}{queue}"
378
+
379
+ def _delayed_key(self, queue: str) -> str:
380
+ return f"{self.prefix}{queue}:delayed"
381
+
382
+ def _failed_key(self, queue: str) -> str:
383
+ return f"{self.prefix}{queue}:failed"
384
+
385
+ def _leases_key(self, queue: str) -> str:
386
+ return f"{self.prefix}{queue}:leases"
387
+
388
+ def _processing_key(self, queue: str, worker_id: str) -> str:
389
+ return f"{self.prefix}{queue}:processing:{worker_id}"
390
+
391
+ def _processing_prefix(self, queue: str) -> str:
392
+ return f"{self.prefix}{queue}:processing:"
393
+
394
+ async def _eval_lua(
395
+ self,
396
+ client: aioredis.Redis,
397
+ script: str,
398
+ numkeys: int,
399
+ *keys_and_args: str | float,
400
+ ) -> Any:
401
+ """Execute a Lua script with cached EVALSHA and transparent NoScriptError reload."""
402
+ sha = self._script_shas.get(script)
403
+ if sha is None and hasattr(client, "script_load"):
404
+ try:
405
+ sha = await client.script_load(script)
406
+ self._script_shas[script] = sha
407
+ except (RedisError, ConnectionError, OSError):
408
+ sha = None
409
+
410
+ if sha is not None and hasattr(client, "evalsha"):
411
+ try:
412
+ return await client.evalsha(sha, numkeys, *keys_and_args)
413
+ except NoScriptError:
414
+ sha = await client.script_load(script)
415
+ self._script_shas[script] = sha
416
+ return await client.evalsha(sha, numkeys, *keys_and_args)
417
+
418
+ return await client.eval(script, numkeys, *keys_and_args)
419
+
420
+ async def pop_and_lease(
421
+ self,
422
+ queue: str,
423
+ worker_id: str,
424
+ lease_duration: float = 60.0,
425
+ ) -> str | None:
426
+ """
427
+ Atomically move a job from the ready queue to this worker's processing list
428
+ and register a lease in the leases sorted set.
429
+ """
430
+ client = await self.get_client()
431
+ deadline = time.time() + float(lease_duration)
432
+ res = await self._eval_lua(
433
+ client,
434
+ _POP_AND_LEASE_LUA,
435
+ 3,
436
+ self._queue_key(queue),
437
+ self._processing_key(queue, worker_id),
438
+ self._leases_key(queue),
439
+ worker_id,
440
+ str(deadline),
441
+ )
442
+ if isinstance(res, (bytes, bytearray)):
443
+ return res.decode("utf-8")
444
+ return str(res) if res is not None else None
445
+
446
+ async def ack(
447
+ self,
448
+ queue: str,
449
+ worker_id: str,
450
+ raw_payload: str,
451
+ ) -> bool:
452
+ """
453
+ Atomically acknowledge a completed job: removes it from worker's processing list
454
+ and deletes its lease from the leases set.
455
+ """
456
+ client = await self.get_client()
457
+ member = f"{worker_id}\x1f{raw_payload}"
458
+ res = await self._eval_lua(
459
+ client,
460
+ _ACK_JOB_LUA,
461
+ 2,
462
+ self._processing_key(queue, worker_id),
463
+ self._leases_key(queue),
464
+ raw_payload,
465
+ member,
466
+ )
467
+ return bool(res)
468
+
469
+ async def release(
470
+ self,
471
+ queue: str,
472
+ worker_id: str,
473
+ raw_payload: str,
474
+ delay: float = 0.0,
475
+ new_payload: str | None = None,
476
+ ) -> bool:
477
+ """
478
+ Atomically release an in-flight job: removes it from processing list and leases set,
479
+ and re-queues it onto the ready queue (at the head) or the delayed set if delay > 0.
480
+ """
481
+ client = await self.get_client()
482
+ payload_to_store = new_payload if new_payload is not None else raw_payload
483
+ target_key = (
484
+ self._delayed_key(queue) if delay > 0 else self._queue_key(queue)
485
+ )
486
+ member = f"{worker_id}\x1f{raw_payload}"
487
+ res = await self._eval_lua(
488
+ client,
489
+ _RELEASE_JOB_LUA,
490
+ 3,
491
+ self._processing_key(queue, worker_id),
492
+ self._leases_key(queue),
493
+ target_key,
494
+ raw_payload,
495
+ member,
496
+ payload_to_store,
497
+ str(delay),
498
+ str(time.time()),
499
+ )
500
+ return bool(res)
501
+
502
+ async def reap_expired_leases(
503
+ self,
504
+ queue: str,
505
+ batch_size: int = 50,
506
+ ) -> int:
507
+ """
508
+ Scan for expired leases, remove them from crashed worker processing lists,
509
+ increment their attempt count, and either move to DLQ (if tries reached)
510
+ or re-queue at the head of the ready queue.
511
+ """
512
+ client = await self.get_client()
513
+ res = await self._eval_lua(
514
+ client,
515
+ _REAP_LEASES_LUA,
516
+ 3,
517
+ self._leases_key(queue),
518
+ self._queue_key(queue),
519
+ self._failed_key(queue),
520
+ str(time.time()),
521
+ str(batch_size),
522
+ self._processing_prefix(queue),
523
+ str(self.failed_max_size),
524
+ )
525
+ return int(res) if res is not None else 0
526
+
527
+ async def processing_size(
528
+ self,
529
+ queue: str = "default",
530
+ worker_id: str | None = None,
531
+ ) -> int:
532
+ """Return the number of in-flight jobs being processed."""
533
+ client = await self.get_client()
534
+ if worker_id is not None:
535
+ return int(
536
+ await client.llen(self._processing_key(queue, worker_id))
537
+ )
538
+
539
+ total = 0
540
+ async for key in client.scan_iter(
541
+ match=f"{self._processing_prefix(queue)}*"
542
+ ):
543
+ total += int(await client.llen(key))
544
+ return total
545
+
546
+ async def leases_size(self, queue: str = "default") -> int:
547
+ """Return the number of active leases recorded for the given queue."""
548
+ client = await self.get_client()
549
+ return int(await client.zcard(self._leases_key(queue)))
550
+
551
+ async def push(
552
+ self,
553
+ job: Job,
554
+ queue: str | None = None,
555
+ delay: float = 0,
556
+ ) -> str:
557
+ """
558
+ Serialize a Job instance and push it onto the Redis queue.
559
+ If delay > 0, schedules it in the delayed sorted set.
560
+ """
561
+ client = await self.get_client()
562
+ target_queue = queue or job.queue or "default"
563
+ job.queue = target_queue
564
+ job.pushed_at = time.time()
565
+
566
+ payload_dict = job.serialize()
567
+ payload_str = json.dumps(payload_dict, ensure_ascii=False)
568
+
569
+ if delay > 0:
570
+ score = time.time() + float(delay)
571
+ await client.zadd(self._delayed_key(target_queue), {payload_str: score})
572
+ logger.debug(
573
+ "Scheduled job %s on %s with delay %ss",
574
+ job.job_id,
575
+ target_queue,
576
+ delay,
577
+ )
578
+ else:
579
+ await client.rpush(self._queue_key(target_queue), payload_str)
580
+ logger.debug("Pushed job %s to %s", job.job_id, target_queue)
581
+
582
+ return job.job_id
583
+
584
+ async def push_raw(
585
+ self,
586
+ queue: str,
587
+ raw_payload: str,
588
+ delay: float = 0,
589
+ ) -> None:
590
+ """
591
+ Push a raw JSON string envelope onto the target queue or delayed set.
592
+ """
593
+ client = await self.get_client()
594
+ if delay > 0:
595
+ score = time.time() + float(delay)
596
+ await client.zadd(self._delayed_key(queue), {raw_payload: score})
597
+ else:
598
+ await client.rpush(self._queue_key(queue), raw_payload)
599
+
600
+ async def push_failed(
601
+ self,
602
+ job: Job | None,
603
+ exception: BaseException,
604
+ queue: str = "default",
605
+ payload: dict[str, Any] | None = None,
606
+ ) -> None:
607
+ """
608
+ Push a failed job record to the Dead Letter Queue (DLQ).
609
+ """
610
+ client = await self.get_client()
611
+ target_queue = queue or (job.queue if job else "default")
612
+
613
+ job_id = (
614
+ job.job_id
615
+ if job
616
+ else (payload.get("job_id") if payload else uuid.uuid4().hex)
617
+ )
618
+ job_class = (
619
+ f"{job.__class__.__module__}:{job.__class__.__qualname__}"
620
+ if job
621
+ else (payload.get("job_class") if payload else "unknown")
622
+ )
623
+
624
+ record: dict[str, Any] = {
625
+ "job_id": job_id,
626
+ "job_class": job_class,
627
+ "queue": target_queue,
628
+ "payload": payload
629
+ if payload is not None
630
+ else (job.serialize() if job else {}),
631
+ "exception": {
632
+ "type": type(exception).__name__,
633
+ "message": str(exception),
634
+ "traceback": traceback.format_exc(),
635
+ },
636
+ "failed_at": time.time(),
637
+ "attempts": job.attempts
638
+ if job
639
+ else (payload.get("attempts", 0) if payload else 0),
640
+ "tries": job.tries if job else (payload.get("tries", 0) if payload else 0),
641
+ }
642
+
643
+ record_str = json.dumps(record, ensure_ascii=False)
644
+ failed_key = self._failed_key(target_queue)
645
+ await client.rpush(failed_key, record_str)
646
+ if self.failed_max_size > 0:
647
+ await client.ltrim(failed_key, -self.failed_max_size, -1)
648
+ logger.error(
649
+ "Moved job %s [%s] to Dead Letter Queue (%s)",
650
+ job_id,
651
+ job_class,
652
+ failed_key,
653
+ )
654
+
655
+ async def size(self, queue: str = "default") -> int:
656
+ """Return the number of pending jobs in the active queue."""
657
+ client = await self.get_client()
658
+ return int(await client.llen(self._queue_key(queue)))
659
+
660
+ async def delayed_size(self, queue: str = "default") -> int:
661
+ """Return the number of scheduled jobs in the delayed set."""
662
+ client = await self.get_client()
663
+ return int(await client.zcard(self._delayed_key(queue)))
664
+
665
+ async def failed_size(self, queue: str = "default") -> int:
666
+ """Return the number of failed jobs in the Dead Letter Queue."""
667
+ client = await self.get_client()
668
+ return int(await client.llen(self._failed_key(queue)))
669
+
670
+ async def get_failed_jobs(
671
+ self,
672
+ queue: str = "default",
673
+ start: int = 0,
674
+ stop: int = -1,
675
+ ) -> list[dict[str, Any]]:
676
+ """Retrieve recorded jobs from the Dead Letter Queue."""
677
+ client = await self.get_client()
678
+ items = await client.lrange(self._failed_key(queue), start, stop)
679
+ results: list[dict[str, Any]] = []
680
+ for raw in items:
681
+ try:
682
+ results.append(json.loads(raw))
683
+ except (json.JSONDecodeError, TypeError, KeyError):
684
+ results.append({"raw": raw})
685
+ return results
686
+
687
+ async def retry_failed(
688
+ self,
689
+ queue: str = "default",
690
+ job_id: str | None = None,
691
+ ) -> int:
692
+ """
693
+ Requeue failed jobs from the Dead Letter Queue back into the active ready queue.
694
+ If job_id is None or 'all', retries all failed jobs.
695
+ """
696
+ client = await self.get_client()
697
+ failed_key = self._failed_key(queue)
698
+ items = await client.lrange(failed_key, 0, -1)
699
+ retried_count = 0
700
+
701
+ for raw in items:
702
+ raw_str = (
703
+ raw.decode("utf-8", errors="replace")
704
+ if isinstance(raw, (bytes, bytearray))
705
+ else str(raw)
706
+ )
707
+ try:
708
+ record = json.loads(raw_str)
709
+ except (json.JSONDecodeError, TypeError, KeyError):
710
+ logger.debug("Skipping unparseable failed job record", exc_info=True)
711
+ continue
712
+
713
+ rec_id = record.get("job_id")
714
+ if job_id in (None, "all") or rec_id == job_id:
715
+ payload = record.get("payload")
716
+ parsed_payload: dict[str, Any] | None = None
717
+ if isinstance(payload, dict):
718
+ parsed_payload = dict(payload)
719
+ elif isinstance(payload, str):
720
+ try:
721
+ maybe_dict = json.loads(payload)
722
+ if isinstance(maybe_dict, dict):
723
+ parsed_payload = maybe_dict
724
+ except (json.JSONDecodeError, TypeError):
725
+ parsed_payload = None
726
+
727
+ if parsed_payload is not None:
728
+ parsed_payload["attempts"] = 0
729
+ if "job_class" in parsed_payload:
730
+ key = Job.get_app_key()
731
+ if key is not None and str(key).strip():
732
+ validate_signing_key(str(key), purpose="APP_KEY")
733
+ parsed_payload["signature"] = Job._compute_signature(
734
+ parsed_payload, str(key)
735
+ )
736
+ elif not Job.get_allow_unsigned():
737
+ raise ConfigurationError(
738
+ "APP_KEY (>=32 bytes) is required to re-sign retried queue jobs."
739
+ )
740
+ requeue_str = json.dumps(parsed_payload, ensure_ascii=False)
741
+ elif isinstance(payload, str):
742
+ requeue_str = payload
743
+ else:
744
+ requeue_str = raw_str
745
+
746
+ await client.rpush(self._queue_key(queue), requeue_str)
747
+ await client.lrem(failed_key, 1, raw_str)
748
+ retried_count += 1
749
+ if job_id not in (None, "all"):
750
+ break
751
+
752
+ return retried_count
753
+
754
+ async def flush_failed(self, queue: str = "default") -> int:
755
+ """Clear all failed records from the Dead Letter Queue for the given queue name."""
756
+ client = await self.get_client()
757
+ failed_key = self._failed_key(queue)
758
+ count = int(await client.llen(failed_key))
759
+ await client.delete(failed_key)
760
+ return count
761
+
762
+ async def clear(self, queue: str = "default") -> None:
763
+ """Clear active, delayed, failed, lease, and processing queues for the given queue name."""
764
+ client = await self.get_client()
765
+ keys_to_delete = [
766
+ self._queue_key(queue),
767
+ self._delayed_key(queue),
768
+ self._failed_key(queue),
769
+ self._leases_key(queue),
770
+ ]
771
+ async for key in client.scan_iter(
772
+ match=f"{self._processing_prefix(queue)}*"
773
+ ):
774
+ keys_to_delete.append(key)
775
+
776
+ if keys_to_delete:
777
+ await client.delete(*keys_to_delete)
778
+
779
+ async def close(self) -> None:
780
+ """Close the Redis client and connection pool if owned."""
781
+ if self._owns_client and self._client is not None:
782
+ await self._client.aclose()
783
+ self._client = None
784
+ if self._owns_pool and self._pool is not None:
785
+ await self._pool.aclose()
786
+ self._pool = None
787
+
788
+ async def aclose(self) -> None:
789
+ """Alias for close()."""
790
+ await self.close()
791
+
792
+ async def __aenter__(self) -> Self:
793
+ return self
794
+
795
+ async def __aexit__(self, *args: object) -> None:
796
+ await self.close()
797
+
798
+
799
+ async def push(
800
+ job: Job,
801
+ queue: str | None = None,
802
+ delay: float = 0,
803
+ ) -> str:
804
+ """
805
+ Push a Job onto the default QueueDispatcher.
806
+ """
807
+ return await QueueDispatcher.get_instance().push(job, queue=queue, delay=delay)
808
+
809
+
810
+ # Wire the Job.dispatch() hook to delegate to QueueDispatcher.get_instance().push
811
+ set_dispatch_handler(
812
+ lambda j, q, d: QueueDispatcher.get_instance().push(j, queue=q, delay=d)
813
+ )