From 78c31f069456a185a2c5a8017e947e15519dbeb9 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Wed, 1 Jul 2026 07:25:05 -0700 Subject: [PATCH] Add `vercel-django-tasks` -- Vercel Queues backend for Django Tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Django 6 task backend backed by Vercel Queues. Configure `VercelQueuesBackend` in Django’s `TASKS` setting and declare normal `@task` functions; enqueue and deferred execution map onto Vercel Queue messages and delivery delays. The integration registers each configured Django task queue as a Vercel Queue push subscriber. Task execution updates Django task state, emits the standard task signals, and maps retryable failures onto queue redelivery with configurable exponential backoff. Task results are stored in Vercel Runtime Cache with configurable namespacing and expiry. The backend supports synchronous and asynchronous enqueueing and result lookup, and uses an explicit raw JSON topic transport so task payload validation does not require Pydantic. --- .../vercel-django-tasks/initial.feature.md | 2 + integrations/vercel-django-tasks/LICENSE | 21 + integrations/vercel-django-tasks/README.md | 44 ++ .../examples/chunks/README.md | 39 ++ .../examples/chunks/chunks/__init__.py | 1 + .../examples/chunks/chunks/apps.py | 8 + .../examples/chunks/chunks/tasks.py | 8 + .../examples/chunks/chunks/urls.py | 9 + .../examples/chunks/chunks/views.py | 42 ++ .../chunks/chunks_project/__init__.py | 1 + .../examples/chunks/chunks_project/asgi.py | 9 + .../chunks/chunks_project/settings.py | 12 + .../examples/chunks/chunks_project/urls.py | 7 + .../examples/chunks/chunks_project/wsgi.py | 9 + .../examples/chunks/manage.py | 16 + .../examples/chunks/pyproject.toml | 22 + .../vercel-django-tasks/pyproject.toml | 75 +++ .../vercel-django-tasks/tests/__init__.py | 1 + .../vercel-django-tasks/tests/conftest.py | 5 + .../vercel-django-tasks/tests/settings.py | 13 + .../tests/unit/__init__.py | 1 + .../tests/unit/test_django_examples.py | 172 +++++ .../tests/unit/test_django_tasks.py | 583 ++++++++++++++++ .../vercel/integrations/django/__init__.py | 23 + .../vercel/integrations/django/_backend.py | 630 ++++++++++++++++++ .../vercel/integrations/django/py.typed | 1 + .../vercel/integrations/django/version.py | 3 + pyproject.toml | 6 +- scripts/bundle_release.py | 2 + tests/unit/test_release_system.py | 51 ++ uv.lock | 163 ++++- 31 files changed, 1953 insertions(+), 26 deletions(-) create mode 100644 changes/vercel-django-tasks/initial.feature.md create mode 100644 integrations/vercel-django-tasks/LICENSE create mode 100644 integrations/vercel-django-tasks/README.md create mode 100644 integrations/vercel-django-tasks/examples/chunks/README.md create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks/__init__.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks/apps.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks/tasks.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks/urls.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks/views.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks_project/__init__.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks_project/asgi.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks_project/settings.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks_project/urls.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/chunks_project/wsgi.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/manage.py create mode 100644 integrations/vercel-django-tasks/examples/chunks/pyproject.toml create mode 100644 integrations/vercel-django-tasks/pyproject.toml create mode 100644 integrations/vercel-django-tasks/tests/__init__.py create mode 100644 integrations/vercel-django-tasks/tests/conftest.py create mode 100644 integrations/vercel-django-tasks/tests/settings.py create mode 100644 integrations/vercel-django-tasks/tests/unit/__init__.py create mode 100644 integrations/vercel-django-tasks/tests/unit/test_django_examples.py create mode 100644 integrations/vercel-django-tasks/tests/unit/test_django_tasks.py create mode 100644 integrations/vercel-django-tasks/vercel/integrations/django/__init__.py create mode 100644 integrations/vercel-django-tasks/vercel/integrations/django/_backend.py create mode 100644 integrations/vercel-django-tasks/vercel/integrations/django/py.typed create mode 100644 integrations/vercel-django-tasks/vercel/integrations/django/version.py 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..63a551ed --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/README.md @@ -0,0 +1,39 @@ +# 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 both the web +entrypoint and a queue subscriber: + +```toml +[tool.vercel] +entrypoint = "chunks_project.wsgi:application" + +[[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. The runtime activates the Django Tasks integration +for both the web and subscriber functions. No manual integration setup, 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..103362e9 --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/chunks_project/wsgi.py @@ -0,0 +1,9 @@ +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() 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..26f492ad --- /dev/null +++ b/integrations/vercel-django-tasks/examples/chunks/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "vercel-django-tasks-example-chunks" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [ + "Django>=6", + "vercel-cache", + "vercel-django-tasks", +] + +[tool.vercel] +entrypoint = "chunks_project.wsgi:application" + +# 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..5a06d267 --- /dev/null +++ b/integrations/vercel-django-tasks/tests/unit/test_django_examples.py @@ -0,0 +1,172 @@ +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 +import tomllib +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 = tomllib.loads((EXAMPLE_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert "Django>=6" in pyproject["project"]["dependencies"] + assert pyproject["tool"]["vercel"]["entrypoint"] == "chunks_project.wsgi:application" + assert pyproject["tool"]["vercel"]["subscribers"] == [{"entrypoint": "chunks_project.wsgi"}] + + wsgi = (EXAMPLE_ROOT / "chunks_project" / "wsgi.py").read_text(encoding="utf-8") + assert "install_vercel_django_task_integration" not in wsgi + + +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 ee38324f..5f2f4a9a 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", @@ -30,14 +31,14 @@ dev = [ "tomli>=2.0.0,<3; python_version < '3.11'", "vendoring>=1,<2", "lograil~=0.7.0", - "ggt>=1.5.2; python_version >= '3.11'", + "ggt>=1.5.5; python_version >= '3.11'", ] [tool.uv] default-groups = ["dev"] package = false exclude-newer = "2 days" -exclude-newer-package = { ggt = "2026-08-10T00:00:00Z", lograil = "2026-08-10T21:20:07.442Z" } +exclude-newer-package = { ggt = "2026-08-27T00:00:00Z", lograil = "2026-08-10T21:20:07.442Z" } [tool.uv.workspace] members = ["src/*", "integrations/*"] @@ -82,6 +83,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 173e722c..3e0dc781 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P2D" [options.exclude-newer-package] -ggt = "2026-08-10T00:00:00Z" +ggt = "2026-08-27T00:00:00Z" lograil = "2026-08-10T21:20:07.442Z" [manifest] @@ -23,6 +23,7 @@ members = [ "vercel-cache", "vercel-celery", "vercel-connect", + "vercel-django-tasks", "vercel-dramatiq", "vercel-headers", "vercel-internal-core", @@ -37,8 +38,9 @@ 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 = "ggt", marker = "python_full_version >= '3.11'", specifier = ">=1.5.5" }, { name = "hatchling", specifier = ">=1.27.0,<2" }, { name = "hypothesis", specifier = ">=6.0.0,<7" }, { name = "lograil", specifier = "~=0.7.0" }, @@ -124,6 +126,18 @@ 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 = "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 +596,73 @@ 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.11.*'", + "python_full_version < '3.11'", +] +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" @@ -605,7 +686,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -648,14 +729,14 @@ wheels = [ [[package]] name = "ggt" -version = "1.5.2" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/fe/85fc86ad1900ba592c69d3a9135c0e105110df4ede4274169f9e8e401d17/ggt-1.5.2.tar.gz", hash = "sha256:68b66d46009192c556fc247407f4298523a246e7712b36ed9c6405ea68100822", size = 197150, upload-time = "2026-08-09T02:08:11.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/ca/4fe1070242edd6f1981ca53ebe6c0175f32eaa72e046462c0cc84ce1cfb4/ggt-1.5.5.tar.gz", hash = "sha256:20a671444ee1b26aadb623ab951ac1ea4f17023b333dff7cbb4feed75227a903", size = 199433, upload-time = "2026-08-26T16:46:34.585Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/41/a5bb6632bb6b77d8c551e18353e3c7609d98ef5c7f6cc30a88f924c38be8/ggt-1.5.2-py3-none-any.whl", hash = "sha256:08fbe1652abcb264cbf4d33a9bed74f9bd192511e04d8a920196c3de47317712", size = 123191, upload-time = "2026-08-09T02:08:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2f/15d2f9bea4df60080ff19bf7ae7c16d4e82c5cbe8338f46d829a260e5632/ggt-1.5.5-py3-none-any.whl", hash = "sha256:0b0d9beec4290f382ac210c2479bb6782c66082d40ff23d17582e9e1f57f7ddc", size = 124216, upload-time = "2026-08-26T16:46:33.353Z" }, ] [[package]] @@ -786,7 +867,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2016,6 +2097,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 +2405,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" @@ -2441,12 +2540,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "click" }, - { name = "jsonschema" }, - { name = "packaging" }, - { name = "requests" }, - { name = "rich" }, - { name = "toml" }, + { name = "click", marker = "python_full_version < '3.11'" }, + { name = "jsonschema", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "rich", marker = "python_full_version < '3.11'" }, + { name = "toml", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/bd/b92bbd4a5e6fb52c05af4fdef86726e05c38e5a36313716cf37e38183c65/vendoring-1.2.0.tar.gz", hash = "sha256:6340a84bf542222c96f22ebc3cb87e4d86932dc04bc8d446e38285594702c00e", size = 21475, upload-time = "2021-10-14T06:18:33.641Z" } wheels = [ @@ -2463,13 +2562,13 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "click" }, - { name = "jsonschema" }, - { name = "packaging" }, - { name = "pip" }, - { name = "requests" }, - { name = "rich" }, - { name = "tomli" }, + { name = "click", marker = "python_full_version >= '3.11'" }, + { name = "jsonschema", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pip", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "rich", marker = "python_full_version >= '3.11'" }, + { name = "tomli", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/a6/2ad9ec99117eccf78ba6e69e7c904a3dc08ca2fba50e8869a3f02c1bc518/vendoring-1.4.0.tar.gz", hash = "sha256:48113adfb29db5e5051da823c083219567b9b3405859682cc04400a7743279d9", size = 24761, upload-time = "2026-05-19T14:39:50.773Z" } wheels = [ @@ -2580,6 +2679,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" } @@ -2848,11 +2963,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]]