pax_global_header00006660000000000000000000000064151414076140014515gustar00rootroot0000000000000052 comment=caaf8090ef08759243094b746d9f7260b8d9199b django-tasks-0.12.0/000077500000000000000000000000001514140761400141625ustar00rootroot00000000000000django-tasks-0.12.0/.github/000077500000000000000000000000001514140761400155225ustar00rootroot00000000000000django-tasks-0.12.0/.github/workflows/000077500000000000000000000000001514140761400175575ustar00rootroot00000000000000django-tasks-0.12.0/.github/workflows/ci.yml000066400000000000000000000042231514140761400206760ustar00rootroot00000000000000name: CI on: pull_request: push: branches: - master tags: - "*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] django-version: ["4.2", "5.0", "5.1", "5.2", "6.0"] exclude: - django-version: "6.0" python-version: "3.10" - django-version: "6.0" python-version: "3.11" - django-version: "4.2" python-version: "3.14" steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} - uses: taiki-e/install-action@just - name: Install dependencies run: | pip install --upgrade pip pip install -e . --group dev pip install Django~=${{ matrix.django-version }} - name: Lint run: just lint - name: Run tests if: ${{ !cancelled() }} run: just test -v2 build: permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing runs-on: ubuntu-latest needs: - test steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.14" - name: Install dependencies run: | python -m pip install --upgrade pip build - name: Build package run: python -m build - name: Save built package uses: actions/upload-artifact@v6 with: name: package path: dist - name: Publish to PyPi if: ${{ github.ref_type == 'tag' }} uses: pypa/gh-action-pypi-publish@release/v1 with: print-hash: true django-tasks-0.12.0/.gitignore000066400000000000000000000067031514140761400161600ustar00rootroot00000000000000# Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py *.sqlite3 *.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ### Python Patch ### # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration poetry.toml # ruff .ruff_cache/ # LSP config files pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python # Editor config files .vscode django-tasks-0.12.0/CONTRIBUTING.md000066400000000000000000000017551514140761400164230ustar00rootroot00000000000000# Contributing Found a bug? Want to fix an open issue? Got an idea for an improvement? Please contribute! **All** contributions are welcome, from absolutely anyone. Just open a PR, Issue or Discussion (as relevant) - no need to ask beforehand. If you're going to work on an issue, it's a good idea to say so on the issue, to make sure work isn't duplicated. Because this repository is a backport of the `django.tasks` package, notable improvements should be raised upstream before being included here. This repository will still serve as a place for discussions. ## Development set up Fork, then clone the repo: ```sh git clone git@github.com:your-username/django-tasks.git ``` Set up a venv: ```sh python -m venv .venv source .venv/bin/activate python -m pip install -e --group dev ``` Then you can run the tests with the [just](https://just.systems/man/en/) command runner: ```sh just test ``` If you don't have `just` installed, you can look in the `justfile` for the commands that are run. django-tasks-0.12.0/LICENSE000066400000000000000000000030171514140761400151700ustar00rootroot00000000000000Copyright (c) Jake Howard and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. django-tasks-0.12.0/README.md000066400000000000000000000163071514140761400154500ustar00rootroot00000000000000# Django Tasks [![CI](https://github.com/RealOrangeOne/django-tasks/actions/workflows/ci.yml/badge.svg)](https://github.com/RealOrangeOne/django-tasks/actions/workflows/ci.yml) ![PyPI](https://img.shields.io/pypi/v/django-tasks.svg) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-tasks.svg) ![PyPI - Status](https://img.shields.io/pypi/status/django-tasks.svg) ![PyPI - License](https://img.shields.io/pypi/l/django-tasks.svg) An backport of `django.tasks` - Django's built-in [Tasks framework](https://docs.djangoproject.com/en/stable/topics/tasks/). ## Installation ``` python -m pip install django-tasks ``` The first step is to add `django_tasks` to your `INSTALLED_APPS`. ```python INSTALLED_APPS = [ # ... "django_tasks", ] ``` Secondly, you'll need to configure a backend. This connects the tasks to whatever is going to execute them. If omitted, the following configuration is used: ```python TASKS = { "default": { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend" } } ``` A few backends are included by default: - `django_tasks.backends.dummy.DummyBackend`: Don't execute the tasks, just store them. This is especially useful for testing. - `django_tasks.backends.immediate.ImmediateBackend`: Execute the task immediately in the current thread Prior to `0.12.0`, [`django-tasks-db`](https://github.com/RealOrangeOne/django-tasks-db) and [`django-tasks-rq`](https://github.com/RealOrangeOne/django-tasks-rq) were also included to provide database and RQ based backends. ## Usage ### Defining tasks A task is created with the `task` decorator. ```python from django_tasks import task @task() def calculate_meaning_of_life() -> int: return 42 ``` The task decorator accepts a few arguments to customize the task: - `priority`: The priority of the task (between -100 and 100. Larger numbers are higher priority. 0 by default) - `queue_name`: Whether to run the task on a specific queue - `backend`: Name of the backend for this task to use (as defined in `TASKS`) ```python modified_task = calculate_meaning_of_life.using(priority=10) ``` In addition to the above attributes, `run_after` can be passed to specify a specific time the task should run. #### Task context Sometimes the running task may need to know context about how it was enqueued. To receive the task context as an argument to your task function, pass `takes_context` to the decorator and ensure the task takes a `context` as the first argument. ```python from django_tasks import task, TaskContext @task(takes_context=True) def calculate_meaning_of_life(context: TaskContext) -> int: return 42 ``` The task context has the following attributes: - `task_result`: The running task result - `attempt`: The current attempt number for the task This API will be extended with additional features in future. ### Enqueueing tasks To execute a task, call the `enqueue` method on it: ```python result = calculate_meaning_of_life.enqueue() ``` The returned `TaskResult` can be interrogated to query the current state of the running task, as well as its return value. If the task takes arguments, these can be passed as-is to `enqueue`. ### Queue names By default, tasks are enqueued onto the "default" queue. When using multiple queues, it can be useful to constrain the allowed names, so tasks aren't missed. ```python TASKS = { "default": { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend", "QUEUES": ["default", "special"] } } ``` Enqueueing tasks to an unknown queue name raises `InvalidTaskError`. To disable queue name validation, set `QUEUES` to `[]`. ### Retrieving task result When enqueueing a task, you get a `TaskResult`, however it may be useful to retrieve said result from somewhere else (another request, another task etc). This can be done with `get_result` (or `aget_result`): ```python result_id = result.id # Later, somewhere else... calculate_meaning_of_life.get_result(result_id) ``` A result `id` should be considered an opaque string, whose length could be up to 64 characters. ID generation is backend-specific. Only tasks of the same type can be retrieved this way. To retrieve the result of any task, you can call `get_result` on the backend: ```python from django_tasks import default_task_backend default_task_backend.get_result(result_id) ``` ### Return values If your task returns something, it can be retrieved from the `.return_value` attribute on a `TaskResult`. Accessing this property on an unsuccessful task (ie not `SUCCESSFUL`) will raise a `ValueError`. ```python assert result.status == TaskResultStatus.SUCCESSFUL assert result.return_value == 42 ``` If a result has been updated in the background, you can call `refresh` on it to update its values. Results obtained using `get_result` will always be up-to-date. ```python assert result.status == TaskResultStatus.READY result.refresh() assert result.status == TaskResultStatus.SUCCESSFUL ``` #### Errors If a task raised an exception, its `.errors` contains information about the error: ```python assert result.errors[0].exception_class == ValueError ``` Note that this is just the type of exception, and contains no other values. The traceback information is reduced to a string that you can print to help debugging: ```python assert isinstance(result.errors[0].traceback, str) ``` Note that currently, whilst `.errors` is a list, it will only ever contain a single element. #### Attempts The number of times a task has been run is stored as the `.attempts` attribute. This will currently only ever be 0 or 1. The date of the last attempt is stored as `.last_attempted_at`. ### Backend introspecting Because `django-tasks` enables support for multiple different backends, those backends may not support all features, and it can be useful to determine this at runtime to ensure the chosen task queue meets the requirements, or to gracefully degrade functionality if it doesn't. - `supports_defer`: Can tasks be enqueued with the `run_after` attribute? - `supports_async_task`: Can coroutines be enqueued? - `supports_get_result`: Can results be retrieved after the fact (from **any** thread / process)? - `supports_priority`: Can tasks be executed in a given priority order? ```python from django_tasks import default_task_backend assert default_task_backend.supports_get_result ``` This is particularly useful in combination with Django's [system check framework](https://docs.djangoproject.com/en/stable/topics/checks/). ### Signals A few [Signals](https://docs.djangoproject.com/en/stable/topics/signals/) are provided to more easily respond to certain task events. Whilst signals are available, they may not be the most maintainable approach. - `django_tasks.signals.task_enqueued`: Called when a task is enqueued. The sender is the backend class. Also called with the enqueued `task_result`. - `django_tasks.signals.task_finished`: Called when a task finishes (`SUCCESSFUL` or `FAILED`). The sender is the backend class. Also called with the finished `task_result`. - `django_tasks.signals.task_started`: Called immediately before a task starts executing. The sender is the backend class. Also called with the started `task_result`. ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for information on how to contribute. django-tasks-0.12.0/django_tasks/000077500000000000000000000000001514140761400166315ustar00rootroot00000000000000django-tasks-0.12.0/django_tasks/__init__.py000066400000000000000000000042431514140761400207450ustar00rootroot00000000000000# ruff: noqa: E402 import django_stubs_ext django_stubs_ext.monkeypatch() import importlib.metadata from django.conf import global_settings from django.utils.connection import BaseConnectionHandler, ConnectionProxy from django.utils.module_loading import import_string from .backends.base import BaseTaskBackend from .base import ( DEFAULT_TASK_BACKEND_ALIAS, DEFAULT_TASK_QUEUE_NAME, TaskContext, TaskResult, TaskResultStatus, task, ) from .exceptions import InvalidTaskBackendError __version__ = importlib.metadata.version(__name__) __all__ = [ "task_backends", "default_task_backend", "DEFAULT_TASK_BACKEND_ALIAS", "DEFAULT_TASK_QUEUE_NAME", "TaskResultStatus", "TaskResult", "TaskContext", "task", ] class TaskBackendHandler(BaseConnectionHandler[BaseTaskBackend]): settings_name = "TASKS" exception_class = InvalidTaskBackendError def configure_settings(self, settings: dict | None) -> dict: try: task_settings = super().configure_settings(settings) except AttributeError: # HACK: Force a default task backend. # Can be replaced with `django.conf.global_settings` once vendored. task_settings = None if task_settings is None or task_settings is getattr( global_settings, self.settings_name, None ): task_settings = { DEFAULT_TASK_BACKEND_ALIAS: { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend" } } return task_settings def create_connection(self, alias: str) -> BaseTaskBackend: params = self.settings[alias] backend = params["BACKEND"] try: backend_cls = import_string(backend) except ImportError as e: raise InvalidTaskBackendError( f"Could not find backend '{backend}': {e}" ) from e return backend_cls(alias=alias, params=params) # type:ignore[no-any-return] task_backends = TaskBackendHandler() default_task_backend: BaseTaskBackend = ConnectionProxy( # type:ignore[assignment] task_backends, DEFAULT_TASK_BACKEND_ALIAS ) django-tasks-0.12.0/django_tasks/apps.py000066400000000000000000000002611514140761400201450ustar00rootroot00000000000000from django.apps import AppConfig class TasksAppConfig(AppConfig): name = "django_tasks" def ready(self) -> None: from . import checks, signals # noqa: F401 django-tasks-0.12.0/django_tasks/backends/000077500000000000000000000000001514140761400204035ustar00rootroot00000000000000django-tasks-0.12.0/django_tasks/backends/__init__.py000066400000000000000000000000001514140761400225020ustar00rootroot00000000000000django-tasks-0.12.0/django_tasks/backends/base.py000066400000000000000000000106771514140761400217020ustar00rootroot00000000000000from abc import ABCMeta, abstractmethod from collections.abc import Iterable from inspect import iscoroutinefunction from typing import Any, TypeVar from asgiref.sync import sync_to_async from django.conf import settings from django.core import checks from django.utils import timezone from django.utils.inspect import get_func_args from typing_extensions import ParamSpec from django_tasks.base import ( DEFAULT_TASK_PRIORITY, TASK_MAX_PRIORITY, TASK_MIN_PRIORITY, Task, TaskResult, ) from django_tasks.exceptions import InvalidTaskError from django_tasks.utils import is_module_level_function T = TypeVar("T") P = ParamSpec("P") class BaseTaskBackend(metaclass=ABCMeta): alias: str task_class = Task supports_defer = False """Does the backend support Tasks to be enqueued with the run_after attribute?""" supports_async_task = False """Does the backend support coroutines to be enqueued?""" supports_get_result = False """Does the backend support results being retrieved (from any thread / process)?""" supports_priority = False """Does the backend support tasks being executed in a given priority order?""" def __init__(self, alias: str, params: dict) -> None: from django_tasks import DEFAULT_TASK_QUEUE_NAME self.alias = alias self.queues = set(params.get("QUEUES", [DEFAULT_TASK_QUEUE_NAME])) self.options = params.get("OPTIONS", {}) def validate_task(self, task: Task) -> None: """ Determine whether the provided Task can be executed by the backend. """ if not is_module_level_function(task.func): raise InvalidTaskError("Task function must be defined at a module level.") if not self.supports_async_task and iscoroutinefunction(task.func): raise InvalidTaskError("Backend does not support async tasks.") task_func_args = get_func_args(task.func) if task.takes_context and ( not task_func_args or task_func_args[0] != "context" ): raise InvalidTaskError( "Task takes context but does not have a first argument of 'context'." ) if not self.supports_priority and task.priority != DEFAULT_TASK_PRIORITY: raise InvalidTaskError( "Backend does not support setting priority of tasks." ) if ( task.priority < TASK_MIN_PRIORITY or task.priority > TASK_MAX_PRIORITY or int(task.priority) != task.priority ): raise InvalidTaskError( f"priority must be a whole number between {TASK_MIN_PRIORITY} and {TASK_MAX_PRIORITY}." ) if not self.supports_defer and task.run_after is not None: raise InvalidTaskError("Backend does not support run_after.") if ( settings.USE_TZ and task.run_after is not None and not timezone.is_aware(task.run_after) ): raise InvalidTaskError("run_after must be an aware datetime.") if self.queues and task.queue_name not in self.queues: raise InvalidTaskError( f"Queue '{task.queue_name}' is not valid for backend." ) @abstractmethod def enqueue( self, task: Task[P, T], args: P.args, # type:ignore[valid-type] kwargs: P.kwargs, # type:ignore[valid-type] ) -> TaskResult[T]: """ Queue up a task to be executed """ async def aenqueue( self, task: Task[P, T], args: P.args, # type:ignore[valid-type] kwargs: P.kwargs, # type:ignore[valid-type] ) -> TaskResult[T]: """ Queue up a task function (or coroutine) to be executed """ return await sync_to_async(self.enqueue, thread_sensitive=True)( task=task, args=args, kwargs=kwargs ) def get_result(self, result_id: str) -> TaskResult: """ Retrieve a result by id if it exists, otherwise raise ResultDoesNotExist. """ raise NotImplementedError( "This backend does not support retrieving or refreshing results." ) async def aget_result(self, result_id: str) -> TaskResult: """See get_result().""" return await sync_to_async(self.get_result, thread_sensitive=True)( result_id=result_id ) def check(self, **kwargs: Any) -> Iterable[checks.CheckMessage]: return [] django-tasks-0.12.0/django_tasks/backends/dummy.py000066400000000000000000000041651514140761400221160ustar00rootroot00000000000000from copy import deepcopy from typing import TypeVar from django.utils import timezone from typing_extensions import ParamSpec from django_tasks.base import Task, TaskResult, TaskResultStatus from django_tasks.exceptions import TaskResultDoesNotExist from django_tasks.signals import task_enqueued from django_tasks.utils import get_random_id from .base import BaseTaskBackend T = TypeVar("T") P = ParamSpec("P") class DummyBackend(BaseTaskBackend): supports_defer = True supports_async_task = True supports_priority = True results: list[TaskResult] def __init__(self, alias: str, params: dict) -> None: super().__init__(alias, params) self.results = [] def enqueue( self, task: Task[P, T], args: P.args, # type:ignore[valid-type] kwargs: P.kwargs, # type:ignore[valid-type] ) -> TaskResult[T]: self.validate_task(task) result: TaskResult[T] = TaskResult( task=task, id=get_random_id(), status=TaskResultStatus.READY, enqueued_at=timezone.now(), started_at=None, last_attempted_at=None, finished_at=None, args=args, kwargs=kwargs, backend=self.alias, errors=[], worker_ids=[], ) self.results.append(result) task_enqueued.send(type(self), task_result=result) # Copy the task to prevent mutation issues return deepcopy(result) # We don't set `supports_get_result` as the results are scoped to the current thread def get_result(self, result_id: str) -> TaskResult: try: return next(result for result in self.results if result.id == result_id) except StopIteration: raise TaskResultDoesNotExist(result_id) from None async def aget_result(self, result_id: str) -> TaskResult: try: return next(result for result in self.results if result.id == result_id) except StopIteration: raise TaskResultDoesNotExist(result_id) from None def clear(self) -> None: self.results.clear() django-tasks-0.12.0/django_tasks/backends/immediate.py000066400000000000000000000067101514140761400227170ustar00rootroot00000000000000import logging from typing import TypeVar from django.utils import timezone from typing_extensions import ParamSpec from django_tasks.base import Task, TaskContext, TaskError, TaskResult, TaskResultStatus from django_tasks.signals import task_enqueued, task_finished, task_started from django_tasks.utils import ( get_exception_traceback, get_module_path, get_random_id, normalize_json, ) from .base import BaseTaskBackend logger = logging.getLogger(__name__) T = TypeVar("T") P = ParamSpec("P") class ImmediateBackend(BaseTaskBackend): supports_async_task = True supports_priority = True def __init__(self, alias: str, params: dict): super().__init__(alias, params) self.worker_id = get_random_id() def _execute_task(self, task_result: TaskResult) -> None: """ Execute the Task for the given TaskResult, mutating it with the outcome """ object.__setattr__(task_result, "enqueued_at", timezone.now()) task_enqueued.send(type(self), task_result=task_result) task = task_result.task task_start_time = timezone.now() object.__setattr__(task_result, "status", TaskResultStatus.RUNNING) object.__setattr__(task_result, "started_at", task_start_time) object.__setattr__(task_result, "last_attempted_at", task_start_time) task_result.worker_ids.append(self.worker_id) task_started.send(sender=type(self), task_result=task_result) try: if task.takes_context: raw_return_value = task.call( TaskContext(task_result=task_result), *task_result.args, **task_result.kwargs, ) else: raw_return_value = task.call(*task_result.args, **task_result.kwargs) object.__setattr__( task_result, "_return_value", normalize_json(raw_return_value), ) except KeyboardInterrupt: # If the user tried to terminate, let them raise except BaseException as e: object.__setattr__(task_result, "finished_at", timezone.now()) task_result.errors.append( TaskError( exception_class_path=get_module_path(type(e)), traceback=get_exception_traceback(e), ) ) object.__setattr__(task_result, "status", TaskResultStatus.FAILED) task_finished.send(type(self), task_result=task_result) else: object.__setattr__(task_result, "finished_at", timezone.now()) object.__setattr__(task_result, "status", TaskResultStatus.SUCCESSFUL) task_finished.send(type(self), task_result=task_result) def enqueue( self, task: Task[P, T], args: P.args, # type:ignore[valid-type] kwargs: P.kwargs, # type:ignore[valid-type] ) -> TaskResult[T]: self.validate_task(task) task_result: TaskResult[T] = TaskResult( task=task, id=get_random_id(), status=TaskResultStatus.READY, enqueued_at=None, started_at=None, last_attempted_at=None, finished_at=None, args=args, kwargs=kwargs, backend=self.alias, errors=[], worker_ids=[], ) self._execute_task(task_result) return task_result django-tasks-0.12.0/django_tasks/base.py000066400000000000000000000241051514140761400201170ustar00rootroot00000000000000from collections.abc import Callable from dataclasses import dataclass, field, replace from datetime import datetime from inspect import isclass, iscoroutinefunction from typing import ( TYPE_CHECKING, Any, Concatenate, Generic, Literal, TypeVar, cast, overload, ) from asgiref.sync import async_to_sync, sync_to_async from django.db.models.enums import TextChoices from django.utils.module_loading import import_string from django.utils.translation import pgettext_lazy from django.utils.version import PY311 from typing_extensions import ParamSpec, Self from .exceptions import TaskResultMismatch from .utils import ( get_module_path, normalize_json, ) if TYPE_CHECKING: from .backends.base import BaseTaskBackend DEFAULT_TASK_BACKEND_ALIAS = "default" DEFAULT_TASK_QUEUE_NAME = "default" TASK_MIN_PRIORITY = -100 TASK_MAX_PRIORITY = 100 DEFAULT_TASK_PRIORITY = 0 TASK_REFRESH_ATTRS = { "errors", "_return_value", "finished_at", "started_at", "last_attempted_at", "status", "enqueued_at", "worker_ids", } class TaskResultStatus(TextChoices): READY = ("READY", pgettext_lazy("Task", "Ready")) """The Task has just been enqueued, or is ready to be executed again.""" RUNNING = ("RUNNING", pgettext_lazy("Task", "Running")) """The Task is currently running.""" FAILED = ("FAILED", pgettext_lazy("Task", "Failed")) """The Task raised an exception during execution, or was unable to start.""" SUCCESSFUL = ("SUCCESSFUL", pgettext_lazy("Task", "Successful")) """The Task has finished running successfully.""" T = TypeVar("T") P = ParamSpec("P") @dataclass(frozen=True, slots=PY311, kw_only=True) # type: ignore[literal-required] class Task(Generic[P, T]): func: Callable[P, T] """The Task function""" priority: int = DEFAULT_TASK_PRIORITY """The Task's priority""" backend: str = DEFAULT_TASK_BACKEND_ALIAS """The name of the backend the Task will run on""" queue_name: str = DEFAULT_TASK_QUEUE_NAME """The name of the queue the Task will run on""" run_after: datetime | None = None """The earliest this Task will run""" takes_context: bool = False """ Whether the Task receives the Task context when executed. """ def __post_init__(self) -> None: self.get_backend().validate_task(self) @property def name(self) -> str: """ An identifier for the task """ return self.func.__name__ def using( self, *, priority: int | None = None, queue_name: str | None = None, run_after: datetime | None = None, backend: str | None = None, ) -> Self: """ Create a new Task with modified defaults. """ changes: dict[str, Any] = {} if priority is not None: changes["priority"] = priority if queue_name is not None: changes["queue_name"] = queue_name if run_after is not None: changes["run_after"] = run_after if backend is not None: changes["backend"] = backend return replace(self, **changes) def enqueue(self, *args: P.args, **kwargs: P.kwargs) -> "TaskResult[T]": """ Queue up the Task to be executed. """ return self.get_backend().enqueue(self, args, kwargs) async def aenqueue(self, *args: P.args, **kwargs: P.kwargs) -> "TaskResult[T]": """ Queue up the Task to be executed. """ return await self.get_backend().aenqueue(self, args, kwargs) def get_result(self, result_id: str) -> "TaskResult[T]": """ Retrieve the result for a Task of this type by id if it exists, otherwise raise ResultDoesNotExist. """ result = self.get_backend().get_result(result_id) if result.task.func != self.func: raise TaskResultMismatch( f"Task does not match (received {result.task.module_path!r})" ) return result async def aget_result(self, result_id: str) -> "TaskResult[T]": """See get_result().""" result = await self.get_backend().aget_result(result_id) if result.task.func != self.func: raise TaskResultMismatch( f"Task does not match (received {result.task.module_path!r})" ) return result def call(self, *args: P.args, **kwargs: P.kwargs) -> T: if iscoroutinefunction(self.func): return async_to_sync(self.func)(*args, **kwargs) # type:ignore[no-any-return] return self.func(*args, **kwargs) async def acall(self, *args: P.args, **kwargs: P.kwargs) -> T: if iscoroutinefunction(self.func): return await self.func(*args, **kwargs) # type:ignore[no-any-return] return await sync_to_async(self.func)(*args, **kwargs) def get_backend(self) -> "BaseTaskBackend": from . import task_backends return task_backends[self.backend] @property def module_path(self) -> str: return get_module_path(self.func) # Bare decorator usage # e.g. @task @overload def task(function: Callable[P, T], **kwargs: Any) -> Task[P, T]: ... # Decorator with arguments # e.g. @task() or @task(priority=1, ...) @overload def task( *, priority: int = DEFAULT_TASK_PRIORITY, queue_name: str = DEFAULT_TASK_QUEUE_NAME, backend: str = DEFAULT_TASK_BACKEND_ALIAS, takes_context: Literal[False] = False, **kwargs: Any, ) -> Callable[[Callable[P, T]], Task[P, T]]: ... # Decorator with context and arguments # e.g. @task(takes_context=True, ...) @overload def task( *, priority: int = DEFAULT_TASK_PRIORITY, queue_name: str = DEFAULT_TASK_QUEUE_NAME, backend: str = DEFAULT_TASK_BACKEND_ALIAS, takes_context: Literal[True], **kwargs: Any, ) -> Callable[[Callable[Concatenate["TaskContext", P], T]], Task[P, T]]: ... # Implementation def task( function: Callable[P, T] | None = None, *, priority: int = DEFAULT_TASK_PRIORITY, queue_name: str = DEFAULT_TASK_QUEUE_NAME, backend: str = DEFAULT_TASK_BACKEND_ALIAS, takes_context: bool = False, **kwargs: Any, ) -> ( Task[P, T] | Callable[[Callable[P, T]], Task[P, T]] | Callable[[Callable[Concatenate["TaskContext", P], T]], Task[P, T]] ): """ A decorator used to create a task. """ from . import task_backends def wrapper(f: Callable[P, T]) -> Task[P, T]: return task_backends[backend].task_class( priority=priority, func=f, queue_name=queue_name, backend=backend, takes_context=takes_context, run_after=None, **kwargs, ) if function: return wrapper(function) return wrapper @dataclass(frozen=True, slots=PY311, kw_only=True) # type: ignore[literal-required] class TaskError: exception_class_path: str traceback: str @property def exception_class(self) -> type[BaseException]: # Lazy resolve the exception class exception_class = import_string(self.exception_class_path) if not isclass(exception_class) or not issubclass( exception_class, BaseException ): raise ValueError( f"{self.exception_class_path!r} does not reference a valid exception." ) return exception_class @dataclass(frozen=True, slots=PY311, kw_only=True) # type: ignore[literal-required] class TaskResult(Generic[T]): task: Task """Task for which this is a result""" id: str """A unique identifier for the task result""" status: TaskResultStatus """Status of the running Task""" enqueued_at: datetime | None """Time this Task was enqueued""" started_at: datetime | None """Time this Task was started""" finished_at: datetime | None """Time this Task was finished""" last_attempted_at: datetime | None """Time this Task was last attempted to be run""" args: list """The arguments to pass to the task function""" kwargs: dict[str, Any] """The keyword arguments to pass to the task function""" backend: str """The name of the backend the task is run with""" errors: list[TaskError] """Errors raised when running the task""" worker_ids: list[str] """The workers which have processed the task""" _return_value: T | None = field(init=False, default=None) def __post_init__(self) -> None: object.__setattr__(self, "args", normalize_json(self.args)) object.__setattr__(self, "kwargs", normalize_json(self.kwargs)) @property def return_value(self) -> T | None: """ The return value of the task. If the task didn't succeed, an exception is raised. This is to distinguish against the task returning None. """ if self.status == TaskResultStatus.SUCCESSFUL: return cast(T, self._return_value) elif self.status == TaskResultStatus.FAILED: raise ValueError("Task failed") else: raise ValueError("Task has not finished yet") @property def is_finished(self) -> bool: """Has the task finished?""" return self.status in {TaskResultStatus.FAILED, TaskResultStatus.SUCCESSFUL} @property def attempts(self) -> int: return len(self.worker_ids) def refresh(self) -> None: """ Reload the cached task data from the task store """ refreshed_task = self.task.get_backend().get_result(self.id) for attr in TASK_REFRESH_ATTRS: object.__setattr__(self, attr, getattr(refreshed_task, attr)) async def arefresh(self) -> None: """ Reload the cached task data from the task store """ refreshed_task = await self.task.get_backend().aget_result(self.id) for attr in TASK_REFRESH_ATTRS: object.__setattr__(self, attr, getattr(refreshed_task, attr)) @dataclass(frozen=True, slots=PY311, kw_only=True) # type: ignore[literal-required] class TaskContext: task_result: TaskResult @property def attempt(self) -> int: return self.task_result.attempts django-tasks-0.12.0/django_tasks/checks.py000066400000000000000000000006651514140761400204520ustar00rootroot00000000000000from collections.abc import Iterable, Sequence from typing import Any from django.apps.config import AppConfig from django.core import checks from django_tasks import task_backends @checks.register def check_tasks( app_configs: Sequence[AppConfig] = None, **kwargs: Any ) -> Iterable[checks.CheckMessage]: """Checks all registered task backends.""" for backend in task_backends.all(): yield from backend.check() django-tasks-0.12.0/django_tasks/exceptions.py000066400000000000000000000006701514140761400213670ustar00rootroot00000000000000from django.core.exceptions import ImproperlyConfigured class TaskException(Exception): # noqa: N818 """Base class for task-related exceptions. Do not raise directly.""" class InvalidTaskError(TaskException): """ The provided Task is invalid. """ class InvalidTaskBackendError(ImproperlyConfigured): pass class TaskResultDoesNotExist(TaskException): pass class TaskResultMismatch(TaskException): pass django-tasks-0.12.0/django_tasks/py.typed000066400000000000000000000000001514140761400203160ustar00rootroot00000000000000django-tasks-0.12.0/django_tasks/signals.py000066400000000000000000000035331514140761400206470ustar00rootroot00000000000000import logging from asgiref.local import Local from django.core.signals import setting_changed from django.dispatch import Signal, receiver from django_tasks import BaseTaskBackend, TaskResult, TaskResultStatus task_enqueued = Signal() task_finished = Signal() task_started = Signal() logger = logging.getLogger("django_tasks") @receiver(setting_changed) def clear_tasks_handlers(*, setting: str, **kwargs: dict) -> None: """ Reset the connection handler whenever the settings change. """ if setting == "TASKS": from django_tasks import task_backends task_backends._settings = task_backends.settings = ( # type:ignore[attr-defined] task_backends.configure_settings(None) ) task_backends._connections = Local() # type:ignore[attr-defined] @receiver(task_enqueued) def log_task_enqueued( sender: type[BaseTaskBackend], task_result: TaskResult, **kwargs: dict ) -> None: logger.debug( "Task id=%s path=%s enqueued backend=%s", task_result.id, task_result.task.module_path, task_result.backend, ) @receiver(task_started) def log_task_started( sender: type[BaseTaskBackend], task_result: TaskResult, **kwargs: dict ) -> None: logger.info( "Task id=%s path=%s state=%s", task_result.id, task_result.task.module_path, task_result.status, ) @receiver(task_finished) def log_task_finished( sender: type[BaseTaskBackend], task_result: TaskResult, **kwargs: dict ) -> None: if task_result.status == TaskResultStatus.FAILED: # Use exception to integrate with error monitoring tools log_method = logger.exception else: log_method = logger.info log_method( "Task id=%s path=%s state=%s", task_result.id, task_result.task.module_path, task_result.status, ) django-tasks-0.12.0/django_tasks/utils.py000066400000000000000000000031521514140761400203440ustar00rootroot00000000000000import inspect from collections.abc import Callable, Mapping, Sequence from traceback import format_exception from typing import Any, TypeVar from django.utils.crypto import get_random_string from typing_extensions import ParamSpec T = TypeVar("T") P = ParamSpec("P") def is_module_level_function(func: Callable) -> bool: if not inspect.isfunction(func) or inspect.isbuiltin(func): return False if "" in func.__qualname__: return False return True def normalize_json(obj: Any) -> Any: """Recursively normalize an object into JSON-compatible types.""" match obj: case Mapping(): return {normalize_json(k): normalize_json(v) for k, v in obj.items()} case bytes(): try: return obj.decode("utf-8") except UnicodeDecodeError as e: raise ValueError(f"Unsupported value: {type(obj)}") from e case str() | int() | float() | bool() | None: return obj case Sequence(): # str and bytes were already handled. return [normalize_json(v) for v in obj] case _: # Other types can't be serialized to JSON raise TypeError(f"Unsupported type: {type(obj)}") def get_module_path(val: Any) -> str: return f"{val.__module__}.{val.__qualname__}" def get_exception_traceback(exc: BaseException) -> str: return "".join(format_exception(exc)) def get_random_id() -> str: """ Return a random string for use as a Task or worker id. Whilst 64 characters is the max, just use 32 as a sensible middle-ground. """ return get_random_string(32) django-tasks-0.12.0/justfile000066400000000000000000000007311514140761400157330ustar00rootroot00000000000000# Recipes @default: just --list test *ARGS: python -m manage check python -m coverage run --source=django_tasks -m manage test --shuffle --noinput {{ ARGS }} python -m coverage report python -m coverage html format: python -m ruff check django_tasks tests --fix python -m ruff format django_tasks tests lint: python -m ruff check django_tasks tests python -m ruff format django_tasks tests --check python -m mypy django_tasks tests django-tasks-0.12.0/manage.py000077500000000000000000000003701514140761400157670ustar00rootroot00000000000000#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-tasks-0.12.0/pyproject.toml000066400000000000000000000052501514140761400171000ustar00rootroot00000000000000[build-system] build-backend = "setuptools.build_meta" requires = [ "setuptools", ] [project] name = "django-tasks" description = "A backport of Django's built in Tasks framework" authors = [ {name = "Jake Howard"}, ] version = "0.12.0" readme = "README.md" license = "BSD-3-Clause" license-files = ["LICENSE"] classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Intended Audience :: Developers", "Operating System :: OS Independent", "Natural Language :: English", "Topic :: Internet :: WWW/HTTP", "Typing :: Typed" ] requires-python = ">=3.10" dependencies = [ "Django>=4.2", "typing_extensions", "django-stubs-ext", ] [project.urls] Source = "https://github.com/RealOrangeOne/django-tasks" Issues = "https://github.com/RealOrangeOne/django-tasks/issues" Changelog = "https://github.com/RealOrangeOne/django-tasks/releases" [dependency-groups] dev = [ "ruff", "coverage", "django-stubs[compatible-mypy]", ] [tool.ruff.lint] select = ["E", "F", "I", "W", "N", "B", "A", "C4", "T20", "DJ", "S", "UP"] ignore = ["E501", "DJ008"] [tool.ruff.lint.per-file-ignores] "tests/db_worker_test_settings.py" = ["F403", "F405"] "tests/settings_fast.py" = ["F403", "F405"] "tests/**.py" = ["S101", "S603", "S105"] [tool.mypy] plugins = ["mypy_django_plugin.main"] warn_unused_ignores = true warn_return_any = true show_error_codes = true strict_optional = true implicit_optional = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_decorators = true check_untyped_defs = true ignore_missing_imports = true [tool.django-stubs] django_settings_module = "tests.settings" [tool.coverage.run] branch = true [tool.coverage.report] show_missing = true ignore_errors = true exclude_also = [ # Don't complain if tests don't hit defensive assertion code: "raise AssertionError", "raise NotImplementedError", # Don't complain about abstract methods, they aren't run: "@(abc.)?abstractmethod", # Nor complain about type checking "if TYPE_CHECKING:", ] django-tasks-0.12.0/tests/000077500000000000000000000000001514140761400153245ustar00rootroot00000000000000django-tasks-0.12.0/tests/__init__.py000066400000000000000000000000001514140761400174230ustar00rootroot00000000000000django-tasks-0.12.0/tests/settings.py000066400000000000000000000024451514140761400175430ustar00rootroot00000000000000import os import sys BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) IN_TEST = "IN_TEST" in os.environ or (len(sys.argv) > 1 and sys.argv[1] == "test") ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.messages", "django.contrib.sessions", "django.contrib.staticfiles", "django_tasks", "tests", ] TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "django.template.context_processors.static", ] }, }, ] MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ] STATIC_URL = "/static/" SECRET_KEY = "abcde12345" ROOT_URLCONF = "tests.urls" USE_TZ = True if not IN_TEST: DEBUG = True django-tasks-0.12.0/tests/tasks.py000066400000000000000000000027041514140761400170260ustar00rootroot00000000000000import time from typing import Any from django_tasks import TaskContext, task @task() def noop_task(*args: Any, **kwargs: Any) -> None: return None @task def noop_task_from_bare_decorator(*args: Any, **kwargs: Any) -> None: return None @task() async def noop_task_async(*args: Any, **kwargs: Any) -> None: return None @task() def calculate_meaning_of_life() -> int: return 42 @task() def failing_task_value_error() -> None: raise ValueError("This task failed due to ValueError") @task() def failing_task_system_exit() -> None: raise SystemExit("This task failed due to SystemExit") @task() def failing_task_keyboard_interrupt() -> None: raise KeyboardInterrupt("This task failed due to KeyboardInterrupt") @task() def complex_exception() -> None: raise ValueError(ValueError("This task failed")) @task() def complex_return_value() -> Any: # Return something which isn't JSON serializable nor picklable return lambda: True @task() def exit_task() -> None: exit(1) @task() def hang() -> None: """ Do nothing for 5 minutes """ time.sleep(300) @task() def sleep_for(seconds: float) -> None: time.sleep(seconds) @task(takes_context=True) def get_task_id(context: TaskContext) -> str: return context.task_result.id @task(takes_context=True) def test_context(context: TaskContext, attempt: int) -> None: assert isinstance(context, TaskContext) assert context.attempt == attempt django-tasks-0.12.0/tests/tests/000077500000000000000000000000001514140761400164665ustar00rootroot00000000000000django-tasks-0.12.0/tests/tests/__init__.py000066400000000000000000000000001514140761400205650ustar00rootroot00000000000000django-tasks-0.12.0/tests/tests/test_compat.py000066400000000000000000000041441514140761400213650ustar00rootroot00000000000000from unittest import skipUnless from django import VERSION from django.test import SimpleTestCase, override_settings from django_tasks import task, task_backends from django_tasks.backends.immediate import ImmediateBackend from django_tasks.base import Task, TaskResult HAS_DJANGO_TASKS = VERSION >= (6, 0) def _test_task_func() -> None: pass class DjangoCompatTestCase(SimpleTestCase): def test_uses_lib_tasks_by_default(self) -> None: self.assertIsInstance(task_backends["default"], ImmediateBackend) self.assertEqual(task_backends["default"].task_class, Task) @skipUnless(HAS_DJANGO_TASKS, "Requires django.tasks") def test_django_using_lib_backend(self) -> None: from django.tasks import task, task_backends with override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend" } } ): self.assertIsInstance(task_backends["default"], ImmediateBackend) test_task_func_task = task(_test_task_func) self.assertIsInstance(test_task_func_task, Task) result = test_task_func_task.enqueue() self.assertIsInstance(result, TaskResult) self.assertIsNone(result.return_value) @skipUnless(HAS_DJANGO_TASKS, "Requires django.tasks") def test_lib_using_django_backend(self) -> None: from django.tasks.backends.immediate import ImmediateBackend from django.tasks.base import Task, TaskResult with override_settings( TASKS={ "default": { "BACKEND": "django.tasks.backends.immediate.ImmediateBackend" } } ): self.assertIsInstance(task_backends["default"], ImmediateBackend) test_task_func_task = task( _test_task_func, ) self.assertIsInstance(test_task_func_task, Task) result = test_task_func_task.enqueue() self.assertIsInstance(result, TaskResult) self.assertIsNone(result.return_value) django-tasks-0.12.0/tests/tests/test_custom_backend.py000066400000000000000000000106651514140761400230700ustar00rootroot00000000000000import logging from dataclasses import dataclass from typing import Any from unittest import mock from django.test import SimpleTestCase, override_settings from django.utils.version import PY311, PY312 from django_tasks import default_task_backend, task, task_backends from django_tasks.backends.base import BaseTaskBackend from django_tasks.base import Task from django_tasks.exceptions import InvalidTaskError from django_tasks.utils import get_module_path from tests import tasks as test_tasks @dataclass(frozen=True, slots=PY311, kw_only=True) # type: ignore[literal-required] class CustomTask(Task): other_data: str = "" class CustomBackend(BaseTaskBackend): def __init__(self, alias: str, params: dict) -> None: super().__init__(alias, params) self.prefix = self.options.get("prefix", "") def enqueue(self, *args: Any, **kwargs: Any) -> Any: logger = logging.getLogger(__name__) logger.info(f"{self.prefix}Task enqueued.") class CustomBackendNoEnqueue(BaseTaskBackend): pass class CustomTaskBackend(BaseTaskBackend): task_class = CustomTask supports_priority = True def enqueue(self, *args: Any, **kwargs: Any) -> Any: pass @override_settings( TASKS={ "default": { "BACKEND": get_module_path(CustomBackend), "OPTIONS": {"prefix": "PREFIX: "}, }, "no_enqueue": { "BACKEND": get_module_path(CustomBackendNoEnqueue), }, } ) class CustomBackendTestCase(SimpleTestCase): def test_using_correct_backend(self) -> None: self.assertEqual(default_task_backend, task_backends["default"]) self.assertIsInstance(task_backends["default"], CustomBackend) @mock.patch.multiple(CustomBackend, supports_async_task=False) def test_enqueue_async_task_on_non_async_backend(self) -> None: with self.assertRaisesMessage( InvalidTaskError, "Backend does not support async tasks" ): default_task_backend.validate_task(test_tasks.noop_task_async) def test_backend_does_not_support_priority(self) -> None: with self.assertRaisesMessage( InvalidTaskError, "Backend does not support setting priority of tasks." ): test_tasks.noop_task.using(priority=10) def test_options(self) -> None: with self.assertLogs(__name__, level="INFO") as captured_logs: test_tasks.noop_task.enqueue() self.assertEqual(len(captured_logs.output), 1) self.assertIn("PREFIX: Task enqueued", captured_logs.output[0]) def test_no_enqueue(self) -> None: if PY312: error_message = "Can't instantiate abstract class CustomBackendNoEnqueue without an implementation for abstract method 'enqueue'" else: error_message = "Can't instantiate abstract class CustomBackendNoEnqueue with abstract method enqueue" with self.assertRaisesMessage( TypeError, error_message, ): test_tasks.noop_task.using(backend="no_enqueue") @override_settings( TASKS={ "default": { "BACKEND": f"{CustomTaskBackend.__module__}." f"{CustomTaskBackend.__qualname__}", "QUEUES": ["default", "high"], }, } ) class CustomTaskTestCase(SimpleTestCase): def test_custom_task_default_values(self) -> None: my_task = task()(test_tasks.noop_task.func) self.assertIsInstance(my_task, CustomTask) self.assertEqual(my_task.other_data, "") # type: ignore[attr-defined] def test_custom_task_with_custom_values(self) -> None: my_task = task(other_data="other")(test_tasks.noop_task.func) self.assertIsInstance(my_task, CustomTask) self.assertEqual(my_task.other_data, "other") # type: ignore[attr-defined] def test_custom_task_with_standard_and_custom_values(self) -> None: my_task = task(priority=10, queue_name="high", other_data="other")( test_tasks.noop_task.func ) self.assertIsInstance(my_task, CustomTask) self.assertEqual(my_task.priority, 10) self.assertEqual(my_task.queue_name, "high") self.assertEqual(my_task.other_data, "other") # type: ignore[attr-defined] self.assertFalse(my_task.takes_context) self.assertIsNone(my_task.run_after) def test_custom_task_invalid_argument(self) -> None: with self.assertRaises(TypeError): task(unknown_param=123)(test_tasks.noop_task.func) django-tasks-0.12.0/tests/tests/test_dummy_backend.py000066400000000000000000000206501514140761400227040ustar00rootroot00000000000000import json from typing import cast from unittest import mock from django.test import ( SimpleTestCase, override_settings, ) from django.urls import reverse from django_tasks import TaskResultStatus, default_task_backend, task_backends from django_tasks.backends.dummy import DummyBackend from django_tasks.base import Task from django_tasks.exceptions import InvalidTaskError, TaskResultDoesNotExist from tests import tasks as test_tasks @override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.dummy.DummyBackend", "QUEUES": [], } } ) class DummyBackendTestCase(SimpleTestCase): def setUp(self) -> None: default_task_backend.clear() # type:ignore[attr-defined] def test_using_correct_backend(self) -> None: self.assertEqual(default_task_backend, task_backends["default"]) self.assertIsInstance(task_backends["default"], DummyBackend) self.assertEqual(default_task_backend.alias, "default") self.assertEqual(default_task_backend.options, {}) def test_enqueue_task(self) -> None: for task in [test_tasks.noop_task, test_tasks.noop_task_async]: with self.subTest(task): result = cast(Task, task).enqueue(1, two=3) self.assertEqual(result.status, TaskResultStatus.READY) self.assertFalse(result.is_finished) self.assertIsNone(result.started_at) self.assertIsNone(result.last_attempted_at) self.assertIsNone(result.finished_at) with self.assertRaisesMessage(ValueError, "Task has not finished yet"): result.return_value # noqa:B018 self.assertEqual(result.task, task) self.assertEqual(result.args, [1]) self.assertEqual(result.kwargs, {"two": 3}) self.assertEqual(result.attempts, 0) self.assertIn(result, default_task_backend.results) # type:ignore[attr-defined] async def test_enqueue_task_async(self) -> None: for task in [test_tasks.noop_task, test_tasks.noop_task_async]: with self.subTest(task): result = await cast(Task, task).aenqueue() self.assertEqual(result.status, TaskResultStatus.READY) self.assertFalse(result.is_finished) self.assertIsNone(result.started_at) self.assertIsNone(result.last_attempted_at) self.assertIsNone(result.finished_at) with self.assertRaisesMessage(ValueError, "Task has not finished yet"): result.return_value # noqa:B018 self.assertEqual(result.task, task) self.assertEqual(result.args, []) self.assertEqual(result.kwargs, {}) self.assertEqual(result.attempts, 0) self.assertIn(result, default_task_backend.results) # type:ignore[attr-defined] def test_get_result(self) -> None: result = default_task_backend.enqueue(test_tasks.noop_task, (), {}) new_result = default_task_backend.get_result(result.id) self.assertEqual(result, new_result) async def test_get_result_async(self) -> None: result = await default_task_backend.aenqueue(test_tasks.noop_task, (), {}) new_result = await default_task_backend.aget_result(result.id) self.assertEqual(result, new_result) def test_refresh_result(self) -> None: result = default_task_backend.enqueue( test_tasks.calculate_meaning_of_life, (), {} ) enqueued_result = default_task_backend.results[0] # type:ignore[attr-defined] object.__setattr__(enqueued_result, "status", TaskResultStatus.SUCCESSFUL) self.assertEqual(result.status, TaskResultStatus.READY) result.refresh() self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL) async def test_refresh_result_async(self) -> None: result = await default_task_backend.aenqueue( test_tasks.calculate_meaning_of_life, (), {} ) enqueued_result = default_task_backend.results[0] # type:ignore[attr-defined] object.__setattr__(enqueued_result, "status", TaskResultStatus.SUCCESSFUL) self.assertEqual(result.status, TaskResultStatus.READY) await result.arefresh() self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL) async def test_get_missing_result(self) -> None: with self.assertRaises(TaskResultDoesNotExist): default_task_backend.get_result("123") with self.assertRaises(TaskResultDoesNotExist): await default_task_backend.aget_result("123") def test_meaning_of_life_view(self) -> None: for url in [ reverse("meaning-of-life"), reverse("meaning-of-life-async"), ]: with self.subTest(url): response = self.client.get(url) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(data["result"], None) self.assertEqual(data["status"], TaskResultStatus.READY) result = default_task_backend.get_result(data["result_id"]) self.assertEqual(result.status, TaskResultStatus.READY) def test_get_result_from_different_request(self) -> None: response = self.client.get(reverse("meaning-of-life")) self.assertEqual(response.status_code, 200) data = json.loads(response.content) result_id = data["result_id"] response = self.client.get(reverse("result", args=[result_id])) self.assertEqual(response.status_code, 200) self.assertEqual( json.loads(response.content), {"result_id": result_id, "result": None, "status": TaskResultStatus.READY}, ) def test_enqueue_logs(self) -> None: with self.assertLogs("django_tasks", level="DEBUG") as captured_logs: result = test_tasks.noop_task.enqueue() self.assertEqual(len(captured_logs.output), 1) self.assertIn("enqueued", captured_logs.output[0]) self.assertIn(result.id, captured_logs.output[0]) def test_errors(self) -> None: result = test_tasks.noop_task.enqueue() self.assertEqual(result.errors, []) def test_validate_disallowed_async_task(self) -> None: with mock.patch.multiple(default_task_backend, supports_async_task=False): with self.assertRaisesMessage( InvalidTaskError, "Backend does not support async tasks" ): default_task_backend.validate_task(test_tasks.noop_task_async) def test_check(self) -> None: errors = list(default_task_backend.check()) self.assertEqual(len(errors), 0, errors) def test_takes_context(self) -> None: result = test_tasks.get_task_id.enqueue() self.assertEqual(result.status, TaskResultStatus.READY) def test_clear(self) -> None: result = test_tasks.noop_task.enqueue() default_task_backend.get_result(result.id) default_task_backend.clear() # type:ignore[attr-defined] with self.assertRaisesMessage(TaskResultDoesNotExist, result.id): default_task_backend.get_result(result.id) def test_validate_on_enqueue(self) -> None: task_with_custom_queue_name = test_tasks.noop_task.using( queue_name="unknown_queue" ) with override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.dummy.DummyBackend", "QUEUES": ["queue-1"], } } ): with self.assertRaisesMessage( InvalidTaskError, "Queue 'unknown_queue' is not valid for backend" ): task_with_custom_queue_name.enqueue() async def test_validate_on_aenqueue(self) -> None: task_with_custom_queue_name = test_tasks.noop_task.using( queue_name="unknown_queue" ) with override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.dummy.DummyBackend", "QUEUES": ["queue-1"], } } ): with self.assertRaisesMessage( InvalidTaskError, "Queue 'unknown_queue' is not valid for backend" ): await task_with_custom_queue_name.aenqueue() django-tasks-0.12.0/tests/tests/test_immediate_backend.py000066400000000000000000000310171514140761400235060ustar00rootroot00000000000000import json from typing import cast from django.test import SimpleTestCase, override_settings from django.urls import reverse from django.utils import timezone from django_tasks import TaskResultStatus, default_task_backend, task_backends from django_tasks.backends.immediate import ImmediateBackend from django_tasks.base import Task from django_tasks.exceptions import InvalidTaskError from tests import tasks as test_tasks @override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend", "QUEUES": [], } } ) class ImmediateBackendTestCase(SimpleTestCase): def test_using_correct_backend(self) -> None: self.assertEqual(default_task_backend, task_backends["default"]) self.assertIsInstance(task_backends["default"], ImmediateBackend) self.assertEqual(default_task_backend.alias, "default") self.assertEqual(default_task_backend.options, {}) def test_enqueue_task(self) -> None: for task in [test_tasks.noop_task, test_tasks.noop_task_async]: with self.subTest(task): result = cast(Task, task).enqueue(1, two=3) self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL) self.assertTrue(result.is_finished) self.assertIsNotNone(result.started_at) self.assertIsNotNone(result.last_attempted_at) self.assertIsNotNone(result.finished_at) self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type, misc] self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type, misc] self.assertIsNone(result.return_value) self.assertEqual(result.task, task) self.assertEqual(result.args, [1]) self.assertEqual(result.kwargs, {"two": 3}) self.assertEqual(result.attempts, 1) async def test_enqueue_task_async(self) -> None: for task in [test_tasks.noop_task, test_tasks.noop_task_async]: with self.subTest(task): result = await cast(Task, task).aenqueue() self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL) self.assertTrue(result.is_finished) self.assertIsNotNone(result.started_at) self.assertIsNotNone(result.last_attempted_at) self.assertIsNotNone(result.finished_at) self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type, misc] self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type, misc] self.assertIsNone(result.return_value) self.assertEqual(result.task, task) self.assertEqual(result.args, []) self.assertEqual(result.kwargs, {}) self.assertEqual(result.attempts, 1) def test_catches_exception(self) -> None: test_data = [ ( test_tasks.failing_task_value_error, # task function ValueError, # expected exception "This task failed due to ValueError", # expected message ), ( test_tasks.failing_task_system_exit, SystemExit, "This task failed due to SystemExit", ), ] for task, exception, message in test_data: with ( self.subTest(task), self.assertLogs("django_tasks", level="ERROR") as captured_logs, ): result = task.enqueue() # assert logging self.assertEqual(len(captured_logs.output), 1) self.assertIn(message, captured_logs.output[0]) # assert result self.assertEqual(result.status, TaskResultStatus.FAILED) with self.assertRaisesMessage(ValueError, "Task failed"): result.return_value # noqa: B018 self.assertTrue(result.is_finished) self.assertIsNotNone(result.started_at) self.assertIsNotNone(result.last_attempted_at) self.assertIsNotNone(result.finished_at) self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type, misc] self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type, misc] self.assertEqual(result.errors[0].exception_class, exception) traceback = result.errors[0].traceback self.assertTrue( traceback and traceback.endswith(f"{exception.__name__}: {message}\n"), traceback, ) self.assertEqual(result.task, task) self.assertEqual(result.args, []) self.assertEqual(result.kwargs, {}) def test_throws_keyboard_interrupt(self) -> None: with self.assertRaises(KeyboardInterrupt): with self.assertNoLogs("django_tasks", level="ERROR"): default_task_backend.enqueue( test_tasks.failing_task_keyboard_interrupt, [], {} ) def test_complex_exception(self) -> None: with self.assertLogs("django_tasks", level="ERROR"): result = test_tasks.complex_exception.enqueue() self.assertEqual(result.status, TaskResultStatus.FAILED) self.assertIsNotNone(result.started_at) self.assertIsNotNone(result.last_attempted_at) self.assertIsNotNone(result.finished_at) self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type,misc] self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type,misc] self.assertIsNone(result._return_value) self.assertEqual(result.errors[0].exception_class, ValueError) self.assertIn( 'ValueError(ValueError("This task failed"))', result.errors[0].traceback ) self.assertEqual(result.task, test_tasks.complex_exception) self.assertEqual(result.args, []) self.assertEqual(result.kwargs, {}) def test_complex_return_value(self) -> None: with self.assertLogs("django_tasks", level="ERROR"): result = test_tasks.complex_return_value.enqueue() self.assertEqual(result.status, TaskResultStatus.FAILED) self.assertIsNotNone(result.started_at) self.assertIsNotNone(result.last_attempted_at) self.assertIsNotNone(result.finished_at) self.assertGreaterEqual(result.started_at, result.enqueued_at) # type:ignore[arg-type,misc] self.assertGreaterEqual(result.finished_at, result.started_at) # type:ignore[arg-type,misc] self.assertIsNone(result._return_value) self.assertEqual(result.errors[0].exception_class, TypeError) self.assertIn("Unsupported type", result.errors[0].traceback) def test_result(self) -> None: result = default_task_backend.enqueue( test_tasks.calculate_meaning_of_life, [], {} ) self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL) self.assertEqual(result.return_value, 42) async def test_result_async(self) -> None: result = await default_task_backend.aenqueue( test_tasks.calculate_meaning_of_life, [], {} ) self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL) self.assertEqual(result.return_value, 42) async def test_cannot_get_result(self) -> None: with self.assertRaisesMessage( NotImplementedError, "This backend does not support retrieving or refreshing results.", ): default_task_backend.get_result("123") with self.assertRaisesMessage( NotImplementedError, "This backend does not support retrieving or refreshing results.", ): await default_task_backend.aget_result(123) # type:ignore[arg-type] async def test_cannot_refresh_result(self) -> None: result = await default_task_backend.aenqueue( test_tasks.calculate_meaning_of_life, (), {} ) with self.assertRaisesMessage( NotImplementedError, "This backend does not support retrieving or refreshing results.", ): await result.arefresh() with self.assertRaisesMessage( NotImplementedError, "This backend does not support retrieving or refreshing results.", ): result.refresh() def test_cannot_pass_run_after(self) -> None: with self.assertRaisesMessage( InvalidTaskError, "Backend does not support run_after", ): default_task_backend.validate_task( test_tasks.failing_task_value_error.using(run_after=timezone.now()) ) def test_meaning_of_life_view(self) -> None: for url in [ reverse("meaning-of-life"), reverse("meaning-of-life-async"), ]: with self.subTest(url): response = self.client.get(url) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(data["result"], 42) self.assertEqual(data["status"], TaskResultStatus.SUCCESSFUL) def test_get_result_from_different_request(self) -> None: response = self.client.get(reverse("meaning-of-life")) self.assertEqual(response.status_code, 200) data = json.loads(response.content) result_id = data["result_id"] with self.assertRaisesMessage( NotImplementedError, "This backend does not support retrieving or refreshing results.", ): response = self.client.get(reverse("result", args=[result_id])) def test_enqueue_logs(self) -> None: with self.assertLogs("django_tasks", level="DEBUG") as captured_logs: result = test_tasks.noop_task.enqueue() self.assertEqual(len(captured_logs.output), 3) self.assertIn("enqueued", captured_logs.output[0]) self.assertIn(result.id, captured_logs.output[0]) self.assertIn("state=RUNNING", captured_logs.output[1]) self.assertIn(result.id, captured_logs.output[1]) self.assertIn("state=SUCCESSFUL", captured_logs.output[2]) self.assertIn(result.id, captured_logs.output[2]) def test_failed_logs(self) -> None: with self.assertLogs("django_tasks", level="DEBUG") as captured_logs: result = test_tasks.failing_task_value_error.enqueue() self.assertEqual(len(captured_logs.output), 3) self.assertIn("state=RUNNING", captured_logs.output[1]) self.assertIn(result.id, captured_logs.output[1]) self.assertIn("state=FAILED", captured_logs.output[2]) self.assertIn(result.id, captured_logs.output[2]) def test_check(self) -> None: errors = list(default_task_backend.check()) self.assertEqual(len(errors), 0, errors) def test_takes_context(self) -> None: result = test_tasks.get_task_id.enqueue() self.assertEqual(result.return_value, result.id) def test_context(self) -> None: result = test_tasks.test_context.enqueue(1) self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL) def test_validate_on_enqueue(self) -> None: task_with_custom_queue_name = test_tasks.noop_task.using( queue_name="unknown_queue" ) with override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend", "QUEUES": ["queue-1"], } } ): with self.assertRaisesMessage( InvalidTaskError, "Queue 'unknown_queue' is not valid for backend" ): task_with_custom_queue_name.enqueue() async def test_validate_on_aenqueue(self) -> None: task_with_custom_queue_name = test_tasks.noop_task.using( queue_name="unknown_queue" ) with override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend", "QUEUES": ["queue-1"], } } ): with self.assertRaisesMessage( InvalidTaskError, "Queue 'unknown_queue' is not valid for backend" ): await task_with_custom_queue_name.aenqueue() django-tasks-0.12.0/tests/tests/test_tasks.py000066400000000000000000000273151514140761400212340ustar00rootroot00000000000000import dataclasses from datetime import datetime from django.test import SimpleTestCase, override_settings from django.utils import timezone from django.utils.module_loading import import_string from django_tasks import ( DEFAULT_TASK_QUEUE_NAME, TaskResultStatus, default_task_backend, task, task_backends, ) from django_tasks.backends.dummy import DummyBackend from django_tasks.backends.immediate import ImmediateBackend from django_tasks.base import TASK_MAX_PRIORITY, TASK_MIN_PRIORITY, Task from django_tasks.exceptions import ( InvalidTaskBackendError, InvalidTaskError, TaskResultDoesNotExist, TaskResultMismatch, ) from tests import tasks as test_tasks @override_settings( TASKS={ "default": { "BACKEND": "django_tasks.backends.dummy.DummyBackend", "QUEUES": ["default", "queue_1"], }, "immediate": { "BACKEND": "django_tasks.backends.immediate.ImmediateBackend", "QUEUES": [], }, "missing": {"BACKEND": "does.not.exist"}, } ) class TaskTestCase(SimpleTestCase): def setUp(self) -> None: default_task_backend.clear() # type:ignore[attr-defined] def test_using_correct_backend(self) -> None: self.assertEqual(default_task_backend, task_backends["default"]) self.assertIsInstance(task_backends["default"], DummyBackend) def test_task_decorator(self) -> None: self.assertIsInstance(test_tasks.noop_task, Task) self.assertIsInstance(test_tasks.noop_task_async, Task) self.assertIsInstance(test_tasks.noop_task_from_bare_decorator, Task) def test_enqueue_task(self) -> None: result = test_tasks.noop_task.enqueue() self.assertEqual(result.status, TaskResultStatus.READY) self.assertEqual(result.task, test_tasks.noop_task) self.assertEqual(result.args, []) self.assertEqual(result.kwargs, {}) self.assertEqual(default_task_backend.results, [result]) # type:ignore[attr-defined] async def test_enqueue_task_async(self) -> None: result = await test_tasks.noop_task.aenqueue() self.assertEqual(result.status, TaskResultStatus.READY) self.assertEqual(result.task, test_tasks.noop_task) self.assertEqual(result.args, []) self.assertEqual(result.kwargs, {}) self.assertEqual(default_task_backend.results, [result]) # type:ignore[attr-defined] def test_enqueue_with_invalid_argument(self) -> None: with self.assertRaisesMessage( TypeError, "Unsupported type: " ): test_tasks.noop_task.enqueue(datetime.now()) async def test_aenqueue_with_invalid_argument(self) -> None: with self.assertRaisesMessage( TypeError, "Unsupported type: " ): await test_tasks.noop_task.aenqueue(datetime.now()) def test_using_priority(self) -> None: self.assertEqual(test_tasks.noop_task.priority, 0) self.assertEqual(test_tasks.noop_task.using(priority=1).priority, 1) self.assertEqual(test_tasks.noop_task.priority, 0) def test_using_queue_name(self) -> None: self.assertEqual(test_tasks.noop_task.queue_name, DEFAULT_TASK_QUEUE_NAME) self.assertEqual( test_tasks.noop_task.using(queue_name="queue_1").queue_name, "queue_1" ) self.assertEqual(test_tasks.noop_task.queue_name, DEFAULT_TASK_QUEUE_NAME) def test_using_run_after(self) -> None: now = timezone.now() self.assertIsNone(test_tasks.noop_task.run_after) self.assertEqual(test_tasks.noop_task.using(run_after=now).run_after, now) self.assertIsNone(test_tasks.noop_task.run_after) def test_using_unknown_backend(self) -> None: self.assertEqual(test_tasks.noop_task.backend, "default") with self.assertRaisesMessage( InvalidTaskBackendError, "The connection 'unknown' doesn't exist." ): test_tasks.noop_task.using(backend="unknown") def test_using_missing_backend(self) -> None: self.assertEqual(test_tasks.noop_task.backend, "default") with self.assertRaisesMessage( InvalidTaskBackendError, "Could not find backend 'does.not.exist': No module named 'does'", ): test_tasks.noop_task.using(backend="missing") def test_using_creates_new_instance(self) -> None: new_task = test_tasks.noop_task.using() self.assertEqual(new_task, test_tasks.noop_task) self.assertIsNot(new_task, test_tasks.noop_task) def test_chained_using(self) -> None: now = timezone.now() run_after_task = test_tasks.noop_task.using(run_after=now) self.assertEqual(run_after_task.run_after, now) priority_task = run_after_task.using(priority=10) self.assertEqual(priority_task.priority, 10) self.assertEqual(priority_task.run_after, now) self.assertEqual(run_after_task.priority, 0) async def test_refresh_result(self) -> None: result = await test_tasks.noop_task.aenqueue() original_result = dataclasses.asdict(result) result.refresh() self.assertEqual(dataclasses.asdict(result), original_result) await result.arefresh() self.assertEqual(dataclasses.asdict(result), original_result) def test_naive_datetime(self) -> None: with self.assertRaisesMessage( InvalidTaskError, "run_after must be an aware datetime" ): test_tasks.noop_task.using(run_after=datetime.now()) def test_invalid_priority(self) -> None: with self.assertRaisesMessage( InvalidTaskError, f"priority must be a whole number between {TASK_MIN_PRIORITY} and {TASK_MAX_PRIORITY}", ): test_tasks.noop_task.using(priority=-101) with self.assertRaisesMessage( InvalidTaskError, f"priority must be a whole number between {TASK_MIN_PRIORITY} and {TASK_MAX_PRIORITY}", ): test_tasks.noop_task.using(priority=101) with self.assertRaisesMessage( InvalidTaskError, f"priority must be a whole number between {TASK_MIN_PRIORITY} and {TASK_MAX_PRIORITY}", ): test_tasks.noop_task.using(priority=3.1) # type:ignore[arg-type] test_tasks.noop_task.using(priority=100) test_tasks.noop_task.using(priority=-100) test_tasks.noop_task.using(priority=0) def test_unknown_queue_name(self) -> None: with self.assertRaisesMessage( InvalidTaskError, "Queue 'queue-2' is not valid for backend" ): test_tasks.noop_task.using(queue_name="queue-2") # Validation is bypassed when the backend QUEUES is an empty list. self.assertEqual( test_tasks.noop_task.using( queue_name="queue-2", backend="immediate" ).queue_name, "queue-2", ) def test_call_task(self) -> None: self.assertEqual(test_tasks.calculate_meaning_of_life.call(), 42) async def test_call_task_async(self) -> None: self.assertEqual(await test_tasks.calculate_meaning_of_life.acall(), 42) async def test_call_async_task(self) -> None: self.assertIsNone(await test_tasks.noop_task_async.acall()) def test_call_async_task_sync(self) -> None: self.assertIsNone(test_tasks.noop_task_async.call()) def test_get_result(self) -> None: result = default_task_backend.enqueue(test_tasks.noop_task, (), {}) new_result = test_tasks.noop_task.get_result(result.id) self.assertEqual(result, new_result) async def test_get_result_async(self) -> None: result = await default_task_backend.aenqueue(test_tasks.noop_task, (), {}) new_result = await test_tasks.noop_task.aget_result(result.id) self.assertEqual(result, new_result) async def test_get_missing_result(self) -> None: with self.assertRaises(TaskResultDoesNotExist): test_tasks.noop_task.get_result("123") with self.assertRaises(TaskResultDoesNotExist): await test_tasks.noop_task.aget_result("123") def test_get_incorrect_result(self) -> None: result = default_task_backend.enqueue(test_tasks.noop_task_async, (), {}) with self.assertRaisesMessage(TaskResultMismatch, "Task does not match"): test_tasks.noop_task.get_result(result.id) async def test_get_incorrect_result_async(self) -> None: result = await default_task_backend.aenqueue(test_tasks.noop_task_async, (), {}) with self.assertRaisesMessage(TaskResultMismatch, "Task does not match"): await test_tasks.noop_task.aget_result(result.id) def test_invalid_function(self) -> None: for invalid_function in [any, self.test_invalid_function]: with self.subTest(invalid_function): with self.assertRaisesMessage( InvalidTaskError, "Task function must be defined at a module level", ): task()(invalid_function) # type:ignore[arg-type] def test_get_backend(self) -> None: self.assertEqual(test_tasks.noop_task.backend, "default") self.assertIsInstance(test_tasks.noop_task.get_backend(), DummyBackend) immediate_task = test_tasks.noop_task.using(backend="immediate") self.assertEqual(immediate_task.backend, "immediate") self.assertIsInstance(immediate_task.get_backend(), ImmediateBackend) def test_name(self) -> None: self.assertEqual(test_tasks.noop_task.name, "noop_task") self.assertEqual(test_tasks.noop_task_async.name, "noop_task_async") def test_module_path(self) -> None: self.assertEqual(test_tasks.noop_task.module_path, "tests.tasks.noop_task") self.assertEqual( test_tasks.noop_task_async.module_path, "tests.tasks.noop_task_async" ) self.assertIs( import_string(test_tasks.noop_task.module_path), test_tasks.noop_task ) self.assertIs( import_string(test_tasks.noop_task_async.module_path), test_tasks.noop_task_async, ) @override_settings(TASKS={}) def test_no_backends(self) -> None: with self.assertRaises(InvalidTaskBackendError): test_tasks.noop_task.enqueue() def test_task_error_invalid_exception(self) -> None: with self.assertLogs("django_tasks"): immediate_task = test_tasks.failing_task_value_error.using( backend="immediate" ).enqueue() self.assertEqual(len(immediate_task.errors), 1) object.__setattr__( immediate_task.errors[0], "exception_class_path", "subprocess.run" ) with self.assertRaisesMessage( ValueError, "'subprocess.run' does not reference a valid exception." ): immediate_task.errors[0].exception_class # noqa: B018 def test_task_error_unknown_module(self) -> None: with self.assertLogs("django_tasks"): immediate_task = test_tasks.failing_task_value_error.using( backend="immediate" ).enqueue() self.assertEqual(len(immediate_task.errors), 1) object.__setattr__( immediate_task.errors[0], "exception_class_path", "does.not.exist" ) with self.assertRaises(ImportError): immediate_task.errors[0].exception_class # noqa: B018 def test_takes_context_without_taking_context(self) -> None: with self.assertRaisesMessage( InvalidTaskError, "Task takes context but does not have a first argument of 'context'", ): task(takes_context=True)(test_tasks.calculate_meaning_of_life.func) # type: ignore[arg-type] django-tasks-0.12.0/tests/tests/test_utils.py000066400000000000000000000110661514140761400212430ustar00rootroot00000000000000import datetime import json import subprocess from collections import UserList, defaultdict from decimal import Decimal from django.test import SimpleTestCase from django_tasks import utils from tests import tasks as test_tasks class IsModuleLevelFunctionTestCase(SimpleTestCase): @classmethod def _class_method(cls) -> None: return None @staticmethod def _static_method() -> None: return None def test_builtin(self) -> None: self.assertFalse(utils.is_module_level_function(any)) self.assertFalse(utils.is_module_level_function(isinstance)) def test_from_module(self) -> None: self.assertTrue(utils.is_module_level_function(subprocess.run)) self.assertTrue(utils.is_module_level_function(subprocess.check_output)) self.assertTrue(utils.is_module_level_function(test_tasks.noop_task.func)) def test_private_function(self) -> None: def private_function() -> None: pass self.assertFalse(utils.is_module_level_function(private_function)) def test_coroutine(self) -> None: self.assertTrue(utils.is_module_level_function(test_tasks.noop_task_async.func)) def test_method(self) -> None: self.assertFalse(utils.is_module_level_function(self.test_method)) self.assertFalse(utils.is_module_level_function(self.setUp)) def test_unbound_method(self) -> None: self.assertTrue( utils.is_module_level_function(self.__class__.test_unbound_method) ) self.assertTrue(utils.is_module_level_function(self.__class__.setUp)) def test_lambda(self) -> None: self.assertFalse(utils.is_module_level_function(lambda: True)) def test_class_and_static_method(self) -> None: self.assertTrue(utils.is_module_level_function(self._static_method)) self.assertFalse(utils.is_module_level_function(self._class_method)) class JSONNormalizeTestCase(SimpleTestCase): def test_converts_json_types(self) -> None: for test_case, expected in [ # type: ignore (None, "null"), (True, "true"), (False, "false"), (2, "2"), (3.0, "3.0"), (1e23 + 1, "1e+23"), ("1", '"1"'), (b"hello", '"hello"'), ([], "[]"), (UserList([1, 2]), "[1, 2]"), ({}, "{}"), ({1: "a"}, '{"1": "a"}'), ({"foo": (1, 2, 3)}, '{"foo": [1, 2, 3]}'), (defaultdict(list), "{}"), (float("nan"), "NaN"), (float("inf"), "Infinity"), (float("-inf"), "-Infinity"), ]: with self.subTest(test_case): normalized = utils.normalize_json(test_case) # Ensure that the normalized result is serializable. self.assertEqual(json.dumps(normalized), expected) def test_bytes_decode_error(self) -> None: with self.assertRaisesMessage(ValueError, "Unsupported value"): utils.normalize_json(b"\xff") def test_encode_error(self) -> None: for test_case in [ self, any, object(), datetime.datetime.now(), set(), Decimal("3.42"), ]: with ( self.subTest(test_case), self.assertRaisesMessage(TypeError, "Unsupported type"), ): utils.normalize_json(test_case) class RandomIdTestCase(SimpleTestCase): def test_correct_length(self) -> None: self.assertEqual(len(utils.get_random_id()), 32) def test_random_ish(self) -> None: random_ids = [utils.get_random_id() for _ in range(1000)] self.assertEqual(len(random_ids), len(set(random_ids))) class ExceptionTracebackTestCase(SimpleTestCase): def test_literal_exception(self) -> None: self.assertEqual( utils.get_exception_traceback(ValueError("Failure")), "ValueError: Failure\n", ) def test_exception(self) -> None: try: 1 / 0 # noqa:B018 except ZeroDivisionError as e: traceback = utils.get_exception_traceback(e) self.assertIn("ZeroDivisionError: division by zero", traceback) else: self.fail("ZeroDivisionError not raised") def test_complex_exception(self) -> None: try: {}[datetime.datetime.now()] # type: ignore except KeyError as e: traceback = utils.get_exception_traceback(e) self.assertIn("KeyError: datetime.datetime", traceback) else: self.fail("KeyError not raised") django-tasks-0.12.0/tests/urls.py000066400000000000000000000006641514140761400166710ustar00rootroot00000000000000from django.contrib import admin from django.urls import path from . import views urlpatterns = [ path("meaning-of-life/", views.calculate_meaning_of_life, name="meaning-of-life"), path("result/", views.get_task_result, name="result"), path( "meaning-of-life-async/", views.calculate_meaning_of_life_async, name="meaning-of-life-async", ), path("admin/", admin.site.urls), ] django-tasks-0.12.0/tests/views.py000066400000000000000000000030141514140761400170310ustar00rootroot00000000000000from typing import Any from django.http import Http404, HttpRequest, HttpResponse, JsonResponse from django_tasks import TaskResultStatus, default_task_backend from django_tasks.base import TaskResult from django_tasks.exceptions import TaskResultDoesNotExist from . import tasks def get_result_value(result: TaskResult) -> Any: if result.status == TaskResultStatus.SUCCESSFUL: return result.return_value elif result.status == TaskResultStatus.FAILED: return result.errors[0].traceback return None def calculate_meaning_of_life(request: HttpRequest) -> HttpResponse: result = tasks.calculate_meaning_of_life.enqueue() return JsonResponse( { "result_id": result.id, "result": get_result_value(result), "status": result.status, } ) async def calculate_meaning_of_life_async(request: HttpRequest) -> HttpResponse: result = await tasks.calculate_meaning_of_life.aenqueue() return JsonResponse( { "result_id": result.id, "result": get_result_value(result), "status": result.status, } ) async def get_task_result(request: HttpRequest, result_id: str) -> HttpResponse: try: result = await default_task_backend.aget_result(result_id) except TaskResultDoesNotExist: raise Http404 from None return JsonResponse( { "result_id": result.id, "result": get_result_value(result), "status": result.status, } )