django-tasks-inprocess 0.0.1a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for more information:
4
+ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5
+ # https://containers.dev/guide/dependabot
6
+
7
+ version: 2
8
+ updates:
9
+ - package-ecosystem: "devcontainers"
10
+ directory: "/"
11
+ schedule:
12
+ interval: weekly
13
+ - package-ecosystem: "github-actions"
14
+ directory: "/"
15
+ schedule:
16
+ interval: weekly
@@ -0,0 +1,57 @@
1
+ name: Build & Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ name: Build distribution 📦
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+ with:
18
+ persist-credentials: false
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v6
22
+ with:
23
+ python-version: "3.x"
24
+
25
+ - name: Install pypa/build
26
+ run: |
27
+ python -m pip install --upgrade pip build --user
28
+
29
+ - name: Build a binary wheel and a source tarball
30
+ run: python -m build
31
+
32
+ - name: Store the distribution packages
33
+ uses: actions/upload-artifact@v6
34
+ with:
35
+ name: python-package-distributions
36
+ path: dist/
37
+
38
+ publish-to-pypi:
39
+ name: Publish Python 🐍 distribution 📦 to PyPI
40
+ runs-on: ubuntu-latest
41
+ needs:
42
+ - build
43
+ environment:
44
+ name: release
45
+ url: https://pypi.org/p/django-tasks-inprocess
46
+ permissions:
47
+ id-token: write
48
+
49
+ steps:
50
+ - name: Download all the dists
51
+ uses: actions/download-artifact@v7
52
+ with:
53
+ name: python-package-distributions
54
+ path: dist/
55
+
56
+ - name: Publish distribution 📦 to PyPI
57
+ uses: pypa/gh-action-pypi-publish@v1.13.0
@@ -0,0 +1,42 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: test-${{ github.head_ref }}
11
+ cancel-in-progress: true
12
+
13
+ env:
14
+ PYTHONUNBUFFERED: "1"
15
+ FORCE_COLOR: "1"
16
+
17
+ jobs:
18
+ run:
19
+ name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
20
+ runs-on: ${{ matrix.os }}
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: [ubuntu-latest, windows-latest, macos-latest]
25
+ python-version: ['3.12', '3.13', '3.14']
26
+
27
+ steps:
28
+ - uses: actions/checkout@v6
29
+
30
+ - name: Set up Python ${{ matrix.python-version }}
31
+ uses: actions/setup-python@v6
32
+ with:
33
+ python-version: ${{ matrix.python-version }}
34
+
35
+ - name: Install Hatch
36
+ run: pip install --upgrade hatch
37
+
38
+ - name: Run static analysis
39
+ run: hatch fmt --check
40
+
41
+ - name: Run tests
42
+ run: hatch test --python ${{ matrix.python-version }} --cover --randomize --parallel --retries 2 --retry-delay 1
@@ -0,0 +1 @@
1
+ __pycache__/
@@ -0,0 +1,50 @@
1
+ repos:
2
+ - repo: meta
3
+ hooks:
4
+ - id: check-hooks-apply
5
+ - id: check-useless-excludes
6
+
7
+ - repo: https://github.com/pre-commit/pre-commit-hooks
8
+ rev: v6.0.0
9
+ hooks:
10
+ - id: check-added-large-files
11
+ - id: check-ast
12
+ - id: check-builtin-literals
13
+ - id: check-case-conflict
14
+ - id: check-executables-have-shebangs
15
+ - id: check-illegal-windows-names
16
+ - id: check-json
17
+ - id: check-merge-conflict
18
+ - id: check-shebang-scripts-are-executable
19
+ - id: check-toml
20
+ - id: check-vcs-permalinks
21
+ - id: check-yaml
22
+ - id: debug-statements
23
+ - id: destroyed-symlinks
24
+ - id: detect-private-key
25
+ - id: end-of-file-fixer
26
+ - id: fix-byte-order-marker
27
+ - id: forbid-submodules
28
+ - id: name-tests-test
29
+ args:
30
+ - --pytest-test-first
31
+ exclude: |
32
+ (?x)^(
33
+ tests/settings.py|
34
+ tests/tasks.py
35
+ )$
36
+ - id: no-commit-to-branch
37
+ - id: trailing-whitespace
38
+
39
+ - repo: https://github.com/astral-sh/ruff-pre-commit
40
+ rev: v0.15.2
41
+ hooks:
42
+ - id: ruff
43
+ args: [--fix]
44
+ - id: ruff-format
45
+
46
+ - repo: https://github.com/adamchainz/django-upgrade
47
+ rev: "1.29.1"
48
+ hooks:
49
+ - id: django-upgrade
50
+ args: [--target-version, "6.0"]
@@ -0,0 +1,8 @@
1
+ {
2
+ "recommendations": [
3
+ "charliermarsh.ruff",
4
+ "github.vscode-github-actions",
5
+ "redhat.vscode-yaml",
6
+ "tombi-toml.tombi"
7
+ ]
8
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "python.testing.pytestArgs": [
3
+ "tests"
4
+ ],
5
+ "python.testing.unittestEnabled": false,
6
+ "python.testing.pytestEnabled": true,
7
+ "[python]": {
8
+ "editor.formatOnSave": true,
9
+ "editor.codeActionsOnSave": {
10
+ "source.organizeImports": "explicit"
11
+ },
12
+ "editor.defaultFormatter": "charliermarsh.ruff"
13
+ },
14
+ "[toml]": {
15
+ "editor.defaultFormatter": "tombi-toml.tombi"
16
+ }
17
+ }
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Christian Hartung <hartung@live.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-tasks-inprocess
3
+ Version: 0.0.1a1
4
+ Summary: A Django Tasks backend inspired by Starlette's In-process Background Workers
5
+ Project-URL: Documentation, https://github.com/hartungstenio/django-tasks-inprocess#readme
6
+ Project-URL: Issues, https://github.com/hartungstenio/django-tasks-inprocess/issues
7
+ Project-URL: Source, https://github.com/hartungstenio/django-tasks-inprocess
8
+ Author-email: Christian Hartung <6785871+hartungstenio@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE.txt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 6.0
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Natural Language :: English
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: Implementation :: CPython
23
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.12
27
+ Requires-Dist: django>=6.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # django-tasks-inprocess
31
+
32
+ [![PyPI - Version](https://img.shields.io/pypi/v/django-tasks-inprocess.svg)](https://pypi.org/project/django-tasks-inprocess)
33
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-tasks-inprocess.svg)](https://pypi.org/project/django-tasks-inprocess)
34
+
35
+ -----
36
+
37
+ ## Table of Contents
38
+
39
+ - [Installation](#installation)
40
+ - [License](#license)
41
+
42
+ ## Installation
43
+
44
+ ```console
45
+ pip install django-tasks-inprocess
46
+ ```
47
+
48
+ ## License
49
+
50
+ `django-tasks-inprocess` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,21 @@
1
+ # django-tasks-inprocess
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/django-tasks-inprocess.svg)](https://pypi.org/project/django-tasks-inprocess)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-tasks-inprocess.svg)](https://pypi.org/project/django-tasks-inprocess)
5
+
6
+ -----
7
+
8
+ ## Table of Contents
9
+
10
+ - [Installation](#installation)
11
+ - [License](#license)
12
+
13
+ ## Installation
14
+
15
+ ```console
16
+ pip install django-tasks-inprocess
17
+ ```
18
+
19
+ ## License
20
+
21
+ `django-tasks-inprocess` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env python
2
+ import os
3
+ import sys
4
+
5
+ if __name__ == "__main__":
6
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
7
+
8
+ from django.core.management import execute_from_command_line
9
+
10
+ execute_from_command_line(sys.argv)
@@ -0,0 +1,178 @@
1
+ [project]
2
+ name = "django-tasks-inprocess"
3
+ description = "A Django Tasks backend inspired by Starlette's In-process Background Workers"
4
+ readme = "README.md"
5
+ requires-python = ">=3.12"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Christian Hartung", email = "6785871+hartungstenio@users.noreply.github.com" },
9
+ ]
10
+ keywords = []
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Web Environment",
14
+ "Framework :: Django",
15
+ "Framework :: Django :: 6.0",
16
+ "Intended Audience :: Developers",
17
+ "Natural Language :: English",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Programming Language :: Python :: Implementation :: CPython",
24
+ "Programming Language :: Python :: Implementation :: PyPy",
25
+ "Topic :: Internet :: WWW/HTTP",
26
+ "Typing :: Typed"
27
+ ]
28
+ dependencies = [
29
+ "django>=6.0",
30
+ ]
31
+ dynamic = ["version"]
32
+
33
+ [project.urls]
34
+ Documentation = "https://github.com/hartungstenio/django-tasks-inprocess#readme"
35
+ Issues = "https://github.com/hartungstenio/django-tasks-inprocess/issues"
36
+ Source = "https://github.com/hartungstenio/django-tasks-inprocess"
37
+
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "coverage[toml]>=6.5",
42
+ "pre-commit",
43
+ "pytest",
44
+ "pytest-asyncio",
45
+ "pytest-django",
46
+ ]
47
+
48
+ [build-system]
49
+ requires = ["hatch-vcs", "hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.coverage.run]
53
+ source_pkgs = ["django_tasks_inprocess", "tests"]
54
+ branch = true
55
+ parallel = true
56
+ omit = [
57
+ "src/django_tasks_inprocess/__about__.py",
58
+ ]
59
+
60
+ [tool.coverage.paths]
61
+ django_tasks_inprocess = [
62
+ "src/django_tasks_inprocess",
63
+ "*/django-tasks-inprocess/src/django_tasks_inprocess"
64
+ ]
65
+ tests = ["tests", "*/django-tasks-inprocess/tests"]
66
+
67
+ [tool.coverage.report]
68
+ exclude_lines = [
69
+ "no cov",
70
+ "if __name__ == .__main__.:",
71
+ "if TYPE_CHECKING:",
72
+ ]
73
+
74
+ [tool.hatch.build.targets.wheel]
75
+ sources = ["src"]
76
+
77
+ [tool.hatch.version]
78
+ source = "vcs"
79
+
80
+ [tool.hatch.envs.default]
81
+ dependency-groups = ["dev"]
82
+
83
+ [tool.hatch.envs.hatch-test]
84
+ extra-dependencies = [
85
+ "faker",
86
+ "pytest-asyncio",
87
+ "pytest-django",
88
+ ]
89
+
90
+ [[tool.hatch.envs.hatch-test.matrix]]
91
+ django = ["6.0"]
92
+ python = ["3.12", "3.13", "3.14"]
93
+
94
+ [tool.hatch.envs.hatch-test.overrides]
95
+ matrix.django.dependencies = [
96
+ { value = "django>=6.0,<6.1", if = ["6.0"] },
97
+ ]
98
+
99
+ [tool.hatch.envs.hatch-static-analysis]
100
+ config-path = "none"
101
+ dependencies = ["ruff==0.15.*"]
102
+
103
+ [tool.pytest]
104
+ pythonpath = [".", "src"]
105
+ DJANGO_SETTINGS_MODULE = "tests.settings"
106
+ asyncio_default_fixture_loop_scope = "function"
107
+
108
+ [tool.ruff]
109
+ line-length = 120
110
+
111
+ [tool.ruff.format]
112
+ docstring-code-format = true
113
+
114
+ [tool.ruff.lint]
115
+ select = [
116
+ "ERA",
117
+ "ANN",
118
+ "ASYNC",
119
+ "S",
120
+ "BLE",
121
+ "FBT",
122
+ "B",
123
+ "A",
124
+ "COM818",
125
+ "C4",
126
+ "DTZ",
127
+ "T10",
128
+ "DJ",
129
+ "EM",
130
+ "EXE",
131
+ "INT",
132
+ "ISC",
133
+ "ICN",
134
+ "LOG",
135
+ "G",
136
+ "PIE",
137
+ "T20",
138
+ "PYI",
139
+ "PT",
140
+ "RSE",
141
+ "RET",
142
+ "SLF",
143
+ "SIM",
144
+ "SLOT",
145
+ "TID",
146
+ "TD",
147
+ "TC",
148
+ "PTH",
149
+ "FLY",
150
+ "I",
151
+ "C90",
152
+ "N",
153
+ "PERF",
154
+ "E",
155
+ "W",
156
+ "D",
157
+ "F",
158
+ "PGH",
159
+ "PL",
160
+ "UP",
161
+ "FURB",
162
+ "RUF",
163
+ "TRY",
164
+ ]
165
+
166
+ [tool.ruff.lint.pydocstyle]
167
+ convention = "pep257"
168
+
169
+ [tool.ruff.lint.flake8-tidy-imports]
170
+ ban-relative-imports = "parents"
171
+
172
+ [tool.ruff.lint.per-file-ignores]
173
+ "manage.py" = ["PLC", "ANN", "D"]
174
+ "*/models.py" = ["D106"]
175
+ "*/apps.py" = ["D100"]
176
+ "*/urls.py" = ["D100"]
177
+ "*/migrations/*.py" = ["D", "RUF012"]
178
+ "tests/*.py" = ["D", "S101"]
@@ -0,0 +1,5 @@
1
+ """A Django Tasks backend inspired by Starlette's In-process Background Workers."""
2
+
3
+ from .backend import InProcessTaskBackend
4
+
5
+ __all__ = ["InProcessTaskBackend"]
@@ -0,0 +1,204 @@
1
+ """This module contains the actual backend implementation."""
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from collections.abc import Mapping, Sequence
6
+ from contextvars import ContextVar
7
+ from traceback import format_exception
8
+ from typing import Any, Final, TypedDict, override
9
+
10
+ from django.core.signals import request_finished
11
+ from django.tasks.backends.base import BaseTaskBackend
12
+ from django.tasks.base import Task, TaskContext, TaskError, TaskResult, TaskResultStatus
13
+ from django.tasks.signals import task_enqueued, task_finished, task_started
14
+ from django.utils import timezone
15
+ from django.utils.crypto import get_random_string
16
+ from django.utils.json import normalize_json
17
+
18
+
19
+ class TaskBackendParams(TypedDict, total=False):
20
+ """TypedDict representing arguments to the backend constructor."""
21
+
22
+ QUEUES: Sequence[str]
23
+ """Specify the queue names supported by the backend."""
24
+
25
+ OPTIONS: Mapping[str, Any]
26
+ """Extra parameters to pass to the Task backend."""
27
+
28
+
29
+ class InProcessTaskBackend(BaseTaskBackend):
30
+ """
31
+ A Task Backend that executes tasks at the end of the current request.
32
+
33
+ Tasks are attached to the current request, and will be executed once the response has been sent.
34
+ """
35
+
36
+ supports_defer: Final[bool] = False
37
+ supports_async_task: Final[bool] = True
38
+ supports_get_result: Final[bool] = False
39
+ supports_priority: Final[bool] = False
40
+
41
+ def __init__(self, alias: str, params: TaskBackendParams) -> None:
42
+ """Initialize the backend."""
43
+ super().__init__(alias, params)
44
+ self.worker_id = get_random_string(32)
45
+ self.tasks = ContextVar[deque[TaskResult]](f"{alias}_inprocess_tasks")
46
+
47
+ def _enqueue(self, task: Task, args: list[Any], kwargs: dict[str, Any]) -> TaskResult:
48
+ self.validate_task(task)
49
+
50
+ task_result = TaskResult(
51
+ task=task,
52
+ id=get_random_string(32),
53
+ status=TaskResultStatus.READY,
54
+ enqueued_at=timezone.now(),
55
+ started_at=None,
56
+ last_attempted_at=None,
57
+ finished_at=None,
58
+ args=args,
59
+ kwargs=kwargs,
60
+ backend=self.alias,
61
+ errors=[],
62
+ worker_ids=[],
63
+ )
64
+
65
+ try:
66
+ queued_tasks = self.tasks.get()
67
+ except LookupError:
68
+ queued_tasks = deque()
69
+ self.tasks.set(queued_tasks)
70
+
71
+ queued_tasks.append(task_result)
72
+ return task_result
73
+
74
+ @override
75
+ def enqueue(self, task: Task, args: list[Any], kwargs: dict[str, Any]) -> TaskResult:
76
+ """Schedule the task to run at the end of the current request."""
77
+ task_result: TaskResult = self._enqueue(task, args, kwargs)
78
+ task_enqueued.send(type(self), task_result=task_result)
79
+ request_finished.connect(self.execute_tasks, dispatch_uid="_in_process_task_backend")
80
+ return task_result
81
+
82
+ @override
83
+ async def aenqueue(self, task: Task, args: list[Any], kwargs: dict[str, Any]) -> TaskResult:
84
+ """
85
+ Schedule the task to run at the end of the current request.
86
+
87
+ Async version of :meth:`InProcessTaskBackend.enqueue`.
88
+ """
89
+ task_result: TaskResult = self._enqueue(task, args, kwargs)
90
+ await task_enqueued.asend(type(self), task_result=task_result)
91
+ request_finished.connect(self.aexecute_tasks, dispatch_uid="_in_process_task_backend")
92
+ return task_result
93
+
94
+ def _execute_task(self, task_result: TaskResult) -> None:
95
+ """Execute the Task for the given TaskResult, mutating it with the outcome."""
96
+ task = task_result.task
97
+ task_start_time = timezone.now()
98
+ object.__setattr__(task_result, "status", TaskResultStatus.RUNNING)
99
+ object.__setattr__(task_result, "started_at", task_start_time)
100
+ object.__setattr__(task_result, "last_attempted_at", task_start_time)
101
+ task_result.worker_ids.append(self.worker_id)
102
+ task_started.send(sender=type(self), task_result=task_result)
103
+
104
+ try:
105
+ if task.takes_context:
106
+ raw_return_value = task.call(
107
+ TaskContext(task_result=task_result),
108
+ *task_result.args,
109
+ **task_result.kwargs,
110
+ )
111
+ else:
112
+ raw_return_value = task.call(*task_result.args, **task_result.kwargs)
113
+
114
+ object.__setattr__(
115
+ task_result,
116
+ "_return_value",
117
+ normalize_json(raw_return_value),
118
+ )
119
+ except KeyboardInterrupt:
120
+ # If the user tried to terminate, let them
121
+ raise
122
+ except BaseException as e: # noqa: BLE001
123
+ object.__setattr__(task_result, "finished_at", timezone.now())
124
+ object.__setattr__(task_result, "status", TaskResultStatus.FAILED)
125
+ exception_type = type(e)
126
+ task_result.errors.append(
127
+ TaskError(
128
+ exception_class_path=(f"{exception_type.__module__}.{exception_type.__qualname__}"),
129
+ traceback="".join(format_exception(e)),
130
+ )
131
+ )
132
+ task_finished.send(type(self), task_result=task_result)
133
+ else:
134
+ object.__setattr__(task_result, "finished_at", timezone.now())
135
+ object.__setattr__(task_result, "status", TaskResultStatus.SUCCESSFUL)
136
+ task_finished.send(type(self), task_result=task_result)
137
+
138
+ def execute_tasks(self, **_kwargs: Any) -> None: # noqa: ANN401
139
+ """Execute each task."""
140
+ try:
141
+ queued_tasks = self.tasks.get()
142
+ except LookupError:
143
+ pass
144
+ else:
145
+ while queued_tasks:
146
+ self._execute_task(queued_tasks.pop())
147
+
148
+ async def _aexecute_task(self, task_result: TaskResult) -> None:
149
+ """Execute the Task for the given TaskResult, mutating it with the outcome."""
150
+ task = task_result.task
151
+ task_start_time = timezone.now()
152
+ object.__setattr__(task_result, "status", TaskResultStatus.RUNNING)
153
+ object.__setattr__(task_result, "started_at", task_start_time)
154
+ object.__setattr__(task_result, "last_attempted_at", task_start_time)
155
+ task_result.worker_ids.append(self.worker_id)
156
+ await task_started.asend(sender=type(self), task_result=task_result)
157
+
158
+ try:
159
+ if task.takes_context:
160
+ raw_return_value = await task.acall(
161
+ TaskContext(task_result=task_result),
162
+ *task_result.args,
163
+ **task_result.kwargs,
164
+ )
165
+ else:
166
+ raw_return_value = await task.acall(*task_result.args, **task_result.kwargs)
167
+
168
+ object.__setattr__(
169
+ task_result,
170
+ "_return_value",
171
+ normalize_json(raw_return_value),
172
+ )
173
+ except KeyboardInterrupt:
174
+ # If the user tried to terminate, let them
175
+ raise
176
+ except BaseException as e: # noqa: BLE001
177
+ object.__setattr__(task_result, "finished_at", timezone.now())
178
+ object.__setattr__(task_result, "status", TaskResultStatus.FAILED)
179
+ exception_type = type(e)
180
+ task_result.errors.append(
181
+ TaskError(
182
+ exception_class_path=(f"{exception_type.__module__}.{exception_type.__qualname__}"),
183
+ traceback="".join(format_exception(e)),
184
+ )
185
+ )
186
+ else:
187
+ object.__setattr__(task_result, "finished_at", timezone.now())
188
+ object.__setattr__(task_result, "status", TaskResultStatus.SUCCESSFUL)
189
+
190
+ await task_finished.asend(type(self), task_result=task_result)
191
+
192
+ async def aexecute_tasks(self, **_kwargs: Any) -> None: # noqa: ANN401
193
+ """Execute the queued tasks.
194
+
195
+ Async version of :meth:`InProcessTaskBackend.execute_tasks`.
196
+ """
197
+ try:
198
+ queued_tasks = self.tasks.get()
199
+ except LookupError:
200
+ pass
201
+ else:
202
+ async with asyncio.TaskGroup() as tg:
203
+ while queued_tasks:
204
+ tg.create_task(self._aexecute_task(queued_tasks.pop()))
File without changes
@@ -0,0 +1,24 @@
1
+ from pathlib import Path
2
+
3
+ BASE_DIR = Path(__file__).resolve().parent.parent
4
+
5
+ SECRET_KEY = "django-insecure-key" # noqa: S105
6
+
7
+ DEBUG = True
8
+
9
+ ALLOWED_HOSTS = ["*"]
10
+
11
+ INSTALLED_APPS = [
12
+ "django_tasks_inprocess",
13
+ "tests",
14
+ ]
15
+
16
+
17
+ USE_TZ = True
18
+
19
+ TASKS = {
20
+ "default": {
21
+ "BACKEND": "django_tasks_inprocess.InProcessTaskBackend",
22
+ "QUEUES": ["default", "queue-1"],
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ """Test tasks."""
2
+
3
+ from django.tasks import task
4
+
5
+
6
+ @task
7
+ def sync_noop() -> None:
8
+ """Do nothing."""
9
+
10
+
11
+ @task
12
+ async def async_noop() -> None:
13
+ """Do nothing."""
@@ -0,0 +1,152 @@
1
+ import asyncio
2
+ import time
3
+ from typing import TYPE_CHECKING
4
+
5
+ import pytest
6
+ from django.core.signals import request_finished
7
+ from django.tasks import TaskResult, TaskResultStatus, task_backends
8
+ from django.utils import timezone
9
+
10
+ from django_tasks_inprocess.backend import InProcessTaskBackend
11
+
12
+ from .tasks import async_noop, sync_noop
13
+
14
+ if TYPE_CHECKING:
15
+ from django.tasks.backends.base import BaseTaskBackend
16
+
17
+
18
+ class TestInProcessTaskBackend:
19
+ """Tests for InProcessTaskBackend."""
20
+
21
+ def test_backend_introspection(self) -> None:
22
+ backend = task_backends["default"]
23
+
24
+ assert isinstance(backend, InProcessTaskBackend)
25
+ assert backend.supports_async_task is True
26
+ assert backend.supports_defer is False
27
+ assert backend.supports_get_result is False
28
+ assert backend.supports_priority is False
29
+
30
+ def test_enqueue_task(self) -> None:
31
+ backend: BaseTaskBackend = task_backends["default"]
32
+ assert isinstance(backend, InProcessTaskBackend)
33
+ now = timezone.now()
34
+ time.sleep(1) # to avoid resolution issues
35
+
36
+ task_result = backend.enqueue(sync_noop, (), {})
37
+
38
+ assert request_finished.disconnect(dispatch_uid="_in_process_task_backend") is True
39
+
40
+ time.sleep(1) # to avoid resolution issues
41
+ assert isinstance(task_result, TaskResult)
42
+ assert task_result.task is sync_noop
43
+ assert task_result.status == TaskResultStatus.READY
44
+ assert now < task_result.enqueued_at < timezone.now()
45
+ assert task_result.started_at is None
46
+ assert task_result.last_attempted_at is None
47
+ assert task_result.finished_at is None
48
+ assert task_result.args == []
49
+ assert task_result.kwargs == {}
50
+ assert task_result.backend == backend.alias
51
+ assert task_result.errors == []
52
+ assert task_result.worker_ids == []
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_aenqueue_task(self) -> None:
56
+ backend: BaseTaskBackend = task_backends["default"]
57
+ assert isinstance(backend, InProcessTaskBackend)
58
+ now = timezone.now()
59
+ await asyncio.sleep(1) # to avoid resolution issues
60
+
61
+ task_result = await backend.aenqueue(sync_noop, (), {})
62
+
63
+ assert request_finished.disconnect(dispatch_uid="_in_process_task_backend") is True
64
+
65
+ await asyncio.sleep(1)
66
+ assert isinstance(task_result, TaskResult)
67
+ assert task_result.task is sync_noop
68
+ assert task_result.status == TaskResultStatus.READY
69
+ assert now < task_result.enqueued_at < timezone.now()
70
+ assert task_result.started_at is None
71
+ assert task_result.last_attempted_at is None
72
+ assert task_result.finished_at is None
73
+ assert task_result.args == []
74
+ assert task_result.kwargs == {}
75
+ assert task_result.backend == backend.alias
76
+ assert task_result.errors == []
77
+ assert task_result.worker_ids == []
78
+
79
+ def test_execute_tasks(self, subtests: pytest.Subtests) -> None:
80
+ backend: BaseTaskBackend = task_backends["default"]
81
+ assert isinstance(backend, InProcessTaskBackend)
82
+ try:
83
+ with subtests.test("Single sync task"):
84
+ task_result = backend.enqueue(sync_noop, (), {})
85
+
86
+ got = request_finished.send(None)
87
+
88
+ assert task_result.status == TaskResultStatus.SUCCESSFUL
89
+ assert any(result is None for receiver, result in got if receiver == backend.execute_tasks)
90
+
91
+ with subtests.test("Single async task"):
92
+ task_result = backend.enqueue(async_noop, (), {})
93
+
94
+ got = request_finished.send(None)
95
+
96
+ assert task_result.status == TaskResultStatus.SUCCESSFUL
97
+ assert any(result is None for receiver, result in got if receiver == backend.execute_tasks)
98
+
99
+ with subtests.test("Multiple tasks"):
100
+ task_results = [backend.enqueue(sync_noop, (), {}) for _ in range(5)]
101
+ task_results.extend([backend.enqueue(async_noop, (), {}) for _ in range(5)])
102
+
103
+ got = request_finished.send(None)
104
+
105
+ assert all(task_result.status == TaskResultStatus.SUCCESSFUL for task_result in task_results)
106
+ assert any(result is None for receiver, result in got if receiver == backend.execute_tasks)
107
+
108
+ with subtests.test("Without tasks"):
109
+ got = request_finished.send(None)
110
+
111
+ assert any(result is None for receiver, result in got if receiver == backend.execute_tasks)
112
+
113
+ finally:
114
+ assert request_finished.disconnect(dispatch_uid="_in_process_task_backend") is True
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_execute_tasks_async(self, subtests: pytest.Subtests) -> None:
118
+ backend: BaseTaskBackend = task_backends["default"]
119
+ assert isinstance(backend, InProcessTaskBackend)
120
+ try:
121
+ with subtests.test("Single sync task"):
122
+ task_result = await backend.aenqueue(sync_noop, (), {})
123
+
124
+ got = await request_finished.asend(None)
125
+
126
+ assert task_result.status == TaskResultStatus.SUCCESSFUL
127
+ assert any(result is None for receiver, result in got if receiver == backend.aexecute_tasks)
128
+
129
+ with subtests.test("Single async task"):
130
+ task_result = await backend.aenqueue(async_noop, (), {})
131
+
132
+ got = await request_finished.asend(None)
133
+
134
+ assert task_result.status == TaskResultStatus.SUCCESSFUL
135
+ assert any(result is None for receiver, result in got if receiver == backend.aexecute_tasks)
136
+
137
+ with subtests.test("Multiple tasks"):
138
+ task_results = [backend.enqueue(sync_noop, (), {}) for _ in range(5)]
139
+ task_results.extend([backend.enqueue(async_noop, (), {}) for _ in range(5)])
140
+
141
+ got = await request_finished.asend(None)
142
+
143
+ assert all(task_result.status == TaskResultStatus.SUCCESSFUL for task_result in task_results)
144
+ assert any(result is None for receiver, result in got if receiver == backend.aexecute_tasks)
145
+
146
+ with subtests.test("Without tasks"):
147
+ got = await request_finished.asend(None)
148
+
149
+ assert any(result is None for receiver, result in got if receiver == backend.aexecute_tasks)
150
+
151
+ finally:
152
+ assert request_finished.disconnect(dispatch_uid="_in_process_task_backend") is True