django-qstash 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-qstash might be problematic. Click here for more details.

@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .tasks import shared_task
4
+
5
+ __all__ = ["shared_task"]
django_qstash/tasks.py ADDED
@@ -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)
django_qstash/utils.py ADDED
@@ -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, ""
django_qstash/views.py ADDED
@@ -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,8 @@
1
+ django_qstash/__init__.py,sha256=HvBJshBllZs_V6B2p1DtRYkcU1CiDglPlk43V8MYleI,81
2
+ django_qstash/tasks.py,sha256=X2gFILRvUF2GFuwyAUT43Zvw7OsdFci7870VhRNQ5-M,4929
3
+ django_qstash/utils.py,sha256=wrTU30cobO2di18BNEFtKD4ih2euf7eQNpg6p6TkQ1Y,1185
4
+ django_qstash/views.py,sha256=ucxKQdBoz9Db_1wpuE3U0BBC4nPqtFv05Kb0wggrpOg,4270
5
+ django_qstash-0.0.1.dist-info/METADATA,sha256=QZayHLC5YDuYHMFtMbmBdx8ZTllFhnXk8F2xAxGWcpI,3079
6
+ django_qstash-0.0.1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
7
+ django_qstash-0.0.1.dist-info/top_level.txt,sha256=AlV3WSK1A0ZvKuCLsINtIJhJW8zo7SEB-D3_RAjZ0hI,14
8
+ django_qstash-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ django_qstash