swiftpy-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|