Skip to content

Commit

Permalink
Update task discovery
Browse files Browse the repository at this point in the history
  • Loading branch information
jmitchel3 committed Jan 3, 2025
1 parent 0ccd744 commit efe1085
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 56 deletions.
72 changes: 53 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ This allows us to:
- [`.apply_async()` With Time Delay](#apply_async-with-time-delay)
- [JSON-ready Arguments](#json-ready-arguments)
- [Example Task](#example-task)
- [Configuration](#configuration)
- [Management Commands](#management-commands)
- [Development Usage](#development-usage)
- [Django Settings Configuration](#django-settings-configuration)
- [Schedule Tasks (Optional)](#schedule-tasks-optional)
- [Installation](#installation-1)
- [Schedule a Task](#schedule-a-task)
- [Store Task Results (Optional)](#store-task-results-optional)
- [Clear Stale Results](#clear-stale-results)
- [Definitions](#definitions)
Expand Down Expand Up @@ -130,7 +134,6 @@ def hello_world(name: str, age: int = None, activity: str = None):
print(f"Hello {name}! I see you're {activity} at {age} years old.")
```


### Regular Task Call
Nothing special here. Just call the function like any other to verify it works.

Expand Down Expand Up @@ -227,32 +230,49 @@ math_add_task.apply_async(
The `.delay()` method does not support a countdown parameter because it simply passes the arguments (*args, **kwargs) to the `apply_async()` method.


## Configuration
## Management Commands

In Django settings, you can configure the following:
- `python manage.py available_tasks` to view all available tasks

_Requires `django_qstash.schedules` installed._
- `python manage.py task_schedules --list` see all schedules relate to the `DJANGO_QSTASH_DOMAIN`
- `python manage.py task_schedules --sync` sync schedules based on the `DJANGO_QSTASH_DOMAIN` to store in the Django Admin.

## Development Usage

django-qstash requires a public domain to work (e.g. `https://djangoqstash.com`). There are many ways to do this, we recommend:

`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.com`
- [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control.
- [ngrok](https://ngrok.com/)

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/).
Once you have a domain name, you can configure the `DJANGO_QSTASH_DOMAIN` setting in your Django settings.


## Django Settings Configuration

In Django settings, you can configure the following:

`DJANGO_QSTASH_WEBHOOK_PATH` (default:`/qstash/webhook/`): The path where QStash will send webhooks to your Django application.
- `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.

`DJANGO_QSTASH_FORCE_HTTPS` (default:`True`): Whether to force HTTPS for the webhook.
- `DJANGO_QSTASH_WEBHOOK_PATH` (default:`/qstash/webhook/`): The path where QStash will send webhooks to your Django application.

`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).
- `DJANGO_QSTASH_FORCE_HTTPS` (default:`True`): Whether to force HTTPS for the webhook.

- `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).


## Schedule Tasks (Optional)

Schedules are a way to schedule tasks to run at a specific time via Cron schedules. They are created via the `django_qstash.schedules` app.
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.

### Installation

Update your `INSTALLED_APPS` setting to include `django_qstash.schedules`.

```python
INSTALLED_APPS = [
# ...
"django_qstash",
"django_qstash", # required
"django_qstash.schedules",
# ...
]
Expand All @@ -263,28 +283,42 @@ Run migrations:
python manage.py migrate django_qstash_schedules
```

Schedule management command:

`python manage.py task_schedules --list` see all schedules relate to the `DJANGO_QSTASH_DOMAIN`
## Schedule a Task

Tasks must exist before you can schedule them. Review [Define a Task](#define-a-task) for more information.

Here's how you can schedule a task:
- Django Admin (`/admin/django_qstash_schedules/taskschedule/add/`)
- Django shell (`python manage.py shell`)


`python manage.py task_schedules --sync` sync schedules based on the `DJANGO_QSTASH_DOMAIN` to store in the Django Admin.

Two ways to create a schedule:
1. In the Django Admin (`/admin/django_qstash_schedules/taskschedule/add/`)
2. In the Django shell:

```python
from django_qstash.schedules.models import TaskSchedule
from django_qstash.discovery.utils import discover_tasks

all_available_tasks = discover_tasks(paths_only=True)

desired_task = "django_qstash.results.clear_stale_results_task"
# or desired_task = "example_app.tasks.my_task"

task_to_use = desired_task
if desired_task not in available_task_locations:
task_to_use = available_task_locations[0]

print(f"Using task: {task_to_use}")

TaskSchedule.objects.create(
name="My Schedule",
cron="0 0 * * *",
task_name="example_app.tasks.my_task",
task_name=task_to_use,
args=["arg1", "arg2"],
kwargs={"kwarg1": "value1", "kwarg2": "value2"},
)
```
- `example_app.tasks.my_task` is an example task that `django_qstash` can find (see above)
- `django_qstash.results.clear_stale_results_task` is a built-in task that `django_qstash.results` provides
- `args` and `kwargs` are the arguments to pass to the task
- `cron` is the cron schedule to run the task. Use [contrab.guru](https://crontab.guru/) for writing the cron format.

Expand Down
12 changes: 6 additions & 6 deletions src/django_qstash/discovery/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ def __init__(self, *args, **kwargs):
kwargs.pop("max_length", None)

# Get tasks before calling parent to set choices
tasks = discover_tasks()
tasks = discover_tasks(locations_only=False)

# Convert tasks to choices using (task_name, task_name) format
task_choices = [(task_value, task_label) for task_value, task_label in tasks]
task_choices = [(task["location"], task["field_label"]) for task in tasks]

kwargs["choices"] = task_choices
kwargs["validators"] = [task_exists_validator] + kwargs.get("validators", [])
Expand All @@ -30,9 +30,9 @@ def get_task(self):
Returns the actual task dot notation path for the selected value
"""
if self.data:
tasks = discover_tasks()
tasks = discover_tasks(locations_only=False)

for task_value, task_label in tasks:
if task_label == self.data:
return task_value
for task in tasks:
if task["field_label"] == self.data:
return task["location"]
return None
12 changes: 10 additions & 2 deletions src/django_qstash/discovery/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@


@lru_cache(maxsize=None)
def discover_tasks() -> list[tuple[str, str]]:
def discover_tasks(locations_only: bool = False) -> list[str] | list[dict]:
"""
Automatically discover tasks in Django apps and return them as a list of tuples.
Each tuple contains (dot_notation_path, task_name).
Expand Down Expand Up @@ -72,13 +72,21 @@ def discover_tasks() -> list[tuple[str, str]]:
label = value
else:
label = f"{attr.name} ({package}.tasks)"
discovered_tasks.append((value, label))
discovered_tasks.append(
{
"name": attr.name,
"field_label": label,
"location": f"{package}.tasks.{attr_name}",
}
)
except Exception as e:
warnings.warn(
f"Failed to import tasks from {package}: {str(e)}",
RuntimeWarning,
stacklevel=2,
)
if locations_only:
return [x["location"] for x in discovered_tasks]
return discovered_tasks


Expand Down
4 changes: 2 additions & 2 deletions src/django_qstash/discovery/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ def task_exists_validator(task_name):
Raises:
ValidationError: If the task cannot be found
"""
tasks = discover_tasks()
available_tasks = [task[0] for task in tasks]
discover_tasks.cache_clear()
available_tasks = discover_tasks(locations_only=True)

if task_name not in available_tasks:
raise ValidationError(
Expand Down
37 changes: 37 additions & 0 deletions src/django_qstash/management/commands/available_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

from django.core.management.base import BaseCommand

from django_qstash.discovery.utils import discover_tasks


class Command(BaseCommand):
help = "View all available tasks"

def add_arguments(self, parser):
parser.add_argument(
"--locations",
action="store_true",
help="Only show task paths",
)

def handle(self, *args, **options):
locations_only = options["locations"] or False
self.stdout.write("Available tasks:")
discover_tasks.cache_clear()
if locations_only:
tasks = discover_tasks(locations_only=locations_only)
for task in tasks:
self.stdout.write(f"\t- {self.style.SQL_FIELD(task)}")
else:
tasks = discover_tasks(locations_only=False)
for task in tasks:
name = task["name"]
field_label = task["field_label"]
location = task["location"]
self.stdout.write(
f" Name: {self.style.SQL_FIELD(name)}\n"
f" Location: {self.style.SQL_FIELD(location)}\n"
f" Field Label: {self.style.SQL_FIELD(field_label)}"
)
self.stdout.write("")
10 changes: 7 additions & 3 deletions tests/discovery/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django_qstash.discovery.models import TaskField


@override_settings(INSTALLED_APPS=["tests.discovery"])
# @override_settings(INSTALLED_APPS=["tests.discovery"])
class TestTaskChoiceField(TestCase):
def test_field_initialization(self):
"""Test that the field initializes with correct choices and validators"""
Expand All @@ -22,13 +22,17 @@ def test_field_initialization(self):
"tests.discovery.tasks.debug_task",
"tests.discovery.tasks.debug_task",
),
(
"django_qstash.results.tasks.clear_stale_results_task",
"Cleanup Task Results (django_qstash.results.tasks)",
),
]

# Check choices are set correctly
self.assertEqual(len(field.choices), len(expected_choices))
self.assertEqual(
[choice[1] for choice in field.choices],
[choice[1] for choice in expected_choices],
sorted([choice[1] for choice in field.choices]),
sorted([choice[1] for choice in expected_choices]),
)

# Check validator is present
Expand Down
36 changes: 18 additions & 18 deletions tests/discovery/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
from __future__ import annotations

from django.test import override_settings

from django_qstash.discovery.utils import discover_tasks


@override_settings(INSTALLED_APPS=["tests.discovery"])
def test_discovers_basic_task():
"""Test that basic task discovery works"""
tasks = discover_tasks()
expected_tasks = [
(
"tests.discovery.tasks.custom_name_task",
"Custom Name Task (tests.discovery.tasks)",
),
(
"tests.discovery.tasks.debug_task",
"tests.discovery.tasks.debug_task",
),
{
"name": "Custom Name Task",
"field_label": "Custom Name Task (tests.discovery.tasks)",
"location": "tests.discovery.tasks.custom_name_task",
},
{
"name": "debug_task",
"field_label": "tests.discovery.tasks.debug_task",
"location": "tests.discovery.tasks.debug_task",
},
{
"name": "Cleanup Task Results",
"field_label": "Cleanup Task Results (django_qstash.results.tasks)",
"location": "django_qstash.results.tasks.clear_stale_results_task",
},
]
assert len(tasks) == len(expected_tasks)

task_values = [task[0] for task in tasks]
expected_task_values = [task[0] for task in expected_tasks]
assert expected_task_values == task_values
task_labels = [task[1] for task in tasks]
expected_task_labels = [task[1] for task in expected_tasks]
assert expected_task_labels == task_labels
tasks_set = {tuple(sorted(t.items())) for t in tasks}
expected_tasks_set = {tuple(sorted(t.items())) for t in expected_tasks}
assert tasks_set == expected_tasks_set
11 changes: 5 additions & 6 deletions tests/discovery/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@
import pytest
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from django.test import override_settings

from django_qstash.discovery.validators import task_exists_validator


@override_settings(INSTALLED_APPS=["tests.discovery"])
class TestTaskExistsValidator(SimpleTestCase):
def test_validates_existing_task(self):
"""Test that validator passes for existing tasks"""
Expand All @@ -21,7 +19,8 @@ def test_raises_for_non_existent_task(self):
with pytest.raises(ValidationError) as exc_info:
task_exists_validator("non.existent.task")

assert "Task 'non.existent.task' not found" in str(exc_info.value)
assert "Available tasks:" in str(exc_info.value)
assert "tests.discovery.tasks.debug_task" in str(exc_info.value)
assert "tests.discovery.tasks.custom_name_task" in str(exc_info.value)
error_message = str(exc_info.value)
assert "Task 'non.existent.task' not found" in error_message
assert "Available tasks:" in error_message
assert "tests.discovery.tasks.debug_task" in error_message
assert "tests.discovery.tasks.custom_name_task" in error_message
52 changes: 52 additions & 0 deletions tests/management/test_available_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import annotations

from io import StringIO

import pytest
from django.core.management import call_command


@pytest.mark.django_db
class TestAvailableTasks:
def test_available_tasks_basic(self):
"""Test that the available_tasks command outputs all task information"""
out = StringIO()
call_command("available_tasks", stdout=out)
output = out.getvalue()

# Check header
assert "Available tasks:" in output

# Updated assertions to match actual implementation
assert "Name: Custom Name Task" in output
assert "Location: tests.discovery.tasks.custom_name_task" in output
assert "Field Label: Custom Name Task (tests.discovery.tasks)" in output

assert "Name: debug_task" in output
assert "Location: tests.discovery.tasks.debug_task" in output
assert "Field Label: tests.discovery.tasks.debug_task" in output

assert "Name: Cleanup Task Results" in output
assert (
"Location: django_qstash.results.tasks.clear_stale_results_task" in output
)
assert (
"Field Label: Cleanup Task Results (django_qstash.results.tasks)" in output
)

def test_available_tasks_locations_only(self):
"""Test that the --locations flag only shows task paths"""
out = StringIO()
call_command("available_tasks", "--locations", stdout=out)
output = out.getvalue()

# Check header
assert "Available tasks:" in output

# Updated assertions to match actual output format
assert "tests.discovery.tasks.custom_name_task" in output
assert "tests.discovery.tasks.debug_task" in output

# Verify other details are not present
assert "Name:" not in output
assert "Field Label:" not in output
Loading

0 comments on commit efe1085

Please sign in to comment.