vercel-django-tasks 0.7.2__tar.gz

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.
@@ -0,0 +1,129 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ # C extensions
6
+ *.so
7
+
8
+ # Distribution / packaging
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ share/python-wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # PyInstallerhave
29
+ *.manifest
30
+ *.spec
31
+
32
+ # Installer logs
33
+ pip-log.txt
34
+ pip-delete-this-directory.txt
35
+
36
+ # Unit test / coverage reports
37
+ .benchmarks/
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ .ruff_cache/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Scrapy
57
+ .scrapy
58
+
59
+ # Sphinx documentation
60
+ docs/_build/
61
+
62
+ # PyBuilder
63
+ target/
64
+
65
+ # Jupyter Notebook
66
+ .ipynb_checkpoints
67
+
68
+ # IPython
69
+ profile_default/
70
+ ipython_config.py
71
+
72
+ # pyenv
73
+ .python-version
74
+
75
+ # pipenv
76
+ Pipfile.lock
77
+
78
+ # poetry
79
+ poetry.lock
80
+
81
+ # PDM
82
+ pdm.lock
83
+ .pdm.toml
84
+
85
+ # Hatch
86
+ .hatch/
87
+
88
+ # pyright/mypy
89
+ .mypy_cache/
90
+ .dmypy.json
91
+ dmypy.json
92
+
93
+ # pyre
94
+ .pyre/
95
+
96
+ # pytype
97
+ .pytype/
98
+
99
+ # Caches
100
+ __pypackages__/
101
+
102
+ # Editor/project files
103
+ .DS_Store
104
+ .idea/
105
+ .vscode/
106
+ .zed/
107
+ *.swp
108
+ *.swo
109
+
110
+ # Virtual environments
111
+ .env
112
+ .venv
113
+ .venv.*
114
+ env/
115
+ venv/
116
+ **/.env
117
+ **/.venv
118
+ **/.venv.*
119
+ **/env/
120
+ **/venv/
121
+ ENV/
122
+ env.bak/
123
+ venv.bak/
124
+ # dotenv anywhere
125
+ **/.env
126
+ **/.env.*
127
+ **/*.env
128
+
129
+ .claude
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vercel, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: vercel-django-tasks
3
+ Version: 0.7.2
4
+ Summary: Django task backend backed by Vercel Queue Service
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: django>=6.0; python_version >= '3.12'
9
+ Requires-Dist: vercel-cache
10
+ Requires-Dist: vercel-queue>=0.6.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # vercel-django-tasks
14
+
15
+ Django task backend backed by Vercel Queue Service. The installer uses
16
+ `VercelQueuesBackend` as Django's default task backend when no `TASKS`
17
+ backends are configured and registers generated queue subscribers.
18
+
19
+ Register push subscribers during application startup:
20
+
21
+ ```python
22
+ from vercel.integrations.django import install_vercel_django_task_integration
23
+
24
+ install_vercel_django_task_integration()
25
+ ```
26
+
27
+ No `TASKS` setting is required. Configure one explicitly to customize the
28
+ backend or to use a different backend:
29
+
30
+ ```python
31
+ TASKS = {
32
+ "default": {
33
+ "BACKEND": "vercel.integrations.django.VercelQueuesBackend",
34
+ "QUEUES": ["default"],
35
+ "OPTIONS": {
36
+ "result_namespace": "django-task-results",
37
+ "result_ttl_seconds": 86400,
38
+ },
39
+ },
40
+ }
41
+ ```
42
+
43
+ Declare the module that loads your Django application in `pyproject.toml` so
44
+ Vercel generates a queue subscriber function:
45
+
46
+ ```toml
47
+ [[tool.vercel.subscribers]]
48
+ entrypoint = "my_project.wsgi"
49
+ ```
50
+
51
+ No manual queue endpoint is required.
52
+
53
+ Task result state is stored in Vercel Runtime Cache. Results are cache-backed
54
+ and expire according to `result_ttl_seconds`; they are not durable storage.
55
+
56
+ This package depends on Django, `vercel-queue`, and `vercel-cache`.
@@ -0,0 +1,44 @@
1
+ # vercel-django-tasks
2
+
3
+ Django task backend backed by Vercel Queue Service. The installer uses
4
+ `VercelQueuesBackend` as Django's default task backend when no `TASKS`
5
+ backends are configured and registers generated queue subscribers.
6
+
7
+ Register push subscribers during application startup:
8
+
9
+ ```python
10
+ from vercel.integrations.django import install_vercel_django_task_integration
11
+
12
+ install_vercel_django_task_integration()
13
+ ```
14
+
15
+ No `TASKS` setting is required. Configure one explicitly to customize the
16
+ backend or to use a different backend:
17
+
18
+ ```python
19
+ TASKS = {
20
+ "default": {
21
+ "BACKEND": "vercel.integrations.django.VercelQueuesBackend",
22
+ "QUEUES": ["default"],
23
+ "OPTIONS": {
24
+ "result_namespace": "django-task-results",
25
+ "result_ttl_seconds": 86400,
26
+ },
27
+ },
28
+ }
29
+ ```
30
+
31
+ Declare the module that loads your Django application in `pyproject.toml` so
32
+ Vercel generates a queue subscriber function:
33
+
34
+ ```toml
35
+ [[tool.vercel.subscribers]]
36
+ entrypoint = "my_project.wsgi"
37
+ ```
38
+
39
+ No manual queue endpoint is required.
40
+
41
+ Task result state is stored in Vercel Runtime Cache. Results are cache-backed
42
+ and expire according to `result_ttl_seconds`; they are not durable storage.
43
+
44
+ This package depends on Django, `vercel-queue`, and `vercel-cache`.
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vercel-django-tasks"
7
+ dynamic = ["version"]
8
+ description = "Django task backend backed by Vercel Queue Service"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "LICENSE.*"]
13
+ dependencies = [
14
+ "Django>=6.0; python_version >= '3.12'",
15
+ "vercel-cache",
16
+ "vercel-queue>=0.6.0",
17
+ ]
18
+
19
+ [tool.uv.sources]
20
+ vercel-cache = { workspace = true }
21
+ vercel-queue = { workspace = true }
22
+
23
+ [tool.hatch.version]
24
+ path = "vercel/integrations/django/version.py"
25
+
26
+ [tool.hatch.build.targets.sdist]
27
+ include = [
28
+ "/vercel/integrations/django/**/*.py",
29
+ "/vercel/integrations/django/py.typed",
30
+ "/README.md",
31
+ "/pyproject.toml",
32
+ "/LICENSE",
33
+ ]
34
+ exclude = [
35
+ "/**/__pycache__",
36
+ ]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ dev-mode-dirs = ["."]
40
+ only-include = [
41
+ "/vercel/integrations/django",
42
+ ]
43
+ exclude = [
44
+ "/**/__pycache__",
45
+ ]
46
+
47
+ [tool.ruff]
48
+ extend = "../ruff.toml"
49
+
50
+ [tool.ty.environment]
51
+ python-version = "3.12"
52
+
53
+ [tool.ty.rules]
54
+ unresolved-attribute = "ignore"
55
+
56
+ [tool.pytest.ini_options]
57
+ addopts = "--no-header --capture=tee-sys"
58
+ asyncio_mode = "auto"
59
+ testpaths = ["tests"]
60
+
61
+ [tool.poe]
62
+ include = "../../scripts/poe/poe.toml"
63
+ verbosity = -1
64
+
65
+ [tool.poe.tasks.test]
66
+ shell = """
67
+ if python -c 'import sys; raise SystemExit(sys.version_info < (3, 12))'; then
68
+ $PYTEST
69
+ else
70
+ echo "Skipping vercel-django-tasks tests: Django 6 requires Python 3.12+"
71
+ fi
72
+ """
73
+
74
+ [tool.poe.tasks.typecheck-mypy]
75
+ cmd = "$MYPY --python-version 3.12"
@@ -0,0 +1,23 @@
1
+ """Django task integration for Vercel Queue Service."""
2
+
3
+ import sys
4
+
5
+ if sys.version_info >= (3, 12):
6
+ pass
7
+ else: # pragma: no cover - dependency marker mirrors this gate.
8
+ raise RuntimeError(
9
+ "vercel.integrations.django requires Python 3.12 or newer because Django 6 "
10
+ "does not support earlier Python versions."
11
+ )
12
+
13
+ from ._backend import (
14
+ VercelQueuesBackend,
15
+ install_vercel_django_task_integration,
16
+ )
17
+ from .version import __version__
18
+
19
+ __all__ = [
20
+ "VercelQueuesBackend",
21
+ "__version__",
22
+ "install_vercel_django_task_integration",
23
+ ]
@@ -0,0 +1,630 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, TypedDict, cast
4
+
5
+ import copy
6
+ import math
7
+ import threading
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from traceback import format_exception
11
+
12
+ import vercel.queue as vqs
13
+ import vercel.queue.sync as vqs_sync
14
+ from django.conf import global_settings, settings
15
+ from django.tasks import DEFAULT_TASK_BACKEND_ALIAS, task_backends
16
+ from django.tasks.backends.base import BaseTaskBackend
17
+ from django.tasks.base import (
18
+ DEFAULT_TASK_PRIORITY,
19
+ Task,
20
+ TaskContext,
21
+ TaskError,
22
+ TaskResult,
23
+ TaskResultStatus,
24
+ )
25
+ from django.tasks.exceptions import TaskResultDoesNotExist
26
+ from django.tasks.signals import task_enqueued, task_finished, task_started
27
+ from django.utils import timezone as django_timezone
28
+ from django.utils.crypto import get_random_string
29
+ from django.utils.json import normalize_json
30
+ from django.utils.module_loading import import_string
31
+ from vercel.cache import RuntimeCache
32
+
33
+ from .version import __version__
34
+
35
+ __all__ = [
36
+ "VercelQueuesBackend",
37
+ "__version__",
38
+ "install_vercel_django_task_integration",
39
+ ]
40
+
41
+ _CONSUMER_GROUP = "django-tasks"
42
+ _MAX_ATTEMPTS = 3
43
+ _RETRY_BACKOFF_BASE_SECONDS = 5
44
+ _RETRY_BACKOFF_FACTOR = 2.0
45
+ _MAX_RETRY_DELAY_SECONDS = 60 * 60
46
+ _DEFAULT_RESULT_TTL_SECONDS = 24 * 60 * 60
47
+ _DEFAULT_RESULT_NAMESPACE = "django-task-results"
48
+ _DEFAULT_TASK_BACKEND = {
49
+ "BACKEND": "vercel.integrations.django.VercelQueuesBackend",
50
+ }
51
+ _ENVELOPE_VERSION = 1
52
+ _RESULT_WRAPPER_MARKER = "__vercel_django_task_result__"
53
+ _RESULT_WRAPPER_VERSION = 1
54
+
55
+
56
+ class _TaskEnvelope(TypedDict):
57
+ version: int
58
+ task: str
59
+ queue: str
60
+ args: list[Any]
61
+ kwargs: dict[str, Any]
62
+
63
+
64
+ _StoredTaskRecord = dict[str, Any]
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class _BackendOptions:
69
+ result_namespace: str = _DEFAULT_RESULT_NAMESPACE
70
+ result_ttl_seconds: int = _DEFAULT_RESULT_TTL_SECONDS
71
+
72
+ @classmethod
73
+ def parse(cls, options: object) -> _BackendOptions:
74
+ if not isinstance(options, dict):
75
+ raise TypeError("VercelQueuesBackend OPTIONS must be a dictionary")
76
+
77
+ unknown = set(options) - {"result_namespace", "result_ttl_seconds"}
78
+ if unknown:
79
+ names = ", ".join(sorted(map(str, unknown)))
80
+ raise ValueError(f"Unknown VercelQueuesBackend option(s): {names}")
81
+
82
+ namespace = options.get("result_namespace", _DEFAULT_RESULT_NAMESPACE)
83
+ if not isinstance(namespace, str) or not namespace:
84
+ raise ValueError("result_namespace must be a non-empty string")
85
+
86
+ ttl = options.get("result_ttl_seconds", _DEFAULT_RESULT_TTL_SECONDS)
87
+ if not isinstance(ttl, int) or isinstance(ttl, bool) or ttl <= 0:
88
+ raise ValueError("result_ttl_seconds must be a positive integer")
89
+
90
+ return cls(result_namespace=namespace, result_ttl_seconds=ttl)
91
+
92
+
93
+ class _TaskEnvelopeTransport(vqs.RawJsonTransport[_TaskEnvelope]):
94
+ def validate_payload(self, payload: Any) -> _TaskEnvelope:
95
+ return _parse_envelope(payload)
96
+
97
+
98
+ class _RuntimeCacheResults:
99
+ def __init__(self, *, namespace: str, ttl: int) -> None:
100
+ self.ttl = ttl
101
+ self._runtime_cache = RuntimeCache(
102
+ namespace=str(vqs.sanitize_name(namespace)),
103
+ strict=True,
104
+ )
105
+
106
+ def get(self, result_id: str) -> _StoredTaskRecord | None:
107
+ value = self._runtime_cache.get(result_id)
108
+ if value is None:
109
+ return None
110
+ return _unwrap_result_record(value)
111
+
112
+ def set(self, result_id: str, record: _StoredTaskRecord) -> None:
113
+ self._runtime_cache.set(
114
+ result_id,
115
+ _wrap_result_record(record),
116
+ {"name": result_id, "ttl": self.ttl},
117
+ )
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class _PreparedEnqueue:
122
+ envelope: _TaskEnvelope
123
+ delay: vqs.Duration | None
124
+
125
+
126
+ _registered_subscribers: dict[tuple[str, str, str], Any] = {}
127
+ _registration_lock = threading.RLock()
128
+
129
+
130
+ def _now() -> datetime:
131
+ return django_timezone.now()
132
+
133
+
134
+ def _json_normalize(value: Any) -> Any:
135
+ return normalize_json(value)
136
+
137
+
138
+ def _set_result_attr(result: TaskResult, name: str, value: Any) -> None:
139
+ object.__setattr__(result, name, value) # noqa: PLC2801
140
+
141
+
142
+ def _exception_class_path(exc: BaseException) -> str:
143
+ exc_type = type(exc)
144
+ return f"{exc_type.__module__}.{exc_type.__qualname__}"
145
+
146
+
147
+ def _task_error(exc: BaseException) -> TaskError:
148
+ return TaskError(
149
+ exception_class_path=_exception_class_path(exc),
150
+ traceback="".join(format_exception(exc)),
151
+ )
152
+
153
+
154
+ def _retry_delay_seconds(attempt: int) -> int:
155
+ delay = float(_RETRY_BACKOFF_BASE_SECONDS) * math.pow(
156
+ _RETRY_BACKOFF_FACTOR,
157
+ max(0, attempt - 1),
158
+ )
159
+ if not math.isfinite(delay):
160
+ return _MAX_RETRY_DELAY_SECONDS
161
+ return int(max(0, min(float(_MAX_RETRY_DELAY_SECONDS), delay)))
162
+
163
+
164
+ def _parse_iso_datetime(value: object) -> datetime | None:
165
+ if value is None:
166
+ return None
167
+ if not isinstance(value, str) or not value:
168
+ raise TypeError("task result timestamp must be a string or null")
169
+ raw = value[:-1] + "+00:00" if value.endswith("Z") else value
170
+ try:
171
+ parsed = datetime.fromisoformat(raw)
172
+ except ValueError as exc:
173
+ raise ValueError("task result timestamp is invalid") from exc
174
+ if parsed.tzinfo is None:
175
+ return parsed.replace(tzinfo=timezone.utc)
176
+ return parsed
177
+
178
+
179
+ def _wrap_result_record(record: _StoredTaskRecord) -> dict[str, object]:
180
+ return {
181
+ _RESULT_WRAPPER_MARKER: _RESULT_WRAPPER_VERSION,
182
+ "record": record,
183
+ }
184
+
185
+
186
+ def _unwrap_result_record(value: object) -> _StoredTaskRecord:
187
+ if not isinstance(value, dict):
188
+ raise TypeError("Runtime Cache result payload is not an object")
189
+ if value.get(_RESULT_WRAPPER_MARKER) != _RESULT_WRAPPER_VERSION:
190
+ raise ValueError("Runtime Cache result payload has an unknown version")
191
+ record = value.get("record")
192
+ if not isinstance(record, dict):
193
+ raise TypeError("Runtime Cache result record is not an object")
194
+ return cast("_StoredTaskRecord", record)
195
+
196
+
197
+ def _parse_envelope(payload: Any) -> _TaskEnvelope:
198
+ if not isinstance(payload, dict):
199
+ raise TypeError("Invalid task payload: expected object")
200
+ if payload.get("version") != _ENVELOPE_VERSION:
201
+ raise ValueError("Invalid task payload: unknown envelope version")
202
+ task_path = payload.get("task")
203
+ queue = payload.get("queue")
204
+ args = payload.get("args")
205
+ kwargs = payload.get("kwargs")
206
+ if not isinstance(task_path, str) or not task_path:
207
+ raise TypeError("Invalid task payload: task must be a non-empty string")
208
+ if not isinstance(queue, str) or not queue:
209
+ raise TypeError("Invalid task payload: queue must be a non-empty string")
210
+ if not isinstance(args, list) or not isinstance(kwargs, dict):
211
+ raise TypeError("Invalid task payload: args and kwargs are required")
212
+ return cast("_TaskEnvelope", payload)
213
+
214
+
215
+ class VercelQueuesBackend(BaseTaskBackend):
216
+ task_class: type[Task] = Task
217
+ supports_defer = True
218
+ supports_async_task = True
219
+ supports_get_result = True
220
+ supports_priority = False
221
+
222
+ def __init__(self, alias: str, params: dict[str, Any]) -> None:
223
+ super().__init__(alias, params)
224
+ self._cfg = _BackendOptions.parse(self.options)
225
+ self._sync_queue_client: vqs_sync.QueueClient | None = None
226
+ self._async_queue_client: vqs.QueueClient | None = None
227
+ self._results = _RuntimeCacheResults(
228
+ namespace=self._cfg.result_namespace,
229
+ ttl=self._cfg.result_ttl_seconds,
230
+ )
231
+ self.worker_id = get_random_string(32)
232
+
233
+ def _topic(self, queue_name: str) -> vqs.Topic[_TaskEnvelope]:
234
+ return vqs.Topic[_TaskEnvelope](
235
+ vqs.sanitize_name(queue_name),
236
+ transport=_TaskEnvelopeTransport(),
237
+ )
238
+
239
+ def _sync_client(self) -> vqs_sync.QueueClient:
240
+ if self._sync_queue_client is None:
241
+ self._sync_queue_client = vqs_sync.QueueClient()
242
+ return self._sync_queue_client
243
+
244
+ def _async_client(self) -> vqs.QueueClient:
245
+ if self._async_queue_client is None:
246
+ self._async_queue_client = vqs.QueueClient()
247
+ return self._async_queue_client
248
+
249
+ def close(self) -> None:
250
+ """Clear backend-owned queue clients."""
251
+ self._sync_queue_client = None
252
+ self._async_queue_client = None
253
+
254
+ def _task_from_module_path(self, *, module_path: str, queue_name: str) -> Task:
255
+ imported = import_string(module_path)
256
+ if isinstance(imported, Task):
257
+ func = imported.func
258
+ takes_context = imported.takes_context
259
+ else:
260
+ func = imported
261
+ takes_context = False
262
+ if not callable(func):
263
+ raise TypeError(f"Task function is not callable: {module_path!r}")
264
+ return self.task_class(
265
+ func=func,
266
+ priority=DEFAULT_TASK_PRIORITY,
267
+ queue_name=queue_name,
268
+ backend=self.alias,
269
+ takes_context=takes_context,
270
+ run_after=None,
271
+ )
272
+
273
+ def _prepare_enqueue(
274
+ self,
275
+ task: Task,
276
+ args: list[Any],
277
+ kwargs: dict[str, Any],
278
+ ) -> _PreparedEnqueue:
279
+ self.validate_task(task)
280
+ envelope: _TaskEnvelope = {
281
+ "version": _ENVELOPE_VERSION,
282
+ "task": task.module_path,
283
+ "queue": task.queue_name,
284
+ "args": cast("list[Any]", _json_normalize(list(args))),
285
+ "kwargs": cast("dict[str, Any]", _json_normalize(dict(kwargs))),
286
+ }
287
+ delay: vqs.Duration | None = None
288
+ if task.run_after is not None:
289
+ seconds = (task.run_after - _now()).total_seconds()
290
+ if seconds > 0:
291
+ delay = int(seconds)
292
+ return _PreparedEnqueue(envelope=envelope, delay=delay)
293
+
294
+ def _finalize_enqueue(
295
+ self,
296
+ task: Task,
297
+ prepared: _PreparedEnqueue,
298
+ message_id: object,
299
+ ) -> TaskResult:
300
+ if message_id is None:
301
+ raise RuntimeError("Vercel Queue accepted the task without returning a message id")
302
+ result: TaskResult = TaskResult(
303
+ task=task,
304
+ id=str(message_id),
305
+ status=TaskResultStatus.READY,
306
+ enqueued_at=_now(),
307
+ started_at=None,
308
+ last_attempted_at=None,
309
+ finished_at=None,
310
+ args=prepared.envelope["args"],
311
+ kwargs=prepared.envelope["kwargs"],
312
+ backend=self.alias,
313
+ errors=[],
314
+ worker_ids=[],
315
+ )
316
+ self._store_result(result)
317
+ task_enqueued.send(type(self), task_result=result)
318
+ return copy.deepcopy(result)
319
+
320
+ def enqueue(
321
+ self,
322
+ task: Task,
323
+ args: list[Any],
324
+ kwargs: dict[str, Any],
325
+ ) -> TaskResult:
326
+ prepared = self._prepare_enqueue(task, args, kwargs)
327
+ message_id = self._sync_client().send(
328
+ self._topic(task.queue_name),
329
+ prepared.envelope,
330
+ delay=prepared.delay,
331
+ )
332
+ return self._finalize_enqueue(task, prepared, message_id)
333
+
334
+ async def aenqueue(
335
+ self,
336
+ task: Task,
337
+ args: list[Any],
338
+ kwargs: dict[str, Any],
339
+ ) -> TaskResult:
340
+ prepared = self._prepare_enqueue(task, args, kwargs)
341
+ message_id = await self._async_client().send(
342
+ self._topic(task.queue_name),
343
+ prepared.envelope,
344
+ delay=prepared.delay,
345
+ )
346
+ return self._finalize_enqueue(task, prepared, message_id)
347
+
348
+ def _serialize_result(self, result: TaskResult) -> _StoredTaskRecord:
349
+ def _datetime(value: datetime | None) -> str | None:
350
+ return value.isoformat() if value is not None else None
351
+
352
+ record: _StoredTaskRecord = {
353
+ "version": 1,
354
+ "id": result.id,
355
+ "task": result.task.module_path,
356
+ "queue": result.task.queue_name,
357
+ "status": str(result.status),
358
+ "enqueued_at": _datetime(result.enqueued_at),
359
+ "started_at": _datetime(result.started_at),
360
+ "finished_at": _datetime(result.finished_at),
361
+ "last_attempted_at": _datetime(result.last_attempted_at),
362
+ "args": _json_normalize(list(result.args)),
363
+ "kwargs": _json_normalize(dict(result.kwargs)),
364
+ "worker_ids": list(result.worker_ids),
365
+ "errors": [
366
+ {
367
+ "exception_class_path": error.exception_class_path,
368
+ "traceback": error.traceback,
369
+ }
370
+ for error in result.errors
371
+ ],
372
+ }
373
+ if result.status == TaskResultStatus.SUCCESSFUL:
374
+ record["return_value"] = _json_normalize(result.return_value)
375
+ return record
376
+
377
+ def _deserialize_result(self, record: _StoredTaskRecord) -> TaskResult:
378
+ required = {
379
+ "version",
380
+ "id",
381
+ "task",
382
+ "queue",
383
+ "status",
384
+ "enqueued_at",
385
+ "started_at",
386
+ "finished_at",
387
+ "last_attempted_at",
388
+ "args",
389
+ "kwargs",
390
+ "worker_ids",
391
+ "errors",
392
+ }
393
+ if record.get("version") != 1 or not required.issubset(record):
394
+ raise ValueError("Runtime Cache task result record is malformed")
395
+
396
+ result_id = record["id"]
397
+ module_path = record["task"]
398
+ queue_name = record["queue"]
399
+ args = record["args"]
400
+ kwargs = record["kwargs"]
401
+ worker_ids = record["worker_ids"]
402
+ errors = record["errors"]
403
+ if not all(
404
+ isinstance(value, str) and value for value in (result_id, module_path, queue_name)
405
+ ):
406
+ raise TypeError("Runtime Cache task result identity is malformed")
407
+ if not isinstance(args, list) or not isinstance(kwargs, dict):
408
+ raise TypeError("Runtime Cache task result arguments are malformed")
409
+ if not isinstance(worker_ids, list) or not all(
410
+ isinstance(item, str) for item in worker_ids
411
+ ):
412
+ raise TypeError("Runtime Cache task result worker IDs are malformed")
413
+ if not isinstance(errors, list) or not all(isinstance(item, dict) for item in errors):
414
+ raise TypeError("Runtime Cache task result errors are malformed")
415
+
416
+ try:
417
+ status = TaskResultStatus(record["status"])
418
+ except (TypeError, ValueError) as exc:
419
+ raise ValueError("Runtime Cache task result status is malformed") from exc
420
+
421
+ task = self._task_from_module_path(
422
+ module_path=module_path,
423
+ queue_name=queue_name,
424
+ )
425
+ result: TaskResult = TaskResult(
426
+ task=task,
427
+ id=result_id,
428
+ status=status,
429
+ enqueued_at=_parse_iso_datetime(record["enqueued_at"]),
430
+ started_at=_parse_iso_datetime(record["started_at"]),
431
+ finished_at=_parse_iso_datetime(record["finished_at"]),
432
+ last_attempted_at=_parse_iso_datetime(record["last_attempted_at"]),
433
+ args=args,
434
+ kwargs=kwargs,
435
+ backend=self.alias,
436
+ errors=[
437
+ TaskError(
438
+ exception_class_path=str(error.get("exception_class_path") or ""),
439
+ traceback=str(error.get("traceback") or ""),
440
+ )
441
+ for error in errors
442
+ ],
443
+ worker_ids=worker_ids,
444
+ )
445
+ if "return_value" in record:
446
+ _set_result_attr(result, "_return_value", record["return_value"])
447
+ return result
448
+
449
+ def _store_result(self, result: TaskResult) -> None:
450
+ self._results.set(result.id, self._serialize_result(result))
451
+
452
+ def get_result(self, result_id: str) -> TaskResult:
453
+ try:
454
+ record = self._results.get(str(result_id))
455
+ except (ImportError, TypeError, ValueError):
456
+ raise TaskResultDoesNotExist(result_id) from None
457
+ if record is None:
458
+ raise TaskResultDoesNotExist(result_id)
459
+ try:
460
+ return self._deserialize_result(record)
461
+ except (ImportError, TypeError, ValueError):
462
+ raise TaskResultDoesNotExist(result_id) from None
463
+
464
+ async def aget_result(self, result_id: str) -> TaskResult:
465
+ return self.get_result(result_id)
466
+
467
+ def _load_or_initialize_result(
468
+ self,
469
+ *,
470
+ message_id: str,
471
+ envelope: _TaskEnvelope,
472
+ task: Task,
473
+ ) -> TaskResult:
474
+ try:
475
+ result = self.get_result(message_id)
476
+ except TaskResultDoesNotExist:
477
+ result = TaskResult(
478
+ task=task,
479
+ id=message_id,
480
+ status=TaskResultStatus.READY,
481
+ enqueued_at=None,
482
+ started_at=None,
483
+ last_attempted_at=None,
484
+ finished_at=None,
485
+ args=envelope["args"],
486
+ kwargs=envelope["kwargs"],
487
+ backend=self.alias,
488
+ errors=[],
489
+ worker_ids=[],
490
+ )
491
+ _set_result_attr(result, "task", task)
492
+ _set_result_attr(result, "args", envelope["args"])
493
+ _set_result_attr(result, "kwargs", envelope["kwargs"])
494
+ return result
495
+
496
+ def _start_result(self, result: TaskResult) -> None:
497
+ now = _now()
498
+ _set_result_attr(result, "status", TaskResultStatus.RUNNING)
499
+ if result.started_at is None:
500
+ _set_result_attr(result, "started_at", now)
501
+ _set_result_attr(result, "last_attempted_at", now)
502
+ result.worker_ids.append(self.worker_id)
503
+ self._store_result(result)
504
+ task_started.send(sender=type(self), task_result=result)
505
+
506
+ def _finish_result(
507
+ self,
508
+ result: TaskResult,
509
+ *,
510
+ return_value: Any = None,
511
+ error: BaseException | None = None,
512
+ ) -> int | None:
513
+ if error is None:
514
+ _set_result_attr(result, "_return_value", _json_normalize(return_value))
515
+ _set_result_attr(result, "status", TaskResultStatus.SUCCESSFUL)
516
+ _set_result_attr(result, "finished_at", _now())
517
+ self._store_result(result)
518
+ task_finished.send(sender=type(self), task_result=result)
519
+ return None
520
+
521
+ result.errors.append(_task_error(error))
522
+ attempt = len(result.worker_ids)
523
+ if attempt < _MAX_ATTEMPTS:
524
+ _set_result_attr(result, "status", TaskResultStatus.READY)
525
+ _set_result_attr(result, "finished_at", None)
526
+ self._store_result(result)
527
+ return _retry_delay_seconds(attempt)
528
+
529
+ _set_result_attr(result, "status", TaskResultStatus.FAILED)
530
+ _set_result_attr(result, "finished_at", _now())
531
+ self._store_result(result)
532
+ task_finished.send(sender=type(self), task_result=result)
533
+ return None
534
+
535
+ async def _execute_message(self, message: vqs.Message[_TaskEnvelope]) -> int | None:
536
+ envelope = _parse_envelope(message.payload)
537
+ queue_name = envelope["queue"]
538
+ if self.queues and queue_name not in self.queues:
539
+ raise ValueError(f"Queue {queue_name!r} is not valid for backend {self.alias!r}")
540
+ task = self._task_from_module_path(
541
+ module_path=envelope["task"],
542
+ queue_name=queue_name,
543
+ )
544
+ result = self._load_or_initialize_result(
545
+ message_id=message.metadata.message_id,
546
+ envelope=envelope,
547
+ task=task,
548
+ )
549
+ self._start_result(result)
550
+ try:
551
+ if task.takes_context:
552
+ return_value = await task.acall(
553
+ TaskContext(task_result=result),
554
+ *result.args,
555
+ **result.kwargs,
556
+ )
557
+ else:
558
+ return_value = await task.acall(*result.args, **result.kwargs)
559
+ except Exception as exc: # noqa: BLE001
560
+ return self._finish_result(result, error=exc)
561
+ return self._finish_result(result, return_value=return_value)
562
+
563
+
564
+ def _resolve_backend(alias: str) -> VercelQueuesBackend:
565
+ backend = task_backends[alias]
566
+ if not isinstance(backend, VercelQueuesBackend):
567
+ raise TypeError(
568
+ f"Backend {alias!r} is {backend.__class__.__name__}, expected VercelQueuesBackend."
569
+ )
570
+ return backend
571
+
572
+
573
+ def _register_task_queues(backend_alias: str) -> None:
574
+ backend = _resolve_backend(backend_alias)
575
+ with _registration_lock:
576
+ for queue_name in sorted(backend.queues):
577
+ topic = backend._topic(queue_name) # noqa: SLF001
578
+ key = (backend.alias, str(topic.name), _CONSUMER_GROUP)
579
+ if key in _registered_subscribers:
580
+ continue
581
+
582
+ async def callback(
583
+ message: vqs.Message[_TaskEnvelope],
584
+ *,
585
+ _backend: VercelQueuesBackend = backend,
586
+ ) -> None:
587
+ retry_after = await _backend._execute_message(message) # noqa: SLF001
588
+ if retry_after is not None:
589
+ raise vqs.RetryAfter(retry_after)
590
+
591
+ callback.__name__ = f"vercel_django_task_{backend.alias}_{topic.name}"
592
+ subscriber = vqs.subscribe(
593
+ topic=topic,
594
+ consumer_group=_CONSUMER_GROUP,
595
+ max_attempts=_MAX_ATTEMPTS,
596
+ )(callback)
597
+ _registered_subscribers[key] = subscriber
598
+
599
+
600
+ def install_vercel_django_task_integration(
601
+ backend_alias: str = "default",
602
+ *,
603
+ register_queues: bool = True,
604
+ ) -> None:
605
+ """Install the default backend when needed and register its queue subscribers."""
606
+ _configure_default_task_backend()
607
+ if register_queues:
608
+ _register_task_queues(backend_alias)
609
+
610
+
611
+ def _configure_default_task_backend() -> None:
612
+ global_settings.TASKS[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND)
613
+ if not settings.configured:
614
+ return
615
+
616
+ configured_backends = settings.TASKS
617
+ if settings.is_overridden("TASKS") and configured_backends:
618
+ return
619
+
620
+ configured_backends[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND)
621
+ task_backends.settings[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND)
622
+
623
+ connections: Any = getattr(task_backends, "_connections") # noqa: B009
624
+ if hasattr(connections, DEFAULT_TASK_BACKEND_ALIAS):
625
+ existing_backend = getattr(connections, DEFAULT_TASK_BACKEND_ALIAS)
626
+ if not isinstance(existing_backend, VercelQueuesBackend):
627
+ close = getattr(existing_backend, "close", None)
628
+ if close is not None:
629
+ close()
630
+ delattr(connections, DEFAULT_TASK_BACKEND_ALIAS)
@@ -0,0 +1,3 @@
1
+ """Package version metadata."""
2
+
3
+ __version__ = "0.7.2"