django-qstash 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-qstash might be problematic. Click here for more details.
- django_qstash-0.0.1/PKG-INFO +95 -0
- django_qstash-0.0.1/README.md +66 -0
- django_qstash-0.0.1/pyproject.toml +42 -0
- django_qstash-0.0.1/setup.cfg +4 -0
- django_qstash-0.0.1/src/django_qstash/__init__.py +5 -0
- django_qstash-0.0.1/src/django_qstash/tasks.py +151 -0
- django_qstash-0.0.1/src/django_qstash/utils.py +37 -0
- django_qstash-0.0.1/src/django_qstash/views.py +124 -0
- django_qstash-0.0.1/src/django_qstash.egg-info/PKG-INFO +95 -0
- django_qstash-0.0.1/src/django_qstash.egg-info/SOURCES.txt +14 -0
- django_qstash-0.0.1/src/django_qstash.egg-info/dependency_links.txt +1 -0
- django_qstash-0.0.1/src/django_qstash.egg-info/requires.txt +3 -0
- django_qstash-0.0.1/src/django_qstash.egg-info/top_level.txt +1 -0
- django_qstash-0.0.1/tests/test_tasks.py +53 -0
- django_qstash-0.0.1/tests/test_utils.py +49 -0
- django_qstash-0.0.1/tests/test_views.py +75 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-qstash
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A drop-in replacement for Celery's shared_task with Upstash QStash.
|
|
5
|
+
Author-email: Justin Mitchel <justin@codingforentrepreneurs.com>
|
|
6
|
+
Project-URL: Changelog, https://github.com/jmitchel3/django-qstash
|
|
7
|
+
Project-URL: Documentation, https://github.com/jmitchel3/django-qstash
|
|
8
|
+
Project-URL: Funding, https://github.com/jmitchel3/django-qstash
|
|
9
|
+
Project-URL: Repository, https://github.com/jmitchel3/django-qstash
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Django :: 4.2
|
|
12
|
+
Classifier: Framework :: Django :: 5.0
|
|
13
|
+
Classifier: Framework :: Django :: 5.1
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: django>=4.2
|
|
27
|
+
Requires-Dist: qstash>=2
|
|
28
|
+
Requires-Dist: requests>=2.30
|
|
29
|
+
|
|
30
|
+
# Django QStash `pip install django-qstash`
|
|
31
|
+
|
|
32
|
+
A drop-in replacement for Celery's shared_task leveraging Upstash QStash for a truly serverless Django application to run background tasks asynchronously from the request/response cycle.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install django-qstash
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Depends on:
|
|
41
|
+
|
|
42
|
+
- [Python 3.10+](https://www.python.org/)
|
|
43
|
+
- [Django 5+](https://docs.djangoproject.com/)
|
|
44
|
+
- [qstash-py](https://github.com/upstash/qstash-py)
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# from celery import shared_task
|
|
50
|
+
from django_qstash import shared_task
|
|
51
|
+
|
|
52
|
+
@shared_task
|
|
53
|
+
def math_add_task(a, b, save_to_file=False):
|
|
54
|
+
logger.info(f"Adding {a} and {b}")
|
|
55
|
+
if save_to_file:
|
|
56
|
+
with open("math-add-result.txt", "w") as f:
|
|
57
|
+
f.write(f"{a} + {b} = {a + b}")
|
|
58
|
+
return a + b
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
math_add_task.apply_async(args=(12, 454), save_to_file=True)
|
|
63
|
+
|
|
64
|
+
# or
|
|
65
|
+
|
|
66
|
+
math_add_task.delay(12, 454, save_to_file=True)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
### Environment variables
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
QSTASH_TOKEN="your_token"
|
|
77
|
+
QSTASH_CURRENT_SIGNING_KEY="your_current_signing_key"
|
|
78
|
+
QSTASH_NEXT_SIGNING_KEY="your_next_signing_key"
|
|
79
|
+
|
|
80
|
+
# required for django-qstash
|
|
81
|
+
DJANGO_QSTASH_DOMAIN="https://example.com"
|
|
82
|
+
DJANGO_QSTASH_WEBHOOK_PATH="/qstash/webhook/"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.net`
|
|
88
|
+
|
|
89
|
+
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
`DJANGO_QSTASH_WEBHOOK_PATH`: The path where QStash will send webhooks to your Django application. Defaults to `/qstash/webhook/`
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
`DJANGO_QSTASH_FORCE_HTTPS`: Whether to force HTTPS for the webhook. Defaults to `True`.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Django QStash `pip install django-qstash`
|
|
2
|
+
|
|
3
|
+
A drop-in replacement for Celery's shared_task leveraging Upstash QStash for a truly serverless Django application to run background tasks asynchronously from the request/response cycle.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install django-qstash
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Depends on:
|
|
12
|
+
|
|
13
|
+
- [Python 3.10+](https://www.python.org/)
|
|
14
|
+
- [Django 5+](https://docs.djangoproject.com/)
|
|
15
|
+
- [qstash-py](https://github.com/upstash/qstash-py)
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
# from celery import shared_task
|
|
21
|
+
from django_qstash import shared_task
|
|
22
|
+
|
|
23
|
+
@shared_task
|
|
24
|
+
def math_add_task(a, b, save_to_file=False):
|
|
25
|
+
logger.info(f"Adding {a} and {b}")
|
|
26
|
+
if save_to_file:
|
|
27
|
+
with open("math-add-result.txt", "w") as f:
|
|
28
|
+
f.write(f"{a} + {b} = {a + b}")
|
|
29
|
+
return a + b
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
math_add_task.apply_async(args=(12, 454), save_to_file=True)
|
|
34
|
+
|
|
35
|
+
# or
|
|
36
|
+
|
|
37
|
+
math_add_task.delay(12, 454, save_to_file=True)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
### Environment variables
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
QSTASH_TOKEN="your_token"
|
|
48
|
+
QSTASH_CURRENT_SIGNING_KEY="your_current_signing_key"
|
|
49
|
+
QSTASH_NEXT_SIGNING_KEY="your_next_signing_key"
|
|
50
|
+
|
|
51
|
+
# required for django-qstash
|
|
52
|
+
DJANGO_QSTASH_DOMAIN="https://example.com"
|
|
53
|
+
DJANGO_QSTASH_WEBHOOK_PATH="/qstash/webhook/"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.net`
|
|
59
|
+
|
|
60
|
+
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
`DJANGO_QSTASH_WEBHOOK_PATH`: The path where QStash will send webhooks to your Django application. Defaults to `/qstash/webhook/`
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
`DJANGO_QSTASH_FORCE_HTTPS`: Whether to force HTTPS for the webhook. Defaults to `True`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "setuptools.build_meta"
|
|
3
|
+
requires = [
|
|
4
|
+
"setuptools",
|
|
5
|
+
]
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "django-qstash"
|
|
9
|
+
version = "0.0.1"
|
|
10
|
+
description = "A drop-in replacement for Celery's shared_task with Upstash QStash."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = { file = "LICENSE" }
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Justin Mitchel", email = "justin@codingforentrepreneurs.com" },
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.10"
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Framework :: Django :: 4.2",
|
|
20
|
+
"Framework :: Django :: 5.0",
|
|
21
|
+
"Framework :: Django :: 5.1",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Natural Language :: English",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
dependencies = [
|
|
35
|
+
"django>=4.2",
|
|
36
|
+
"qstash>=2",
|
|
37
|
+
"requests>=2.30",
|
|
38
|
+
]
|
|
39
|
+
urls.Changelog = "https://github.com/jmitchel3/django-qstash"
|
|
40
|
+
urls.Documentation = "https://github.com/jmitchel3/django-qstash"
|
|
41
|
+
urls.Funding = "https://github.com/jmitchel3/django-qstash"
|
|
42
|
+
urls.Repository = "https://github.com/jmitchel3/django-qstash"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
6
|
+
from qstash import QStash
|
|
7
|
+
|
|
8
|
+
QSTASH_TOKEN = getattr(settings, "QSTASH_TOKEN", None)
|
|
9
|
+
DJANGO_QSTASH_DOMAIN = getattr(settings, "DJANGO_QSTASH_DOMAIN", None)
|
|
10
|
+
DJANGO_QSTASH_WEBHOOK_PATH = getattr(
|
|
11
|
+
settings, "DJANGO_QSTASH_WEBHOOK_PATH", "/qstash/webhook/"
|
|
12
|
+
)
|
|
13
|
+
if not QSTASH_TOKEN or not DJANGO_QSTASH_DOMAIN:
|
|
14
|
+
raise ImproperlyConfigured("QSTASH_TOKEN and DJANGO_QSTASH_DOMAIN must be set")
|
|
15
|
+
|
|
16
|
+
# Initialize QStash client once
|
|
17
|
+
qstash_client = QStash(QSTASH_TOKEN)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QStashTask:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
func: Optional[Callable] = None,
|
|
24
|
+
name: Optional[str] = None,
|
|
25
|
+
delay_seconds: Optional[int] = None,
|
|
26
|
+
deduplicated: bool = False,
|
|
27
|
+
**options: Dict[str, Any],
|
|
28
|
+
):
|
|
29
|
+
self.func = func
|
|
30
|
+
self.name = name or (func.__name__ if func else None)
|
|
31
|
+
self.delay_seconds = delay_seconds
|
|
32
|
+
self.deduplicated = deduplicated
|
|
33
|
+
self.options = options
|
|
34
|
+
self.callback_domain = DJANGO_QSTASH_DOMAIN.rstrip("/")
|
|
35
|
+
self.webhook_path = DJANGO_QSTASH_WEBHOOK_PATH.strip("/")
|
|
36
|
+
|
|
37
|
+
if func is not None:
|
|
38
|
+
functools.update_wrapper(self, func)
|
|
39
|
+
|
|
40
|
+
def __get__(self, obj, objtype):
|
|
41
|
+
"""Support for instance methods"""
|
|
42
|
+
return functools.partial(self.__call__, obj)
|
|
43
|
+
|
|
44
|
+
def __call__(self, *args, **kwargs):
|
|
45
|
+
"""
|
|
46
|
+
Execute the task, either directly or via QStash based on context
|
|
47
|
+
"""
|
|
48
|
+
# Handle the case when the decorator is used without parameters
|
|
49
|
+
if self.func is None:
|
|
50
|
+
return self.__class__(
|
|
51
|
+
args[0],
|
|
52
|
+
name=self.name,
|
|
53
|
+
delay_seconds=self.delay_seconds,
|
|
54
|
+
deduplicated=self.deduplicated,
|
|
55
|
+
**self.options,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# If called directly (not through delay/apply_async), execute the function
|
|
59
|
+
if not getattr(self, "_is_delayed", False):
|
|
60
|
+
return self.func(*args, **kwargs)
|
|
61
|
+
|
|
62
|
+
# Reset the delayed flag
|
|
63
|
+
self._is_delayed = False
|
|
64
|
+
|
|
65
|
+
# Prepare the payload
|
|
66
|
+
payload = {
|
|
67
|
+
"function": self.func.__name__,
|
|
68
|
+
"module": self.func.__module__,
|
|
69
|
+
"args": args, # Send args as-is
|
|
70
|
+
"kwargs": kwargs,
|
|
71
|
+
"task_name": self.name,
|
|
72
|
+
"options": self.options,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Ensure callback URL is properly formatted
|
|
76
|
+
callback_domain = self.callback_domain
|
|
77
|
+
if not callback_domain.startswith(("http://", "https://")):
|
|
78
|
+
callback_domain = f"https://{callback_domain}"
|
|
79
|
+
|
|
80
|
+
url = f"{callback_domain}/{self.webhook_path}/"
|
|
81
|
+
# Send to QStash using the official SDK
|
|
82
|
+
response = qstash_client.message.publish_json(
|
|
83
|
+
url=url,
|
|
84
|
+
body=payload,
|
|
85
|
+
delay=f"{self.delay_seconds}s" if self.delay_seconds else None,
|
|
86
|
+
retries=self.options.get("max_retries", 3),
|
|
87
|
+
content_based_deduplication=self.deduplicated,
|
|
88
|
+
)
|
|
89
|
+
# Return an AsyncResult-like object for Celery compatibility
|
|
90
|
+
return AsyncResult(response.message_id)
|
|
91
|
+
|
|
92
|
+
def delay(self, *args, **kwargs) -> "AsyncResult":
|
|
93
|
+
"""Celery-compatible delay() method"""
|
|
94
|
+
self._is_delayed = True
|
|
95
|
+
return self(*args, **kwargs)
|
|
96
|
+
|
|
97
|
+
def apply_async(
|
|
98
|
+
self,
|
|
99
|
+
args: Optional[Tuple] = None,
|
|
100
|
+
kwargs: Optional[Dict] = None,
|
|
101
|
+
countdown: Optional[int] = None,
|
|
102
|
+
**options: Dict[str, Any],
|
|
103
|
+
) -> "AsyncResult":
|
|
104
|
+
"""Celery-compatible apply_async() method"""
|
|
105
|
+
self._is_delayed = True
|
|
106
|
+
if countdown is not None:
|
|
107
|
+
self.delay_seconds = countdown
|
|
108
|
+
self.options.update(options)
|
|
109
|
+
|
|
110
|
+
# Fix: Ensure we're passing the arguments correctly
|
|
111
|
+
args = args or ()
|
|
112
|
+
kwargs = kwargs or {}
|
|
113
|
+
return self(*args, **kwargs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AsyncResult:
|
|
117
|
+
"""Minimal Celery AsyncResult-compatible class"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, task_id: str):
|
|
120
|
+
self.task_id = task_id
|
|
121
|
+
|
|
122
|
+
def get(self, timeout: Optional[int] = None) -> Any:
|
|
123
|
+
"""Simulate Celery's get() method"""
|
|
124
|
+
raise NotImplementedError("QStash doesn't support result retrieval")
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def id(self) -> str:
|
|
128
|
+
return self.task_id
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def shared_task(
|
|
132
|
+
func: Optional[Callable] = None,
|
|
133
|
+
name: Optional[str] = None,
|
|
134
|
+
deduplicated: bool = False,
|
|
135
|
+
**options: Dict[str, Any],
|
|
136
|
+
) -> QStashTask:
|
|
137
|
+
"""
|
|
138
|
+
Decorator that mimics Celery's shared_task
|
|
139
|
+
|
|
140
|
+
Can be used as:
|
|
141
|
+
@shared_task
|
|
142
|
+
def my_task():
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
@shared_task(name="custom_name", deduplicated=True)
|
|
146
|
+
def my_task():
|
|
147
|
+
pass
|
|
148
|
+
"""
|
|
149
|
+
if func is not None:
|
|
150
|
+
return QStashTask(func, name=name, deduplicated=deduplicated, **options)
|
|
151
|
+
return lambda f: QStashTask(f, name=name, deduplicated=deduplicated, **options)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Tuple
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def import_string(import_path: str) -> Any:
|
|
9
|
+
"""
|
|
10
|
+
Import a module path and return the attribute/class designated by the last name.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
import_string('myapp.tasks.mytask') -> mytask function
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
module_path, class_name = import_path.rsplit(".", 1)
|
|
17
|
+
module = importlib.import_module(module_path)
|
|
18
|
+
return getattr(module, class_name)
|
|
19
|
+
except (ImportError, AttributeError) as e:
|
|
20
|
+
raise ImportError(f"Could not import '{import_path}': {e}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_task_payload(payload: dict) -> Tuple[bool, str]:
|
|
24
|
+
"""Validate the task payload has all required fields"""
|
|
25
|
+
required_fields = {"function", "module", "args", "kwargs"}
|
|
26
|
+
missing_fields = required_fields - set(payload.keys())
|
|
27
|
+
|
|
28
|
+
if missing_fields:
|
|
29
|
+
return False, f"Missing required fields: {', '.join(missing_fields)}"
|
|
30
|
+
|
|
31
|
+
if not isinstance(payload["args"], (list, tuple)):
|
|
32
|
+
return False, "Args must be a list or tuple"
|
|
33
|
+
|
|
34
|
+
if not isinstance(payload["kwargs"], dict):
|
|
35
|
+
return False, "Kwargs must be a dictionary"
|
|
36
|
+
|
|
37
|
+
return True, ""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.http import (
|
|
6
|
+
HttpRequest,
|
|
7
|
+
HttpResponse,
|
|
8
|
+
HttpResponseBadRequest,
|
|
9
|
+
HttpResponseForbidden,
|
|
10
|
+
)
|
|
11
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
12
|
+
from django.views.decorators.http import require_http_methods
|
|
13
|
+
from qstash import Receiver
|
|
14
|
+
|
|
15
|
+
from . import utils
|
|
16
|
+
|
|
17
|
+
DJANGO_QSTASH_FORCE_HTTPS = getattr(settings, "DJANGO_QSTASH_FORCE_HTTPS", True)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Initialize the QStash receiver
|
|
23
|
+
receiver = Receiver(
|
|
24
|
+
current_signing_key=settings.QSTASH_CURRENT_SIGNING_KEY,
|
|
25
|
+
next_signing_key=settings.QSTASH_NEXT_SIGNING_KEY,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@csrf_exempt
|
|
30
|
+
@require_http_methods(["POST"])
|
|
31
|
+
def qstash_webhook_view(request: HttpRequest) -> HttpResponse:
|
|
32
|
+
"""
|
|
33
|
+
Webhook handler for QStash callbacks.
|
|
34
|
+
|
|
35
|
+
Expects a POST request with:
|
|
36
|
+
- Upstash-Signature header for verification
|
|
37
|
+
- JSON body containing task information:
|
|
38
|
+
{
|
|
39
|
+
"function": "full.path.to.function",
|
|
40
|
+
"module": "module.path",
|
|
41
|
+
"args": [...],
|
|
42
|
+
"kwargs": {...},
|
|
43
|
+
"task_name": "optional_task_name",
|
|
44
|
+
"options": {...}
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
# Get the signature from headers
|
|
49
|
+
signature = request.headers.get("Upstash-Signature")
|
|
50
|
+
if not signature:
|
|
51
|
+
return HttpResponseForbidden("Missing Upstash-Signature header")
|
|
52
|
+
|
|
53
|
+
# Verify the signature using the QStash SDK
|
|
54
|
+
url = request.build_absolute_uri()
|
|
55
|
+
if DJANGO_QSTASH_FORCE_HTTPS and not url.startswith("https://"):
|
|
56
|
+
url = url.replace("http://", "https://")
|
|
57
|
+
try:
|
|
58
|
+
receiver.verify(
|
|
59
|
+
body=request.body.decode(),
|
|
60
|
+
signature=signature,
|
|
61
|
+
url=url,
|
|
62
|
+
)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Signature verification failed: {e}")
|
|
65
|
+
return HttpResponseForbidden("Invalid signature")
|
|
66
|
+
|
|
67
|
+
# Parse the payload
|
|
68
|
+
try:
|
|
69
|
+
payload = json.loads(request.body.decode())
|
|
70
|
+
except json.JSONDecodeError as e:
|
|
71
|
+
logger.error(f"Failed to parse JSON payload: {e}")
|
|
72
|
+
return HttpResponseBadRequest("Invalid JSON payload")
|
|
73
|
+
|
|
74
|
+
# Validate payload structure
|
|
75
|
+
is_valid, error_message = utils.validate_task_payload(payload)
|
|
76
|
+
if not is_valid:
|
|
77
|
+
logger.error(f"Invalid payload structure: {error_message}")
|
|
78
|
+
return HttpResponseBadRequest(error_message)
|
|
79
|
+
|
|
80
|
+
# Import the function
|
|
81
|
+
try:
|
|
82
|
+
function_path = f"{payload['module']}.{payload['function']}"
|
|
83
|
+
task_func = utils.import_string(function_path)
|
|
84
|
+
except ImportError as e:
|
|
85
|
+
logger.error(f"Failed to import task function: {e}")
|
|
86
|
+
return HttpResponseBadRequest(f"Could not import task function: {e}")
|
|
87
|
+
|
|
88
|
+
# Execute the task
|
|
89
|
+
try:
|
|
90
|
+
if hasattr(task_func, "__call__") and hasattr(task_func, "actual_func"):
|
|
91
|
+
# If it's a wrapped function, call the actual function directly
|
|
92
|
+
result = task_func.actual_func(*payload["args"], **payload["kwargs"])
|
|
93
|
+
else:
|
|
94
|
+
result = task_func(*payload["args"], **payload["kwargs"])
|
|
95
|
+
|
|
96
|
+
# Prepare the response
|
|
97
|
+
response_data = {
|
|
98
|
+
"status": "success",
|
|
99
|
+
"task_name": payload.get("task_name", function_path),
|
|
100
|
+
"result": result if result is not None else "null",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return HttpResponse(
|
|
104
|
+
json.dumps(response_data), content_type="application/json"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.exception(f"Task execution failed: {e}")
|
|
109
|
+
error_response = {
|
|
110
|
+
"status": "error",
|
|
111
|
+
"task_name": payload.get("task_name", function_path),
|
|
112
|
+
"error": str(e),
|
|
113
|
+
}
|
|
114
|
+
return HttpResponse(
|
|
115
|
+
json.dumps(error_response), status=500, content_type="application/json"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.exception(f"Unexpected error in webhook handler: {e}")
|
|
120
|
+
return HttpResponse(
|
|
121
|
+
json.dumps({"status": "error", "error": "Internal server error"}),
|
|
122
|
+
status=500,
|
|
123
|
+
content_type="application/json",
|
|
124
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-qstash
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A drop-in replacement for Celery's shared_task with Upstash QStash.
|
|
5
|
+
Author-email: Justin Mitchel <justin@codingforentrepreneurs.com>
|
|
6
|
+
Project-URL: Changelog, https://github.com/jmitchel3/django-qstash
|
|
7
|
+
Project-URL: Documentation, https://github.com/jmitchel3/django-qstash
|
|
8
|
+
Project-URL: Funding, https://github.com/jmitchel3/django-qstash
|
|
9
|
+
Project-URL: Repository, https://github.com/jmitchel3/django-qstash
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Django :: 4.2
|
|
12
|
+
Classifier: Framework :: Django :: 5.0
|
|
13
|
+
Classifier: Framework :: Django :: 5.1
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: django>=4.2
|
|
27
|
+
Requires-Dist: qstash>=2
|
|
28
|
+
Requires-Dist: requests>=2.30
|
|
29
|
+
|
|
30
|
+
# Django QStash `pip install django-qstash`
|
|
31
|
+
|
|
32
|
+
A drop-in replacement for Celery's shared_task leveraging Upstash QStash for a truly serverless Django application to run background tasks asynchronously from the request/response cycle.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install django-qstash
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Depends on:
|
|
41
|
+
|
|
42
|
+
- [Python 3.10+](https://www.python.org/)
|
|
43
|
+
- [Django 5+](https://docs.djangoproject.com/)
|
|
44
|
+
- [qstash-py](https://github.com/upstash/qstash-py)
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# from celery import shared_task
|
|
50
|
+
from django_qstash import shared_task
|
|
51
|
+
|
|
52
|
+
@shared_task
|
|
53
|
+
def math_add_task(a, b, save_to_file=False):
|
|
54
|
+
logger.info(f"Adding {a} and {b}")
|
|
55
|
+
if save_to_file:
|
|
56
|
+
with open("math-add-result.txt", "w") as f:
|
|
57
|
+
f.write(f"{a} + {b} = {a + b}")
|
|
58
|
+
return a + b
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
math_add_task.apply_async(args=(12, 454), save_to_file=True)
|
|
63
|
+
|
|
64
|
+
# or
|
|
65
|
+
|
|
66
|
+
math_add_task.delay(12, 454, save_to_file=True)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
### Environment variables
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
QSTASH_TOKEN="your_token"
|
|
77
|
+
QSTASH_CURRENT_SIGNING_KEY="your_current_signing_key"
|
|
78
|
+
QSTASH_NEXT_SIGNING_KEY="your_next_signing_key"
|
|
79
|
+
|
|
80
|
+
# required for django-qstash
|
|
81
|
+
DJANGO_QSTASH_DOMAIN="https://example.com"
|
|
82
|
+
DJANGO_QSTASH_WEBHOOK_PATH="/qstash/webhook/"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.net`
|
|
88
|
+
|
|
89
|
+
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
`DJANGO_QSTASH_WEBHOOK_PATH`: The path where QStash will send webhooks to your Django application. Defaults to `/qstash/webhook/`
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
`DJANGO_QSTASH_FORCE_HTTPS`: Whether to force HTTPS for the webhook. Defaults to `True`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/django_qstash/__init__.py
|
|
4
|
+
src/django_qstash/tasks.py
|
|
5
|
+
src/django_qstash/utils.py
|
|
6
|
+
src/django_qstash/views.py
|
|
7
|
+
src/django_qstash.egg-info/PKG-INFO
|
|
8
|
+
src/django_qstash.egg-info/SOURCES.txt
|
|
9
|
+
src/django_qstash.egg-info/dependency_links.txt
|
|
10
|
+
src/django_qstash.egg-info/requires.txt
|
|
11
|
+
src/django_qstash.egg-info/top_level.txt
|
|
12
|
+
tests/test_tasks.py
|
|
13
|
+
tests/test_utils.py
|
|
14
|
+
tests/test_views.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_qstash
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from unittest.mock import Mock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from django_qstash.tasks import shared_task
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@shared_task
|
|
9
|
+
def sample_task(x, y):
|
|
10
|
+
return x + y
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@shared_task(name="custom_task", deduplicated=True)
|
|
14
|
+
def sample_task_with_options(x, y):
|
|
15
|
+
return x * y
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.django_db
|
|
19
|
+
class TestQStashTasks:
|
|
20
|
+
def test_basic_task_execution(self):
|
|
21
|
+
"""Test that tasks can be executed directly"""
|
|
22
|
+
result = sample_task(2, 3)
|
|
23
|
+
assert result == 5
|
|
24
|
+
|
|
25
|
+
def test_task_with_options(self):
|
|
26
|
+
"""Test that tasks with custom options work"""
|
|
27
|
+
result = sample_task_with_options(4, 5)
|
|
28
|
+
assert result == 20
|
|
29
|
+
|
|
30
|
+
@patch("django_qstash.tasks.qstash_client")
|
|
31
|
+
def test_task_delay(self, mock_client):
|
|
32
|
+
"""Test that delay() sends task to QStash"""
|
|
33
|
+
mock_response = Mock()
|
|
34
|
+
mock_response.message_id = "test-id-123"
|
|
35
|
+
mock_client.message.publish_json.return_value = mock_response
|
|
36
|
+
|
|
37
|
+
result = sample_task.delay(2, 3)
|
|
38
|
+
|
|
39
|
+
assert result.task_id == "test-id-123"
|
|
40
|
+
mock_client.message.publish_json.assert_called_once()
|
|
41
|
+
|
|
42
|
+
@patch("django_qstash.tasks.qstash_client")
|
|
43
|
+
def test_task_apply_async(self, mock_client):
|
|
44
|
+
"""Test that apply_async() works with countdown"""
|
|
45
|
+
mock_response = Mock()
|
|
46
|
+
mock_response.message_id = "test-id-456"
|
|
47
|
+
mock_client.message.publish_json.return_value = mock_response
|
|
48
|
+
|
|
49
|
+
result = sample_task.apply_async(args=(2, 3), countdown=60)
|
|
50
|
+
|
|
51
|
+
assert result.task_id == "test-id-456"
|
|
52
|
+
call_kwargs = mock_client.message.publish_json.call_args[1]
|
|
53
|
+
assert call_kwargs["delay"] == "60s"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from django_qstash.utils import import_string, validate_task_payload
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_import_string():
|
|
7
|
+
"""Test importing functions by string path"""
|
|
8
|
+
# Import a real Python function
|
|
9
|
+
func = import_string("json.dumps")
|
|
10
|
+
assert callable(func)
|
|
11
|
+
|
|
12
|
+
# Test invalid import
|
|
13
|
+
with pytest.raises(ImportError):
|
|
14
|
+
import_string("nonexistent.module.function")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_validate_task_payload():
|
|
18
|
+
"""Test task payload validation"""
|
|
19
|
+
# Valid payload
|
|
20
|
+
valid_payload = {
|
|
21
|
+
"function": "task_func",
|
|
22
|
+
"module": "my_app.tasks",
|
|
23
|
+
"args": [1, 2, 3],
|
|
24
|
+
"kwargs": {"key": "value"},
|
|
25
|
+
}
|
|
26
|
+
is_valid, message = validate_task_payload(valid_payload)
|
|
27
|
+
assert is_valid
|
|
28
|
+
assert message == ""
|
|
29
|
+
|
|
30
|
+
# Missing required field
|
|
31
|
+
invalid_payload = {
|
|
32
|
+
"function": "task_func",
|
|
33
|
+
"args": [1, 2, 3],
|
|
34
|
+
"kwargs": {"key": "value"},
|
|
35
|
+
}
|
|
36
|
+
is_valid, message = validate_task_payload(invalid_payload)
|
|
37
|
+
assert not is_valid
|
|
38
|
+
assert "module" in message
|
|
39
|
+
|
|
40
|
+
# Invalid args type
|
|
41
|
+
invalid_payload = {
|
|
42
|
+
"function": "task_func",
|
|
43
|
+
"module": "my_app.tasks",
|
|
44
|
+
"args": "not a list",
|
|
45
|
+
"kwargs": {"key": "value"},
|
|
46
|
+
}
|
|
47
|
+
is_valid, message = validate_task_payload(invalid_payload)
|
|
48
|
+
assert not is_valid
|
|
49
|
+
assert "Args must be" in message
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from django.test import Client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.django_db
|
|
9
|
+
class TestQStashWebhook:
|
|
10
|
+
def setup_method(self):
|
|
11
|
+
self.client = Client()
|
|
12
|
+
self.url = "/qstash/webhook/" # Adjust if your URL is different
|
|
13
|
+
|
|
14
|
+
@patch("django_qstash.views.receiver")
|
|
15
|
+
def test_valid_webhook_request(self, mock_receiver):
|
|
16
|
+
"""Test webhook with valid signature and payload"""
|
|
17
|
+
payload = {
|
|
18
|
+
"function": "sample_task",
|
|
19
|
+
"module": "tests.test_tasks",
|
|
20
|
+
"args": [2, 3],
|
|
21
|
+
"kwargs": {},
|
|
22
|
+
"task_name": "test_task",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Mock signature verification
|
|
26
|
+
mock_receiver.verify.return_value = True
|
|
27
|
+
|
|
28
|
+
response = self.client.post(
|
|
29
|
+
self.url,
|
|
30
|
+
data=json.dumps(payload),
|
|
31
|
+
content_type="application/json",
|
|
32
|
+
headers={"upstash-signature": "mock-signature"},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert response.status_code == 200
|
|
36
|
+
response_data = json.loads(response.content)
|
|
37
|
+
assert response_data["status"] == "success"
|
|
38
|
+
|
|
39
|
+
def test_missing_signature(self):
|
|
40
|
+
"""Test webhook request without signature header"""
|
|
41
|
+
response = self.client.post(
|
|
42
|
+
self.url, data=json.dumps({}), content_type="application/json"
|
|
43
|
+
)
|
|
44
|
+
assert response.status_code == 403
|
|
45
|
+
|
|
46
|
+
@patch("django_qstash.views.receiver")
|
|
47
|
+
def test_invalid_json_payload(self, mock_receiver):
|
|
48
|
+
"""Test webhook with invalid JSON payload"""
|
|
49
|
+
mock_receiver.verify.return_value = True
|
|
50
|
+
|
|
51
|
+
response = self.client.post(
|
|
52
|
+
self.url,
|
|
53
|
+
data="invalid json",
|
|
54
|
+
content_type="application/json",
|
|
55
|
+
headers={"upstash-signature": "mock-signature"},
|
|
56
|
+
)
|
|
57
|
+
assert response.status_code == 400
|
|
58
|
+
|
|
59
|
+
@patch("django_qstash.views.receiver")
|
|
60
|
+
def test_invalid_payload_structure(self, mock_receiver):
|
|
61
|
+
"""Test webhook with missing required fields"""
|
|
62
|
+
mock_receiver.verify.return_value = True
|
|
63
|
+
|
|
64
|
+
payload = {
|
|
65
|
+
"function": "sample_task",
|
|
66
|
+
# missing required fields
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
response = self.client.post(
|
|
70
|
+
self.url,
|
|
71
|
+
data=json.dumps(payload),
|
|
72
|
+
content_type="application/json",
|
|
73
|
+
headers={"upstash-signature": "mock-signature"},
|
|
74
|
+
)
|
|
75
|
+
assert response.status_code == 400
|