mirror of
https://github.com/django/django.git
synced 2025-09-17 14:39:17 +00:00
Fixed #35859 -- Added background Tasks framework interface.
This work implements what was defined in DEP 14 (https://github.com/django/deps/blob/main/accepted/0014-background-workers.rst). Thanks to Raphael Gaschignard, Eric Holscher, Ran Benita, Sarah Boyce, Jacob Walls, and Natalia Bidart for the reviews.
This commit is contained in:
parent
218f69f05e
commit
4289966d1b
@ -672,3 +672,8 @@ SECURE_CSP_REPORT_ONLY = {}
|
|||||||
# HTTPS as the default protocol in urlize and urlizetrunc when no protocol is
|
# HTTPS as the default protocol in urlize and urlizetrunc when no protocol is
|
||||||
# provided. Set to True to assume HTTPS during the Django 6.x release cycle.
|
# provided. Set to True to assume HTTPS during the Django 6.x release cycle.
|
||||||
URLIZE_ASSUME_HTTPS = False
|
URLIZE_ASSUME_HTTPS = False
|
||||||
|
|
||||||
|
#########
|
||||||
|
# TASKS #
|
||||||
|
#########
|
||||||
|
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
|
||||||
|
45
django/tasks/__init__.py
Normal file
45
django/tasks/__init__.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
from django.utils.connection import BaseConnectionHandler, ConnectionProxy
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from . import checks, signals # NOQA
|
||||||
|
from .base import (
|
||||||
|
DEFAULT_TASK_BACKEND_ALIAS,
|
||||||
|
DEFAULT_TASK_QUEUE_NAME,
|
||||||
|
Task,
|
||||||
|
TaskContext,
|
||||||
|
TaskResult,
|
||||||
|
TaskResultStatus,
|
||||||
|
task,
|
||||||
|
)
|
||||||
|
from .exceptions import InvalidTaskBackend
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_TASK_BACKEND_ALIAS",
|
||||||
|
"DEFAULT_TASK_QUEUE_NAME",
|
||||||
|
"default_task_backend",
|
||||||
|
"task",
|
||||||
|
"task_backends",
|
||||||
|
"Task",
|
||||||
|
"TaskContext",
|
||||||
|
"TaskResult",
|
||||||
|
"TaskResultStatus",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBackendHandler(BaseConnectionHandler):
|
||||||
|
settings_name = "TASKS"
|
||||||
|
exception_class = InvalidTaskBackend
|
||||||
|
|
||||||
|
def create_connection(self, alias):
|
||||||
|
params = self.settings[alias]
|
||||||
|
backend = params["BACKEND"]
|
||||||
|
try:
|
||||||
|
backend_cls = import_string(backend)
|
||||||
|
except ImportError as e:
|
||||||
|
raise InvalidTaskBackend(f"Could not find backend '{backend}': {e}") from e
|
||||||
|
return backend_cls(alias=alias, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
task_backends = TaskBackendHandler()
|
||||||
|
|
||||||
|
default_task_backend = ConnectionProxy(task_backends, DEFAULT_TASK_BACKEND_ALIAS)
|
0
django/tasks/backends/__init__.py
Normal file
0
django/tasks/backends/__init__.py
Normal file
138
django/tasks/backends/base.py
Normal file
138
django/tasks/backends/base.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from inspect import iscoroutinefunction
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core import checks
|
||||||
|
from django.db import connections
|
||||||
|
from django.tasks import DEFAULT_TASK_QUEUE_NAME
|
||||||
|
from django.tasks.base import (
|
||||||
|
DEFAULT_TASK_PRIORITY,
|
||||||
|
TASK_MAX_PRIORITY,
|
||||||
|
TASK_MIN_PRIORITY,
|
||||||
|
Task,
|
||||||
|
)
|
||||||
|
from django.tasks.exceptions import InvalidTask
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.inspect import get_func_args, is_module_level_function
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTaskBackend(metaclass=ABCMeta):
|
||||||
|
task_class = Task
|
||||||
|
|
||||||
|
# Does the backend support Tasks to be enqueued with the run_after
|
||||||
|
# attribute?
|
||||||
|
supports_defer = False
|
||||||
|
|
||||||
|
# Does the backend support coroutines to be enqueued?
|
||||||
|
supports_async_task = False
|
||||||
|
|
||||||
|
# Does the backend support results being retrieved (from any
|
||||||
|
# thread/process)?
|
||||||
|
supports_get_result = False
|
||||||
|
|
||||||
|
# Does the backend support executing Tasks in a given
|
||||||
|
# priority order?
|
||||||
|
supports_priority = False
|
||||||
|
|
||||||
|
def __init__(self, alias, params):
|
||||||
|
self.alias = alias
|
||||||
|
self.queues = set(params.get("QUEUES", [DEFAULT_TASK_QUEUE_NAME]))
|
||||||
|
self.enqueue_on_commit = bool(params.get("ENQUEUE_ON_COMMIT", True))
|
||||||
|
self.options = params.get("OPTIONS", {})
|
||||||
|
|
||||||
|
def _get_enqueue_on_commit_for_task(self, task):
|
||||||
|
return (
|
||||||
|
task.enqueue_on_commit
|
||||||
|
if task.enqueue_on_commit is not None
|
||||||
|
else self.enqueue_on_commit
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_task(self, task):
|
||||||
|
"""
|
||||||
|
Determine whether the provided Task can be executed by the backend.
|
||||||
|
"""
|
||||||
|
if not is_module_level_function(task.func):
|
||||||
|
raise InvalidTask("Task function must be defined at a module level.")
|
||||||
|
|
||||||
|
if not self.supports_async_task and iscoroutinefunction(task.func):
|
||||||
|
raise InvalidTask("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 InvalidTask(
|
||||||
|
"Task takes context but does not have a first argument of 'context'."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.supports_priority and task.priority != DEFAULT_TASK_PRIORITY:
|
||||||
|
raise InvalidTask("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 InvalidTask(
|
||||||
|
f"priority must be a whole number between {TASK_MIN_PRIORITY} and "
|
||||||
|
f"{TASK_MAX_PRIORITY}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.supports_defer and task.run_after is not None:
|
||||||
|
raise InvalidTask("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 InvalidTask("run_after must be an aware datetime.")
|
||||||
|
|
||||||
|
if self.queues and task.queue_name not in self.queues:
|
||||||
|
raise InvalidTask(f"Queue '{task.queue_name}' is not valid for backend.")
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def enqueue(self, task, args, kwargs):
|
||||||
|
"""Queue up a task to be executed."""
|
||||||
|
|
||||||
|
async def aenqueue(self, task, args, kwargs):
|
||||||
|
"""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):
|
||||||
|
"""
|
||||||
|
Retrieve a task result by id.
|
||||||
|
|
||||||
|
Raise TaskResultDoesNotExist if such result does not exist.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"This backend does not support retrieving or refreshing results."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def aget_result(self, result_id):
|
||||||
|
"""See get_result()."""
|
||||||
|
return await sync_to_async(self.get_result, thread_sensitive=True)(
|
||||||
|
result_id=result_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def check(self, **kwargs):
|
||||||
|
if self.enqueue_on_commit and not connections._settings:
|
||||||
|
yield checks.Error(
|
||||||
|
"ENQUEUE_ON_COMMIT cannot be used when no databases are configured.",
|
||||||
|
hint="Set ENQUEUE_ON_COMMIT to False",
|
||||||
|
id="tasks.E001",
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
self.enqueue_on_commit
|
||||||
|
and not connections["default"].features.supports_transactions
|
||||||
|
):
|
||||||
|
yield checks.Error(
|
||||||
|
"ENQUEUE_ON_COMMIT cannot be used on a database which doesn't support "
|
||||||
|
"transactions.",
|
||||||
|
hint="Set ENQUEUE_ON_COMMIT to False",
|
||||||
|
id="tasks.E002",
|
||||||
|
)
|
69
django/tasks/backends/dummy.py
Normal file
69
django/tasks/backends/dummy.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.tasks.base import TaskResult, TaskResultStatus
|
||||||
|
from django.tasks.exceptions import TaskResultDoesNotExist
|
||||||
|
from django.tasks.signals import task_enqueued
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
|
from .base import BaseTaskBackend
|
||||||
|
|
||||||
|
|
||||||
|
class DummyBackend(BaseTaskBackend):
|
||||||
|
supports_defer = True
|
||||||
|
supports_async_task = True
|
||||||
|
supports_priority = True
|
||||||
|
|
||||||
|
def __init__(self, alias, params):
|
||||||
|
super().__init__(alias, params)
|
||||||
|
self.results = []
|
||||||
|
|
||||||
|
def _store_result(self, result):
|
||||||
|
object.__setattr__(result, "enqueued_at", timezone.now())
|
||||||
|
self.results.append(result)
|
||||||
|
task_enqueued.send(type(self), task_result=result)
|
||||||
|
|
||||||
|
def enqueue(self, task, args, kwargs):
|
||||||
|
self.validate_task(task)
|
||||||
|
|
||||||
|
result = TaskResult(
|
||||||
|
task=task,
|
||||||
|
id=get_random_string(32),
|
||||||
|
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=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._get_enqueue_on_commit_for_task(task) is not False:
|
||||||
|
transaction.on_commit(partial(self._store_result, result))
|
||||||
|
else:
|
||||||
|
self._store_result(result)
|
||||||
|
|
||||||
|
# Copy the task to prevent mutation issues.
|
||||||
|
return deepcopy(result)
|
||||||
|
|
||||||
|
def get_result(self, result_id):
|
||||||
|
# Results are only scoped to the current thread, hence
|
||||||
|
# supports_get_result is False.
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
self.results.clear()
|
100
django/tasks/backends/immediate.py
Normal file
100
django/tasks/backends/immediate.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from traceback import format_exception
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.tasks.base import TaskContext, TaskError, TaskResult, TaskResultStatus
|
||||||
|
from django.tasks.signals import task_enqueued, task_finished, task_started
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.utils.json import normalize_json
|
||||||
|
|
||||||
|
from .base import BaseTaskBackend
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ImmediateBackend(BaseTaskBackend):
|
||||||
|
supports_async_task = True
|
||||||
|
supports_priority = True
|
||||||
|
|
||||||
|
def __init__(self, alias, params):
|
||||||
|
super().__init__(alias, params)
|
||||||
|
self.worker_id = get_random_string(32)
|
||||||
|
|
||||||
|
def _execute_task(self, task_result):
|
||||||
|
"""
|
||||||
|
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())
|
||||||
|
exception_type = type(e)
|
||||||
|
task_result.errors.append(
|
||||||
|
TaskError(
|
||||||
|
exception_class_path=(
|
||||||
|
f"{exception_type.__module__}.{exception_type.__qualname__}"
|
||||||
|
),
|
||||||
|
traceback="".join(format_exception(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, args, kwargs):
|
||||||
|
self.validate_task(task)
|
||||||
|
|
||||||
|
task_result = TaskResult(
|
||||||
|
task=task,
|
||||||
|
id=get_random_string(32),
|
||||||
|
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=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._get_enqueue_on_commit_for_task(task) is not False:
|
||||||
|
transaction.on_commit(partial(self._execute_task, task_result))
|
||||||
|
else:
|
||||||
|
self._execute_task(task_result)
|
||||||
|
|
||||||
|
return task_result
|
253
django/tasks/base.py
Normal file
253
django/tasks/base.py
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime
|
||||||
|
from inspect import isclass, iscoroutinefunction
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync, sync_to_async
|
||||||
|
|
||||||
|
from django.db.models.enums import TextChoices
|
||||||
|
from django.utils.json import normalize_json
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
from django.utils.translation import pgettext_lazy
|
||||||
|
|
||||||
|
from .exceptions import TaskResultMismatch
|
||||||
|
|
||||||
|
DEFAULT_TASK_BACKEND_ALIAS = "default"
|
||||||
|
DEFAULT_TASK_PRIORITY = 0
|
||||||
|
DEFAULT_TASK_QUEUE_NAME = "default"
|
||||||
|
TASK_MAX_PRIORITY = 100
|
||||||
|
TASK_MIN_PRIORITY = -100
|
||||||
|
TASK_REFRESH_ATTRS = {
|
||||||
|
"errors",
|
||||||
|
"_return_value",
|
||||||
|
"finished_at",
|
||||||
|
"started_at",
|
||||||
|
"last_attempted_at",
|
||||||
|
"status",
|
||||||
|
"enqueued_at",
|
||||||
|
"worker_ids",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultStatus(TextChoices):
|
||||||
|
# The Task has just been enqueued, or is ready to be executed again.
|
||||||
|
READY = ("READY", pgettext_lazy("Task", "Ready"))
|
||||||
|
# The Task is currently running.
|
||||||
|
RUNNING = ("RUNNING", pgettext_lazy("Task", "Running"))
|
||||||
|
# The Task raised an exception during execution, or was unable to start.
|
||||||
|
FAILED = ("FAILED", pgettext_lazy("Task", "Failed"))
|
||||||
|
# The Task has finished running successfully.
|
||||||
|
SUCCESSFUL = ("SUCCESSFUL", pgettext_lazy("Task", "Successful"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class Task:
|
||||||
|
priority: int
|
||||||
|
func: Callable # The Task function.
|
||||||
|
backend: str
|
||||||
|
queue_name: str
|
||||||
|
run_after: Optional[datetime] # The earliest this Task will run.
|
||||||
|
|
||||||
|
# Whether the Task will be enqueued when the current transaction commits,
|
||||||
|
# immediately, or whatever the backend decides.
|
||||||
|
enqueue_on_commit: Optional[bool]
|
||||||
|
|
||||||
|
# Whether the Task receives the Task context when executed.
|
||||||
|
takes_context: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.get_backend().validate_task(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.func.__name__
|
||||||
|
|
||||||
|
def using(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
priority=None,
|
||||||
|
queue_name=None,
|
||||||
|
run_after=None,
|
||||||
|
backend=None,
|
||||||
|
):
|
||||||
|
"""Create a new Task with modified defaults."""
|
||||||
|
|
||||||
|
changes = {}
|
||||||
|
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, **kwargs):
|
||||||
|
"""Queue up the Task to be executed."""
|
||||||
|
return self.get_backend().enqueue(self, args, kwargs)
|
||||||
|
|
||||||
|
async def aenqueue(self, *args, **kwargs):
|
||||||
|
"""Queue up the Task to be executed."""
|
||||||
|
return await self.get_backend().aenqueue(self, args, kwargs)
|
||||||
|
|
||||||
|
def get_result(self, result_id):
|
||||||
|
"""
|
||||||
|
Retrieve a task result by id.
|
||||||
|
|
||||||
|
Raise TaskResultDoesNotExist if such result does not exist, or raise
|
||||||
|
TaskResultMismatch if the result exists but belongs to another Task.
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
"""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, **kwargs):
|
||||||
|
if iscoroutinefunction(self.func):
|
||||||
|
return async_to_sync(self.func)(*args, **kwargs)
|
||||||
|
return self.func(*args, **kwargs)
|
||||||
|
|
||||||
|
async def acall(self, *args, **kwargs):
|
||||||
|
if iscoroutinefunction(self.func):
|
||||||
|
return await self.func(*args, **kwargs)
|
||||||
|
return await sync_to_async(self.func)(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_backend(self):
|
||||||
|
from . import task_backends
|
||||||
|
|
||||||
|
return task_backends[self.backend]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def module_path(self):
|
||||||
|
return f"{self.func.__module__}.{self.func.__qualname__}"
|
||||||
|
|
||||||
|
|
||||||
|
def task(
|
||||||
|
function=None,
|
||||||
|
*,
|
||||||
|
priority=DEFAULT_TASK_PRIORITY,
|
||||||
|
queue_name=DEFAULT_TASK_QUEUE_NAME,
|
||||||
|
backend=DEFAULT_TASK_BACKEND_ALIAS,
|
||||||
|
enqueue_on_commit=None,
|
||||||
|
takes_context=False,
|
||||||
|
):
|
||||||
|
from . import task_backends
|
||||||
|
|
||||||
|
def wrapper(f):
|
||||||
|
return task_backends[backend].task_class(
|
||||||
|
priority=priority,
|
||||||
|
func=f,
|
||||||
|
queue_name=queue_name,
|
||||||
|
backend=backend,
|
||||||
|
enqueue_on_commit=enqueue_on_commit,
|
||||||
|
takes_context=takes_context,
|
||||||
|
run_after=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if function:
|
||||||
|
return wrapper(function)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class TaskError:
|
||||||
|
exception_class_path: str
|
||||||
|
traceback: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exception_class(self):
|
||||||
|
# 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=True, kw_only=True)
|
||||||
|
class TaskResult:
|
||||||
|
task: Task
|
||||||
|
|
||||||
|
id: str # Unique identifier for the task result.
|
||||||
|
status: TaskResultStatus
|
||||||
|
enqueued_at: Optional[datetime] # Time the task was enqueued.
|
||||||
|
started_at: Optional[datetime] # Time the task was started.
|
||||||
|
finished_at: Optional[datetime] # Time the task was finished.
|
||||||
|
|
||||||
|
# Time the task was last attempted to be run.
|
||||||
|
last_attempted_at: Optional[datetime]
|
||||||
|
|
||||||
|
args: list # Arguments to pass to the task function.
|
||||||
|
kwargs: Dict[str, Any] # Keyword arguments to pass to the task function.
|
||||||
|
backend: str
|
||||||
|
errors: list[TaskError] # Errors raised when running the task.
|
||||||
|
worker_ids: list[str] # Workers which have processed the task.
|
||||||
|
|
||||||
|
_return_value: Optional[Any] = field(init=False, default=None)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
object.__setattr__(self, "args", normalize_json(self.args))
|
||||||
|
object.__setattr__(self, "kwargs", normalize_json(self.kwargs))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def return_value(self):
|
||||||
|
"""
|
||||||
|
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 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):
|
||||||
|
return self.status in {TaskResultStatus.FAILED, TaskResultStatus.SUCCESSFUL}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempts(self):
|
||||||
|
return len(self.worker_ids)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""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):
|
||||||
|
"""
|
||||||
|
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=True, kw_only=True)
|
||||||
|
class TaskContext:
|
||||||
|
task_result: TaskResult
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempt(self):
|
||||||
|
return self.task_result.attempts
|
11
django/tasks/checks.py
Normal file
11
django/tasks/checks.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from django.core import checks
|
||||||
|
|
||||||
|
|
||||||
|
@checks.register
|
||||||
|
def check_tasks(app_configs=None, **kwargs):
|
||||||
|
"""Checks all registered Task backends."""
|
||||||
|
|
||||||
|
from . import task_backends
|
||||||
|
|
||||||
|
for backend in task_backends.all():
|
||||||
|
yield from backend.check()
|
21
django/tasks/exceptions.py
Normal file
21
django/tasks/exceptions.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
|
||||||
|
class TaskException(Exception):
|
||||||
|
"""Base class for task-related exceptions. Do not raise directly."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTask(TaskException):
|
||||||
|
"""The provided Task is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTaskBackend(ImproperlyConfigured):
|
||||||
|
"""The provided Task backend is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultDoesNotExist(TaskException):
|
||||||
|
"""The requested TaskResult does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultMismatch(TaskException):
|
||||||
|
"""The requested TaskResult is invalid."""
|
64
django/tasks/signals.py
Normal file
64
django/tasks/signals.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from asgiref.local import Local
|
||||||
|
|
||||||
|
from django.core.signals import setting_changed
|
||||||
|
from django.dispatch import Signal, receiver
|
||||||
|
|
||||||
|
from .base import TaskResultStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger("django.tasks")
|
||||||
|
|
||||||
|
task_enqueued = Signal()
|
||||||
|
task_finished = Signal()
|
||||||
|
task_started = Signal()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(setting_changed)
|
||||||
|
def clear_tasks_handlers(*, setting, **kwargs):
|
||||||
|
"""Reset the connection handler whenever the settings change."""
|
||||||
|
if setting == "TASKS":
|
||||||
|
from . import task_backends
|
||||||
|
|
||||||
|
task_backends._settings = task_backends.settings = (
|
||||||
|
task_backends.configure_settings(None)
|
||||||
|
)
|
||||||
|
task_backends._connections = Local()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(task_enqueued)
|
||||||
|
def log_task_enqueued(sender, task_result, **kwargs):
|
||||||
|
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, task_result, **kwargs):
|
||||||
|
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, task_result, **kwargs):
|
||||||
|
logger.log(
|
||||||
|
(
|
||||||
|
logging.ERROR
|
||||||
|
if task_result.status == TaskResultStatus.FAILED
|
||||||
|
else logging.INFO
|
||||||
|
),
|
||||||
|
"Task id=%s path=%s state=%s",
|
||||||
|
task_result.id,
|
||||||
|
task_result.task.module_path,
|
||||||
|
task_result.status,
|
||||||
|
# Signal is sent inside exception handlers, so exc_info() is available.
|
||||||
|
exc_info=sys.exc_info(),
|
||||||
|
)
|
@ -74,3 +74,13 @@ def method_has_no_args(meth):
|
|||||||
|
|
||||||
def func_supports_parameter(func, name):
|
def func_supports_parameter(func, name):
|
||||||
return any(param.name == name for param in _get_callable_parameters(func))
|
return any(param.name == name for param in _get_callable_parameters(func))
|
||||||
|
|
||||||
|
|
||||||
|
def is_module_level_function(func):
|
||||||
|
if not inspect.isfunction(func) or inspect.isbuiltin(func):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "<locals>" in func.__qualname__:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
19
django/utils/json.py
Normal file
19
django/utils/json.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_json(obj):
|
||||||
|
"""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:
|
||||||
|
raise ValueError(f"Unsupported value: {type(obj)}")
|
||||||
|
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)}")
|
@ -597,6 +597,14 @@ Signals
|
|||||||
a lazy reference to the sender ``<app label>.<model>``, but app
|
a lazy reference to the sender ``<app label>.<model>``, but app
|
||||||
``<app label>`` isn't installed or doesn't provide model ``<model>``.
|
``<app label>`` isn't installed or doesn't provide model ``<model>``.
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
-----
|
||||||
|
|
||||||
|
* **tasks.E001**: ``ENQUEUE_ON_COMMIT`` cannot be used when no databases are
|
||||||
|
configured.
|
||||||
|
* **tasks.E002**: ``ENQUEUE_ON_COMMIT`` cannot be used on a database which
|
||||||
|
doesn't support transactions.
|
||||||
|
|
||||||
Templates
|
Templates
|
||||||
---------
|
---------
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ API Reference
|
|||||||
schema-editor
|
schema-editor
|
||||||
settings
|
settings
|
||||||
signals
|
signals
|
||||||
|
tasks
|
||||||
templates/index
|
templates/index
|
||||||
template-response
|
template-response
|
||||||
unicode
|
unicode
|
||||||
|
@ -2766,6 +2766,82 @@ backend definition in :setting:`STORAGES`.
|
|||||||
Defining this setting overrides the default value and is *not* merged with
|
Defining this setting overrides the default value and is *not* merged with
|
||||||
it.
|
it.
|
||||||
|
|
||||||
|
.. setting:: TASKS
|
||||||
|
|
||||||
|
``TASKS``
|
||||||
|
---------
|
||||||
|
|
||||||
|
.. versionadded:: 6.0
|
||||||
|
|
||||||
|
Default::
|
||||||
|
|
||||||
|
{
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
A dictionary containing the settings for all Task backends to be used with
|
||||||
|
Django. It is a nested dictionary whose contents maps backend aliases to a
|
||||||
|
dictionary containing the options for each backend.
|
||||||
|
|
||||||
|
The :setting:`TASKS` setting must configure a ``default`` backend; any number
|
||||||
|
of additional backends may also be specified. Depending on which backend is
|
||||||
|
used, other options may be required. The following options are available as
|
||||||
|
standard.
|
||||||
|
|
||||||
|
.. setting:: TASKS-BACKEND
|
||||||
|
|
||||||
|
``BACKEND``
|
||||||
|
~~~~~~~~~~~
|
||||||
|
|
||||||
|
Default: ``''`` (Empty string)
|
||||||
|
|
||||||
|
The Tasks backend to use. The built-in backends are:
|
||||||
|
|
||||||
|
* ``'django.tasks.backends.dummy.DummyBackend'``
|
||||||
|
* ``'django.tasks.backends.immediate.ImmediateBackend'``
|
||||||
|
|
||||||
|
You can use a backend that doesn't ship with Django by setting
|
||||||
|
:setting:`BACKEND <TASKS-BACKEND>` to a fully-qualified path of a backend
|
||||||
|
class (i.e. ``mypackage.backends.whatever.WhateverBackend``).
|
||||||
|
|
||||||
|
.. setting:: TASKS-ENQUEUE_ON_COMMIT
|
||||||
|
|
||||||
|
``ENQUEUE_ON_COMMIT``
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Default: ``True``
|
||||||
|
|
||||||
|
Whether to enqueue a Task only after the current transaction, if any, commits
|
||||||
|
successfully, instead of enqueueing immediately.
|
||||||
|
|
||||||
|
This can also be configured on a per-Task basis.
|
||||||
|
|
||||||
|
See :ref:`Task transactions <task-transactions>` for more information.
|
||||||
|
|
||||||
|
.. setting:: TASKS-QUEUES
|
||||||
|
|
||||||
|
``QUEUES``
|
||||||
|
~~~~~~~~~~
|
||||||
|
|
||||||
|
Default: ``["default"]``
|
||||||
|
|
||||||
|
Specify the queue names supported by the backend. This can be used to ensure
|
||||||
|
Tasks aren't enqueued to queues which do not exist.
|
||||||
|
|
||||||
|
To disable queue name validation, set to an empty list (``[]``).
|
||||||
|
|
||||||
|
.. setting:: TASKS-OPTIONS
|
||||||
|
|
||||||
|
``OPTIONS``
|
||||||
|
~~~~~~~~~~~
|
||||||
|
|
||||||
|
Default: ``{}``
|
||||||
|
|
||||||
|
Extra parameters to pass to the Task backend. Available parameters vary
|
||||||
|
depending on the Task backend.
|
||||||
|
|
||||||
.. setting:: TEMPLATES
|
.. setting:: TEMPLATES
|
||||||
|
|
||||||
``TEMPLATES``
|
``TEMPLATES``
|
||||||
|
@ -703,3 +703,60 @@ Arguments sent with this signal:
|
|||||||
The database connection that was opened. This can be used in a
|
The database connection that was opened. This can be used in a
|
||||||
multiple-database configuration to differentiate connection signals
|
multiple-database configuration to differentiate connection signals
|
||||||
from different databases.
|
from different databases.
|
||||||
|
|
||||||
|
Tasks signals
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. versionadded:: 6.0
|
||||||
|
|
||||||
|
Signals sent by the :doc:`tasks </ref/tasks>` framework.
|
||||||
|
|
||||||
|
``task_enqueued``
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. data:: django.tasks.signals.task_enqueued
|
||||||
|
:module:
|
||||||
|
|
||||||
|
Sent once a Task has been enqueued. If
|
||||||
|
:attr:`django.tasks.Task.enqueue_on_commit` is set, the signal is only sent
|
||||||
|
once the transaction commits successfully.
|
||||||
|
|
||||||
|
Arguments sent with this signal:
|
||||||
|
|
||||||
|
``sender``
|
||||||
|
The backend class which the Task was enqueued on to.
|
||||||
|
|
||||||
|
``task_result``
|
||||||
|
The enqueued :class:`TaskResult <django.tasks.TaskResult>`.
|
||||||
|
|
||||||
|
``task_started``
|
||||||
|
----------------
|
||||||
|
|
||||||
|
.. data:: django.tasks.signals.task_started
|
||||||
|
:module:
|
||||||
|
|
||||||
|
Sent when a Task has started executing.
|
||||||
|
|
||||||
|
Arguments sent with this signal:
|
||||||
|
|
||||||
|
``sender``
|
||||||
|
The backend class which the Task was enqueued on to.
|
||||||
|
|
||||||
|
``task_result``
|
||||||
|
The started :class:`TaskResult <django.tasks.TaskResult>`.
|
||||||
|
|
||||||
|
``task_finished``
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. data:: django.tasks.signals.task_finished
|
||||||
|
:module:
|
||||||
|
|
||||||
|
Sent once a Task has finished executing, successfully or otherwise.
|
||||||
|
|
||||||
|
Arguments sent with this signal:
|
||||||
|
|
||||||
|
``sender``
|
||||||
|
The backend class which the Task was enqueued on to.
|
||||||
|
|
||||||
|
``task_result``
|
||||||
|
The finished :class:`TaskResult <django.tasks.TaskResult>`.
|
||||||
|
444
docs/ref/tasks.txt
Normal file
444
docs/ref/tasks.txt
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
=====
|
||||||
|
Tasks
|
||||||
|
=====
|
||||||
|
|
||||||
|
.. versionadded:: 6.0
|
||||||
|
|
||||||
|
.. module:: django.tasks
|
||||||
|
:synopsis: Django's built-in background Task system.
|
||||||
|
|
||||||
|
Task definition
|
||||||
|
===============
|
||||||
|
|
||||||
|
The ``task`` decorator
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
.. function:: task(*, priority=0, queue_name="default", backend="default", enqueue_on_commit=None, takes_context=False)
|
||||||
|
|
||||||
|
The ``@task`` decorator defines a :class:`Task` instance. This has the
|
||||||
|
following optional arguments:
|
||||||
|
|
||||||
|
* ``priority``: Sets the :attr:`~Task.priority` of the ``Task``. Defaults
|
||||||
|
to 0.
|
||||||
|
* ``queue_name``: Sets the :attr:`~Task.queue_name` of the ``Task``.
|
||||||
|
Defaults to ``"default"``.
|
||||||
|
* ``backend``: Sets the :attr:`~Task.backend` of the ``Task``. Defaults to
|
||||||
|
``"default"``.
|
||||||
|
* ``enqueue_on_commit``: Sets :attr:`~Task.enqueue_on_commit` for the
|
||||||
|
``Task``. Defaults to ``None``.
|
||||||
|
* ``takes_context``: Controls whether the ``Task`` function accepts a
|
||||||
|
:class:`TaskContext`. Defaults to ``False``. See :ref:`Task context
|
||||||
|
<task-context>` for details.
|
||||||
|
|
||||||
|
If the defined ``Task`` is not valid according to the backend,
|
||||||
|
:exc:`~django.tasks.exceptions.InvalidTask` is raised.
|
||||||
|
|
||||||
|
See :ref:`defining tasks <defining-tasks>` for usage examples.
|
||||||
|
|
||||||
|
``Task``
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. class:: Task
|
||||||
|
|
||||||
|
Represents a Task to be run in the background. Tasks should be defined
|
||||||
|
using the :func:`task` decorator.
|
||||||
|
|
||||||
|
Attributes of ``Task`` cannot be modified. See :ref:`modifying Tasks
|
||||||
|
<modifying-tasks>` for details.
|
||||||
|
|
||||||
|
.. attribute:: Task.priority
|
||||||
|
|
||||||
|
The priority of the ``Task``. Priorities must be between -100 and 100,
|
||||||
|
where larger numbers are higher priority, and will be run sooner.
|
||||||
|
|
||||||
|
The backend must have :attr:`.supports_priority` set to ``True`` to use
|
||||||
|
this feature.
|
||||||
|
|
||||||
|
.. attribute:: Task.backend
|
||||||
|
|
||||||
|
The alias of the backend the ``Task`` should be enqueued to. This must
|
||||||
|
match a backend defined in :setting:`BACKEND <TASKS-BACKEND>`.
|
||||||
|
|
||||||
|
.. attribute:: Task.queue_name
|
||||||
|
|
||||||
|
The name of the queue the ``Task`` will be enqueued on to. Defaults to
|
||||||
|
``"default"``. This must match a queue defined in
|
||||||
|
:setting:`QUEUES <TASKS-QUEUES>`, unless
|
||||||
|
:setting:`QUEUES <TASKS-QUEUES>` is set to ``[]``.
|
||||||
|
|
||||||
|
.. attribute:: Task.run_after
|
||||||
|
|
||||||
|
The earliest time the ``Task`` will be executed. This can be a
|
||||||
|
:class:`timedelta <datetime.timedelta>`, which is used relative to the
|
||||||
|
current time, a timezone-aware :class:`datetime <datetime.datetime>`,
|
||||||
|
or ``None`` if not constrained. Defaults to ``None``.
|
||||||
|
|
||||||
|
The backend must have :attr:`.supports_defer` set to ``True`` to use
|
||||||
|
this feature. Otherwise,
|
||||||
|
:exc:`~django.tasks.exceptions.InvalidTask` is raised.
|
||||||
|
|
||||||
|
.. attribute:: Task.enqueue_on_commit
|
||||||
|
|
||||||
|
Whether the ``Task`` should be enqueued when the transaction commits
|
||||||
|
successfully, or immediately. Defaults to :setting:`ENQUEUE_ON_COMMIT
|
||||||
|
<TASKS-ENQUEUE_ON_COMMIT>` for the backend.
|
||||||
|
|
||||||
|
See :ref:`Task transactions <task-transactions>` for more information.
|
||||||
|
|
||||||
|
.. attribute:: Task.name
|
||||||
|
|
||||||
|
The name of the function decorated with :func:`task`. This name is not
|
||||||
|
necessarily unique.
|
||||||
|
|
||||||
|
.. method:: Task.using(*, priority=None, backend=None, queue_name=None, run_after=None)
|
||||||
|
|
||||||
|
Creates a new ``Task`` with modified defaults. The existing ``Task`` is
|
||||||
|
left unchanged.
|
||||||
|
|
||||||
|
``using`` allows modifying the following attributes:
|
||||||
|
|
||||||
|
* :attr:`priority <Task.priority>`
|
||||||
|
* :attr:`backend <Task.backend>`
|
||||||
|
* :attr:`queue_name <Task.queue_name>`
|
||||||
|
* :attr:`run_after <Task.run_after>`
|
||||||
|
|
||||||
|
See :ref:`modifying Tasks <modifying-tasks>` for usage examples.
|
||||||
|
|
||||||
|
.. method:: Task.enqueue(*args, **kwargs)
|
||||||
|
|
||||||
|
Enqueues the ``Task`` to the ``Task`` backend for later execution.
|
||||||
|
|
||||||
|
Arguments are passed to the ``Task``'s function after a round-trip
|
||||||
|
through a :func:`json.dumps`/:func:`json.loads` cycle. Hence, all
|
||||||
|
arguments must be JSON-serializable and preserve their type after the
|
||||||
|
round-trip.
|
||||||
|
|
||||||
|
If the ``Task`` is not valid according to the backend,
|
||||||
|
:exc:`~django.tasks.exceptions.InvalidTask` is raised.
|
||||||
|
|
||||||
|
See :ref:`enqueueing Tasks <enqueueing-tasks>` for usage examples.
|
||||||
|
|
||||||
|
.. method:: Task.aenqueue(*args, **kwargs)
|
||||||
|
|
||||||
|
The ``async`` variant of :meth:`enqueue <Task.enqueue>`.
|
||||||
|
|
||||||
|
.. method:: Task.get_result(result_id)
|
||||||
|
|
||||||
|
Retrieves a result by its id.
|
||||||
|
|
||||||
|
If the result does not exist, :exc:`TaskResultDoesNotExist
|
||||||
|
<django.tasks.exceptions.TaskResultDoesNotExist>` is raised. If the
|
||||||
|
result is not the same type as the current Task,
|
||||||
|
:exc:`TaskResultMismatch <django.tasks.exceptions.TaskResultMismatch>`
|
||||||
|
is raised. If the backend does not support ``get_result()``,
|
||||||
|
:exc:`NotImplementedError` is raised.
|
||||||
|
|
||||||
|
.. method:: Task.aget_result(*args, **kwargs)
|
||||||
|
|
||||||
|
The ``async`` variant of :meth:`get_result <Task.get_result>`.
|
||||||
|
|
||||||
|
Task context
|
||||||
|
============
|
||||||
|
|
||||||
|
.. class:: TaskContext
|
||||||
|
|
||||||
|
Contains context for the running :class:`Task`. Context only passed to a
|
||||||
|
``Task`` if it was defined with ``takes_context=True``.
|
||||||
|
|
||||||
|
Attributes of ``TaskContext`` cannot be modified.
|
||||||
|
|
||||||
|
.. attribute:: TaskContext.task_result
|
||||||
|
|
||||||
|
The :class:`TaskResult` currently being run.
|
||||||
|
|
||||||
|
.. attribute:: TaskContext.attempt
|
||||||
|
|
||||||
|
The number of the current execution attempts for this Task, starting at
|
||||||
|
1.
|
||||||
|
|
||||||
|
Task results
|
||||||
|
============
|
||||||
|
|
||||||
|
.. class:: TaskResultStatus
|
||||||
|
|
||||||
|
An Enum representing the status of a :class:`TaskResult`.
|
||||||
|
|
||||||
|
.. attribute:: TaskResultStatus.READY
|
||||||
|
|
||||||
|
The :class:`Task` has just been enqueued, or is ready to be executed
|
||||||
|
again.
|
||||||
|
|
||||||
|
.. attribute:: TaskResultStatus.RUNNING
|
||||||
|
|
||||||
|
The :class:`Task` is currently being executed.
|
||||||
|
|
||||||
|
.. attribute:: TaskResultStatus.FAILED
|
||||||
|
|
||||||
|
The :class:`Task` raised an exception during execution, or was unable
|
||||||
|
to start.
|
||||||
|
|
||||||
|
.. attribute:: TaskResultStatus.SUCCESSFUL
|
||||||
|
|
||||||
|
The :class:`Task` has finished executing successfully.
|
||||||
|
|
||||||
|
.. class:: TaskResult
|
||||||
|
|
||||||
|
The ``TaskResult`` stores the information about a specific execution of a
|
||||||
|
:class:`Task`.
|
||||||
|
|
||||||
|
Attributes of ``TaskResult`` cannot be modified.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.task
|
||||||
|
|
||||||
|
The :class:`Task` the result was enqueued for.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.id
|
||||||
|
|
||||||
|
A unique identifier for the result, which can be passed to
|
||||||
|
:meth:`Task.get_result`.
|
||||||
|
|
||||||
|
The format of the id will depend on the backend being used. Task result
|
||||||
|
ids are always strings less than 64 characters.
|
||||||
|
|
||||||
|
See :ref:`Task results <task-results>` for more details.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.status
|
||||||
|
|
||||||
|
The :class:`status <TaskResultStatus>` of the result.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.enqueued_at
|
||||||
|
|
||||||
|
The time when the ``Task`` was enqueued.
|
||||||
|
|
||||||
|
If :attr:`Task.enqueue_on_commit` was set, this is the time the
|
||||||
|
transaction committed.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.started_at
|
||||||
|
|
||||||
|
The time when the ``Task`` began execution, on its first attempt.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.last_attempted_at
|
||||||
|
|
||||||
|
The time when the most recent ``Task`` run began execution.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.finished_at
|
||||||
|
|
||||||
|
The time when the ``Task`` finished execution, whether it failed or
|
||||||
|
succeeded.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.backend
|
||||||
|
|
||||||
|
The backend the result is from.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.errors
|
||||||
|
|
||||||
|
A list of :class:`TaskError` instances for the errors raised as part of
|
||||||
|
each execution of the Task.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.return_value
|
||||||
|
|
||||||
|
The return value from the ``Task`` function.
|
||||||
|
|
||||||
|
If the ``Task`` did not finish successfully, :exc:`ValueError` is
|
||||||
|
raised.
|
||||||
|
|
||||||
|
See :ref:`return values <task-return-values>` for usage examples.
|
||||||
|
|
||||||
|
.. method:: TaskResult.refresh
|
||||||
|
|
||||||
|
Refresh the result's attributes from the queue store.
|
||||||
|
|
||||||
|
.. method:: TaskResult.arefresh
|
||||||
|
|
||||||
|
The ``async`` variant of :meth:`TaskResult.refresh`.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.is_finished
|
||||||
|
|
||||||
|
Whether the ``Task`` has finished (successfully or not).
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.attempts
|
||||||
|
|
||||||
|
The number of times the Task has been run.
|
||||||
|
|
||||||
|
If the task is currently running, it does not count as an attempt.
|
||||||
|
|
||||||
|
.. attribute:: TaskResult.worker_ids
|
||||||
|
|
||||||
|
The ids of the workers which have executed the Task.
|
||||||
|
|
||||||
|
|
||||||
|
Task errors
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. class:: TaskError
|
||||||
|
|
||||||
|
Contains information about the error raised during the execution of a
|
||||||
|
``Task``.
|
||||||
|
|
||||||
|
.. attribute:: TaskError.traceback
|
||||||
|
|
||||||
|
The traceback (as a string) from the raised exception when the ``Task``
|
||||||
|
failed.
|
||||||
|
|
||||||
|
.. attribute:: TaskError.exception_class
|
||||||
|
|
||||||
|
The exception class raised when executing the ``Task``.
|
||||||
|
|
||||||
|
Backends
|
||||||
|
========
|
||||||
|
|
||||||
|
Base backend
|
||||||
|
------------
|
||||||
|
|
||||||
|
.. module:: django.tasks.backends.base
|
||||||
|
|
||||||
|
.. class:: BaseTaskBackend
|
||||||
|
|
||||||
|
``BaseTaskBackend`` is the parent class for all Task backends.
|
||||||
|
|
||||||
|
.. attribute:: BaseTaskBackend.options
|
||||||
|
|
||||||
|
A dictionary of extra parameters for the Task backend. These are
|
||||||
|
provided using the :setting:`OPTIONS <TASKS-OPTIONS>` setting.
|
||||||
|
|
||||||
|
.. method:: BaseTaskBackend.enqueue(task, args, kwargs)
|
||||||
|
|
||||||
|
Task backends which subclass ``BaseTaskBackend`` should implement this
|
||||||
|
method as a minimum.
|
||||||
|
|
||||||
|
When implemented, ``enqueue()`` enqueues the ``task``, a :class:`.Task`
|
||||||
|
instance, for later execution. ``args`` are the positional arguments
|
||||||
|
and ``kwargs`` are the keyword arguments to be passed to the ``task``.
|
||||||
|
Returns a :class:`~django.tasks.TaskResult`.
|
||||||
|
|
||||||
|
.. method:: BaseTaskBackend.aenqueue(task, args, kwargs)
|
||||||
|
|
||||||
|
The ``async`` variant of :meth:`BaseTaskBackend.enqueue`.
|
||||||
|
|
||||||
|
.. method:: BaseTaskBackend.get_result(result_id)
|
||||||
|
|
||||||
|
Retrieve a result by its id. If the result does not exist,
|
||||||
|
:exc:`TaskResultDoesNotExist
|
||||||
|
<django.tasks.exceptions.TaskResultDoesNotExist>` is raised.
|
||||||
|
|
||||||
|
If the backend does not support ``get_result()``,
|
||||||
|
:exc:`NotImplementedError` is raised.
|
||||||
|
|
||||||
|
.. method:: BaseTaskBackend.aget_result(result_id)
|
||||||
|
|
||||||
|
The ``async`` variant of :meth:`BaseTaskBackend.get_result`.
|
||||||
|
|
||||||
|
.. method:: BaseTaskBackend.validate_task(task)
|
||||||
|
|
||||||
|
Validates whether the provided ``Task`` is able to be enqueued using
|
||||||
|
the backend. If the Task is not valid,
|
||||||
|
:exc:`InvalidTask <django.tasks.exceptions.InvalidTask>`
|
||||||
|
is raised.
|
||||||
|
|
||||||
|
Feature flags
|
||||||
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Some backends may not support all features Django provides. It's possible to
|
||||||
|
identify the supported functionality of a backend, and potentially change
|
||||||
|
behavior accordingly.
|
||||||
|
|
||||||
|
.. attribute:: BaseTaskBackend.supports_defer
|
||||||
|
|
||||||
|
Whether the backend supports enqueueing Tasks to be executed after a
|
||||||
|
specific time using the :attr:`~django.tasks.Task.run_after` attribute.
|
||||||
|
|
||||||
|
.. attribute:: BaseTaskBackend.supports_async_task
|
||||||
|
|
||||||
|
Whether the backend supports enqueueing async functions (coroutines).
|
||||||
|
|
||||||
|
.. attribute:: BaseTaskBackend.supports_get_result
|
||||||
|
|
||||||
|
Whether the backend supports retrieving ``Task`` results from another
|
||||||
|
thread after they have been enqueued.
|
||||||
|
|
||||||
|
.. attribute:: BaseTaskBackend.supports_priority
|
||||||
|
|
||||||
|
Whether the backend supports executing Tasks as ordered by their
|
||||||
|
:attr:`~django.tasks.Task.priority`.
|
||||||
|
|
||||||
|
The below table notes which of the :ref:`built-in backends
|
||||||
|
<task-available-backends>` support which features:
|
||||||
|
|
||||||
|
============================ ======================= ===========================
|
||||||
|
Feature :class:`.DummyBackend` :class:`.ImmediateBackend`
|
||||||
|
============================ ======================= ===========================
|
||||||
|
:attr:`.supports_defer` Yes No
|
||||||
|
:attr:`.supports_async_task` Yes Yes
|
||||||
|
:attr:`.supports_get_result` No No [#fnimmediateresult]_
|
||||||
|
:attr:`.supports_priority` Yes [#fndummypriority]_ Yes [#fnimmediatepriority]_
|
||||||
|
============================ ======================= ===========================
|
||||||
|
|
||||||
|
.. _task-available-backends:
|
||||||
|
|
||||||
|
Available backends
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Immediate backend
|
||||||
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
.. module:: django.tasks.backends.immediate
|
||||||
|
|
||||||
|
.. class:: ImmediateBackend
|
||||||
|
|
||||||
|
The :ref:`immediate backend <immediate-task-backend>` executes Tasks
|
||||||
|
immediately, rather than in the background.
|
||||||
|
|
||||||
|
Dummy backend
|
||||||
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
.. module:: django.tasks.backends.dummy
|
||||||
|
|
||||||
|
.. class:: DummyBackend
|
||||||
|
|
||||||
|
The :ref:`dummy backend <dummy-task-backend>` does not execute enqueued
|
||||||
|
Tasks. Instead, it stores task results for later inspection.
|
||||||
|
|
||||||
|
.. attribute:: DummyBackend.results
|
||||||
|
|
||||||
|
A list of results for the enqueued Tasks, in the order they were
|
||||||
|
enqueued.
|
||||||
|
|
||||||
|
.. method:: DummyBackend.clear
|
||||||
|
|
||||||
|
Clears the list of stored results.
|
||||||
|
|
||||||
|
Exceptions
|
||||||
|
==========
|
||||||
|
|
||||||
|
.. module:: django.tasks.exceptions
|
||||||
|
|
||||||
|
.. exception:: InvalidTask
|
||||||
|
|
||||||
|
Raised when the :class:`.Task` attempting to be enqueued
|
||||||
|
is invalid.
|
||||||
|
|
||||||
|
.. exception:: InvalidTaskBackend
|
||||||
|
|
||||||
|
Raised when the requested :class:`.BaseTaskBackend` is invalid.
|
||||||
|
|
||||||
|
.. exception:: TaskResultDoesNotExist
|
||||||
|
|
||||||
|
Raised by :meth:`~django.tasks.backends.base.BaseTaskBackend.get_result`
|
||||||
|
when the provided ``result_id`` does not exist.
|
||||||
|
|
||||||
|
.. exception:: TaskResultMismatch
|
||||||
|
|
||||||
|
Raised by :meth:`~django.tasks.Task.get_result` when the provided
|
||||||
|
``result_id`` is for a different Task than the current Task.
|
||||||
|
|
||||||
|
.. rubric:: Footnotes
|
||||||
|
.. [#fnimmediateresult] The :class:`.ImmediateBackend` doesn't officially
|
||||||
|
support ``get_result()``, despite implementing the API, since the result
|
||||||
|
cannot be retrieved from a different thread.
|
||||||
|
.. [#fndummypriority] The :class:`.DummyBackend` has ``supports_priority=True``
|
||||||
|
so that it can be used as a drop-in replacement in tests. Since this
|
||||||
|
backend never executes Tasks, the ``priority`` value has no effect.
|
||||||
|
.. [#fnimmediatepriority] The :class:`.ImmediateBackend` has
|
||||||
|
``supports_priority=True`` so that it can be used as a drop-in replacement
|
||||||
|
in tests. Because Tasks run as soon as they are scheduled, the ``priority``
|
||||||
|
value has no effect.
|
@ -112,6 +112,45 @@ A `migration guide`_ is available if you're updating from the
|
|||||||
|
|
||||||
.. _migration guide: https://github.com/carltongibson/django-template-partials/blob/main/Migration.md
|
.. _migration guide: https://github.com/carltongibson/django-template-partials/blob/main/Migration.md
|
||||||
|
|
||||||
|
Background Tasks
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Django now includes a built-in Tasks framework for running code outside the
|
||||||
|
HTTP request–response cycle. This enables offloading work, such as sending
|
||||||
|
emails or processing data, to background workers.
|
||||||
|
|
||||||
|
Tasks are defined using the :func:`~django.tasks.task` decorator::
|
||||||
|
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.tasks import task
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def email_users(emails, subject, message):
|
||||||
|
return send_mail(subject, message, None, emails)
|
||||||
|
|
||||||
|
Once defined, tasks can be enqueued through a configured backend::
|
||||||
|
|
||||||
|
email_users.enqueue(
|
||||||
|
emails=["user@example.com"],
|
||||||
|
subject="You have a message",
|
||||||
|
message="Hello there!",
|
||||||
|
)
|
||||||
|
|
||||||
|
Backends are configured via the :setting:`TASKS` setting. Django provides
|
||||||
|
two built-in backends, primarily for development and testing:
|
||||||
|
|
||||||
|
* :class:`~django.tasks.backends.immediate.ImmediateBackend`: executes tasks
|
||||||
|
immediately in the same process.
|
||||||
|
* :class:`~django.tasks.backends.dummy.DummyBackend`: stores tasks without
|
||||||
|
running them, leaving results in the
|
||||||
|
:attr:`~django.tasks.TaskResultStatus.READY` state.
|
||||||
|
|
||||||
|
Django only handles task creation and queuing; it does not provide a worker
|
||||||
|
mechanism to run tasks. Execution must be managed by external infrastructure,
|
||||||
|
such as a separate process or service. See :doc:`/topics/tasks` for an
|
||||||
|
overview, and the :doc:`Tasks reference </ref/tasks>` for API details.
|
||||||
|
|
||||||
Minor features
|
Minor features
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
|
@ -152,6 +152,7 @@ editability
|
|||||||
encodings
|
encodings
|
||||||
Endian
|
Endian
|
||||||
Enero
|
Enero
|
||||||
|
enqueueing
|
||||||
enum
|
enum
|
||||||
environ
|
environ
|
||||||
esque
|
esque
|
||||||
|
@ -33,3 +33,4 @@ Introductions to all the key parts of Django you'll need to know:
|
|||||||
checks
|
checks
|
||||||
external-packages
|
external-packages
|
||||||
async
|
async
|
||||||
|
tasks
|
||||||
|
438
docs/topics/tasks.txt
Normal file
438
docs/topics/tasks.txt
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
========================
|
||||||
|
Django's Tasks framework
|
||||||
|
========================
|
||||||
|
|
||||||
|
.. versionadded:: 6.0
|
||||||
|
|
||||||
|
For a web application, there's often more than just turning HTTP requests into
|
||||||
|
HTTP responses. For some functionality, it may be beneficial to run code
|
||||||
|
outside the request-response cycle.
|
||||||
|
|
||||||
|
That's where background Tasks come in.
|
||||||
|
|
||||||
|
Background Tasks can offload work to be run outside the request-response cycle,
|
||||||
|
to be run elsewhere, potentially at a later date. This keeps requests fast,
|
||||||
|
reduces latency, and improves the user experience. For example, a user
|
||||||
|
shouldn't have to wait for an email to send before their page finishes loading.
|
||||||
|
|
||||||
|
Django's new Tasks framework makes it easy to define and enqueue such work. It
|
||||||
|
does not provide a worker mechanism to run Tasks. The actual execution must be
|
||||||
|
handled by infrastructure outside Django, such as a separate process or
|
||||||
|
service.
|
||||||
|
|
||||||
|
Background Task fundamentals
|
||||||
|
============================
|
||||||
|
|
||||||
|
When work needs to be done in the background, Django creates a ``Task``, which
|
||||||
|
is stored in the Queue Store. This ``Task`` contains all the metadata needed to
|
||||||
|
execute it, as well as a unique identifier for Django to retrieve the result
|
||||||
|
later.
|
||||||
|
|
||||||
|
A Worker will look at the Queue Store for new Tasks to run. When a new Task is
|
||||||
|
added, a Worker claims the Task, executes it, and saves the status and result
|
||||||
|
back to the Queue Store. These workers run outside the request-response
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
.. _configuring-a-task-backend:
|
||||||
|
|
||||||
|
Configuring a Task backend
|
||||||
|
==========================
|
||||||
|
|
||||||
|
The Task backend determines how and where Tasks are stored for execution and
|
||||||
|
how they are executed. Different Task backends have different characteristics
|
||||||
|
and configuration options, which may impact the performance and reliability of
|
||||||
|
your application. Django comes with a number of :ref:`built-in backends
|
||||||
|
<task-available-backends>`. Django does not provide a generic way to execute
|
||||||
|
Tasks, only enqueue them.
|
||||||
|
|
||||||
|
Task backends are configured using the :setting:`TASKS` setting in your
|
||||||
|
settings file. Whilst most applications will only need a single backend,
|
||||||
|
multiple are supported.
|
||||||
|
|
||||||
|
.. _immediate-task-backend:
|
||||||
|
|
||||||
|
Immediate execution
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
This is the default backend if another is not specified in your settings file.
|
||||||
|
The :class:`.ImmediateBackend` runs enqueued Tasks immediately, rather than in
|
||||||
|
the background. This allows background Task functionality to be slowly added to
|
||||||
|
an application, before the required infrastructure is available.
|
||||||
|
|
||||||
|
To use it, set :setting:`BACKEND <TASKS-BACKEND>` to
|
||||||
|
``"django.tasks.backends.immediate.ImmediateBackend"``::
|
||||||
|
|
||||||
|
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
|
||||||
|
|
||||||
|
The :class:`.ImmediateBackend` may also be useful in tests, to bypass the need
|
||||||
|
to run a real background worker in your tests.
|
||||||
|
|
||||||
|
.. admonition:: ``ImmediateBackend`` and ``ENQUEUE_ON_COMMIT``
|
||||||
|
|
||||||
|
When :setting:`ENQUEUE_ON_COMMIT <TASKS-ENQUEUE_ON_COMMIT>` is ``False``,
|
||||||
|
the Task will be executed within the same transaction it was enqueued in.
|
||||||
|
|
||||||
|
See :ref:`Task transactions <task-transactions>` for more information.
|
||||||
|
|
||||||
|
.. _dummy-task-backend:
|
||||||
|
|
||||||
|
Dummy backend
|
||||||
|
-------------
|
||||||
|
|
||||||
|
The :class:`.DummyBackend` doesn't execute enqueued Tasks at all, instead
|
||||||
|
storing results for later use. Task results will forever remain in the
|
||||||
|
:attr:`~django.tasks.TaskResultStatus.READY` state.
|
||||||
|
|
||||||
|
This backend is not intended for use in production - it is provided as a
|
||||||
|
convenience that can be used during development and testing.
|
||||||
|
|
||||||
|
To use it, set :setting:`BACKEND <TASKS-BACKEND>` to
|
||||||
|
``"django.tasks.backends.dummy.DummyBackend"``::
|
||||||
|
|
||||||
|
TASKS = {"default": {"BACKEND": "django.tasks.backends.dummy.DummyBackend"}}
|
||||||
|
|
||||||
|
The results for enqueued Tasks can be retrieved from the backend's
|
||||||
|
:attr:`~django.tasks.backends.dummy.DummyBackend.results` attribute:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> from django.tasks import default_task_backend
|
||||||
|
>>> my_task.enqueue()
|
||||||
|
>>> len(default_task_backend.results)
|
||||||
|
1
|
||||||
|
|
||||||
|
Stored results can be cleared using the
|
||||||
|
:meth:`~django.tasks.backends.dummy.DummyBackend.clear` method:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> default_task_backend.clear()
|
||||||
|
>>> len(default_task_backend.results)
|
||||||
|
0
|
||||||
|
|
||||||
|
Using a custom backend
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
While Django includes support for a number of Task backends out-of-the-box,
|
||||||
|
sometimes you might want to customize the Task backend. To use an external Task
|
||||||
|
backend with Django, use the Python import path as the :setting:`BACKEND
|
||||||
|
<TASKS-BACKEND>` of the :setting:`TASKS` setting, like so::
|
||||||
|
|
||||||
|
TASKS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "path.to.backend",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
A Task backend is a class that inherits
|
||||||
|
:class:`~django.tasks.backends.base.BaseTaskBackend`. At a minimum, it must
|
||||||
|
implement :meth:`.BaseTaskBackend.enqueue`. If you're building your own
|
||||||
|
backend, you can use the built-in Task backends as reference implementations.
|
||||||
|
You'll find the code in the :source:`django/tasks/backends/` directory of the
|
||||||
|
Django source.
|
||||||
|
|
||||||
|
Asynchronous support
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Django has developing support for asynchronous Task backends.
|
||||||
|
|
||||||
|
:class:`~django.tasks.backends.base.BaseTaskBackend` has async variants of all
|
||||||
|
base methods. By convention, the asynchronous versions of all methods are
|
||||||
|
prefixed with ``a``. The arguments for both variants are the same.
|
||||||
|
|
||||||
|
Retrieving backends
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Backends can be retrieved using the ``task_backends`` connection handler::
|
||||||
|
|
||||||
|
from django.tasks import task_backends
|
||||||
|
|
||||||
|
task_backends["default"] # The default backend
|
||||||
|
task_backends["reserve"] # Another backend
|
||||||
|
|
||||||
|
The "default" backend is available as ``default_task_backend``::
|
||||||
|
|
||||||
|
from django.tasks import default_task_backend
|
||||||
|
|
||||||
|
.. _defining-tasks:
|
||||||
|
|
||||||
|
Defining Tasks
|
||||||
|
==============
|
||||||
|
|
||||||
|
Tasks are defined using the :meth:`django.tasks.task` decorator on a
|
||||||
|
module-level function::
|
||||||
|
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.tasks import task
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def email_users(emails, subject, message):
|
||||||
|
return send_mail(
|
||||||
|
subject=subject, message=message, from_email=None, recipient_list=emails
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
The return value of the decorator is a :class:`~django.tasks.Task` instance.
|
||||||
|
|
||||||
|
:class:`~django.tasks.Task` attributes can be customized via the ``@task``
|
||||||
|
decorator arguments::
|
||||||
|
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.tasks import task
|
||||||
|
|
||||||
|
|
||||||
|
@task(priority=2, queue_name="emails", enqueue_on_commit=True)
|
||||||
|
def email_users(emails, subject, message):
|
||||||
|
return send_mail(
|
||||||
|
subject=subject, message=message, from_email=None, recipient_list=emails
|
||||||
|
)
|
||||||
|
|
||||||
|
By convention, Tasks are defined in a ``tasks.py`` file, however this is not
|
||||||
|
enforced.
|
||||||
|
|
||||||
|
.. _task-context:
|
||||||
|
|
||||||
|
Task context
|
||||||
|
------------
|
||||||
|
|
||||||
|
Sometimes, the running ``Task`` may need to know context about how it was
|
||||||
|
enqueued, and how it is being executed. This can be accessed by taking a
|
||||||
|
``context`` argument, which is an instance of
|
||||||
|
:class:`~django.tasks.TaskContext`.
|
||||||
|
|
||||||
|
To receive the Task context as an argument to your Task function, pass
|
||||||
|
``takes_context`` when defining it::
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.tasks import task
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@task(takes_context=True)
|
||||||
|
def email_users(context, emails, subject, message):
|
||||||
|
logger.debug(
|
||||||
|
f"Attempt {context.attempt} to send user email. Task result id: {context.task_result.id}."
|
||||||
|
)
|
||||||
|
return send_mail(
|
||||||
|
subject=subject, message=message, from_email=None, recipient_list=emails
|
||||||
|
)
|
||||||
|
|
||||||
|
.. _modifying-tasks:
|
||||||
|
|
||||||
|
Modifying Tasks
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Before enqueueing Tasks, it may be necessary to modify certain parameters of
|
||||||
|
the Task. For example, to give it a higher priority than it would normally.
|
||||||
|
|
||||||
|
A ``Task`` instance cannot be modified directly. Instead, a modified instance
|
||||||
|
can be created with the :meth:`~django.tasks.Task.using` method, leaving the
|
||||||
|
original as-is. For example:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> email_users.priority
|
||||||
|
0
|
||||||
|
>>> email_users.using(priority=10).priority
|
||||||
|
10
|
||||||
|
|
||||||
|
.. _enqueueing-tasks:
|
||||||
|
|
||||||
|
Enqueueing Tasks
|
||||||
|
================
|
||||||
|
|
||||||
|
To add the Task to the queue store, so it will be executed, call the
|
||||||
|
:meth:`~django.tasks.Task.enqueue` method on it. If the Task takes arguments,
|
||||||
|
these can be passed as-is. For example::
|
||||||
|
|
||||||
|
result = email_users.enqueue(
|
||||||
|
emails=["user@example.com"],
|
||||||
|
subject="You have a message",
|
||||||
|
message="Hello there!",
|
||||||
|
)
|
||||||
|
|
||||||
|
This returns a :class:`~django.tasks.TaskResult`, which can be used to retrieve
|
||||||
|
the result of the Task once it has finished executing.
|
||||||
|
|
||||||
|
To enqueue Tasks in an ``async`` context, :meth:`~django.tasks.Task.aenqueue`
|
||||||
|
is available as an ``async`` variant of :meth:`~django.tasks.Task.enqueue`.
|
||||||
|
|
||||||
|
Because both Task arguments and return values are serialized to JSON, they must
|
||||||
|
be JSON-serializable:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> process_data.enqueue(datetime.now())
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
TypeError: Object of type datetime is not JSON serializable
|
||||||
|
|
||||||
|
Arguments must also be able to round-trip through a :func:`json.dumps`/
|
||||||
|
:func:`json.loads` cycle without changing type. For example, consider this
|
||||||
|
Task::
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def double_dictionary(key):
|
||||||
|
return {key: key * 2}
|
||||||
|
|
||||||
|
With the ``ImmediateBackend`` configured as the default backend:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> result = double_dictionary.enqueue((1, 2, 3))
|
||||||
|
>>> result.status
|
||||||
|
FAILED
|
||||||
|
>>> result.errors[0].traceback
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
TypeError: unhashable type: 'list'
|
||||||
|
|
||||||
|
The ``double_dictionary`` Task fails because after the JSON round-trip the
|
||||||
|
tuple ``(1, 2, 3)`` becomes the list ``[1, 2, 3]``, which cannot be used as a
|
||||||
|
dictionary key.
|
||||||
|
|
||||||
|
In general, complex objects such as model instances, or built-in types like
|
||||||
|
``datetime`` and ``tuple`` cannot be used in Tasks without additional
|
||||||
|
conversion.
|
||||||
|
|
||||||
|
.. _task-transactions:
|
||||||
|
|
||||||
|
Transactions
|
||||||
|
------------
|
||||||
|
|
||||||
|
By default, Tasks are enqueued after the current database transaction (if there
|
||||||
|
is one) commits successfully (using :meth:`transaction.on_commit()
|
||||||
|
<django.db.transaction.on_commit>`), rather than enqueueing immediately. For
|
||||||
|
most backends, Tasks are run in a separate process, using a different database
|
||||||
|
connection. Without waiting for the transaction to commit, workers could start
|
||||||
|
to process a Task which uses objects it can't access yet.
|
||||||
|
|
||||||
|
This behavior can be changed by changing the :setting:`TASKS-ENQUEUE_ON_COMMIT`
|
||||||
|
setting for the backend, or for a specific Task using the ``enqueue_on_commit``
|
||||||
|
parameter.
|
||||||
|
|
||||||
|
For example, consider this simplified example::
|
||||||
|
|
||||||
|
@task
|
||||||
|
def my_task():
|
||||||
|
Thing.objects.get()
|
||||||
|
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
Thing.objects.create()
|
||||||
|
my_task.enqueue()
|
||||||
|
|
||||||
|
|
||||||
|
If :setting:`ENQUEUE_ON_COMMIT <TASKS-ENQUEUE_ON_COMMIT>` is ``False``, then it
|
||||||
|
is possible for ``my_task`` to run before the ``Thing`` is committed to the
|
||||||
|
database, and the Task won't be able to see the created object within your
|
||||||
|
transaction.
|
||||||
|
|
||||||
|
.. _task-results:
|
||||||
|
|
||||||
|
Task results
|
||||||
|
============
|
||||||
|
|
||||||
|
When enqueueing a ``Task``, you receive a :class:`~django.tasks.TaskResult`,
|
||||||
|
however it's likely useful to retrieve the result from somewhere else (for
|
||||||
|
example another request or another Task).
|
||||||
|
|
||||||
|
Each ``TaskResult`` has a unique :attr:`~django.tasks.TaskResult.id`, which can
|
||||||
|
be used to identify and retrieve the result once the code which enqueued the
|
||||||
|
Task has finished.
|
||||||
|
|
||||||
|
The :meth:`~django.tasks.Task.get_result` method can retrieve a result based on
|
||||||
|
its ``id``::
|
||||||
|
|
||||||
|
# Later, somewhere else...
|
||||||
|
result = email_users.get_result(result_id)
|
||||||
|
|
||||||
|
To retrieve a ``TaskResult``, regardless of which kind of ``Task`` it was from,
|
||||||
|
use the :meth:`~django.tasks.Task.get_result` method on the backend::
|
||||||
|
|
||||||
|
from django.tasks import default_task_backend
|
||||||
|
|
||||||
|
result = default_task_backend.get_result(result_id)
|
||||||
|
|
||||||
|
To retrieve results in an ``async`` context,
|
||||||
|
:meth:`~django.tasks.Task.aget_result` is available as an ``async`` variant of
|
||||||
|
:meth:`~django.tasks.Task.get_result` on both the backend and ``Task``.
|
||||||
|
|
||||||
|
Some backends, such as the built-in ``ImmediateBackend`` do not support
|
||||||
|
``get_result()``. Calling ``get_result()`` on these backends will
|
||||||
|
raise :exc:`NotImplementedError`.
|
||||||
|
|
||||||
|
Updating results
|
||||||
|
----------------
|
||||||
|
|
||||||
|
A ``TaskResult`` contains the status of a Task's execution at the point it was
|
||||||
|
retrieved. If the Task finishes after :meth:`~django.tasks.Task.get_result` is
|
||||||
|
called, it will not update.
|
||||||
|
|
||||||
|
To refresh the values, call the :meth:`django.tasks.TaskResult.refresh`
|
||||||
|
method:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> result.status
|
||||||
|
RUNNING
|
||||||
|
>>> result.refresh() # or await result.arefresh()
|
||||||
|
>>> result.status
|
||||||
|
SUCCESSFUL
|
||||||
|
|
||||||
|
.. _task-return-values:
|
||||||
|
|
||||||
|
Return values
|
||||||
|
-------------
|
||||||
|
|
||||||
|
If your Task function returns something, it can be retrieved from the
|
||||||
|
:attr:`django.tasks.TaskResult.return_value` attribute:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> result.status
|
||||||
|
SUCCESSFUL
|
||||||
|
>>> result.return_value
|
||||||
|
42
|
||||||
|
|
||||||
|
If the Task has not finished executing, or has failed, :exc:`ValueError` is
|
||||||
|
raised.
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> result.status
|
||||||
|
RUNNING
|
||||||
|
>>> result.return_value
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
ValueError: Task has not finished yet
|
||||||
|
|
||||||
|
Errors
|
||||||
|
------
|
||||||
|
|
||||||
|
If the Task doesn't succeed, and instead raises an exception, either as part of
|
||||||
|
the Task or as part of running it, the exception and traceback are saved to the
|
||||||
|
:attr:`django.tasks.TaskResult.errors` list.
|
||||||
|
|
||||||
|
Each entry in ``errors`` is a :class:`~django.tasks.TaskError` containing
|
||||||
|
information about error raised during the execution:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> result.errors[0].exception_class
|
||||||
|
<class 'ValueError'>
|
||||||
|
|
||||||
|
Note that this is just the type of exception, and contains no other values. The
|
||||||
|
traceback information is reduced to a string which you can use to help
|
||||||
|
debugging:
|
||||||
|
|
||||||
|
.. code-block:: pycon
|
||||||
|
|
||||||
|
>>> result.errors[0].traceback
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
TypeError: Object of type datetime is not JSON serializable
|
0
tests/tasks/__init__.py
Normal file
0
tests/tasks/__init__.py
Normal file
88
tests/tasks/tasks.py
Normal file
88
tests/tasks/tasks.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from django.tasks import TaskContext, task
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def noop_task(*args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def noop_task_from_bare_decorator(*args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
async def noop_task_async(*args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def calculate_meaning_of_life():
|
||||||
|
return 42
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def failing_task_value_error():
|
||||||
|
raise ValueError("This Task failed due to ValueError")
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def failing_task_system_exit():
|
||||||
|
raise SystemExit("This Task failed due to SystemExit")
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def failing_task_keyboard_interrupt():
|
||||||
|
raise KeyboardInterrupt("This Task failed due to KeyboardInterrupt")
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def complex_exception():
|
||||||
|
raise ValueError(ValueError("This task failed"))
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def complex_return_value():
|
||||||
|
# Return something which isn't JSON serializable nor picklable.
|
||||||
|
return lambda: True
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def exit_task():
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@task(enqueue_on_commit=True)
|
||||||
|
def enqueue_on_commit_task():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@task(enqueue_on_commit=False)
|
||||||
|
def never_enqueue_on_commit_task():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def hang():
|
||||||
|
"""
|
||||||
|
Do nothing for 5 minutes
|
||||||
|
"""
|
||||||
|
time.sleep(300)
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def sleep_for(seconds):
|
||||||
|
time.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
@task(takes_context=True)
|
||||||
|
def get_task_id(context):
|
||||||
|
return context.task_result.id
|
||||||
|
|
||||||
|
|
||||||
|
@task(takes_context=True)
|
||||||
|
def test_context(context, attempt):
|
||||||
|
assert isinstance(context, TaskContext)
|
||||||
|
assert context.attempt == attempt
|
71
tests/tasks/test_custom_backend.py
Normal file
71
tests/tasks/test_custom_backend.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import logging
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.tasks import default_task_backend, task_backends
|
||||||
|
from django.tasks.backends.base import BaseTaskBackend
|
||||||
|
from django.tasks.exceptions import InvalidTask
|
||||||
|
from django.test import SimpleTestCase, override_settings
|
||||||
|
|
||||||
|
from . import tasks as test_tasks
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBackend(BaseTaskBackend):
|
||||||
|
def __init__(self, alias, params):
|
||||||
|
super().__init__(alias, params)
|
||||||
|
self.prefix = self.options.get("prefix", "")
|
||||||
|
|
||||||
|
def enqueue(self, *args, **kwargs):
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"{self.prefix}Task enqueued.")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBackendNoEnqueue(BaseTaskBackend):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": f"{CustomBackend.__module__}.{CustomBackend.__qualname__}",
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
"OPTIONS": {"prefix": "PREFIX: "},
|
||||||
|
},
|
||||||
|
"no_enqueue": {
|
||||||
|
"BACKEND": f"{CustomBackendNoEnqueue.__module__}."
|
||||||
|
f"{CustomBackendNoEnqueue.__qualname__}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
class CustomBackendTestCase(SimpleTestCase):
|
||||||
|
def test_using_correct_backend(self):
|
||||||
|
self.assertEqual(default_task_backend, task_backends["default"])
|
||||||
|
self.assertIsInstance(task_backends["default"], CustomBackend)
|
||||||
|
self.assertEqual(default_task_backend.alias, "default")
|
||||||
|
self.assertEqual(default_task_backend.options, {"prefix": "PREFIX: "})
|
||||||
|
|
||||||
|
@mock.patch.multiple(CustomBackend, supports_async_task=False)
|
||||||
|
def test_enqueue_async_task_on_non_async_backend(self):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "Backend does not support async Tasks."
|
||||||
|
):
|
||||||
|
default_task_backend.validate_task(test_tasks.noop_task_async)
|
||||||
|
|
||||||
|
def test_backend_does_not_support_priority(self):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "Backend does not support setting priority of tasks."
|
||||||
|
):
|
||||||
|
test_tasks.noop_task.using(priority=10)
|
||||||
|
|
||||||
|
def test_options(self):
|
||||||
|
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):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
TypeError,
|
||||||
|
"Can't instantiate abstract class CustomBackendNoEnqueue "
|
||||||
|
"without an implementation for abstract method 'enqueue'",
|
||||||
|
):
|
||||||
|
test_tasks.noop_task.using(backend="no_enqueue")
|
337
tests/tasks/test_dummy_backend.py
Normal file
337
tests/tasks/test_dummy_backend.py
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
from typing import cast
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.utils import ConnectionHandler
|
||||||
|
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 InvalidTask, TaskResultDoesNotExist
|
||||||
|
from django.test import (
|
||||||
|
SimpleTestCase,
|
||||||
|
TransactionTestCase,
|
||||||
|
override_settings,
|
||||||
|
skipIfDBFeature,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import tasks as test_tasks
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
"QUEUES": [],
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
class DummyBackendTestCase(SimpleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
default_task_backend.clear()
|
||||||
|
|
||||||
|
def test_using_correct_backend(self):
|
||||||
|
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):
|
||||||
|
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.assertIs(result.is_finished, False)
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def test_enqueue_task_async(self):
|
||||||
|
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.assertIs(result.is_finished, False)
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_get_result(self):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
result = default_task_backend.enqueue(
|
||||||
|
test_tasks.calculate_meaning_of_life, (), {}
|
||||||
|
)
|
||||||
|
|
||||||
|
enqueued_result = default_task_backend.results[0]
|
||||||
|
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):
|
||||||
|
result = await default_task_backend.aenqueue(
|
||||||
|
test_tasks.calculate_meaning_of_life, (), {}
|
||||||
|
)
|
||||||
|
|
||||||
|
enqueued_result = default_task_backend.results[0]
|
||||||
|
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):
|
||||||
|
with self.assertRaises(TaskResultDoesNotExist):
|
||||||
|
default_task_backend.get_result("123")
|
||||||
|
|
||||||
|
with self.assertRaises(TaskResultDoesNotExist):
|
||||||
|
await default_task_backend.aget_result("123")
|
||||||
|
|
||||||
|
def test_enqueue_on_commit(self):
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(
|
||||||
|
test_tasks.enqueue_on_commit_task
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enqueue_logs(self):
|
||||||
|
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):
|
||||||
|
result = test_tasks.noop_task.enqueue()
|
||||||
|
self.assertEqual(result.errors, [])
|
||||||
|
|
||||||
|
def test_validate_disallowed_async_task(self):
|
||||||
|
with mock.patch.multiple(default_task_backend, supports_async_task=False):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "Backend does not support async Tasks."
|
||||||
|
):
|
||||||
|
default_task_backend.validate_task(test_tasks.noop_task_async)
|
||||||
|
|
||||||
|
def test_check(self):
|
||||||
|
errors = list(default_task_backend.check())
|
||||||
|
self.assertEqual(len(errors), 0, errors)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@mock.patch("django.tasks.backends.base.connections", ConnectionHandler({}))
|
||||||
|
def test_enqueue_on_commit_with_no_databases(self):
|
||||||
|
self.assertIn(
|
||||||
|
"tasks.E001", {error.id for error in default_task_backend.check()}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_takes_context(self):
|
||||||
|
result = test_tasks.get_task_id.enqueue()
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.READY)
|
||||||
|
|
||||||
|
def test_clear(self):
|
||||||
|
result = test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
default_task_backend.get_result(result.id)
|
||||||
|
|
||||||
|
default_task_backend.clear()
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(TaskResultDoesNotExist, result.id):
|
||||||
|
default_task_backend.get_result(result.id)
|
||||||
|
|
||||||
|
def test_validate_on_enqueue(self):
|
||||||
|
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"],
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "Queue 'unknown_queue' is not valid for backend"
|
||||||
|
):
|
||||||
|
task_with_custom_queue_name.enqueue()
|
||||||
|
|
||||||
|
async def test_validate_on_aenqueue(self):
|
||||||
|
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"],
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "Queue 'unknown_queue' is not valid for backend"
|
||||||
|
):
|
||||||
|
await task_with_custom_queue_name.aenqueue()
|
||||||
|
|
||||||
|
|
||||||
|
class DummyBackendTransactionTestCase(TransactionTestCase):
|
||||||
|
available_apps = []
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_wait_until_transaction_commit(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, True)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(test_tasks.noop_task),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 0)
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 1)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_doesnt_wait_until_transaction_commit(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, False)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(test_tasks.noop_task),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
result = test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
self.assertIsNotNone(result.enqueued_at)
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 1)
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 1)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_wait_until_transaction_by_default(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, True)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(test_tasks.noop_task),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
result = test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
self.assertIsNone(result.enqueued_at)
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 0)
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 1)
|
||||||
|
self.assertIsNone(result.enqueued_at)
|
||||||
|
result.refresh()
|
||||||
|
self.assertIsNotNone(result.enqueued_at)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_task_specific_enqueue_on_commit(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, False)
|
||||||
|
self.assertIs(test_tasks.enqueue_on_commit_task.enqueue_on_commit, True)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(
|
||||||
|
test_tasks.enqueue_on_commit_task
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
result = test_tasks.enqueue_on_commit_task.enqueue()
|
||||||
|
|
||||||
|
self.assertIsNone(result.enqueued_at)
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 0)
|
||||||
|
|
||||||
|
self.assertEqual(len(default_task_backend.results), 1)
|
||||||
|
self.assertIsNone(result.enqueued_at)
|
||||||
|
result.refresh()
|
||||||
|
self.assertIsNotNone(result.enqueued_at)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@skipIfDBFeature("supports_transactions")
|
||||||
|
def test_enqueue_on_commit_with_no_transactions(self):
|
||||||
|
self.assertIn(
|
||||||
|
"tasks.E002", {error.id for error in default_task_backend.check()}
|
||||||
|
)
|
387
tests/tasks/test_immediate_backend.py
Normal file
387
tests/tasks/test_immediate_backend.py
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from django.tasks import TaskResultStatus, default_task_backend, task_backends
|
||||||
|
from django.tasks.backends.immediate import ImmediateBackend
|
||||||
|
from django.tasks.exceptions import InvalidTask
|
||||||
|
from django.test import SimpleTestCase, TransactionTestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from . import tasks as test_tasks
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||||
|
"QUEUES": [],
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
class ImmediateBackendTestCase(SimpleTestCase):
|
||||||
|
def test_using_correct_backend(self):
|
||||||
|
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):
|
||||||
|
for task in [test_tasks.noop_task, test_tasks.noop_task_async]:
|
||||||
|
with self.subTest(task):
|
||||||
|
result = task.enqueue(1, two=3)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
||||||
|
self.assertIs(result.is_finished, True)
|
||||||
|
self.assertIsNotNone(result.started_at)
|
||||||
|
self.assertIsNotNone(result.last_attempted_at)
|
||||||
|
self.assertIsNotNone(result.finished_at)
|
||||||
|
self.assertGreaterEqual(result.started_at, result.enqueued_at)
|
||||||
|
self.assertGreaterEqual(result.finished_at, result.started_at)
|
||||||
|
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):
|
||||||
|
for task in [test_tasks.noop_task, test_tasks.noop_task_async]:
|
||||||
|
with self.subTest(task):
|
||||||
|
result = await task.aenqueue()
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
||||||
|
self.assertIs(result.is_finished, True)
|
||||||
|
self.assertIsNotNone(result.started_at)
|
||||||
|
self.assertIsNotNone(result.last_attempted_at)
|
||||||
|
self.assertIsNotNone(result.finished_at)
|
||||||
|
self.assertGreaterEqual(result.started_at, result.enqueued_at)
|
||||||
|
self.assertGreaterEqual(result.finished_at, result.started_at)
|
||||||
|
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):
|
||||||
|
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()
|
||||||
|
|
||||||
|
self.assertEqual(len(captured_logs.output), 1)
|
||||||
|
self.assertIn(message, captured_logs.output[0])
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.FAILED)
|
||||||
|
with self.assertRaisesMessage(ValueError, "Task failed"):
|
||||||
|
result.return_value
|
||||||
|
self.assertIs(result.is_finished, True)
|
||||||
|
self.assertIsNotNone(result.started_at)
|
||||||
|
self.assertIsNotNone(result.last_attempted_at)
|
||||||
|
self.assertIsNotNone(result.finished_at)
|
||||||
|
self.assertGreaterEqual(result.started_at, result.enqueued_at)
|
||||||
|
self.assertGreaterEqual(result.finished_at, result.started_at)
|
||||||
|
self.assertEqual(result.errors[0].exception_class, exception)
|
||||||
|
traceback = result.errors[0].traceback
|
||||||
|
self.assertIs(
|
||||||
|
traceback
|
||||||
|
and traceback.endswith(f"{exception.__name__}: {message}\n"),
|
||||||
|
True,
|
||||||
|
traceback,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.task, task)
|
||||||
|
self.assertEqual(result.args, [])
|
||||||
|
self.assertEqual(result.kwargs, {})
|
||||||
|
|
||||||
|
def test_throws_keyboard_interrupt(self):
|
||||||
|
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):
|
||||||
|
with self.assertLogs("django.tasks", level="ERROR"):
|
||||||
|
result = test_tasks.complex_exception.enqueue()
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.FAILED)
|
||||||
|
with self.assertRaisesMessage(ValueError, "Task failed"):
|
||||||
|
result.return_value
|
||||||
|
self.assertIsNotNone(result.started_at)
|
||||||
|
self.assertIsNotNone(result.last_attempted_at)
|
||||||
|
self.assertIsNotNone(result.finished_at)
|
||||||
|
self.assertGreaterEqual(result.started_at, result.enqueued_at)
|
||||||
|
self.assertGreaterEqual(result.finished_at, result.started_at)
|
||||||
|
|
||||||
|
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):
|
||||||
|
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)
|
||||||
|
self.assertGreaterEqual(result.finished_at, result.started_at)
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def test_cannot_refresh_result(self):
|
||||||
|
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):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask,
|
||||||
|
"Backend does not support run_after.",
|
||||||
|
):
|
||||||
|
default_task_backend.validate_task(
|
||||||
|
test_tasks.failing_task_value_error.using(run_after=timezone.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enqueue_on_commit(self):
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(
|
||||||
|
test_tasks.enqueue_on_commit_task
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enqueue_logs(self):
|
||||||
|
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):
|
||||||
|
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_takes_context(self):
|
||||||
|
result = test_tasks.get_task_id.enqueue()
|
||||||
|
|
||||||
|
self.assertEqual(result.return_value, result.id)
|
||||||
|
|
||||||
|
def test_context(self):
|
||||||
|
result = test_tasks.test_context.enqueue(1)
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
||||||
|
|
||||||
|
def test_validate_on_enqueue(self):
|
||||||
|
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"],
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "Queue 'unknown_queue' is not valid for backend"
|
||||||
|
):
|
||||||
|
task_with_custom_queue_name.enqueue()
|
||||||
|
|
||||||
|
async def test_validate_on_aenqueue(self):
|
||||||
|
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"],
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "Queue 'unknown_queue' is not valid for backend"
|
||||||
|
):
|
||||||
|
await task_with_custom_queue_name.aenqueue()
|
||||||
|
|
||||||
|
|
||||||
|
class ImmediateBackendTransactionTestCase(TransactionTestCase):
|
||||||
|
available_apps = []
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_wait_until_transaction_commit(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, True)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(test_tasks.noop_task),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
result = test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
self.assertIsNone(result.enqueued_at)
|
||||||
|
self.assertEqual(result.attempts, 0)
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.READY)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
||||||
|
self.assertIsNotNone(result.enqueued_at)
|
||||||
|
self.assertEqual(result.attempts, 1)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_doesnt_wait_until_transaction_commit(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, False)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(test_tasks.noop_task),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
result = test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
self.assertIsNotNone(result.enqueued_at)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_wait_until_transaction_by_default(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, True)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(test_tasks.noop_task),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
result = test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
self.assertIsNone(result.enqueued_at)
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.READY)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_task_specific_enqueue_on_commit(self):
|
||||||
|
self.assertIs(default_task_backend.enqueue_on_commit, False)
|
||||||
|
self.assertIs(test_tasks.enqueue_on_commit_task.enqueue_on_commit, True)
|
||||||
|
self.assertIs(
|
||||||
|
default_task_backend._get_enqueue_on_commit_for_task(
|
||||||
|
test_tasks.enqueue_on_commit_task
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
result = test_tasks.enqueue_on_commit_task.enqueue()
|
||||||
|
|
||||||
|
self.assertIsNone(result.enqueued_at)
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.READY)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, TaskResultStatus.SUCCESSFUL)
|
316
tests/tasks/test_tasks.py
Normal file
316
tests/tasks/test_tasks.py
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import dataclasses
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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 (
|
||||||
|
InvalidTask,
|
||||||
|
InvalidTaskBackend,
|
||||||
|
TaskResultDoesNotExist,
|
||||||
|
TaskResultMismatch,
|
||||||
|
)
|
||||||
|
from django.test import SimpleTestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from . import tasks as test_tasks
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
TASKS={
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.tasks.backends.dummy.DummyBackend",
|
||||||
|
"QUEUES": ["default", "queue_1"],
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
},
|
||||||
|
"immediate": {
|
||||||
|
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
|
||||||
|
"ENQUEUE_ON_COMMIT": False,
|
||||||
|
"QUEUES": [],
|
||||||
|
},
|
||||||
|
"missing": {"BACKEND": "does.not.exist"},
|
||||||
|
},
|
||||||
|
USE_TZ=True,
|
||||||
|
)
|
||||||
|
class TaskTestCase(SimpleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
default_task_backend.clear()
|
||||||
|
|
||||||
|
def test_using_correct_backend(self):
|
||||||
|
self.assertEqual(default_task_backend, task_backends["default"])
|
||||||
|
self.assertIsInstance(task_backends["default"], DummyBackend)
|
||||||
|
|
||||||
|
def test_task_decorator(self):
|
||||||
|
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):
|
||||||
|
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])
|
||||||
|
|
||||||
|
async def test_enqueue_task_async(self):
|
||||||
|
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])
|
||||||
|
|
||||||
|
def test_enqueue_with_invalid_argument(self):
|
||||||
|
with self.assertRaisesMessage(TypeError, "Unsupported type"):
|
||||||
|
test_tasks.noop_task.enqueue(datetime.now())
|
||||||
|
|
||||||
|
async def test_aenqueue_with_invalid_argument(self):
|
||||||
|
with self.assertRaisesMessage(TypeError, "Unsupported type"):
|
||||||
|
await test_tasks.noop_task.aenqueue(datetime.now())
|
||||||
|
|
||||||
|
def test_using_priority(self):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
self.assertEqual(test_tasks.noop_task.backend, "default")
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTaskBackend, "The connection 'unknown' doesn't exist."
|
||||||
|
):
|
||||||
|
test_tasks.noop_task.using(backend="unknown")
|
||||||
|
|
||||||
|
def test_using_missing_backend(self):
|
||||||
|
self.assertEqual(test_tasks.noop_task.backend, "default")
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTaskBackend,
|
||||||
|
"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):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "run_after must be an aware datetime."
|
||||||
|
):
|
||||||
|
test_tasks.noop_task.using(run_after=datetime.now())
|
||||||
|
|
||||||
|
def test_invalid_priority(self):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask,
|
||||||
|
f"priority must be a whole number between {TASK_MIN_PRIORITY} and "
|
||||||
|
f"{TASK_MAX_PRIORITY}.",
|
||||||
|
):
|
||||||
|
test_tasks.noop_task.using(priority=-101)
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask,
|
||||||
|
f"priority must be a whole number between {TASK_MIN_PRIORITY} and "
|
||||||
|
f"{TASK_MAX_PRIORITY}.",
|
||||||
|
):
|
||||||
|
test_tasks.noop_task.using(priority=101)
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask,
|
||||||
|
f"priority must be a whole number between {TASK_MIN_PRIORITY} and "
|
||||||
|
f"{TASK_MAX_PRIORITY}.",
|
||||||
|
):
|
||||||
|
test_tasks.noop_task.using(priority=3.1)
|
||||||
|
|
||||||
|
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):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask, "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):
|
||||||
|
self.assertEqual(test_tasks.calculate_meaning_of_life.call(), 42)
|
||||||
|
|
||||||
|
async def test_call_task_async(self):
|
||||||
|
self.assertEqual(await test_tasks.calculate_meaning_of_life.acall(), 42)
|
||||||
|
|
||||||
|
async def test_call_async_task(self):
|
||||||
|
self.assertIsNone(await test_tasks.noop_task_async.acall())
|
||||||
|
|
||||||
|
def test_call_async_task_sync(self):
|
||||||
|
self.assertIsNone(test_tasks.noop_task_async.call())
|
||||||
|
|
||||||
|
def test_get_result(self):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
for invalid_function in [any, self.test_invalid_function]:
|
||||||
|
with self.subTest(invalid_function):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask,
|
||||||
|
"Task function must be defined at a module level.",
|
||||||
|
):
|
||||||
|
task()(invalid_function)
|
||||||
|
|
||||||
|
def test_get_backend(self):
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
self.assertEqual(test_tasks.noop_task.module_path, "tasks.tasks.noop_task")
|
||||||
|
self.assertEqual(
|
||||||
|
test_tasks.noop_task_async.module_path, "tasks.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):
|
||||||
|
with self.assertRaises(InvalidTaskBackend):
|
||||||
|
test_tasks.noop_task.enqueue()
|
||||||
|
|
||||||
|
def test_task_error_invalid_exception(self):
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_task_error_unknown_module(self):
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_takes_context_without_taking_context(self):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
InvalidTask,
|
||||||
|
"Task takes context but does not have a first argument of 'context'.",
|
||||||
|
):
|
||||||
|
task(takes_context=True)(test_tasks.calculate_meaning_of_life.func)
|
@ -1,5 +1,7 @@
|
|||||||
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from django.shortcuts import aget_object_or_404
|
||||||
from django.utils import inspect
|
from django.utils import inspect
|
||||||
|
|
||||||
|
|
||||||
@ -100,3 +102,50 @@ class TestInspectMethods(unittest.TestCase):
|
|||||||
self.assertIs(inspect.func_accepts_kwargs(Person().just_args), False)
|
self.assertIs(inspect.func_accepts_kwargs(Person().just_args), False)
|
||||||
self.assertIs(inspect.func_accepts_kwargs(Person.all_kinds), True)
|
self.assertIs(inspect.func_accepts_kwargs(Person.all_kinds), True)
|
||||||
self.assertIs(inspect.func_accepts_kwargs(Person().just_args), False)
|
self.assertIs(inspect.func_accepts_kwargs(Person().just_args), False)
|
||||||
|
|
||||||
|
|
||||||
|
class IsModuleLevelFunctionTestCase(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def _class_method(cls) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _static_method() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_builtin(self):
|
||||||
|
self.assertIs(inspect.is_module_level_function(any), False)
|
||||||
|
self.assertIs(inspect.is_module_level_function(isinstance), False)
|
||||||
|
|
||||||
|
def test_from_module(self):
|
||||||
|
self.assertIs(inspect.is_module_level_function(subprocess.run), True)
|
||||||
|
self.assertIs(inspect.is_module_level_function(subprocess.check_output), True)
|
||||||
|
self.assertIs(
|
||||||
|
inspect.is_module_level_function(inspect.is_module_level_function), True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_private_function(self):
|
||||||
|
def private_function():
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertIs(inspect.is_module_level_function(private_function), False)
|
||||||
|
|
||||||
|
def test_coroutine(self):
|
||||||
|
self.assertIs(inspect.is_module_level_function(aget_object_or_404), True)
|
||||||
|
|
||||||
|
def test_method(self):
|
||||||
|
self.assertIs(inspect.is_module_level_function(self.test_method), False)
|
||||||
|
self.assertIs(inspect.is_module_level_function(self.setUp), False)
|
||||||
|
|
||||||
|
def test_unbound_method(self):
|
||||||
|
self.assertIs(
|
||||||
|
inspect.is_module_level_function(self.__class__.test_unbound_method), True
|
||||||
|
)
|
||||||
|
self.assertIs(inspect.is_module_level_function(self.__class__.setUp), True)
|
||||||
|
|
||||||
|
def test_lambda(self):
|
||||||
|
self.assertIs(inspect.is_module_level_function(lambda: True), False)
|
||||||
|
|
||||||
|
def test_class_and_static_method(self):
|
||||||
|
self.assertIs(inspect.is_module_level_function(self._static_method), True)
|
||||||
|
self.assertIs(inspect.is_module_level_function(self._class_method), False)
|
||||||
|
46
tests/utils_tests/test_json.py
Normal file
46
tests/utils_tests/test_json.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import json
|
||||||
|
from collections import UserList, defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from django.utils.json import normalize_json
|
||||||
|
|
||||||
|
|
||||||
|
class JSONNormalizeTestCase(SimpleTestCase):
|
||||||
|
def test_converts_json_types(self):
|
||||||
|
for test_case, expected in [
|
||||||
|
(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 = normalize_json(test_case)
|
||||||
|
# Ensure that the normalized result is serializable.
|
||||||
|
self.assertEqual(json.dumps(normalized), expected)
|
||||||
|
|
||||||
|
def test_bytes_decode_error(self):
|
||||||
|
with self.assertRaisesMessage(ValueError, "Unsupported value"):
|
||||||
|
normalize_json(b"\xff")
|
||||||
|
|
||||||
|
def test_encode_error(self):
|
||||||
|
for test_case in [self, any, object(), datetime.now(), set(), Decimal("3.42")]:
|
||||||
|
with (
|
||||||
|
self.subTest(test_case),
|
||||||
|
self.assertRaisesMessage(TypeError, "Unsupported type"),
|
||||||
|
):
|
||||||
|
normalize_json(test_case)
|
Loading…
x
Reference in New Issue
Block a user