django-qstash 0.0.10__py3-none-any.whl → 0.0.12__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.

django_qstash/__init__.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.0.10"
3
+ __version__ = "0.0.12"
4
4
 
5
5
  from django_qstash.app import shared_task
6
+ from django_qstash.app import stashed_task
6
7
 
7
- __all__ = ["shared_task"]
8
+ __all__ = ["stashed_task", "shared_task"]
@@ -3,5 +3,6 @@ from __future__ import annotations
3
3
  from .base import AsyncResult
4
4
  from .base import QStashTask
5
5
  from .decorators import shared_task
6
+ from .decorators import stashed_task
6
7
 
7
- __all__ = ["AsyncResult", "QStashTask", "shared_task"]
8
+ __all__ = ["AsyncResult", "QStashTask", "stashed_task", "shared_task"]
@@ -6,24 +6,43 @@ from typing import Callable
6
6
  from django_qstash.app.base import QStashTask
7
7
 
8
8
 
9
- def shared_task(
9
+ def stashed_task(
10
10
  func: Callable | None = None,
11
11
  name: str | None = None,
12
12
  deduplicated: bool = False,
13
13
  **options: dict[str, Any],
14
14
  ) -> QStashTask:
15
15
  """
16
- Decorator that mimics Celery's shared_task
16
+ Decorator that mimics Celery's shared_task that maintains
17
+ Celery compatibility.
17
18
 
18
19
  Can be used as:
20
+
21
+ from django_qstash import shared_task
22
+
19
23
  @shared_task
20
24
  def my_task():
21
25
  pass
22
26
 
23
- @shared_task(name="custom_name", deduplicated=True)
27
+ @stashed_task(name="custom_name", deduplicated=True)
24
28
  def my_task():
25
29
  pass
26
30
  """
27
31
  if func is not None:
28
32
  return QStashTask(func, name=name, deduplicated=deduplicated, **options)
29
33
  return lambda f: QStashTask(f, name=name, deduplicated=deduplicated, **options)
34
+
35
+
36
+ def shared_task(func: Callable | None = None, **options: dict[str, Any]) -> QStashTask:
37
+ """
38
+ Decorator that is a drop-in replacement for Celery's shared_task.
39
+
40
+ Can be used as:
41
+
42
+ from django_qstash import shared_task
43
+
44
+ @shared_task
45
+ def my_task():
46
+ pass
47
+ """
48
+ return stashed_task(func, **options)
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from django.core.exceptions import ImproperlyConfigured
4
+
3
5
  from django_qstash.settings import DJANGO_QSTASH_DOMAIN
4
6
  from django_qstash.settings import DJANGO_QSTASH_WEBHOOK_PATH
5
7
 
@@ -8,6 +10,10 @@ def get_callback_url() -> str:
8
10
  """
9
11
  Get the callback URL based on the settings.
10
12
  """
13
+ if DJANGO_QSTASH_DOMAIN is None:
14
+ raise ImproperlyConfigured("DJANGO_QSTASH_DOMAIN is not set")
15
+ if DJANGO_QSTASH_WEBHOOK_PATH is None:
16
+ raise ImproperlyConfigured("DJANGO_QSTASH_WEBHOOK_PATH is not set")
11
17
  callback_domain = DJANGO_QSTASH_DOMAIN.rstrip("/")
12
18
  if not callback_domain.startswith(("http://", "https://")):
13
19
  callback_domain = f"https://{callback_domain}"
django_qstash/cron.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ minute_re = re.compile(
6
+ r"^\*$|^[0-5]?\d$|^[0-5]?\d-[0-5]?\d$|^[0-5]?\d(,[0-5]?\d)+$|^\*/([1-9]|[1-5][0-9])$"
7
+ )
8
+
9
+ hour_re = re.compile(
10
+ r"^\*$|^([01]?\d|2[0-3])$|^([01]?\d|2[0-3])-([01]?\d|2[0-3])$|^([01]?\d|2[0-3])(,([01]?\d|2[0-3]))+$|^\*/([1-9]|1[0-9]|2[0-3])$"
11
+ )
12
+
13
+ day_of_month_re = re.compile(
14
+ r"^\*$|^([1-2]?\d|3[01])$|^([1-2]?\d|3[01])-([1-2]?\d|3[01])$|^([1-2]?\d|3[01])(,([1-2]?\d|3[01]))+$|^\*/([1-9]|[12]\d|3[01])$"
15
+ )
16
+
17
+ month_re = re.compile(
18
+ r"^\*$|^([1-9]|1[0-2])$|^([1-9]|1[0-2])-([1-9]|1[0-2])$|^([1-9]|1[0-2])(,([1-9]|1[0-2]))+$|^\*/([1-9]|1[0-2])$"
19
+ )
20
+
21
+ day_of_week_re = re.compile(r"^\*$|^[0-6]$|^[0-6]-[0-6]$|^[0-6](,[0-6])+$|^\*/[1-6]$")
File without changes
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from django.db import models
4
+
5
+
6
+ class TaskStatus(models.TextChoices):
7
+ PENDING = "PENDING", "Pending"
8
+ SUCCESS = "SUCCESS", "Success"
9
+ EXECUTION_ERROR = "EXECUTION_ERROR", "Execution Error"
10
+ INTERNAL_ERROR = "INTERNAL_ERROR", "Internal Error"
11
+ OTHER_ERROR = "OTHER_ERROR", "Other Error"
12
+ UNKNOWN = "UNKNOWN", "Unknown"
django_qstash/handlers.py CHANGED
@@ -9,6 +9,8 @@ from django.conf import settings
9
9
  from django.http import HttpRequest
10
10
  from qstash import Receiver
11
11
 
12
+ from django_qstash.db.models import TaskStatus
13
+
12
14
  from . import utils
13
15
  from .exceptions import PayloadError
14
16
  from .exceptions import SignatureError
@@ -90,7 +92,9 @@ class QStashWebhook:
90
92
 
91
93
  def handle_request(self, request: HttpRequest) -> tuple[dict, int]:
92
94
  """Process webhook request and return response data and status code."""
93
- payload = None # Initialize payload as None
95
+ payload = None
96
+ task_id = request.headers.get("Upstash-Message-Id")
97
+
94
98
  try:
95
99
  body = request.body.decode()
96
100
  self.verify_signature(
@@ -101,14 +105,14 @@ class QStashWebhook:
101
105
 
102
106
  payload = self.parse_payload(body)
103
107
  result = self.execute_task(payload)
104
-
105
108
  store_task_result(
106
- task_id=request.headers.get("Upstash-Message-Id"),
109
+ task_id=task_id,
107
110
  task_name=payload.task_name,
108
- status="SUCCESS",
111
+ status=TaskStatus.SUCCESS,
109
112
  result=result,
110
113
  args=payload.args,
111
114
  kwargs=payload.kwargs,
115
+ function_path=payload.function_path,
112
116
  )
113
117
 
114
118
  return {
@@ -125,16 +129,37 @@ class QStashWebhook:
125
129
  "error": str(e),
126
130
  "task_name": getattr(payload, "task_name", None),
127
131
  }, 400
132
+
128
133
  except TaskError as e:
129
134
  logger.exception("Task execution error: %s", str(e))
135
+ store_task_result(
136
+ task_id=task_id,
137
+ task_name=payload.task_name,
138
+ status=TaskStatus.EXECUTION_ERROR,
139
+ traceback=str(e),
140
+ args=payload.args,
141
+ kwargs=payload.kwargs,
142
+ function_path=payload.function_path,
143
+ )
130
144
  return {
131
145
  "status": "error",
132
146
  "error_type": e.__class__.__name__,
133
147
  "error": str(e),
134
- "task_name": payload.task_name if payload else None,
135
- }, 500
148
+ "task_name": payload.task_name,
149
+ }, 422
150
+
136
151
  except Exception as e:
137
152
  logger.exception("Unexpected error in webhook handler: %s", str(e))
153
+ if payload: # Store unexpected errors only if payload was parsed
154
+ store_task_result(
155
+ task_id=task_id,
156
+ task_name=payload.task_name,
157
+ status=TaskStatus.INTERNAL_ERROR,
158
+ traceback=str(e),
159
+ args=payload.args,
160
+ kwargs=payload.kwargs,
161
+ function_path=payload.function_path,
162
+ )
138
163
  return {
139
164
  "status": "error",
140
165
  "error_type": "InternalServerError",
@@ -17,5 +17,8 @@ class TaskResultAdmin(admin.ModelAdmin):
17
17
  "kwargs",
18
18
  "task_id",
19
19
  "date_created",
20
+ "function_path",
20
21
  ]
21
- list_display = ["task_name", "status", "date_done"]
22
+ search_fields = ["task_name", "task_id", "function_path"]
23
+ list_display = ["task_name", "function_path", "status", "date_done"]
24
+ list_filter = ["status", "date_done"]
@@ -0,0 +1,46 @@
1
+ # Generated by Django 5.1.4 on 2025-01-06 05:39
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.db import migrations
6
+ from django.db import models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+ dependencies = [
11
+ ("django_qstash_results", "0001_initial"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name="taskresult",
17
+ name="function_path",
18
+ field=models.TextField(blank=True, null=True),
19
+ ),
20
+ migrations.AlterField(
21
+ model_name="taskresult",
22
+ name="status",
23
+ field=models.CharField(
24
+ choices=[
25
+ ("PENDING", "Pending"),
26
+ ("SUCCESS", "Success"),
27
+ ("EXECUTION_ERROR", "Execution Error"),
28
+ ("INTERNAL_ERROR", "Internal Error"),
29
+ ("OTHER_ERROR", "Other Error"),
30
+ ("UNKNOWN", "Unknown"),
31
+ ],
32
+ default="PENDING",
33
+ max_length=50,
34
+ ),
35
+ ),
36
+ migrations.AlterField(
37
+ model_name="taskresult",
38
+ name="task_id",
39
+ field=models.CharField(db_index=True, max_length=255),
40
+ ),
41
+ migrations.AlterField(
42
+ model_name="taskresult",
43
+ name="traceback",
44
+ field=models.TextField(blank=True, null=True),
45
+ ),
46
+ ]
@@ -5,26 +5,25 @@ import uuid
5
5
  from django.db import models
6
6
  from django.utils import timezone
7
7
 
8
+ from django_qstash.db.models import TaskStatus
9
+
8
10
 
9
11
  class TaskResult(models.Model):
10
12
  id = models.UUIDField(
11
13
  primary_key=True, default=uuid.uuid1, editable=False, unique=True
12
14
  )
13
- task_id = models.CharField(max_length=255, unique=True, db_index=True)
15
+ task_id = models.CharField(max_length=255, db_index=True)
14
16
  task_name = models.CharField(max_length=255)
15
17
  status = models.CharField(
16
18
  max_length=50,
17
- choices=[
18
- ("PENDING", "Pending"),
19
- ("SUCCESS", "Success"),
20
- ("FAILURE", "Failure"),
21
- ],
22
- default="PENDING",
19
+ choices=TaskStatus.choices,
20
+ default=TaskStatus.PENDING,
23
21
  )
24
22
  date_created = models.DateTimeField(default=timezone.now)
25
23
  date_done = models.DateTimeField(null=True)
26
24
  result = models.JSONField(null=True)
27
- traceback = models.TextField(null=True)
25
+ traceback = models.TextField(blank=True, null=True)
26
+ function_path = models.TextField(blank=True, null=True)
28
27
  args = models.JSONField(null=True)
29
28
  kwargs = models.JSONField(null=True)
30
29
 
@@ -1,17 +1,42 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import logging
5
+ from typing import Any
4
6
 
5
7
  from django.apps import apps
6
8
  from django.utils import timezone
7
9
 
10
+ from django_qstash.db.models import TaskStatus
11
+
8
12
  logger = logging.getLogger(__name__)
9
13
 
10
14
 
15
+ def function_result_to_json(result: Any) -> str:
16
+ """Convert a function result to a JSON string"""
17
+ try:
18
+ data = {"result": result}
19
+ return json.dumps(data)
20
+ except Exception as e:
21
+ logger.exception("Failed to convert function result to JSON: %s", str(e))
22
+ return None
23
+
24
+
11
25
  def store_task_result(
12
- task_id, task_name, status, result=None, traceback=None, args=None, kwargs=None
26
+ task_id,
27
+ task_name,
28
+ status,
29
+ result=None,
30
+ traceback=None,
31
+ args=None,
32
+ kwargs=None,
33
+ error=None,
34
+ function_path=None,
13
35
  ):
14
36
  """Helper function to store task results if the results app is installed"""
37
+ if status not in TaskStatus.values:
38
+ status = TaskStatus.UNKNOWN
39
+
15
40
  try:
16
41
  TaskResult = apps.get_model("django_qstash_results", "TaskResult")
17
42
  task_result = TaskResult.objects.create(
@@ -19,10 +44,11 @@ def store_task_result(
19
44
  task_name=task_name,
20
45
  status=status,
21
46
  date_done=timezone.now(),
22
- result=result,
47
+ result=function_result_to_json(result),
23
48
  traceback=traceback,
24
49
  args=args,
25
50
  kwargs=kwargs,
51
+ function_path=function_path,
26
52
  )
27
53
  return task_result
28
54
  except LookupError:
@@ -7,16 +7,17 @@ from django.apps import apps
7
7
  from django.conf import settings
8
8
  from django.utils import timezone
9
9
 
10
- from django_qstash import shared_task
10
+ from django_qstash import stashed_task
11
+ from django_qstash.db.models import TaskStatus
11
12
 
12
13
  DJANGO_QSTASH_RESULT_TTL = getattr(settings, "DJANGO_QSTASH_RESULT_TTL", 604800)
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
16
17
 
17
- @shared_task(name="Cleanup Task Results")
18
+ @stashed_task(name="Cleanup Task Results")
18
19
  def clear_stale_results_task(
19
- since=None, stdout=None, user_confirm=False, *args, **options
20
+ since=None, stdout=None, user_confirm=False, exclude_errors=True, *args, **options
20
21
  ):
21
22
  delta_seconds = since or DJANGO_QSTASH_RESULT_TTL
22
23
  cutoff_date = timezone.now() - timedelta(seconds=delta_seconds)
@@ -30,6 +31,14 @@ def clear_stale_results_task(
30
31
  logger.exception(msg)
31
32
  raise e
32
33
  qs_to_delete = TaskResult.objects.filter(date_done__lt=cutoff_date)
34
+ if exclude_errors:
35
+ qs_to_delete = qs_to_delete.exclude(
36
+ status__in=[
37
+ TaskStatus.EXECUTION_ERROR,
38
+ TaskStatus.INTERNAL_ERROR,
39
+ TaskStatus.OTHER_ERROR,
40
+ ]
41
+ )
33
42
 
34
43
  if user_confirm:
35
44
  user_input = input("Are you sure? (Y/n): ")
@@ -65,3 +74,12 @@ def clear_stale_results_task(
65
74
  stdout.write(msg)
66
75
  logger.exception(msg)
67
76
  raise e
77
+
78
+
79
+ @stashed_task(name="Clear Task Error Results")
80
+ def clear_task_errors_task(
81
+ since=None, stdout=None, user_confirm=False, *args, **options
82
+ ):
83
+ clear_stale_results_task(
84
+ since=since, stdout=stdout, user_confirm=user_confirm, exclude_errors=False
85
+ )
@@ -7,3 +7,9 @@ class InvalidDurationStringValidationError(ValidationError):
7
7
  """Invalid duration string."""
8
8
 
9
9
  pass
10
+
11
+
12
+ class InvalidCronStringValidationError(ValidationError):
13
+ """Invalid cron string."""
14
+
15
+ pass
@@ -22,3 +22,10 @@ class TaskScheduleForm(forms.ModelForm):
22
22
  "retries",
23
23
  "timeout",
24
24
  ]
25
+
26
+ def clean(self):
27
+ cleaned_data = super().clean()
28
+ # If task_name is not provided, use the task value
29
+ if not cleaned_data.get("task_name") and cleaned_data.get("task"):
30
+ cleaned_data["task_name"] = cleaned_data["task"]
31
+ return cleaned_data
@@ -8,6 +8,7 @@ from django.db import models
8
8
  from django.utils import timezone
9
9
 
10
10
  from django_qstash.discovery.models import TaskField
11
+ from django_qstash.schedules.validators import validate_cron_expression
11
12
  from django_qstash.schedules.validators import validate_duration_string
12
13
 
13
14
  DJANGO_QSTASH_DOMAIN = getattr(settings, "DJANGO_QSTASH_DOMAIN", None)
@@ -56,6 +57,7 @@ class TaskSchedule(models.Model):
56
57
  cron = models.CharField(
57
58
  max_length=255,
58
59
  default="*/5 * * * *",
60
+ validators=[validate_cron_expression],
59
61
  verbose_name="Cron Expression",
60
62
  help_text="Cron expression for scheduling the task",
61
63
  )
@@ -2,6 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
 
5
+ from django.utils.safestring import mark_safe
6
+
7
+ from django_qstash import cron
8
+ from django_qstash.schedules.exceptions import InvalidCronStringValidationError
5
9
  from django_qstash.schedules.exceptions import InvalidDurationStringValidationError
6
10
 
7
11
 
@@ -27,3 +31,39 @@ def validate_duration_string(value):
27
31
  raise InvalidDurationStringValidationError(
28
32
  "Duration too long. Maximum allowed: 7 days (equivalent to: 604800s, 10080m, 168h, 7d)"
29
33
  )
34
+
35
+
36
+ def validate_cron_expression(value: str) -> None:
37
+ """Validates a standard cron expression with 5 fields (minute, hour, day of month, month, day of week)."""
38
+ parts = value.split()
39
+ if len(parts) != 5:
40
+ raise InvalidCronStringValidationError(
41
+ 'Invalid cron format. Must contain 5 fields: "minute hour day_of_month month day_of_week"'
42
+ )
43
+
44
+ field_descriptions = {
45
+ "minute": "0-59",
46
+ "hour": "0-23",
47
+ "day_of_month": "1-31",
48
+ "month": "1-12",
49
+ "day_of_week": "0-6 (0=Sunday, 6=Saturday)",
50
+ }
51
+ field_label = {
52
+ "minute": "minute",
53
+ "hour": "hour",
54
+ "day_of_month": "day of the month",
55
+ "month": "month",
56
+ "day_of_week": "day of the week",
57
+ }
58
+ patterns = {
59
+ "minute": cron.minute_re,
60
+ "hour": cron.hour_re,
61
+ "day_of_month": cron.day_of_month_re,
62
+ "month": cron.month_re,
63
+ "day_of_week": cron.day_of_week_re,
64
+ }
65
+
66
+ for sub_value, (field, pattern) in zip(parts, patterns.items()):
67
+ if not re.match(pattern, sub_value):
68
+ invalid_msg = f"""<b>{sub_value}</b> is not a valid {field_label[field]}. <br/>Must be in range {field_descriptions[field]}. <br/><a target="_blank" href="https://crontab.guru/">crontab.guru</a> is also helpful."""
69
+ raise InvalidCronStringValidationError(mark_safe(invalid_msg))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-qstash
3
- Version: 0.0.10
3
+ Version: 0.0.12
4
4
  Summary: A drop-in replacement for Celery's shared_task with Upstash QStash.
5
5
  Author-email: Justin Mitchel <justin@codingforentrepreneurs.com>
6
6
  Project-URL: Changelog, https://github.com/jmitchel3/django-qstash
@@ -33,16 +33,32 @@ Requires-Dist: requests>=2.30
33
33
 
34
34
  _django-qstash_ is a drop-in replacement for Celery's `shared_task`.
35
35
 
36
- To do this, we use:
36
+
37
+ ## How it works
38
+
39
+ In `tasks.py` in your apps:
40
+
41
+ ```python
42
+ from django_qstash import shared_task
43
+
44
+
45
+ @shared_task
46
+ def my_task():
47
+ pass
48
+ ```
49
+ > To use Celery too, you can use `@stashed_task` instead of `@shared_task` more below.
50
+
51
+ To do this we need:
37
52
 
38
53
  - [Upstash QStash](https://upstash.com/docs/qstash/overall/getstarted)
39
- - A single public _webhook_ to call `@shared_task` functions automatically
54
+ - A single public _webhook_ to call `@stashed_task` functions automatically
40
55
 
41
56
  This allows us to:
42
57
 
58
+ - Nearly identical usage to Celery's `@shared_task` with far less configuration and overhead
43
59
  - Focus just on Django
44
- - Drop Celery
45
- - Truly scale Django to zero
60
+ - Drop Celery completely, scale it down, or use it as normal. django-qstash can work hand-in-hand with Celery
61
+ - Unlock true serverless and scale-to-zero for Django
46
62
  - Run background tasks through webhooks
47
63
  - Cut costs
48
64
  - Trigger GitHub Actions Workflows or GitLab CI/CD pipelines for handling other kinds of background tasks based on our project's code.
@@ -51,6 +67,7 @@ This allows us to:
51
67
  ## Table of Contents
52
68
 
53
69
  - [django-qstash](#django-qstash)
70
+ - [How it works](#how-it-works)
54
71
  - [Table of Contents](#table-of-contents)
55
72
  - [Installation](#installation)
56
73
  - [Using Pip](#using-pip)
@@ -62,18 +79,22 @@ This allows us to:
62
79
  - [Usage](#usage)
63
80
  - [Define a Task](#define-a-task)
64
81
  - [Regular Task Call](#regular-task-call)
65
- - [Async Task](#async-task)
82
+ - [Background Task](#background-task)
66
83
  - [`.delay()`](#delay)
67
84
  - [`.apply_async()`](#apply_async)
68
85
  - [`.apply_async()` With Time Delay](#apply_async-with-time-delay)
69
- - [JSON-ready Arguments](#json-ready-arguments)
86
+ - [Arguments Must be JSON-ready](#arguments-must-be-json-ready)
70
87
  - [Example Task](#example-task)
71
88
  - [Management Commands](#management-commands)
72
- - [Development Usage](#development-usage)
89
+ - [Public Domain In Development](#public-domain-in-development)
73
90
  - [Django Settings Configuration](#django-settings-configuration)
91
+ - [`DJANGO_QSTASH_DOMAIN`](#django_qstash_domain)
92
+ - [`DJANGO_QSTASH_WEBHOOK_PATH`](#django_qstash_webhook_path)
93
+ - [`DJANGO_QSTASH_FORCE_HTTPS`](#django_qstash_force_https)
94
+ - [Example Django Settings](#example-django-settings)
74
95
  - [Schedule Tasks (Optional)](#schedule-tasks-optional)
75
96
  - [Installation](#installation-1)
76
- - [Schedule a Task](#schedule-a-task)
97
+ - [Schedule a Task](#schedule-a-task)
77
98
  - [Store Task Results (Optional)](#store-task-results-optional)
78
99
  - [Clear Stale Results](#clear-stale-results)
79
100
  - [Definitions](#definitions)
@@ -98,9 +119,9 @@ INSTALLED_APPS = [
98
119
  ##...
99
120
  ]
100
121
  ```
101
- - `django_qstash` Includes the `@shared_task` decorator and webhook view
122
+ - `django_qstash` Includes the `@shared_task` and `@stashed_task` decorators and webhook view
102
123
  - `django_qstash.results` (Optional): Store task results in Django DB
103
- - `django_qstash.schedules` (Optional): Use QStash Schedules to run your `django_qstash` tasks. Out of the box support for _django_qstash_ `@shared_task`. Schedule tasks using _cron_ (e.g. `0 0 * * *`) format which is required based on [QStash Schedules](https://upstash.com/docs/qstash/features/schedules). use [contrab.guru](https://crontab.guru/) for writing the cron format.
124
+ - `django_qstash.schedules` (Optional): Use QStash Schedules to run your `django_qstash` tasks. Out of the box support for _django_qstash_ `@stashed_task`. Schedule tasks using _cron_ (e.g. `0 0 * * *`) format which is required based on [QStash Schedules](https://upstash.com/docs/qstash/features/schedules). use [contrab.guru](https://crontab.guru/) for writing the cron format.
104
125
 
105
126
  ### Configure Webhook URL
106
127
 
@@ -144,7 +165,7 @@ There is a sample project in [sample_project/](sample_project/) that shows how a
144
165
 
145
166
  ## Usage
146
167
 
147
- Django-QStash revolves around the `shared_task` decorator. The goal is to be a drop-in replacement for Celery's `shared_task` decorator.
168
+ Django-QStash revolves around the `stashed_task` decorator. The goal is to be a drop-in replacement for Celery's `shared_task` decorator.
148
169
 
149
170
  Here's how it works:
150
171
  - Define a Task
@@ -152,17 +173,30 @@ Here's how it works:
152
173
 
153
174
  ### Define a Task
154
175
  ```python
176
+ # from celery import shared_task
155
177
  from django_qstash import shared_task
178
+ from django_qstash import stashed_task
156
179
 
157
180
 
158
- @shared_task
181
+ @stashed_task
159
182
  def hello_world(name: str, age: int = None, activity: str = None):
160
183
  if age is None:
161
184
  print(f"Hello {name}! I see you're {activity}.")
162
185
  return
163
186
  print(f"Hello {name}! I see you're {activity} at {age} years old.")
187
+
188
+
189
+ @shared_task
190
+ def hello_world_redux(name: str, age: int = None, activity: str = None):
191
+ if age is None:
192
+ print(f"Hello {name}! I see you're {activity}.")
193
+ return
194
+ print(f"Hello {name}! I see you're {activity} at {age} years old.")
164
195
  ```
165
196
 
197
+ - `hello_world` and `hello_world_redux` work the same with django-qstash.
198
+ - If you use Celery's `@shared_task` instead, Celery would handle only `hello_world_redux` and django-qstash would handle only `hello_world`.
199
+
166
200
  ### Regular Task Call
167
201
  Nothing special here. Just call the function like any other to verify it works.
168
202
 
@@ -171,9 +205,11 @@ Nothing special here. Just call the function like any other to verify it works.
171
205
  hello_world("Tony Stark", age=40, activity="building in a cave with a box of scraps.")
172
206
  ```
173
207
 
174
- ### Async Task
208
+ ### Background Task
175
209
 
176
- Using `.delay()` or `.apply_async()` is how you call an async task. This is modeled after Celery and it works as you'd expect.
210
+ Using `.delay()` or `.apply_async()` is how you trigger a background task. These background tasks are actually setting up a QStash message that will be delivered via webhook to your Django application. django-qstash handles the webhook and the message delivery assuming installed correctly.
211
+
212
+ This functionality is modeled after Celery and it works as you'd expect.
177
213
 
178
214
 
179
215
  #### `.delay()`
@@ -206,10 +242,11 @@ hello_world.apply_async(
206
242
  )
207
243
  ```
208
244
 
209
- ### JSON-ready Arguments
245
+ ### Arguments Must be JSON-ready
210
246
 
211
- Each argument needs to be _JSON_ serializable. The way you find out:
247
+ Arguments to django-qstash managed functions must be _JSON_ serializable.
212
248
 
249
+ The way you find out:
213
250
  ```python
214
251
  import json
215
252
 
@@ -220,15 +257,23 @@ data = {
220
257
  print(json.dumps(data))
221
258
  # no errors, you're good to go.
222
259
  ```
260
+ If you have `errors` you'll need to fix them. Here's a few common errors you might see:
261
+
262
+ - Using a Django queryset directly as an argument
263
+ - Using a Django model instance directly as an argument
264
+ - Using a datetime object directly as an argument (e.g. `datetime.datetime` or `datetime.date`) instead of a timestamp or date string (e.g. `datetime.datetime.now().timestamp()` or `datetime.datetime.now.strftime("%Y-%m-%d")`)
223
265
 
224
266
  ### Example Task
225
267
 
226
268
  ```python
227
269
  # from celery import shared_task
228
- from django_qstash import shared_task
270
+ # becomes
271
+ # from django_qstash import shared_task
272
+ # or
273
+ from django_qstash import stashed_task
229
274
 
230
275
 
231
- @shared_task
276
+ @stashed_task
232
277
  def math_add_task(a, b, save_to_file=False, *args, **kwargs):
233
278
  logger.info(f"Adding {a} and {b}")
234
279
  if save_to_file:
@@ -261,38 +306,83 @@ The `.delay()` method does not support a countdown parameter because it simply p
261
306
 
262
307
  ## Management Commands
263
308
 
264
- - `python manage.py available_tasks` to view all available tasks
309
+ - `python manage.py available_tasks` to view all available tasks found by django-qstash. Unlike Celery, django-qstash does not assign tasks to a specific Celery app (e.g. `app = Celery()`).
265
310
 
266
311
  _Requires `django_qstash.schedules` installed._
267
312
  - `python manage.py task_schedules --list` see all schedules relate to the `DJANGO_QSTASH_DOMAIN`
268
313
  - `python manage.py task_schedules --sync` sync schedules based on the `DJANGO_QSTASH_DOMAIN` to store in the Django Admin.
269
314
 
270
- ## Development Usage
315
+ ## Public Domain In Development
271
316
 
272
- django-qstash requires a public domain to work (e.g. `https://djangoqstash.com`). There are many ways to do this, we recommend:
317
+ django-qstash _requires_ a publicly accessible domain to work (e.g. `https://djangoqstash.com`). There are many ways to do this, we recommend:
273
318
 
274
319
  - [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control.
275
320
  - [ngrok](https://ngrok.com/)
276
321
 
277
322
  Once you have a domain name, you can configure the `DJANGO_QSTASH_DOMAIN` setting in your Django settings.
278
323
 
279
-
280
324
  ## Django Settings Configuration
281
325
 
282
- In Django settings, you can configure the following:
326
+ Various options are available to configure django-qstash.
283
327
 
284
- - `DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.com`. Review [Development usage](#development-usage) for setting up a domain name during development.
328
+ ### `DJANGO_QSTASH_DOMAIN`
329
+ - Required: Yes
330
+ - Default:`None`
331
+ - Description: Must be a valid and publicly accessible domain. For example `https://djangoqstash.com`. Review [Development usage](#development-usage) for setting up a domain name during development.
285
332
 
286
- - `DJANGO_QSTASH_WEBHOOK_PATH` (default:`/qstash/webhook/`): The path where QStash will send webhooks to your Django application.
333
+ ### `DJANGO_QSTASH_WEBHOOK_PATH`
334
+ - Required: Yes
335
+ - Default:`/qstash/webhook/`
336
+ - Description: The path where QStash will send webhooks to your Django application.
287
337
 
288
- - `DJANGO_QSTASH_FORCE_HTTPS` (default:`True`): Whether to force HTTPS for the webhook.
338
+ ### `DJANGO_QSTASH_FORCE_HTTPS`
339
+ - Required: No
340
+ - Default: `True`
341
+ - Description: Whether to force HTTPS for the webhook.
289
342
 
290
- - `DJANGO_QSTASH_RESULT_TTL` (default:`604800`): A number of seconds after which task result data can be safely deleted. Defaults to 604800 seconds (7 days or 7 * 24 * 60 * 60).
343
+ ###`DJANGO_QSTASH_RESULT_TTL`
344
+ - Required: No
345
+ - Default:`604800`
346
+ - Description: A number of seconds after which task result data can be safely deleted. Defaults to 604800 seconds (7 days or 7 * 24 * 60 * 60).
347
+
348
+
349
+ ### Example Django Settings
350
+
351
+ For a complete example, review [sample_project/settings.py](sample_project/settings.py) where [python-decouple](https://github.com/henriquebastos/python-decouple) is used to set the environment variables via the `.env` file or system environment variables (for production use).
352
+
353
+ Using `os.environ`:
354
+ ```python
355
+ import os
356
+
357
+ ###########################
358
+ # django settings
359
+ ###########################
360
+ DJANGO_DEBUG = str(os.environ.get("DJANGO_DEBUG")) == "1"
361
+ DJANGO_SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
362
+ ALLOWED_HOSTS = [os.environ.get("ALLOWED_HOST")]
363
+ CSRF_TRUSTED_ORIGINS = [os.environ.get("CSRF_TRUSTED_ORIGIN")]
364
+ ###########################
365
+ # qstash-py settings
366
+ ###########################
367
+ QSTASH_TOKEN = os.environ.get("QSTASH_TOKEN")
368
+ QSTASH_CURRENT_SIGNING_KEY = os.environ.get("QSTASH_CURRENT_SIGNING_KEY")
369
+ QSTASH_NEXT_SIGNING_KEY = os.environ.get("QSTASH_NEXT_SIGNING_KEY")
370
+
371
+ ###########################
372
+ # django_qstash settings
373
+ ###########################
374
+ DJANGO_QSTASH_DOMAIN = os.environ.get("DJANGO_QSTASH_DOMAIN")
375
+ DJANGO_QSTASH_WEBHOOK_PATH = os.environ.get("DJANGO_QSTASH_WEBHOOK_PATH")
376
+ DJANGO_QSTASH_FORCE_HTTPS = True
377
+ DJANGO_QSTASH_RESULT_TTL = 604800
378
+ ```
291
379
 
292
380
 
293
381
  ## Schedule Tasks (Optional)
294
382
 
295
- The `django_qstash.schedules` app schedules tasks using Upstash [QStash Schedules](https://upstash.com/docs/qstash/features/schedules) and the django-qstash `@shared_task` decorator.
383
+ Run background tasks on a CRON schedule.
384
+
385
+ The `django_qstash.schedules` app schedules tasks using Upstash [QStash Schedules](https://upstash.com/docs/qstash/features/schedules) via `@shared_task` or `@stashed_task` decorators along with the `TaskSchedule` model.
296
386
 
297
387
  ### Installation
298
388
 
@@ -312,8 +402,7 @@ Run migrations:
312
402
  python manage.py migrate django_qstash_schedules
313
403
  ```
314
404
 
315
-
316
- ## Schedule a Task
405
+ ### Schedule a Task
317
406
 
318
407
  Tasks must exist before you can schedule them. Review [Define a Task](#define-a-task) for more information.
319
408
 
@@ -321,9 +410,6 @@ Here's how you can schedule a task:
321
410
  - Django Admin (`/admin/django_qstash_schedules/taskschedule/add/`)
322
411
  - Django shell (`python manage.py shell`)
323
412
 
324
-
325
-
326
-
327
413
  ```python
328
414
  from django_qstash.schedules.models import TaskSchedule
329
415
  from django_qstash.discovery.utils import discover_tasks
@@ -352,10 +438,11 @@ TaskSchedule.objects.create(
352
438
  - `cron` is the cron schedule to run the task. Use [contrab.guru](https://crontab.guru/) for writing the cron format.
353
439
 
354
440
 
355
-
356
441
  ## Store Task Results (Optional)
357
442
 
358
- In `django_qstash.results.models` we have the `TaskResult` model class that can be used to track async task results. These entries are created via webhooks.
443
+ Retain the results of background tasks in the database with clear-out functionality.
444
+
445
+ In `django_qstash.results.models` we have the `TaskResult` model class that can be used to track async task results. These entries are created via the django-qstash webhook view handler (`qstash_webhook_view`).
359
446
 
360
447
  To install it, just add `django_qstash.results` to your `INSTALLED_APPS` setting.
361
448
 
@@ -373,6 +460,12 @@ Run migrations:
373
460
  python manage.py migrate django_qstash_results
374
461
  ```
375
462
 
463
+ Key configuration:
464
+
465
+ - [DJANGO_QSTASH_WEBHOOK_PATH](#django-settings-configuration)
466
+ - [DJANGO_QSTASH_DOMAIN](#django-settings-configuration)
467
+ - [DJANGO_QSTASH_RESULT_TTL](#django-settings-configuration)
468
+
376
469
  ### Clear Stale Results
377
470
 
378
471
  We recommend purging the `TaskResult` model after a certain amount of time.
@@ -1,14 +1,17 @@
1
- django_qstash/__init__.py,sha256=LX7-S0Qx-tBkMzcbIy-Fv4WyLP_wY0GnNsO2YG1bJoA,129
2
- django_qstash/callbacks.py,sha256=VG5tGdNzAmUEh7NlpghrxhWvnpRNXZucWmWwxaemw0M,530
1
+ django_qstash/__init__.py,sha256=ENXrTf63lzkaRO580z6PPjYDS7bgC2bNaK24S-uqnbo,188
2
+ django_qstash/callbacks.py,sha256=IQ-D8sPlSRuREZ1zwkRyd2GtxfmrJJh2x4jLd39rZCE,813
3
3
  django_qstash/client.py,sha256=cgHf-g6lDAltY_Vt6GUVJNY2JSz1czBOHL-WVkkLs2M,149
4
+ django_qstash/cron.py,sha256=13OzTMGXFgjNEXjs2Et20WGZYtW9lKlu79BjbRySnVc,716
4
5
  django_qstash/exceptions.py,sha256=pH6kKRJFIVFkDHUJQ9yRWmtGdBBSXpNAwMSFuNzMgPw,392
5
- django_qstash/handlers.py,sha256=mmm8TJOqV3j1rQXooNOa128gtmALXFNCAaDZ5xwIcuw,4950
6
+ django_qstash/handlers.py,sha256=TtYZ-Sr858ajISBpDuHIT7-qaVsoVfTE6vVFJ9-kpPE,5820
6
7
  django_qstash/settings.py,sha256=YvpXMo1AdIWvbotISWJmhg0vrW3A3UQ4BieNzMfRC7Y,524
7
8
  django_qstash/utils.py,sha256=wrTU30cobO2di18BNEFtKD4ih2euf7eQNpg6p6TkQ1Y,1185
8
9
  django_qstash/views.py,sha256=H32f_jGnlwOTO0YG9znNo2b-GRYZ8TM-Wt0T62SGdXM,639
9
- django_qstash/app/__init__.py,sha256=353w1JyvIinkr3aHjyT7I01utFM56lrgIjSSfqtJkA4,187
10
+ django_qstash/app/__init__.py,sha256=kmoCVoESInzCZ_oGUiPVY4GsFQwBC07cqFJCyn9Loyk,240
10
11
  django_qstash/app/base.py,sha256=gM7GIJh_omZcxbmsrwAEadA-N6EuUJbPzh0CflOIVRg,3864
11
- django_qstash/app/decorators.py,sha256=Gn0TAxUHfS4fKUdKdArvHeWlxrRvj9_NOVnC1a6pGTQ,732
12
+ django_qstash/app/decorators.py,sha256=Zkr0dLhW5-7yGmj7JunLGcgzOwsONRyz3YkrD957DqY,1170
13
+ django_qstash/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ django_qstash/db/models.py,sha256=UTmjw76h49HT4hPKaCwnOkHph70GOJ6mMDZt8aKmHH8,372
12
15
  django_qstash/discovery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
16
  django_qstash/discovery/fields.py,sha256=h-31sysbIU05KGKGBAu7uQo9bZnZg3kgjN_ZhPPMTGU,1260
14
17
  django_qstash/discovery/models.py,sha256=9ml9lTKEqEKx2uqYvejZw_BjdnowgFOPE7rYNt_8E9A,685
@@ -20,27 +23,28 @@ django_qstash/management/commands/available_tasks.py,sha256=l-do7Mry83NxbCdyMLcL
20
23
  django_qstash/management/commands/clear_stale_results.py,sha256=mxXXqIy6pnvsN8JVE0xe3mypqtkaZbpqdBjpox-MDik,1402
21
24
  django_qstash/management/commands/task_schedules.py,sha256=b9lJ1vjQKHyGzWAo9csGwE_oaKfgcSC8bPFLt9Ry6WE,4278
22
25
  django_qstash/results/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- django_qstash/results/admin.py,sha256=q9fn3lfn0gviMfiimYij0wBCYww7FxyrOfGPr1NvntA,434
26
+ django_qstash/results/admin.py,sha256=Vr1O6E7vucSnkZ1PwHQ7RYkwOxiX4WHPAKaA7L8Cn-4,580
24
27
  django_qstash/results/apps.py,sha256=4me4cg5yeoeSJTphkHYzGMJUfGucT47FNIUMYu5gmIo,275
25
- django_qstash/results/models.py,sha256=aEiAhGJOuLRtjibUw6xdQqUt3eYKLqY2as4I4QSrF5U,1047
26
- django_qstash/results/services.py,sha256=HvNp5D1tQ__nz4LVUTAGxuyLl_dnlBps4pJ6E9HD2kA,991
27
- django_qstash/results/tasks.py,sha256=NETuiREwCKfajWKCnnB5iAsHoSsvBIThf805_wpzjbw,2166
28
+ django_qstash/results/models.py,sha256=AV7-nVEMq-Xt2QVY6D7NQc4_3E7v-6NA2xngTU8DfXA,1062
29
+ django_qstash/results/services.py,sha256=vax0nsH9VO8nWTbS1Ki762-w-15ADeWSN6Sohpx7Dlg,1590
30
+ django_qstash/results/tasks.py,sha256=kd49OOZyOg6OG3RSyywtZPtpGaPlycY2OXl1BXaoxVM,2746
28
31
  django_qstash/results/migrations/0001_initial.py,sha256=A90SKgWmBf4SIJYG1Jh6-b_81Ia1zIzGj3Bfl1O4-kg,1902
32
+ django_qstash/results/migrations/0002_taskresult_function_path_alter_taskresult_status_and_more.py,sha256=FevtPlzkKHjRD1tcnXskigY5jr2X3gYv7KE2TcdEAxU,1374
29
33
  django_qstash/results/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
34
  django_qstash/schedules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
35
  django_qstash/schedules/admin.py,sha256=ToFkdGfN3smNctTduLQpwxZ5yW20jW4HKRr7x1UilIk,1673
32
36
  django_qstash/schedules/apps.py,sha256=zTyTH6xTZPKsEC2MlJcQBDp2ZiRWdMfq2j2jGbpBLH0,360
33
- django_qstash/schedules/exceptions.py,sha256=fvXArrK24ZBGITijuJ_I5xixURmFrJpQ6Dbi2bLNqCQ,195
37
+ django_qstash/schedules/exceptions.py,sha256=lrX8kKF5H93FIQNaeZR9mlJBEEmtta6T69CevoYk_xA,295
34
38
  django_qstash/schedules/formatters.py,sha256=KMt1457z4Mp0Mob2IxgTBHmye_mRfcLn7Rdf7ThvbEg,1157
35
- django_qstash/schedules/forms.py,sha256=S5Wy2mZha2-th4oAQLjkC9JytfXFNJcL1j18u6rslxI,524
36
- django_qstash/schedules/models.py,sha256=WS4b5ssh07ekiTe5_y4phLKGl7uTXHJBiF4W47vkGec,4357
39
+ django_qstash/schedules/forms.py,sha256=A0TW5xM73Kp7gzieprqaf7leQlqMB7NZFsNn3r_7Jp4,808
40
+ django_qstash/schedules/models.py,sha256=ZcwuIZAn9jC8ZQDfqKTYGjo1C-0WfKf4Mddp_EfoBU4,4476
37
41
  django_qstash/schedules/services.py,sha256=U3c2cZgdKGWgpmhRD0j0wT_T43w7K4pXm552M2sTr_4,2186
38
42
  django_qstash/schedules/signals.py,sha256=g1aRAbZx-stnvD589mZagR6I27E56064fUyWsxKitR4,696
39
- django_qstash/schedules/validators.py,sha256=i8IEjnRVk-iysmqvT_kbPYpxTKCQWoX9P1JHcL3zkhI,925
43
+ django_qstash/schedules/validators.py,sha256=w1dLgf_rAujurcktGPgu1aXejT5BhH5ianKSr7cEYKU,2467
40
44
  django_qstash/schedules/migrations/0001_initial.py,sha256=66cA8xnJV3h7QgzCaOiv-Nu3Xl9IdZQPgQKhxyW3bs4,4516
41
45
  django_qstash/schedules/migrations/0002_taskschedule_updated_at.py,sha256=6hZO0a9P2ZpOROkk7O5UXBhahghU0QfxZl4E-c3HKGw,459
42
46
  django_qstash/schedules/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
- django_qstash-0.0.10.dist-info/METADATA,sha256=MBBDbS-vk7yxaIa4XKKj4GHAO2xWVNIzg3s63PKoggk,15412
44
- django_qstash-0.0.10.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
45
- django_qstash-0.0.10.dist-info/top_level.txt,sha256=AlV3WSK1A0ZvKuCLsINtIJhJW8zo7SEB-D3_RAjZ0hI,14
46
- django_qstash-0.0.10.dist-info/RECORD,,
47
+ django_qstash-0.0.12.dist-info/METADATA,sha256=NIxqfsPXtnoculriqbmMK9d_4PkFwvX-D2ey4U43hY0,19422
48
+ django_qstash-0.0.12.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
49
+ django_qstash-0.0.12.dist-info/top_level.txt,sha256=AlV3WSK1A0ZvKuCLsINtIJhJW8zo7SEB-D3_RAjZ0hI,14
50
+ django_qstash-0.0.12.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (75.7.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5