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
swiftpy/queue/job.py ADDED
@@ -0,0 +1,541 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import importlib
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import math
10
+ import uuid
11
+ from abc import ABC, abstractmethod
12
+ from collections.abc import Callable, Sequence
13
+ from typing import Any, ClassVar
14
+
15
+ from swiftpy.core.config import ConfigurationError
16
+ from swiftpy.database.transaction import get_active_connection
17
+ from swiftpy.support.keys import (
18
+ decode_signing_key,
19
+ resolve_configured_app_key,
20
+ resolve_configured_previous_keys,
21
+ validate_signing_key,
22
+ )
23
+
24
+ logger = logging.getLogger("swiftpy.queue")
25
+
26
+ _dispatch_handler: Callable[[Job, str | None, float], Any] | None = None
27
+
28
+
29
+ def set_dispatch_handler(
30
+ handler: Callable[[Job, str | None, float], Any] | None,
31
+ ) -> None:
32
+ """Set the global dispatch delegate for Job.dispatch()."""
33
+ global _dispatch_handler
34
+ _dispatch_handler = handler
35
+
36
+
37
+ class JobSerializationError(Exception):
38
+ """Raised when a job payload cannot be serialized or deserialized."""
39
+
40
+
41
+ _SIGNATURE_EXCLUDED_FIELDS: frozenset[str] = frozenset({"signature", "attempts"})
42
+
43
+
44
+ def _parse_optional_timeout(
45
+ raw_timeout: Any, *, exc_cls: type[Exception] = JobSerializationError
46
+ ) -> float | None:
47
+ """Validate optional job execution timeout as a positive finite float."""
48
+ if raw_timeout is None:
49
+ return None
50
+ if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)):
51
+ raise exc_cls(
52
+ f"Invalid 'timeout' in job envelope: expected positive number, got {type(raw_timeout).__name__}"
53
+ )
54
+ value = float(raw_timeout)
55
+ if not math.isfinite(value) or value <= 0:
56
+ raise exc_cls(
57
+ f"Invalid 'timeout' in job envelope: must be a finite positive number, got {raw_timeout!r}"
58
+ )
59
+ return value
60
+
61
+
62
+ def _parse_int_field(
63
+ raw_value: Any, *, field_name: str, minimum: int
64
+ ) -> int:
65
+ """Validate integer envelope metadata fields while rejecting booleans."""
66
+ if isinstance(raw_value, bool) or not isinstance(raw_value, int):
67
+ raise JobSerializationError(
68
+ f"Invalid '{field_name}' in job envelope: expected int, got {type(raw_value).__name__}"
69
+ )
70
+ if raw_value < minimum:
71
+ raise JobSerializationError(
72
+ f"Invalid '{field_name}' in job envelope: must be >= {minimum}"
73
+ )
74
+ return raw_value
75
+
76
+
77
+ def parse_class_path(
78
+ class_path: str, *, exc_cls: type[Exception] = JobSerializationError
79
+ ) -> tuple[str, str]:
80
+ """Parse ``module:ClassName`` or ``module.ClassName`` into ``(module, class)``."""
81
+ if ":" in class_path:
82
+ module_name, class_name = class_path.split(":", 1)
83
+ elif "." in class_path:
84
+ module_name, class_name = class_path.rsplit(".", 1)
85
+ else:
86
+ raise exc_cls(f"Invalid job class identifier format: {class_path}")
87
+
88
+ if not module_name or not class_name:
89
+ raise exc_cls(f"Invalid job class identifier format: {class_path}")
90
+ return module_name, class_name
91
+
92
+
93
+ def is_trusted_class_path(
94
+ class_path: str,
95
+ module_name: str,
96
+ *,
97
+ trusted_namespaces: Sequence[str] | None,
98
+ trusted_classes: set[str] | frozenset[str] = frozenset(),
99
+ class_name: str | None = None,
100
+ ) -> bool:
101
+ """Return True if a class path belongs to a trusted namespace or explicit allowlist."""
102
+ if trusted_namespaces is None:
103
+ return True
104
+ resolved_cls = (
105
+ class_name if class_name is not None else parse_class_path(class_path)[1]
106
+ )
107
+ candidates = {
108
+ class_path,
109
+ f"{module_name}:{resolved_cls}",
110
+ f"{module_name}.{resolved_cls}",
111
+ }
112
+ return any(
113
+ module_name == ns.rstrip(".")
114
+ or module_name.startswith(f"{ns.rstrip('.')}.")
115
+ for ns in trusted_namespaces
116
+ ) or bool(candidates & set(trusted_classes))
117
+
118
+
119
+ class Job(ABC):
120
+ """
121
+ Abstract base class for all SwiftPY queued jobs.
122
+
123
+ Subclasses must implement the asynchronous `handle()` method.
124
+ """
125
+
126
+ queue: str = "default"
127
+ tries: int = 3
128
+ backoff: int | float | Sequence[int | float] = 0
129
+ timeout: float | None = None
130
+
131
+ _app_key: ClassVar[str | None] = None
132
+ _previous_keys: ClassVar[list[str]] = []
133
+ _trusted_namespaces: ClassVar[list[str] | None] = None
134
+ _trusted_classes: ClassVar[set[str]] = set()
135
+ _allow_unsigned: ClassVar[bool] = False
136
+
137
+ @classmethod
138
+ def set_allow_unsigned(cls, enabled: bool) -> None:
139
+ """Explicit opt-in to allow unsigned job serialization/deserialization in local tests."""
140
+ cls._allow_unsigned = bool(enabled)
141
+
142
+ @classmethod
143
+ def get_allow_unsigned(cls) -> bool:
144
+ """Return whether unsigned job payloads are explicitly opted in."""
145
+ return cls._allow_unsigned
146
+
147
+ @classmethod
148
+ def set_app_key(cls, key: str | None) -> None:
149
+ """Set the active application key used for HMAC envelope signing."""
150
+ cls._app_key = key
151
+
152
+ @classmethod
153
+ def get_app_key(cls, strict: bool = False) -> str | None:
154
+ """
155
+ Retrieve the active application key.
156
+ Checks explicit Job._app_key, then Config 'app.key' / 'key', then env('APP_KEY').
157
+ """
158
+ if cls._app_key is not None:
159
+ return cls._app_key
160
+ resolved = resolve_configured_app_key()
161
+ if resolved is not None:
162
+ return resolved
163
+ if strict:
164
+ raise ConfigurationError(
165
+ "APP_KEY is required and must be configured for queue signing."
166
+ )
167
+ return None
168
+
169
+ @classmethod
170
+ def set_previous_keys(cls, keys: list[str]) -> None:
171
+ """Set previous retired application keys to accept during key rotation."""
172
+ cls._previous_keys = list(keys)
173
+
174
+ @classmethod
175
+ def get_previous_keys(cls) -> list[str]:
176
+ """Retrieve previous keys configured for key rotation."""
177
+ if cls._previous_keys:
178
+ return list(cls._previous_keys)
179
+ return resolve_configured_previous_keys()
180
+
181
+ @classmethod
182
+ def set_trusted_namespaces(cls, namespaces: list[str] | None) -> None:
183
+ """
184
+ Configure allowed module namespace prefixes (e.g. ['app.', 'swiftpy.']).
185
+ If configured, deserialization of jobs from non-whitelisted modules is rejected
186
+ before importlib.import_module() is called.
187
+ Pass None to use default trusted namespaces ('app', 'swiftpy').
188
+ """
189
+ cls._trusted_namespaces = namespaces
190
+
191
+ @classmethod
192
+ def get_trusted_namespaces(cls) -> list[str] | None:
193
+ """Return configured or default trusted namespaces."""
194
+ if cls._trusted_namespaces is not None:
195
+ return cls._trusted_namespaces
196
+ return ["app", "swiftpy"]
197
+
198
+ @classmethod
199
+ def _compute_signature(cls, envelope: dict[str, Any], key: str) -> str:
200
+ """Compute an HMAC-SHA256 signature over immutable canonical fields."""
201
+ canonical_fields = {
202
+ k: v
203
+ for k, v in envelope.items()
204
+ if k not in _SIGNATURE_EXCLUDED_FIELDS
205
+ }
206
+ canonical_json = json.dumps(
207
+ canonical_fields,
208
+ sort_keys=True,
209
+ separators=(",", ":"),
210
+ ensure_ascii=False,
211
+ )
212
+ secret_bytes = decode_signing_key(key)
213
+ return hmac.new(
214
+ secret_bytes,
215
+ canonical_json.encode("utf-8"),
216
+ hashlib.sha256,
217
+ ).hexdigest()
218
+
219
+ @classmethod
220
+ def allow_job_class(cls, target: type[Job] | str) -> None:
221
+ """Allow a specific Job class or qualname string."""
222
+ if isinstance(target, str):
223
+ cls._trusted_classes.add(target)
224
+ else:
225
+ cls._trusted_classes.add(f"{target.__module__}:{target.__qualname__}")
226
+
227
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
228
+ self.job_id: str = kwargs.pop("job_id", None) or uuid.uuid4().hex
229
+ self.queue = kwargs.pop("queue", getattr(self, "queue", "default"))
230
+ self.tries = kwargs.pop("tries", getattr(self, "tries", 3))
231
+ self.attempts: int = kwargs.pop("attempts", 0)
232
+ self.backoff = kwargs.pop("backoff", getattr(self, "backoff", 0))
233
+ self.timeout: float | None = _parse_optional_timeout(
234
+ kwargs.pop("timeout", getattr(self, "timeout", None)),
235
+ exc_cls=ValueError,
236
+ )
237
+ self.pushed_at: float | None = kwargs.pop("pushed_at", None)
238
+ self.args: tuple[Any, ...] = args
239
+ self.kwargs: dict[str, Any] = kwargs
240
+ self.data: dict[str, Any] = kwargs.pop("data", {})
241
+
242
+ @abstractmethod
243
+ async def handle(self) -> None:
244
+ """
245
+ Execute the job's business logic.
246
+
247
+ Must be implemented by subclasses and will be awaited by a queue worker.
248
+ """
249
+ raise NotImplementedError
250
+
251
+ async def failed(self, exception: BaseException) -> None:
252
+ """
253
+ Hook called when the job has permanently failed and exhausted all retry attempts.
254
+ """
255
+
256
+ def get_backoff_delay(self) -> float:
257
+ """
258
+ Calculate the backoff delay in seconds for the current attempt.
259
+ """
260
+ if isinstance(self.backoff, (int, float)):
261
+ return max(0.0, float(self.backoff))
262
+ if isinstance(self.backoff, (list, tuple)):
263
+ if not self.backoff:
264
+ return 0.0
265
+ idx = max(0, min(self.attempts - 1, len(self.backoff) - 1))
266
+ return max(0.0, float(self.backoff[idx]))
267
+ return 0.0
268
+
269
+ def serialize(
270
+ self,
271
+ app_key: str | None = None,
272
+ *,
273
+ allow_unsigned: bool | None = None,
274
+ ) -> dict[str, Any]:
275
+ """
276
+ Serialize job metadata and payload into a JSON-compatible dictionary.
277
+ Signs the envelope with HMAC-SHA256 using APP_KEY unless unsigned mode is explicitly opted in.
278
+ """
279
+ qualname = f"{self.__class__.__module__}:{self.__class__.__qualname__}"
280
+ envelope: dict[str, Any] = {
281
+ "job_class": qualname,
282
+ "job_id": self.job_id,
283
+ "queue": self.queue,
284
+ "tries": self.tries,
285
+ "attempts": self.attempts,
286
+ "backoff": self.backoff,
287
+ "timeout": self.timeout,
288
+ "pushed_at": self.pushed_at,
289
+ "args": list(self.args),
290
+ "kwargs": self.kwargs,
291
+ "data": self.data,
292
+ }
293
+ key = app_key if app_key is not None else self.get_app_key()
294
+ unsigned_ok = self.get_allow_unsigned() if allow_unsigned is None else allow_unsigned
295
+ if key is None or not str(key).strip():
296
+ if not unsigned_ok:
297
+ raise ConfigurationError(
298
+ "APP_KEY (>=32 bytes) is required for queue job signing. "
299
+ "Configure APP_KEY or pass allow_unsigned=True for local tests."
300
+ )
301
+ return envelope
302
+
303
+ validate_signing_key(str(key), purpose="APP_KEY")
304
+ envelope["signature"] = self._compute_signature(envelope, str(key))
305
+ return envelope
306
+
307
+ @classmethod
308
+ def deserialize(
309
+ cls,
310
+ payload: dict[str, Any] | str,
311
+ app_key: str | None = None,
312
+ *,
313
+ allow_unsigned: bool | None = None,
314
+ ) -> Job:
315
+ """
316
+ Reconstruct a Job instance from a serialized dictionary or JSON string.
317
+ Verifies HMAC signature and trusted namespace before module import.
318
+ """
319
+ key = app_key if app_key is not None else cls.get_app_key()
320
+ unsigned_ok = cls.get_allow_unsigned() if allow_unsigned is None else allow_unsigned
321
+
322
+ if key is None or not str(key).strip():
323
+ if not unsigned_ok:
324
+ raise ConfigurationError(
325
+ "APP_KEY (>=32 bytes) is required for queue job deserialization. "
326
+ "Configure APP_KEY or pass allow_unsigned=True for local tests."
327
+ )
328
+ else:
329
+ validate_signing_key(str(key), purpose="APP_KEY")
330
+
331
+ if isinstance(payload, str):
332
+ try:
333
+ data: dict[str, Any] = json.loads(payload)
334
+ except Exception as exc:
335
+ raise JobSerializationError(
336
+ f"Failed to parse JSON job envelope: {exc}"
337
+ ) from exc
338
+ else:
339
+ data = payload
340
+
341
+ if not isinstance(data, dict):
342
+ raise JobSerializationError(
343
+ f"Invalid job envelope: expected dict, got {type(data).__name__}"
344
+ )
345
+
346
+ # 1. Verify signature BEFORE importing module
347
+ if key is not None and str(key).strip():
348
+ signature = data.get("signature")
349
+ if signature is None or signature == "":
350
+ if not unsigned_ok:
351
+ raise JobSerializationError(
352
+ "Job envelope is missing a signature or signature is invalid"
353
+ )
354
+ elif not isinstance(signature, str):
355
+ raise JobSerializationError(
356
+ "Job envelope is missing a signature or signature is invalid"
357
+ )
358
+ else:
359
+ expected_sig = cls._compute_signature(data, str(key))
360
+ if not hmac.compare_digest(signature, expected_sig):
361
+ matched_prev = False
362
+ for prev_key in cls.get_previous_keys():
363
+ if hmac.compare_digest(
364
+ signature, cls._compute_signature(data, prev_key)
365
+ ):
366
+ matched_prev = True
367
+ break
368
+ if not matched_prev:
369
+ raise JobSerializationError(
370
+ "Job envelope signature verification failed: tampered or untrusted payload"
371
+ )
372
+
373
+ job_class_str = data.get("job_class")
374
+ if not job_class_str or not isinstance(job_class_str, str):
375
+ raise JobSerializationError("Job envelope missing valid 'job_class'")
376
+
377
+ # Resolve module and class name (support module:ClassName or module.ClassName)
378
+ module_name, class_name = parse_class_path(job_class_str)
379
+
380
+ # 2. Check namespace allowlist before importing module
381
+ if not is_trusted_class_path(
382
+ job_class_str,
383
+ module_name,
384
+ trusted_namespaces=cls.get_trusted_namespaces(),
385
+ trusted_classes=cls._trusted_classes,
386
+ class_name=class_name,
387
+ ):
388
+ raise JobSerializationError(
389
+ f"Job class '{job_class_str}' from module '{module_name}' is not in trusted namespaces"
390
+ )
391
+
392
+ try:
393
+ module = importlib.import_module(module_name)
394
+ except Exception as exc:
395
+ raise JobSerializationError(
396
+ f"Could not import module '{module_name}' for job '{job_class_str}': {exc}"
397
+ ) from exc
398
+
399
+ target_cls: Any = module
400
+ for part in class_name.split("."):
401
+ target_cls = getattr(target_cls, part, None)
402
+ if target_cls is None:
403
+ raise JobSerializationError(
404
+ f"Job class '{class_name}' not found in module '{module_name}'"
405
+ )
406
+
407
+ if not isinstance(target_cls, type) or not issubclass(target_cls, Job):
408
+ raise JobSerializationError(
409
+ f"Resolved target '{job_class_str}' is not a subclass of Job"
410
+ )
411
+
412
+ raw_args = data.get("args")
413
+ if raw_args is not None:
414
+ if not isinstance(raw_args, (list, tuple)):
415
+ raise JobSerializationError("Envelope field 'args' must be a list or tuple")
416
+ args = list(raw_args)
417
+ else:
418
+ args = []
419
+
420
+ raw_kwargs = data.get("kwargs")
421
+ if raw_kwargs is not None:
422
+ if not isinstance(raw_kwargs, dict):
423
+ raise JobSerializationError("Envelope field 'kwargs' must be a dict")
424
+ kwargs = dict(raw_kwargs)
425
+ else:
426
+ kwargs = {}
427
+
428
+ if "tries" in data and data["tries"] is not None:
429
+ data["tries"] = _parse_int_field(
430
+ data["tries"], field_name="tries", minimum=1
431
+ )
432
+
433
+ if "attempts" in data and data["attempts"] is not None:
434
+ data["attempts"] = _parse_int_field(
435
+ data["attempts"], field_name="attempts", minimum=0
436
+ )
437
+
438
+ parsed_timeout: float | None = None
439
+ if "timeout" in data and data["timeout"] is not None:
440
+ parsed_timeout = _parse_optional_timeout(
441
+ data["timeout"], exc_cls=JobSerializationError
442
+ )
443
+ data["timeout"] = parsed_timeout
444
+
445
+ if "pushed_at" in data and data["pushed_at"] is not None:
446
+ raw_pushed = data["pushed_at"]
447
+ if (
448
+ isinstance(raw_pushed, bool)
449
+ or not isinstance(raw_pushed, (int, float))
450
+ or not math.isfinite(float(raw_pushed))
451
+ ):
452
+ raise JobSerializationError(
453
+ "Invalid 'pushed_at' in job envelope: expected finite timestamp"
454
+ )
455
+
456
+ if "data" in data and data["data"] is not None and not isinstance(data["data"], dict):
457
+ raise JobSerializationError("Envelope field 'data' must be a dict")
458
+
459
+ try:
460
+ sig = inspect.signature(target_cls.__init__)
461
+ params = sig.parameters
462
+
463
+ init_kwargs = dict(kwargs)
464
+ for meta_key in (
465
+ "job_id",
466
+ "queue",
467
+ "tries",
468
+ "attempts",
469
+ "backoff",
470
+ "timeout",
471
+ "pushed_at",
472
+ "data",
473
+ ):
474
+ if (
475
+ meta_key in params
476
+ and meta_key not in init_kwargs
477
+ and meta_key in data
478
+ ):
479
+ init_kwargs[meta_key] = data[meta_key]
480
+
481
+ job_instance = target_cls(*args, **init_kwargs)
482
+ except Exception as exc:
483
+ raise JobSerializationError(
484
+ f"Failed to instantiate job '{job_class_str}': {exc}"
485
+ ) from exc
486
+
487
+ # Ensure all persisted metadata is set on the instance
488
+ if data.get("job_id"):
489
+ job_instance.job_id = str(data["job_id"])
490
+ if data.get("queue"):
491
+ job_instance.queue = str(data["queue"])
492
+ if "tries" in data and data["tries"] is not None:
493
+ job_instance.tries = int(data["tries"])
494
+ if "attempts" in data and data["attempts"] is not None:
495
+ job_instance.attempts = int(data["attempts"])
496
+ if "backoff" in data and data["backoff"] is not None:
497
+ job_instance.backoff = data["backoff"]
498
+ if "timeout" in data:
499
+ job_instance.timeout = data["timeout"]
500
+ if "pushed_at" in data:
501
+ job_instance.pushed_at = data["pushed_at"]
502
+ if "data" in data and isinstance(data["data"], dict):
503
+ job_instance.data = data["data"]
504
+
505
+ return job_instance
506
+
507
+ async def dispatch(
508
+ self,
509
+ queue: str | None = None,
510
+ delay: float = 0,
511
+ after_commit: bool = False,
512
+ ) -> str:
513
+ """
514
+ Convenience method to push this job instance onto the queue dispatcher.
515
+
516
+ If after_commit is True and a database transaction is active, the dispatch
517
+ is deferred until the transaction successfully commits. If the transaction
518
+ rolls back, the job is dropped. If no transaction is active, it dispatches immediately.
519
+ """
520
+ if _dispatch_handler is None:
521
+ raise RuntimeError(
522
+ "No queue dispatcher has been initialized to dispatch jobs"
523
+ )
524
+
525
+ if after_commit:
526
+ conn = get_active_connection()
527
+ if conn is not None and hasattr(conn, "on_commit"):
528
+
529
+ async def _commit_dispatch() -> None:
530
+ if _dispatch_handler is not None:
531
+ res = _dispatch_handler(self, queue, delay)
532
+ if inspect.isawaitable(res):
533
+ await res
534
+
535
+ conn.on_commit(_commit_dispatch)
536
+ return self.job_id
537
+
538
+ res = _dispatch_handler(self, queue, delay)
539
+ if inspect.isawaitable(res):
540
+ return str(await res)
541
+ return str(res)