diff --git a/changes/vercel-django-tasks/initial.feature.md b/changes/vercel-django-tasks/initial.feature.md new file mode 100644 index 00000000..9cb6463d --- /dev/null +++ b/changes/vercel-django-tasks/initial.feature.md @@ -0,0 +1,2 @@ +Add a Vercel Queues backend for Django Tasks and use it by default when no +task backends are configured. diff --git a/integrations/vercel-django-tasks/LICENSE b/integrations/vercel-django-tasks/LICENSE new file mode 100644 index 00000000..eabe3008 --- /dev/null +++ b/integrations/vercel-django-tasks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/vercel-django-tasks/README.md b/integrations/vercel-django-tasks/README.md new file mode 100644 index 00000000..00289eda --- /dev/null +++ b/integrations/vercel-django-tasks/README.md @@ -0,0 +1,44 @@ +# vercel-django-tasks + +Django task backend backed by Vercel Queue Service. The installer uses +`VercelQueuesBackend` as Django's default task backend when no `TASKS` +backends are configured and registers generated queue subscribers. + +Register push subscribers during application startup: + +```python +from vercel.integrations.django import install_vercel_django_task_integration + +install_vercel_django_task_integration() +``` + +No `TASKS` setting is required. Configure one explicitly to customize the +backend or to use a different backend: + +```python +TASKS = { + "default": { + "BACKEND": "vercel.integrations.django.VercelQueuesBackend", + "QUEUES": ["default"], + "OPTIONS": { + "result_namespace": "django-task-results", + "result_ttl_seconds": 86400, + }, + }, +} +``` + +Declare the module that loads your Django application in `pyproject.toml` so +Vercel generates a queue subscriber function: + +```toml +[[tool.vercel.subscribers]] +entrypoint = "my_project.wsgi" +``` + +No manual queue endpoint is required. + +Task result state is stored in Vercel Runtime Cache. Results are cache-backed +and expire according to `result_ttl_seconds`; they are not durable storage. + +This package depends on Django, `vercel-queue`, and `vercel-cache`. diff --git a/integrations/vercel-django-tasks/examples/chunks/README.md b/integrations/vercel-django-tasks/examples/chunks/README.md new file mode 100644 index 00000000..fe2cc216 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/README.md @@ -0,0 +1,34 @@ +# Django tasks chunks app + +Deploy this directory as a Vercel app: + +```bash +cd integrations/vercel-django-tasks/examples/chunks +vc link +vc deploy +``` + +Queue the chunks by requesting the Django view on the deployed app: + +```bash +vc curl /send_chunks/ +``` + +Expected output includes: + +```text +queued 100 add tasks +results: [0, 2, 4, 6 +``` + +`pyproject.toml` declares the Django WSGI application as a queue subscriber: + +```toml +[[tool.vercel.subscribers]] +entrypoint = "chunks_project.wsgi" +``` + +The Vercel build imports the Django application, introspects the generated +task subscriber registered during startup, and compiles it into a +queue-triggered function. No manual queue view or `vercel.json` trigger +configuration is needed. diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks/__init__.py b/integrations/vercel-django-tasks/examples/chunks/chunks/__init__.py new file mode 100644 index 00000000..7258499c --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks/__init__.py @@ -0,0 +1 @@ +"""Chunks example app.""" diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks/apps.py b/integrations/vercel-django-tasks/examples/chunks/chunks/apps.py new file mode 100644 index 00000000..e9c81a95 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks/apps.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from django.apps import AppConfig + + +class ChunksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "chunks" diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks/tasks.py b/integrations/vercel-django-tasks/examples/chunks/chunks/tasks.py new file mode 100644 index 00000000..2fabc62c --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks/tasks.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from django.tasks import task + + +@task +def add(left: int, right: int) -> int: + return left + right diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks/urls.py b/integrations/vercel-django-tasks/examples/chunks/chunks/urls.py new file mode 100644 index 00000000..ffa089e3 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks/urls.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from django.urls import path + +from . import views + +urlpatterns = [ + path("send_chunks/", views.send_chunks, name="send_chunks"), +] diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks/views.py b/integrations/vercel-django-tasks/examples/chunks/chunks/views.py new file mode 100644 index 00000000..15e6ecd7 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks/views.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import time +from itertools import starmap + +from django.http import HttpRequest, HttpResponse +from django.tasks import TaskResultStatus + +from .tasks import add + +RESULT_TIMEOUT_SECONDS = 30 + + +def send_chunks(request: HttpRequest) -> HttpResponse: + del request + pairs = zip(range(100), range(100), strict=False) + results = list(starmap(add.enqueue, pairs)) + deadline = time.monotonic() + RESULT_TIMEOUT_SECONDS + + while time.monotonic() < deadline: + for result in results: + if not result.is_finished: + result.refresh() + if all(result.is_finished for result in results): + break + time.sleep(0.25) + + if not all(result.is_finished for result in results): + finished = sum(result.is_finished for result in results) + return HttpResponse( + f"queued {len(results)} add tasks\nfinished {finished}/{len(results)} before timeout\n", + content_type="text/plain", + status=504, + ) + + values = [ + result.return_value for result in results if result.status == TaskResultStatus.SUCCESSFUL + ] + return HttpResponse( + f"queued {len(results)} add tasks\nresults: {values}\n", + content_type="text/plain", + ) diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks_project/__init__.py b/integrations/vercel-django-tasks/examples/chunks/chunks_project/__init__.py new file mode 100644 index 00000000..4747eb8a --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks_project/__init__.py @@ -0,0 +1 @@ +"""Django project for the vercel-django-tasks chunks example.""" diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks_project/asgi.py b/integrations/vercel-django-tasks/examples/chunks/chunks_project/asgi.py new file mode 100644 index 00000000..633e331f --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks_project/asgi.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chunks_project.settings") + +application = get_asgi_application() diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks_project/settings.py b/integrations/vercel-django-tasks/examples/chunks/chunks_project/settings.py new file mode 100644 index 00000000..3c38cd97 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks_project/settings.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +SECRET_KEY = "vercel-django-tasks-example-chunks" # noqa: S105 +DEBUG = False +ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = ["chunks"] +MIDDLEWARE: list[str] = [] +ROOT_URLCONF = "chunks_project.urls" +ASGI_APPLICATION = "chunks_project.asgi.application" +WSGI_APPLICATION = "chunks_project.wsgi.application" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks_project/urls.py b/integrations/vercel-django-tasks/examples/chunks/chunks_project/urls.py new file mode 100644 index 00000000..ade52b77 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks_project/urls.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from django.urls import include, path + +urlpatterns = [ + path("", include("chunks.urls")), +] diff --git a/integrations/vercel-django-tasks/examples/chunks/chunks_project/wsgi.py b/integrations/vercel-django-tasks/examples/chunks/chunks_project/wsgi.py new file mode 100644 index 00000000..beef0022 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks_project/wsgi.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chunks_project.settings") + +application = get_wsgi_application() + +from chunks import tasks # noqa: E402 + +from vercel.integrations.django import install_vercel_django_task_integration # noqa: E402 + +_ = tasks +install_vercel_django_task_integration() diff --git a/integrations/vercel-django-tasks/examples/chunks/manage.py b/integrations/vercel-django-tasks/examples/chunks/manage.py new file mode 100644 index 00000000..7861f830 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/manage.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +from __future__ import annotations + +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chunks_project.settings") + from django.core.management import execute_from_command_line # noqa: PLC0415 + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/integrations/vercel-django-tasks/examples/chunks/pyproject.toml b/integrations/vercel-django-tasks/examples/chunks/pyproject.toml new file mode 100644 index 00000000..c9c2464f --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "vercel-django-tasks-example-chunks" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [ + "django", + "vercel-cache", + "vercel-django-tasks", +] + +# Compiles the Django WSGI application into a queue-triggered function. With +# no "topics" filter, the subscriber consumes every task queue registered +# while Django starts up. +[[tool.vercel.subscribers]] +entrypoint = "chunks_project.wsgi" + +[tool.uv.sources] +vercel-cache = { path = "../../../../src/vercel-cache", editable = true } +vercel-django-tasks = { path = "../..", editable = true } diff --git a/integrations/vercel-django-tasks/pyproject.toml b/integrations/vercel-django-tasks/pyproject.toml new file mode 100644 index 00000000..b6cde442 --- /dev/null +++ b/integrations/vercel-django-tasks/pyproject.toml @@ -0,0 +1,75 @@ +[build-system] +requires = ["hatchling>=1.27.0,<2"] +build-backend = "hatchling.build" + +[project] +name = "vercel-django-tasks" +dynamic = ["version"] +description = "Django task backend backed by Vercel Queue Service" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE", "LICENSE.*"] +dependencies = [ + "Django>=6.0; python_version >= '3.12'", + "vercel-cache", + "vercel-queue>=0.6.0", +] + +[tool.uv.sources] +vercel-cache = { workspace = true } +vercel-queue = { workspace = true } + +[tool.hatch.version] +path = "vercel/integrations/django/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/vercel/integrations/django/**/*.py", + "/vercel/integrations/django/py.typed", + "/README.md", + "/pyproject.toml", + "/LICENSE", +] +exclude = [ + "/**/__pycache__", +] + +[tool.hatch.build.targets.wheel] +dev-mode-dirs = ["."] +only-include = [ + "/vercel/integrations/django", +] +exclude = [ + "/**/__pycache__", +] + +[tool.ruff] +extend = "../ruff.toml" + +[tool.ty.environment] +python-version = "3.12" + +[tool.ty.rules] +unresolved-attribute = "ignore" + +[tool.pytest.ini_options] +addopts = "--no-header --capture=tee-sys" +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.poe] +include = "../../scripts/poe/poe.toml" +verbosity = -1 + +[tool.poe.tasks.test] +shell = """ +if python -c 'import sys; raise SystemExit(sys.version_info < (3, 12))'; then + $PYTEST +else + echo "Skipping vercel-django-tasks tests: Django 6 requires Python 3.12+" +fi +""" + +[tool.poe.tasks.typecheck-mypy] +cmd = "$MYPY --python-version 3.12" diff --git a/integrations/vercel-django-tasks/tests/__init__.py b/integrations/vercel-django-tasks/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/integrations/vercel-django-tasks/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/integrations/vercel-django-tasks/tests/conftest.py b/integrations/vercel-django-tasks/tests/conftest.py new file mode 100644 index 00000000..9c404bb0 --- /dev/null +++ b/integrations/vercel-django-tasks/tests/conftest.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +import os + +os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" diff --git a/integrations/vercel-django-tasks/tests/settings.py b/integrations/vercel-django-tasks/tests/settings.py new file mode 100644 index 00000000..849e9bc7 --- /dev/null +++ b/integrations/vercel-django-tasks/tests/settings.py @@ -0,0 +1,13 @@ +SECRET_KEY = "vercel-django-tasks-tests" +USE_TZ = True +INSTALLED_APPS: list[str] = [] +TASKS = { + "default": { + "BACKEND": "vercel.integrations.django.VercelQueuesBackend", + "QUEUES": ["default"], + "OPTIONS": { + "result_namespace": "django.results", + "result_ttl_seconds": 120, + }, + } +} diff --git a/integrations/vercel-django-tasks/tests/unit/__init__.py b/integrations/vercel-django-tasks/tests/unit/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/integrations/vercel-django-tasks/tests/unit/__init__.py @@ -0,0 +1 @@ + diff --git a/integrations/vercel-django-tasks/tests/unit/test_django_examples.py b/integrations/vercel-django-tasks/tests/unit/test_django_examples.py new file mode 100644 index 00000000..b6b50671 --- /dev/null +++ b/integrations/vercel-django-tasks/tests/unit/test_django_examples.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import Any + +import importlib +import sys +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import pytest +from django.tasks import TaskResultStatus, task_backends +from django.test import RequestFactory +from django.urls import resolve + +import vercel.integrations.django._backend as vqs_django + +EXAMPLE_ROOT = Path(__file__).parents[2] / "examples" / "chunks" +EXAMPLE_MODULES = ( + "chunks", + "chunks.apps", + "chunks.tasks", + "chunks.urls", + "chunks.views", + "chunks_project", + "chunks_project.asgi", + "chunks_project.settings", + "chunks_project.urls", + "chunks_project.wsgi", +) + + +@dataclass +class FakeSubscription: + topic: Any + consumer_group: str + callback: Any + + +class FinishedResult: + def __init__(self, value: int) -> None: + self.is_finished = False + self.return_value = value + self.status = TaskResultStatus.SUCCESSFUL + self.refresh_count = 0 + + def refresh(self) -> None: + self.refresh_count += 1 + self.is_finished = True + + +@pytest.fixture +def chunks_example(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: + monkeypatch.syspath_prepend(str(EXAMPLE_ROOT)) + for name in EXAMPLE_MODULES: + sys.modules.pop(name, None) + original_default_task_settings = task_backends.settings.get("default") + try: + yield EXAMPLE_ROOT + finally: + for name in EXAMPLE_MODULES: + sys.modules.pop(name, None) + vqs_django._registered_subscribers.clear() + task_backends.close_all() + clear_task_backend_connection() + if original_default_task_settings is not None: + task_backends.settings["default"] = original_default_task_settings + _ = task_backends["default"] + + +@pytest.fixture +def fake_subscribe(monkeypatch: pytest.MonkeyPatch) -> list[FakeSubscription]: + subscriptions: list[FakeSubscription] = [] + + def subscribe(*, topic: Any = None, consumer_group: str = "default", **kwargs: Any) -> Any: + del kwargs + + def decorator(callback: Any) -> Any: + subscriptions.append( + FakeSubscription( + topic=topic, + consumer_group=consumer_group, + callback=callback, + ) + ) + return callback + + return decorator + + monkeypatch.setattr(vqs_django.vqs, "subscribe", subscribe) + return subscriptions + + +def topic_name(topic: Any) -> str: + return str(getattr(topic, "name", topic)) + + +def configure_example_backend() -> None: + task_backends.close_all() + clear_task_backend_connection() + task_backends.settings["default"] = { + "BACKEND": "vercel.integrations.django.VercelQueuesBackend", + "QUEUES": ["default"], + } + vqs_django._registered_subscribers.clear() + + +def clear_task_backend_connection(alias: str = "default") -> None: + connections: Any = getattr(task_backends, "_connections") # noqa: B009 + if hasattr(connections, alias): + delattr(connections, alias) + + +def test_chunks_example_uses_pyproject_subscriber_contract() -> None: + assert not (EXAMPLE_ROOT / "vercel.json").exists() + + pyproject = (EXAMPLE_ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert "[[tool.vercel.subscribers]]" in pyproject + assert 'entrypoint = "chunks_project.wsgi"' in pyproject + assert "topics =" not in pyproject + + +def test_chunks_example_routes_resolve(chunks_example: Path) -> None: + del chunks_example + + send_match = resolve("/send_chunks/", urlconf="chunks_project.urls") + assert send_match.url_name == "send_chunks" + + +def test_chunks_example_registers_task_queue( + chunks_example: Path, + fake_subscribe: list[FakeSubscription], +) -> None: + del chunks_example + configure_example_backend() + importlib.import_module("chunks.tasks") + + vqs_django.install_vercel_django_task_integration() + + assert [(topic_name(sub.topic), sub.consumer_group) for sub in fake_subscribe] == [ + ("default", "django-tasks") + ] + + +def test_send_chunks_view_enqueues_and_polls_results( + chunks_example: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + del chunks_example + configure_example_backend() + views = importlib.import_module("chunks.views") + queued: list[tuple[int, int]] = [] + results = [FinishedResult(i + i) for i in range(100)] + + class FakeAddTask: + def enqueue(self, left: int, right: int) -> FinishedResult: + queued.append((left, right)) + return results[left] + + monkeypatch.setattr(views, "add", FakeAddTask()) + request = RequestFactory().get("/send_chunks/") + + response = views.send_chunks(request) + + assert response.status_code == 200 + assert queued == list(zip(range(100), range(100), strict=False)) + assert all(result.refresh_count == 1 for result in results) + assert response.content.startswith(b"queued 100 add tasks\nresults: [0, 2, 4") diff --git a/integrations/vercel-django-tasks/tests/unit/test_django_tasks.py b/integrations/vercel-django-tasks/tests/unit/test_django_tasks.py new file mode 100644 index 00000000..9cc29d55 --- /dev/null +++ b/integrations/vercel-django-tasks/tests/unit/test_django_tasks.py @@ -0,0 +1,583 @@ +from __future__ import annotations + +# Settings must be configured before importing django.tasks. +# ruff: noqa: I001 + +from collections.abc import Iterator +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any, ClassVar, cast + +import pytest + +from django.conf import settings + +if not settings.configured: + settings.configure( + SECRET_KEY="tests", + USE_TZ=True, + INSTALLED_APPS=[], + TASKS={ + "default": { + "BACKEND": "vercel.integrations.django.VercelQueuesBackend", + "QUEUES": ["default"], + "OPTIONS": { + "result_namespace": "django.results", + "result_ttl_seconds": 120, + }, + } + }, + ) + +import django +from django.tasks import TaskResultStatus, task, task_backends +from django.tasks.exceptions import TaskResultDoesNotExist +from django.tasks.signals import task_enqueued, task_finished, task_started + +import vercel.integrations.django as public_api +import vercel.integrations.django._backend as vqs_django +from vercel.queue import Message, MessageMetadata, RetryAfter + +django.setup() + + +@task +def add_one(value: int) -> int: + return value + 1 + + +@task(takes_context=True) +def attempt_number(context: Any) -> int: + return context.attempt + + +@task +def fail_forever() -> None: + raise RuntimeError("nope") + + +@task +async def async_add_one(value: int) -> int: + return value + 1 + + +@task(takes_context=True) +async def async_attempt_number(context: Any) -> int: + return context.attempt + + +@task +async def async_fail_forever() -> None: + raise RuntimeError("async nope") + + +@dataclass +class FakeSubscription: + topic: Any + consumer_group: str + max_attempts: int | None + callback: Any + + +def topic_name(topic: Any) -> str: + return str(getattr(topic, "name", topic)) + + +class FakeRuntimeCache: + instances: ClassVar[list[FakeRuntimeCache]] = [] + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.values: dict[str, object] = {} + self.set_options: dict[str, dict[str, object]] = {} + FakeRuntimeCache.instances.append(self) + + def get(self, key: str) -> object | None: + return self.values.get(key) + + def set(self, key: str, value: object, options: dict[str, object]) -> None: + self.values[key] = value + self.set_options[key] = options + + +class FakeSyncQueueClient: + instances: ClassVar[list[FakeSyncQueueClient]] = [] + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.sent: list[dict[str, Any]] = [] + FakeSyncQueueClient.instances.append(self) + + def send(self, topic: Any, payload: dict[str, Any], **kwargs: Any) -> str: + self.sent.append({"topic": topic, "payload": payload, "kwargs": kwargs}) + return "msg_1" + + +class FakeAsyncQueueClient: + instances: ClassVar[list[FakeAsyncQueueClient]] = [] + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.sent: list[dict[str, Any]] = [] + FakeAsyncQueueClient.instances.append(self) + + async def send(self, topic: Any, payload: dict[str, Any], **kwargs: Any) -> str: + self.sent.append({"topic": topic, "payload": payload, "kwargs": kwargs}) + return "msg_async" + + +@pytest.fixture(autouse=True) +def clean_state(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[FakeSubscription]]: + FakeSyncQueueClient.instances.clear() + FakeAsyncQueueClient.instances.clear() + FakeRuntimeCache.instances.clear() + vqs_django._registered_subscribers.clear() + task_backends.close_all() + monkeypatch.setattr(vqs_django, "RuntimeCache", FakeRuntimeCache) + monkeypatch.setattr(vqs_django.vqs_sync, "QueueClient", FakeSyncQueueClient) + monkeypatch.setattr(vqs_django.vqs, "QueueClient", FakeAsyncQueueClient) + existing_backend = task_backends["default"] + if isinstance(existing_backend, vqs_django.VercelQueuesBackend): + existing_backend._sync_queue_client = None + existing_backend._async_queue_client = None + existing_backend._results = vqs_django._RuntimeCacheResults( + namespace=existing_backend._cfg.result_namespace, + ttl=existing_backend._cfg.result_ttl_seconds, + ) + subscriptions: list[FakeSubscription] = [] + + def subscribe( + *, + topic: Any = None, + consumer_group: str = "default", + max_attempts: int | None = None, + **kwargs: Any, + ) -> Any: + del kwargs + + def decorator(callback: Any) -> Any: + subscriptions.append( + FakeSubscription( + topic=topic, + consumer_group=consumer_group, + max_attempts=max_attempts, + callback=callback, + ) + ) + return callback + + return decorator + + monkeypatch.setattr(vqs_django.vqs, "subscribe", subscribe) + try: + yield subscriptions + finally: + vqs_django._registered_subscribers.clear() + task_backends.close_all() + + +def backend() -> vqs_django.VercelQueuesBackend: + return cast("vqs_django.VercelQueuesBackend", task_backends["default"]) + + +def configure_backend( + alias: str, + queues: list[str], + *, + options: object | None = None, +) -> vqs_django.VercelQueuesBackend: + params: dict[str, Any] = { + "BACKEND": "vercel.integrations.django.VercelQueuesBackend", + "QUEUES": queues, + } + if options is not None: + params["OPTIONS"] = options + task_backends.settings[alias] = params + return cast("vqs_django.VercelQueuesBackend", task_backends[alias]) + + +def sent_envelope(*, asynchronous: bool = False) -> dict[str, Any]: + if asynchronous: + return FakeAsyncQueueClient.instances[0].sent[-1]["payload"] + return FakeSyncQueueClient.instances[0].sent[-1]["payload"] + + +def message(payload: dict[str, Any], *, message_id: str = "msg_1") -> Message[Any]: + return Message( + payload=payload, + metadata=MessageMetadata( + message_id=message_id, + delivery_count=1, + created_at=datetime(2026, 1, 1, tzinfo=UTC), + topic=payload["queue"], + consumer_group=vqs_django.vqs.sanitize_name("django-tasks"), + receipt_handle="rh_1", + content_type="application/json", + ), + ) + + +def test_public_api_is_minimal() -> None: + assert public_api.__all__ == [ + "VercelQueuesBackend", + "__version__", + "install_vercel_django_task_integration", + ] + + +@pytest.mark.parametrize( + ("options", "error", "match"), + [ + ({"token": "secret"}, ValueError, "Unknown.*token"), + ({"result_namespace": ""}, ValueError, "non-empty string"), + ({"result_namespace": 1}, ValueError, "non-empty string"), + ({"result_ttl_seconds": 0}, ValueError, "positive integer"), + ({"result_ttl_seconds": True}, ValueError, "positive integer"), + ([], TypeError, "dictionary"), + ], +) +def test_backend_options_are_strict( + options: object, + error: type[Exception], + match: str, +) -> None: + with pytest.raises(error, match=match): + configure_backend("invalid", ["default"], options=options) + + +def test_default_backend_is_installed_only_by_installer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + django_default = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}} + + class FakeSettings: + configured = True + TASKS: ClassVar[dict[str, dict[str, str]]] = dict(django_default) + + @staticmethod + def is_overridden(setting: str) -> bool: + assert setting == "TASKS" + return False + + class FakeImmediateBackend: + closed = False + + def close(self) -> None: + self.closed = True + + existing_backend = FakeImmediateBackend() + fake_task_backends = SimpleNamespace( + settings=dict(django_default), + _connections=SimpleNamespace(default=existing_backend), + ) + monkeypatch.setattr(vqs_django, "settings", FakeSettings()) + monkeypatch.setattr(vqs_django, "global_settings", SimpleNamespace(TASKS={})) + monkeypatch.setattr(vqs_django, "task_backends", fake_task_backends) + + vqs_django._configure_default_task_backend() + + expected = {"BACKEND": "vercel.integrations.django.VercelQueuesBackend"} + assert {"default": expected} == FakeSettings.TASKS + assert fake_task_backends.settings == {"default": expected} + assert existing_backend.closed is True + assert not hasattr(fake_task_backends._connections, "default") + + +def test_installer_can_configure_publish_side_without_registering( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registered = False + + def register(backend_alias: str) -> None: + del backend_alias + nonlocal registered + registered = True + + monkeypatch.setattr(vqs_django, "_register_task_queues", register) + + vqs_django.install_vercel_django_task_integration(register_queues=False) + + assert registered is False + + +def test_installer_preserves_explicit_task_backends( + monkeypatch: pytest.MonkeyPatch, +) -> None: + configured_backends = {"custom": {"BACKEND": "example.CustomBackend"}} + + class FakeSettings: + configured = True + TASKS = configured_backends + + @staticmethod + def is_overridden(setting: str) -> bool: + assert setting == "TASKS" + return True + + monkeypatch.setattr(vqs_django, "settings", FakeSettings()) + monkeypatch.setattr(vqs_django, "global_settings", SimpleNamespace(TASKS={})) + monkeypatch.setattr( + vqs_django, + "task_backends", + SimpleNamespace(settings=dict(configured_backends)), + ) + + vqs_django._configure_default_task_backend() + + assert configured_backends == FakeSettings.TASKS + + +def test_enqueue_sends_private_envelope_and_stores_result() -> None: + result = add_one.enqueue(41) + + client = FakeSyncQueueClient.instances[0] + assert client.kwargs == {} + assert topic_name(client.sent[0]["topic"]) == "default" + assert isinstance(client.sent[0]["topic"].transport, vqs_django._TaskEnvelopeTransport) + assert client.sent[0]["payload"] == { + "version": 1, + "task": add_one.module_path, + "queue": "default", + "args": [41], + "kwargs": {}, + } + assert client.sent[0]["kwargs"] == {"delay": None} + assert result.id == "msg_1" + assert result.status == TaskResultStatus.READY + cache = FakeRuntimeCache.instances[0] + assert cache.kwargs == {"namespace": "django_Dresults", "strict": True} + assert cache.set_options[result.id] == {"name": result.id, "ttl": 120} + assert add_one.get_result(result.id).args == [41] + + +def test_enqueue_normalizes_transport_topic_but_preserves_logical_queue() -> None: + subject = configure_backend("normalized", ["emails.high"]) + task_obj = subject._task_from_module_path( + module_path=add_one.module_path, + queue_name="emails.high", + ) + + subject.enqueue(task_obj, [41], {}) + + sent = FakeSyncQueueClient.instances[0].sent[0] + assert topic_name(sent["topic"]) == "emails_Dhigh" + assert sent["payload"]["queue"] == "emails.high" + + +def test_enqueue_maps_run_after_to_delay() -> None: + add_one.using(run_after=datetime.now(UTC) + timedelta(seconds=90)).enqueue(1) + + assert FakeSyncQueueClient.instances[0].sent[0]["kwargs"]["delay"] in {89, 90} + + +def test_enqueue_reuses_sync_client_and_close_clears_both_clients() -> None: + add_one.enqueue(1) + add_one.enqueue(2) + subject = backend() + subject._async_client() + + assert len(FakeSyncQueueClient.instances) == 1 + assert [item["payload"]["args"] for item in FakeSyncQueueClient.instances[0].sent] == [ + [1], + [2], + ] + + subject.close() + + assert subject._sync_queue_client is None + assert subject._async_queue_client is None + + +@pytest.mark.asyncio +async def test_native_async_enqueue_has_sync_parity_and_reuses_client() -> None: + first = await add_one.aenqueue(1) + second = await add_one.aenqueue(2) + + assert first.id == second.id == "msg_async" + assert len(FakeAsyncQueueClient.instances) == 1 + assert [item["payload"]["args"] for item in FakeAsyncQueueClient.instances[0].sent] == [ + [1], + [2], + ] + assert set(sent_envelope(asynchronous=True)) == {"version", "task", "queue", "args", "kwargs"} + + +def test_private_result_record_round_trip() -> None: + subject = backend() + result = add_one.enqueue(1) + record = subject._serialize_result(result) + + restored = subject._deserialize_result(record) + + assert restored.id == result.id + assert restored.task.module_path == add_one.module_path + assert restored.status == TaskResultStatus.READY + assert restored.args == [1] + + +def test_get_result_raises_for_missing_or_malformed_results() -> None: + cache = FakeRuntimeCache.instances[0] + cache.values["bad-wrapper"] = {"record": {}} + cache.values["bad-record"] = vqs_django._wrap_result_record({"version": 1}) + + for result_id in ("missing", "bad-wrapper", "bad-record"): + with pytest.raises(TaskResultDoesNotExist): + backend().get_result(result_id) + + +def test_runtime_cache_namespace_and_ttl_are_fixed_set_options() -> None: + subject = configure_backend( + "custom-results", + ["default"], + options={"result_namespace": "my.results", "result_ttl_seconds": 45}, + ) + task_obj = subject._task_from_module_path(module_path=add_one.module_path, queue_name="default") + + result = subject.enqueue(task_obj, [1], {}) + + cache = FakeRuntimeCache.instances[-1] + assert cache.kwargs == {"namespace": "my_Dresults", "strict": True} + assert cache.set_options[result.id] == {"name": result.id, "ttl": 45} + + +def test_installer_registration_is_idempotent_and_normalizes_topics( + clean_state: list[FakeSubscription], +) -> None: + subject = configure_backend("normalized", ["emails.high"]) + del subject + + vqs_django.install_vercel_django_task_integration("normalized") + vqs_django.install_vercel_django_task_integration("normalized") + + assert [ + (topic_name(item.topic), item.consumer_group, item.max_attempts) for item in clean_state + ] == [("emails_Dhigh", "django-tasks", 3)] + assert set(vqs_django._registered_subscribers) == { + ("normalized", "emails_Dhigh", "django-tasks") + } + + +@pytest.mark.asyncio +async def test_deferred_execution_success_and_context( + clean_state: list[FakeSubscription], +) -> None: + result = add_one.enqueue(2) + payload = sent_envelope() + vqs_django.install_vercel_django_task_integration() + + await clean_state[0].callback(message(payload, message_id=result.id)) + + refreshed = add_one.get_result(result.id) + assert refreshed.status == TaskResultStatus.SUCCESSFUL + assert refreshed.return_value == 3 + assert refreshed.attempts == 1 + + context_result = attempt_number.enqueue() + await clean_state[0].callback(message(sent_envelope(), message_id=context_result.id)) + assert attempt_number.get_result(context_result.id).return_value == 1 + + +@pytest.mark.asyncio +async def test_execution_emits_lifecycle_signals( + clean_state: list[FakeSubscription], +) -> None: + seen: list[tuple[str, TaskResultStatus]] = [] + + def receiver(sender: object, task_result: Any, signal: Any, **kwargs: Any) -> None: + del sender, kwargs + if signal is task_enqueued: + name = "enqueued" + elif signal is task_started: + name = "started" + else: + name = "finished" + seen.append((name, task_result.status)) + + for signal in (task_enqueued, task_started, task_finished): + signal.connect(receiver, weak=False) + try: + result = add_one.enqueue(1) + vqs_django.install_vercel_django_task_integration() + await clean_state[0].callback(message(sent_envelope(), message_id=result.id)) + finally: + for signal in (task_enqueued, task_started, task_finished): + signal.disconnect(receiver) + + assert seen == [ + ("enqueued", TaskResultStatus.READY), + ("started", TaskResultStatus.RUNNING), + ("finished", TaskResultStatus.SUCCESSFUL), + ] + + +@pytest.mark.asyncio +async def test_retryable_and_terminal_failures( + clean_state: list[FakeSubscription], +) -> None: + result = fail_forever.enqueue() + payload = sent_envelope() + vqs_django.install_vercel_django_task_integration() + + with pytest.raises(RetryAfter) as first: + await clean_state[0].callback(message(payload, message_id=result.id)) + with pytest.raises(RetryAfter) as second: + await clean_state[0].callback(message(payload, message_id=result.id)) + await clean_state[0].callback(message(payload, message_id=result.id)) + + assert first.value.timeout_seconds == 5 + assert second.value.timeout_seconds == 10 + refreshed = fail_forever.get_result(result.id) + assert refreshed.status == TaskResultStatus.FAILED + assert refreshed.attempts == 3 + assert len(refreshed.errors) == 3 + + +@pytest.mark.asyncio +async def test_async_task_success_and_context( + clean_state: list[FakeSubscription], +) -> None: + result = async_add_one.enqueue(2) + vqs_django.install_vercel_django_task_integration() + await clean_state[0].callback(message(sent_envelope(), message_id=result.id)) + assert async_add_one.get_result(result.id).return_value == 3 + + context_result = async_attempt_number.enqueue() + await clean_state[0].callback(message(sent_envelope(), message_id=context_result.id)) + assert async_attempt_number.get_result(context_result.id).return_value == 1 + + +@pytest.mark.asyncio +async def test_async_task_retryable_and_terminal_failure( + clean_state: list[FakeSubscription], +) -> None: + result = async_fail_forever.enqueue() + payload = sent_envelope() + vqs_django.install_vercel_django_task_integration() + + with pytest.raises(RetryAfter): + await clean_state[0].callback(message(payload, message_id=result.id)) + with pytest.raises(RetryAfter): + await clean_state[0].callback(message(payload, message_id=result.id)) + await clean_state[0].callback(message(payload, message_id=result.id)) + + refreshed = async_fail_forever.get_result(result.id) + assert refreshed.status == TaskResultStatus.FAILED + assert len(refreshed.errors) == 3 + + +@pytest.mark.parametrize( + ("payload", "error"), + [ + (None, TypeError), + ({"version": 2, "task": "x", "queue": "q", "args": [], "kwargs": {}}, ValueError), + ({"version": 1, "task": "", "queue": "q", "args": [], "kwargs": {}}, TypeError), + ({"version": 1, "task": "x", "queue": "q", "args": {}, "kwargs": {}}, TypeError), + ], +) +def test_private_envelope_rejects_malformed_payloads( + payload: object, + error: type[Exception], +) -> None: + with pytest.raises(error): + vqs_django._parse_envelope(payload) diff --git a/integrations/vercel-django-tasks/vercel/integrations/django/__init__.py b/integrations/vercel-django-tasks/vercel/integrations/django/__init__.py new file mode 100644 index 00000000..d498a59e --- /dev/null +++ b/integrations/vercel-django-tasks/vercel/integrations/django/__init__.py @@ -0,0 +1,23 @@ +"""Django task integration for Vercel Queue Service.""" + +import sys + +if sys.version_info >= (3, 12): + pass +else: # pragma: no cover - dependency marker mirrors this gate. + raise RuntimeError( + "vercel.integrations.django requires Python 3.12 or newer because Django 6 " + "does not support earlier Python versions." + ) + +from ._backend import ( + VercelQueuesBackend, + install_vercel_django_task_integration, +) +from .version import __version__ + +__all__ = [ + "VercelQueuesBackend", + "__version__", + "install_vercel_django_task_integration", +] diff --git a/integrations/vercel-django-tasks/vercel/integrations/django/_backend.py b/integrations/vercel-django-tasks/vercel/integrations/django/_backend.py new file mode 100644 index 00000000..4dd527cd --- /dev/null +++ b/integrations/vercel-django-tasks/vercel/integrations/django/_backend.py @@ -0,0 +1,630 @@ +from __future__ import annotations + +from typing import Any, TypedDict, cast + +import copy +import math +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from traceback import format_exception + +import vercel.queue as vqs +import vercel.queue.sync as vqs_sync +from django.conf import global_settings, settings +from django.tasks import DEFAULT_TASK_BACKEND_ALIAS, task_backends +from django.tasks.backends.base import BaseTaskBackend +from django.tasks.base import ( + DEFAULT_TASK_PRIORITY, + Task, + TaskContext, + TaskError, + TaskResult, + TaskResultStatus, +) +from django.tasks.exceptions import TaskResultDoesNotExist +from django.tasks.signals import task_enqueued, task_finished, task_started +from django.utils import timezone as django_timezone +from django.utils.crypto import get_random_string +from django.utils.json import normalize_json +from django.utils.module_loading import import_string +from vercel.cache import RuntimeCache + +from .version import __version__ + +__all__ = [ + "VercelQueuesBackend", + "__version__", + "install_vercel_django_task_integration", +] + +_CONSUMER_GROUP = "django-tasks" +_MAX_ATTEMPTS = 3 +_RETRY_BACKOFF_BASE_SECONDS = 5 +_RETRY_BACKOFF_FACTOR = 2.0 +_MAX_RETRY_DELAY_SECONDS = 60 * 60 +_DEFAULT_RESULT_TTL_SECONDS = 24 * 60 * 60 +_DEFAULT_RESULT_NAMESPACE = "django-task-results" +_DEFAULT_TASK_BACKEND = { + "BACKEND": "vercel.integrations.django.VercelQueuesBackend", +} +_ENVELOPE_VERSION = 1 +_RESULT_WRAPPER_MARKER = "__vercel_django_task_result__" +_RESULT_WRAPPER_VERSION = 1 + + +class _TaskEnvelope(TypedDict): + version: int + task: str + queue: str + args: list[Any] + kwargs: dict[str, Any] + + +_StoredTaskRecord = dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class _BackendOptions: + result_namespace: str = _DEFAULT_RESULT_NAMESPACE + result_ttl_seconds: int = _DEFAULT_RESULT_TTL_SECONDS + + @classmethod + def parse(cls, options: object) -> _BackendOptions: + if not isinstance(options, dict): + raise TypeError("VercelQueuesBackend OPTIONS must be a dictionary") + + unknown = set(options) - {"result_namespace", "result_ttl_seconds"} + if unknown: + names = ", ".join(sorted(map(str, unknown))) + raise ValueError(f"Unknown VercelQueuesBackend option(s): {names}") + + namespace = options.get("result_namespace", _DEFAULT_RESULT_NAMESPACE) + if not isinstance(namespace, str) or not namespace: + raise ValueError("result_namespace must be a non-empty string") + + ttl = options.get("result_ttl_seconds", _DEFAULT_RESULT_TTL_SECONDS) + if not isinstance(ttl, int) or isinstance(ttl, bool) or ttl <= 0: + raise ValueError("result_ttl_seconds must be a positive integer") + + return cls(result_namespace=namespace, result_ttl_seconds=ttl) + + +class _TaskEnvelopeTransport(vqs.RawJsonTransport[_TaskEnvelope]): + def validate_payload(self, payload: Any) -> _TaskEnvelope: + return _parse_envelope(payload) + + +class _RuntimeCacheResults: + def __init__(self, *, namespace: str, ttl: int) -> None: + self.ttl = ttl + self._runtime_cache = RuntimeCache( + namespace=str(vqs.sanitize_name(namespace)), + strict=True, + ) + + def get(self, result_id: str) -> _StoredTaskRecord | None: + value = self._runtime_cache.get(result_id) + if value is None: + return None + return _unwrap_result_record(value) + + def set(self, result_id: str, record: _StoredTaskRecord) -> None: + self._runtime_cache.set( + result_id, + _wrap_result_record(record), + {"name": result_id, "ttl": self.ttl}, + ) + + +@dataclass(frozen=True, slots=True) +class _PreparedEnqueue: + envelope: _TaskEnvelope + delay: vqs.Duration | None + + +_registered_subscribers: dict[tuple[str, str, str], Any] = {} +_registration_lock = threading.RLock() + + +def _now() -> datetime: + return django_timezone.now() + + +def _json_normalize(value: Any) -> Any: + return normalize_json(value) + + +def _set_result_attr(result: TaskResult, name: str, value: Any) -> None: + object.__setattr__(result, name, value) # noqa: PLC2801 + + +def _exception_class_path(exc: BaseException) -> str: + exc_type = type(exc) + return f"{exc_type.__module__}.{exc_type.__qualname__}" + + +def _task_error(exc: BaseException) -> TaskError: + return TaskError( + exception_class_path=_exception_class_path(exc), + traceback="".join(format_exception(exc)), + ) + + +def _retry_delay_seconds(attempt: int) -> int: + delay = float(_RETRY_BACKOFF_BASE_SECONDS) * math.pow( + _RETRY_BACKOFF_FACTOR, + max(0, attempt - 1), + ) + if not math.isfinite(delay): + return _MAX_RETRY_DELAY_SECONDS + return int(max(0, min(float(_MAX_RETRY_DELAY_SECONDS), delay))) + + +def _parse_iso_datetime(value: object) -> datetime | None: + if value is None: + return None + if not isinstance(value, str) or not value: + raise TypeError("task result timestamp must be a string or null") + raw = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(raw) + except ValueError as exc: + raise ValueError("task result timestamp is invalid") from exc + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _wrap_result_record(record: _StoredTaskRecord) -> dict[str, object]: + return { + _RESULT_WRAPPER_MARKER: _RESULT_WRAPPER_VERSION, + "record": record, + } + + +def _unwrap_result_record(value: object) -> _StoredTaskRecord: + if not isinstance(value, dict): + raise TypeError("Runtime Cache result payload is not an object") + if value.get(_RESULT_WRAPPER_MARKER) != _RESULT_WRAPPER_VERSION: + raise ValueError("Runtime Cache result payload has an unknown version") + record = value.get("record") + if not isinstance(record, dict): + raise TypeError("Runtime Cache result record is not an object") + return cast("_StoredTaskRecord", record) + + +def _parse_envelope(payload: Any) -> _TaskEnvelope: + if not isinstance(payload, dict): + raise TypeError("Invalid task payload: expected object") + if payload.get("version") != _ENVELOPE_VERSION: + raise ValueError("Invalid task payload: unknown envelope version") + task_path = payload.get("task") + queue = payload.get("queue") + args = payload.get("args") + kwargs = payload.get("kwargs") + if not isinstance(task_path, str) or not task_path: + raise TypeError("Invalid task payload: task must be a non-empty string") + if not isinstance(queue, str) or not queue: + raise TypeError("Invalid task payload: queue must be a non-empty string") + if not isinstance(args, list) or not isinstance(kwargs, dict): + raise TypeError("Invalid task payload: args and kwargs are required") + return cast("_TaskEnvelope", payload) + + +class VercelQueuesBackend(BaseTaskBackend): + task_class: type[Task] = Task + supports_defer = True + supports_async_task = True + supports_get_result = True + supports_priority = False + + def __init__(self, alias: str, params: dict[str, Any]) -> None: + super().__init__(alias, params) + self._cfg = _BackendOptions.parse(self.options) + self._sync_queue_client: vqs_sync.QueueClient | None = None + self._async_queue_client: vqs.QueueClient | None = None + self._results = _RuntimeCacheResults( + namespace=self._cfg.result_namespace, + ttl=self._cfg.result_ttl_seconds, + ) + self.worker_id = get_random_string(32) + + def _topic(self, queue_name: str) -> vqs.Topic[_TaskEnvelope]: + return vqs.Topic[_TaskEnvelope]( + vqs.sanitize_name(queue_name), + transport=_TaskEnvelopeTransport(), + ) + + def _sync_client(self) -> vqs_sync.QueueClient: + if self._sync_queue_client is None: + self._sync_queue_client = vqs_sync.QueueClient() + return self._sync_queue_client + + def _async_client(self) -> vqs.QueueClient: + if self._async_queue_client is None: + self._async_queue_client = vqs.QueueClient() + return self._async_queue_client + + def close(self) -> None: + """Clear backend-owned queue clients.""" + self._sync_queue_client = None + self._async_queue_client = None + + def _task_from_module_path(self, *, module_path: str, queue_name: str) -> Task: + imported = import_string(module_path) + if isinstance(imported, Task): + func = imported.func + takes_context = imported.takes_context + else: + func = imported + takes_context = False + if not callable(func): + raise TypeError(f"Task function is not callable: {module_path!r}") + return self.task_class( + func=func, + priority=DEFAULT_TASK_PRIORITY, + queue_name=queue_name, + backend=self.alias, + takes_context=takes_context, + run_after=None, + ) + + def _prepare_enqueue( + self, + task: Task, + args: list[Any], + kwargs: dict[str, Any], + ) -> _PreparedEnqueue: + self.validate_task(task) + envelope: _TaskEnvelope = { + "version": _ENVELOPE_VERSION, + "task": task.module_path, + "queue": task.queue_name, + "args": cast("list[Any]", _json_normalize(list(args))), + "kwargs": cast("dict[str, Any]", _json_normalize(dict(kwargs))), + } + delay: vqs.Duration | None = None + if task.run_after is not None: + seconds = (task.run_after - _now()).total_seconds() + if seconds > 0: + delay = int(seconds) + return _PreparedEnqueue(envelope=envelope, delay=delay) + + def _finalize_enqueue( + self, + task: Task, + prepared: _PreparedEnqueue, + message_id: object, + ) -> TaskResult: + if message_id is None: + raise RuntimeError("Vercel Queue accepted the task without returning a message id") + result: TaskResult = TaskResult( + task=task, + id=str(message_id), + status=TaskResultStatus.READY, + enqueued_at=_now(), + started_at=None, + last_attempted_at=None, + finished_at=None, + args=prepared.envelope["args"], + kwargs=prepared.envelope["kwargs"], + backend=self.alias, + errors=[], + worker_ids=[], + ) + self._store_result(result) + task_enqueued.send(type(self), task_result=result) + return copy.deepcopy(result) + + def enqueue( + self, + task: Task, + args: list[Any], + kwargs: dict[str, Any], + ) -> TaskResult: + prepared = self._prepare_enqueue(task, args, kwargs) + message_id = self._sync_client().send( + self._topic(task.queue_name), + prepared.envelope, + delay=prepared.delay, + ) + return self._finalize_enqueue(task, prepared, message_id) + + async def aenqueue( + self, + task: Task, + args: list[Any], + kwargs: dict[str, Any], + ) -> TaskResult: + prepared = self._prepare_enqueue(task, args, kwargs) + message_id = await self._async_client().send( + self._topic(task.queue_name), + prepared.envelope, + delay=prepared.delay, + ) + return self._finalize_enqueue(task, prepared, message_id) + + def _serialize_result(self, result: TaskResult) -> _StoredTaskRecord: + def _datetime(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + record: _StoredTaskRecord = { + "version": 1, + "id": result.id, + "task": result.task.module_path, + "queue": result.task.queue_name, + "status": str(result.status), + "enqueued_at": _datetime(result.enqueued_at), + "started_at": _datetime(result.started_at), + "finished_at": _datetime(result.finished_at), + "last_attempted_at": _datetime(result.last_attempted_at), + "args": _json_normalize(list(result.args)), + "kwargs": _json_normalize(dict(result.kwargs)), + "worker_ids": list(result.worker_ids), + "errors": [ + { + "exception_class_path": error.exception_class_path, + "traceback": error.traceback, + } + for error in result.errors + ], + } + if result.status == TaskResultStatus.SUCCESSFUL: + record["return_value"] = _json_normalize(result.return_value) + return record + + def _deserialize_result(self, record: _StoredTaskRecord) -> TaskResult: + required = { + "version", + "id", + "task", + "queue", + "status", + "enqueued_at", + "started_at", + "finished_at", + "last_attempted_at", + "args", + "kwargs", + "worker_ids", + "errors", + } + if record.get("version") != 1 or not required.issubset(record): + raise ValueError("Runtime Cache task result record is malformed") + + result_id = record["id"] + module_path = record["task"] + queue_name = record["queue"] + args = record["args"] + kwargs = record["kwargs"] + worker_ids = record["worker_ids"] + errors = record["errors"] + if not all( + isinstance(value, str) and value for value in (result_id, module_path, queue_name) + ): + raise TypeError("Runtime Cache task result identity is malformed") + if not isinstance(args, list) or not isinstance(kwargs, dict): + raise TypeError("Runtime Cache task result arguments are malformed") + if not isinstance(worker_ids, list) or not all( + isinstance(item, str) for item in worker_ids + ): + raise TypeError("Runtime Cache task result worker IDs are malformed") + if not isinstance(errors, list) or not all(isinstance(item, dict) for item in errors): + raise TypeError("Runtime Cache task result errors are malformed") + + try: + status = TaskResultStatus(record["status"]) + except (TypeError, ValueError) as exc: + raise ValueError("Runtime Cache task result status is malformed") from exc + + task = self._task_from_module_path( + module_path=module_path, + queue_name=queue_name, + ) + result: TaskResult = TaskResult( + task=task, + id=result_id, + status=status, + enqueued_at=_parse_iso_datetime(record["enqueued_at"]), + started_at=_parse_iso_datetime(record["started_at"]), + finished_at=_parse_iso_datetime(record["finished_at"]), + last_attempted_at=_parse_iso_datetime(record["last_attempted_at"]), + args=args, + kwargs=kwargs, + backend=self.alias, + errors=[ + TaskError( + exception_class_path=str(error.get("exception_class_path") or ""), + traceback=str(error.get("traceback") or ""), + ) + for error in errors + ], + worker_ids=worker_ids, + ) + if "return_value" in record: + _set_result_attr(result, "_return_value", record["return_value"]) + return result + + def _store_result(self, result: TaskResult) -> None: + self._results.set(result.id, self._serialize_result(result)) + + def get_result(self, result_id: str) -> TaskResult: + try: + record = self._results.get(str(result_id)) + except (ImportError, TypeError, ValueError): + raise TaskResultDoesNotExist(result_id) from None + if record is None: + raise TaskResultDoesNotExist(result_id) + try: + return self._deserialize_result(record) + except (ImportError, TypeError, ValueError): + raise TaskResultDoesNotExist(result_id) from None + + async def aget_result(self, result_id: str) -> TaskResult: + return self.get_result(result_id) + + def _load_or_initialize_result( + self, + *, + message_id: str, + envelope: _TaskEnvelope, + task: Task, + ) -> TaskResult: + try: + result = self.get_result(message_id) + except TaskResultDoesNotExist: + result = TaskResult( + task=task, + id=message_id, + status=TaskResultStatus.READY, + enqueued_at=None, + started_at=None, + last_attempted_at=None, + finished_at=None, + args=envelope["args"], + kwargs=envelope["kwargs"], + backend=self.alias, + errors=[], + worker_ids=[], + ) + _set_result_attr(result, "task", task) + _set_result_attr(result, "args", envelope["args"]) + _set_result_attr(result, "kwargs", envelope["kwargs"]) + return result + + def _start_result(self, result: TaskResult) -> None: + now = _now() + _set_result_attr(result, "status", TaskResultStatus.RUNNING) + if result.started_at is None: + _set_result_attr(result, "started_at", now) + _set_result_attr(result, "last_attempted_at", now) + result.worker_ids.append(self.worker_id) + self._store_result(result) + task_started.send(sender=type(self), task_result=result) + + def _finish_result( + self, + result: TaskResult, + *, + return_value: Any = None, + error: BaseException | None = None, + ) -> int | None: + if error is None: + _set_result_attr(result, "_return_value", _json_normalize(return_value)) + _set_result_attr(result, "status", TaskResultStatus.SUCCESSFUL) + _set_result_attr(result, "finished_at", _now()) + self._store_result(result) + task_finished.send(sender=type(self), task_result=result) + return None + + result.errors.append(_task_error(error)) + attempt = len(result.worker_ids) + if attempt < _MAX_ATTEMPTS: + _set_result_attr(result, "status", TaskResultStatus.READY) + _set_result_attr(result, "finished_at", None) + self._store_result(result) + return _retry_delay_seconds(attempt) + + _set_result_attr(result, "status", TaskResultStatus.FAILED) + _set_result_attr(result, "finished_at", _now()) + self._store_result(result) + task_finished.send(sender=type(self), task_result=result) + return None + + async def _execute_message(self, message: vqs.Message[_TaskEnvelope]) -> int | None: + envelope = _parse_envelope(message.payload) + queue_name = envelope["queue"] + if self.queues and queue_name not in self.queues: + raise ValueError(f"Queue {queue_name!r} is not valid for backend {self.alias!r}") + task = self._task_from_module_path( + module_path=envelope["task"], + queue_name=queue_name, + ) + result = self._load_or_initialize_result( + message_id=message.metadata.message_id, + envelope=envelope, + task=task, + ) + self._start_result(result) + try: + if task.takes_context: + return_value = await task.acall( + TaskContext(task_result=result), + *result.args, + **result.kwargs, + ) + else: + return_value = await task.acall(*result.args, **result.kwargs) + except Exception as exc: # noqa: BLE001 + return self._finish_result(result, error=exc) + return self._finish_result(result, return_value=return_value) + + +def _resolve_backend(alias: str) -> VercelQueuesBackend: + backend = task_backends[alias] + if not isinstance(backend, VercelQueuesBackend): + raise TypeError( + f"Backend {alias!r} is {backend.__class__.__name__}, expected VercelQueuesBackend." + ) + return backend + + +def _register_task_queues(backend_alias: str) -> None: + backend = _resolve_backend(backend_alias) + with _registration_lock: + for queue_name in sorted(backend.queues): + topic = backend._topic(queue_name) # noqa: SLF001 + key = (backend.alias, str(topic.name), _CONSUMER_GROUP) + if key in _registered_subscribers: + continue + + async def callback( + message: vqs.Message[_TaskEnvelope], + *, + _backend: VercelQueuesBackend = backend, + ) -> None: + retry_after = await _backend._execute_message(message) # noqa: SLF001 + if retry_after is not None: + raise vqs.RetryAfter(retry_after) + + callback.__name__ = f"vercel_django_task_{backend.alias}_{topic.name}" + subscriber = vqs.subscribe( + topic=topic, + consumer_group=_CONSUMER_GROUP, + max_attempts=_MAX_ATTEMPTS, + )(callback) + _registered_subscribers[key] = subscriber + + +def install_vercel_django_task_integration( + backend_alias: str = "default", + *, + register_queues: bool = True, +) -> None: + """Install the default backend when needed and register its queue subscribers.""" + _configure_default_task_backend() + if register_queues: + _register_task_queues(backend_alias) + + +def _configure_default_task_backend() -> None: + global_settings.TASKS[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND) + if not settings.configured: + return + + configured_backends = settings.TASKS + if settings.is_overridden("TASKS") and configured_backends: + return + + configured_backends[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND) + task_backends.settings[DEFAULT_TASK_BACKEND_ALIAS] = dict(_DEFAULT_TASK_BACKEND) + + connections: Any = getattr(task_backends, "_connections") # noqa: B009 + if hasattr(connections, DEFAULT_TASK_BACKEND_ALIAS): + existing_backend = getattr(connections, DEFAULT_TASK_BACKEND_ALIAS) + if not isinstance(existing_backend, VercelQueuesBackend): + close = getattr(existing_backend, "close", None) + if close is not None: + close() + delattr(connections, DEFAULT_TASK_BACKEND_ALIAS) diff --git a/integrations/vercel-django-tasks/vercel/integrations/django/py.typed b/integrations/vercel-django-tasks/vercel/integrations/django/py.typed new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/integrations/vercel-django-tasks/vercel/integrations/django/py.typed @@ -0,0 +1 @@ + diff --git a/integrations/vercel-django-tasks/vercel/integrations/django/version.py b/integrations/vercel-django-tasks/vercel/integrations/django/version.py new file mode 100644 index 00000000..11b0728d --- /dev/null +++ b/integrations/vercel-django-tasks/vercel/integrations/django/version.py @@ -0,0 +1,3 @@ +"""Package version metadata.""" + +__version__ = "0.6.0" diff --git a/pyproject.toml b/pyproject.toml index dca8d7e2..a49fc7dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dev = [ "cryptography>=48.0.1", "uvloop<1", "mypy>=1.20.2,<2", + "django-stubs>=6.0.6,<7", "build<2", "twine<7", "ty==0.0.55", @@ -81,6 +82,7 @@ mypy_path = [ "src/vercel-workflow", "integrations/vercel-apscheduler", "integrations/vercel-celery", + "integrations/vercel-django-tasks", "integrations/vercel-dramatiq", "tests/stubs", ] diff --git a/scripts/bundle_release.py b/scripts/bundle_release.py index b86f22a2..a9c148c3 100644 --- a/scripts/bundle_release.py +++ b/scripts/bundle_release.py @@ -62,6 +62,7 @@ "vercel-cache", "vercel-celery", "vercel-connect", + "vercel-django-tasks", "vercel-dramatiq", "vercel-internal-core", "vercel-internal-telemetry", @@ -72,6 +73,7 @@ PEER_DEPENDENCIES = { "vercel-apscheduler": {"apscheduler"}, "vercel-celery": {"celery"}, + "vercel-django-tasks": {"django"}, "vercel-dramatiq": {"dramatiq"}, } EXTERNAL_DEPENDENCIES = { diff --git a/tests/unit/test_release_system.py b/tests/unit/test_release_system.py index 0b90ab8e..fbfc6ce6 100644 --- a/tests/unit/test_release_system.py +++ b/tests/unit/test_release_system.py @@ -1392,6 +1392,57 @@ def test_dramatiq_bundle_keeps_dramatiq_peer_dependency( ) +def test_django_tasks_bundle_keeps_django_peer_dependency( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(bundle_release, "shared_vendored_version", lambda: "0.7.0") + cache_version = tmp_path / "vercel-cache/version.py" + queue_version = tmp_path / "vercel-queue/version.py" + cache_version.parent.mkdir() + queue_version.parent.mkdir() + cache_version.write_text('__version__ = "0.7.0"\n', encoding="utf-8") + queue_version.write_text('__version__ = "0.7.0"\n', encoding="utf-8") + monkeypatch.setattr( + workspace, + "packages", + lambda: { + "vercel-cache": workspace.Package( + "vercel-cache", tmp_path / "vercel-cache", cache_version, () + ), + "vercel-queue": workspace.Package( + "vercel-queue", tmp_path / "vercel-queue", queue_version, () + ), + }, + ) + data = { + "project": { + "dependencies": [ + "Django>=6.0; python_version >= '3.12'", + "vercel-cache", + "vercel-queue>=0.6.0", + ] + } + } + + assert ( + bundle_release._derive_vendor_requirements( # noqa: SLF001 + "vercel-django-tasks", + data, + ) + == () + ) + assert bundle_release._external_dependencies( # noqa: SLF001 + "vercel-django-tasks", + data, + (), + ) == ( + "Django>=6.0; python_version >= '3.12'", + "vercel-cache-bundle>=0.7.0", + "vercel-queue-bundle>=0.7.0", + "vercel-internal-shared-vendored-deps>=0.7.0", + ) + + def test_apscheduler_bundle_keeps_apscheduler_peer_dependency( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/uv.lock b/uv.lock index c3fed53e..c68a9a3a 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,7 @@ members = [ "vercel-cache", "vercel-celery", "vercel-connect", + "vercel-django-tasks", "vercel-dramatiq", "vercel-headers", "vercel-internal-core", @@ -37,6 +38,7 @@ members = [ dev = [ { name = "build", specifier = "<2" }, { name = "cryptography", specifier = ">=48.0.1" }, + { name = "django-stubs", specifier = ">=6.0.6,<7" }, { name = "fastapi", specifier = ">=0.115.0,<1" }, { name = "ggt", marker = "python_full_version >= '3.11'", specifier = ">=1.5.2" }, { name = "hatchling", specifier = ">=1.27.0,<2" }, @@ -124,6 +126,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -582,6 +605,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "django" +version = "5.2.15" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/e3/31722f7284c9f43333daff9aee9184678e4487adcb5506af0db8cea09ce1/django-5.2.15.tar.gz", hash = "sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7", size = 10873669, upload-time = "2026-06-03T13:03:35.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/b5/38140b1643c00d5c46ce69c78e6980fd285aee223100319631bedee4f5e7/django-5.2.15-py3-none-any.whl", hash = "sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970", size = 8311957, upload-time = "2026-06-03T13:03:31.329Z" }, +] + +[[package]] +name = "django" +version = "6.0.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, +] + +[[package]] +name = "django-stubs" +version = "6.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-stubs-ext" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/de/1b8ccb0909970fb4a8b48426f67132164110304875abdb4e4912c55480f4/django_stubs-6.0.6.tar.gz", hash = "sha256:dfc01e052e33c7f8f0c30c3ff8eda0903ee29ac710d1e46d9effd773744a69b0", size = 281381, upload-time = "2026-06-23T08:22:54.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/b0/b73855fb9cf381fa1d754ea33c25f14f5b11c297f48dc3a4e0359dc3adc2/django_stubs-6.0.6-py3-none-any.whl", hash = "sha256:c488fea05a9eac40ddbdc69887f63a5c0922cb13df285291ee99c9bbc89bc4f1", size = 546491, upload-time = "2026-06-23T08:22:52.466Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "6.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/8b/dc3c37cf994836ee09bc07f6a0c0ea840b975449940bd7ff77cc97a732f3/django_stubs_ext-6.0.6.tar.gz", hash = "sha256:e6f09884e48d7c5b250a373dfa22aa3e83bb91b8babbd8d05187f6b92247f232", size = 6674, upload-time = "2026-06-23T08:21:38.355Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/e4/7d60a1bdb092807318af34a809dc6674b95cc78ec4d3aaa6027174e7201c/django_stubs_ext-6.0.6-py3-none-any.whl", hash = "sha256:5470c970f61a3ccf5aae7633feef1a097f944f417b955da200c9ac26ecd9134b", size = 10361, upload-time = "2026-06-23T08:21:37.228Z" }, +] + [[package]] name = "docutils" version = "0.22.4" @@ -2016,6 +2105,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -2315,6 +2413,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -2580,6 +2687,22 @@ requires-dist = [ { name = "vercel-oidc", extras = ["verify"], editable = "src/vercel-oidc" }, ] +[[package]] +name = "vercel-django-tasks" +source = { editable = "integrations/vercel-django-tasks" } +dependencies = [ + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "vercel-cache" }, + { name = "vercel-queue" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", marker = "python_full_version >= '3.12'", specifier = ">=6.0" }, + { name = "vercel-cache", editable = "src/vercel-cache" }, + { name = "vercel-queue", editable = "src/vercel-queue" }, +] + [[package]] name = "vercel-dramatiq" source = { editable = "integrations/vercel-dramatiq" } @@ -2846,11 +2969,11 @@ wheels = [ [[package]] name = "zipp" -version = "3.23.1" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] [[package]]