diff --git a/django/tasks/base.py b/django/tasks/base.py index 94eb29f2eac9..b5c960a7cf05 100644 --- a/django/tasks/base.py +++ b/django/tasks/base.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field, fields, replace from datetime import datetime from inspect import isclass, iscoroutinefunction from typing import Any @@ -55,6 +55,23 @@ class Task: def __post_init__(self): self.get_backend().validate_task(self) + @classmethod + def _reconstruct(cls, kwargs): + func_path = kwargs["func"] + try: + func = import_string(func_path) + kwargs["func"] = func.func + except (ImportError, AttributeError) as e: + msg = f"Expected {func_path!r} to point to a Task instance." + raise ValueError(msg) from e + return cls(**kwargs) + + def __reduce__(self): + kwargs = {f.name: getattr(self, f.name) for f in fields(self)} + kwargs["func"] = self.module_path + + return (self.__class__._reconstruct, (kwargs,)) + @property def name(self): return self.func.__name__ diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 6253a6fea05c..987d46874aec 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -378,6 +378,9 @@ Tasks forwarded to the backend's :attr:`~django.tasks.backends.base.BaseTaskBackend.task_class`. +* :class:`~django.tasks.Task` and :class:`~django.tasks.TaskResult` instances + can now be pickled and unpickled. + Templates ~~~~~~~~~ diff --git a/tests/tasks/test_tasks.py b/tests/tasks/test_tasks.py index b66c2df05813..267169f4fab0 100644 --- a/tests/tasks/test_tasks.py +++ b/tests/tasks/test_tasks.py @@ -1,4 +1,5 @@ import dataclasses +import pickle from datetime import datetime from django.tasks import ( @@ -269,6 +270,31 @@ def test_module_path(self): test_tasks.noop_task_async, ) + def test_pickle_task(self): + pickled_task = pickle.dumps(test_tasks.noop_task) + unpickled_task = pickle.loads(pickled_task) + + self.assertEqual(unpickled_task, test_tasks.noop_task) + + def test_unpickle_arbitrary_string(self): + kwargs = {"func": "does.not.exist.fake_task"} + msg = "Expected 'does.not.exist.fake_task' to point to a Task instance." + with self.assertRaisesMessage(ValueError, msg): + Task._reconstruct(kwargs) + + def test_unpickle_non_task_object(self): + kwargs = {"func": "builtins.any"} + msg = "Expected 'builtins.any' to point to a Task instance." + with self.assertRaisesMessage(ValueError, msg): + Task._reconstruct(kwargs) + + def test_pickle_task_result(self): + result = test_tasks.noop_task.enqueue() + pickled_result = pickle.dumps(result) + unpickled_result = pickle.loads(pickled_result) + + self.assertEqual(unpickled_result, result) + @override_settings(TASKS={}) def test_no_backends(self): with self.assertRaises(InvalidTaskBackend):