Skip to content

Commit 2538a37

Browse files
committed
Resolve #240 -- Add support for Django's tasks framework (>6.0)
Django's task framework provides native unified framework to interact with any 3rd party task runner, like Celery, Dramatiq or RQ. This step will allow us to greatly redurece the complexity of this package once Django 5.2 LTS reaches EoL. Starting with Django 6.0 we are default to the new task framework while providing fallblack options with pending deprecation warnings.
1 parent bcd5709 commit 2538a37

File tree

7 files changed

+165
-11
lines changed

7 files changed

+165
-11
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,12 @@ jobs:
7272
strategy:
7373
matrix:
7474
python-version:
75-
- "3.10"
75+
- "3.12"
7676
django-version:
7777
# LTS gets tested on all OS
7878
- "4.2"
7979
- "5.1"
80+
- "6.0"
8081
runs-on: ubuntu-latest
8182
steps:
8283
- uses: actions/checkout@v6
@@ -95,6 +96,8 @@ jobs:
9596
name: PyTest
9697
strategy:
9798
matrix:
99+
django-version:
100+
- "5.2" # LTS
98101
extras:
99102
- "celery"
100103
- "dramatiq"
@@ -112,6 +115,7 @@ jobs:
112115
if: matrix.extras == 'dramatiq' || matrix.extras == 'django-rq'
113116
run: sudo apt install -y redis-server
114117
- run: python -m pip install .[test,${{ matrix.extras }}]
118+
- run: python -m pip install django~=${{ matrix.django-version }}.0
115119
- run: python -m pytest
116120
- uses: codecov/codecov-action@v5
117121
with:

README.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Responsive cross-browser image library using modern codes like AVIF & WebP.
99
- serve files with or without a CDN
1010
- placeholders for local development
1111
- migration support
12-
- async image processing for [Celery], [Dramatiq] or [Django RQ][django-rq]
12+
- async image processing for via [Django Tasks][django-tasks]
1313
- [DRF] support
1414

1515
[![PyPi Version](https://img.shields.io/pypi/v/django-pictures.svg)](https://pypi.python.org/pypi/django-pictures/)
@@ -100,13 +100,32 @@ PICTURES = {
100100
"PIXEL_DENSITIES": [1, 2],
101101
"USE_PLACEHOLDERS": True,
102102
"QUEUE_NAME": "pictures",
103+
"BACKEND": "default",
103104
"PROCESSOR": "pictures.tasks.process_picture",
104105
}
105106
```
106107

107108
If you have either Dramatiq or Celery installed, we will default to async
108109
image processing. You will need workers to listen to the `pictures` queue.
109110

111+
> [!IMPORTANT]
112+
> Starting with Django version 6.0, this package leverage Django's built-in
113+
> task framework for async image processing by default. You will need to add
114+
> add the `pictures` queue to your `TASKS` setting:
115+
116+
```python
117+
# settings.py
118+
TASKS = {
119+
"default": {
120+
# ...
121+
"QUEUES": [
122+
# ...
123+
"pictures", # add the new pictures queue here
124+
]
125+
},
126+
}
127+
```
128+
110129
### Placeholders
111130

112131
This library comes with dynamically created placeholders to simplify local
@@ -218,6 +237,38 @@ densities.
218237

219238
### Async image processing
220239

240+
> [!IMPORTANT]
241+
> Starting with Django version 6.0, this package leverage Django's built-in
242+
> task framework for async image processing by default. You can explicitly
243+
> override this behavior via the `PICTURES["PROCESSOR"]` setting.
244+
245+
Images are processed on a separate queue by default, you will add the `pictures`
246+
queue to your task backend configuration. You can override the queue name,
247+
via the `PICTURES["QUEUE_NAME"]` setting.
248+
249+
If you want to run image processing on a separate backend, you can set the
250+
`PICTURES["BACKEND"]` setting to something other than `default`.
251+
252+
You may also override the processor to explicitly use Celery, Dramatiq or
253+
Django RQ until they provide proper integration with Django Tasks.
254+
255+
> [!WARNING]
256+
> The custom Celery, Dramatiq and Django RQ integration is deprecated
257+
> and will be removed in the next major release (version 2.0).
258+
259+
```python
260+
# settings.py
261+
PICTURES = {
262+
"PROCESSOR": "pictures.tasks.celery", # use Celery
263+
# or
264+
"PROCESSOR": "pictures.tasks.dramatiq", # use Dramatiq
265+
# or
266+
"PROCESSOR": "pictures.tasks.django_rq", # use Django RQ
267+
}
268+
```
269+
270+
#### Pre Django 6.0
271+
221272
If you have either Dramatiq or Celery installed, we will default to async
222273
image processing. You will need workers to listen to the `pictures` queue.
223274
You can override the queue name, via the `PICTURES["QUEUE_NAME"]` setting.
@@ -377,8 +428,6 @@ class MyPicture(Picture):
377428
[caniemail-srcset]: https://www.caniemail.com/features/html-srcset/
378429
[caniemail-webp]: https://www.caniemail.com/features/image-webp/
379430
[caniuse-picture]: https://caniuse.com/picture
380-
[celery]: https://docs.celeryproject.org/en/stable/
381-
[django-rq]: https://github.com/rq/django-rq
382-
[dramatiq]: https://dramatiq.io/
431+
[django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
383432
[drf]: https://www.django-rest-framework.org/
384433
[migration]: tests/testapp/migrations/0002_alter_profile_picture.py

pictures/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def get_settings():
2121
"PIXEL_DENSITIES": [1, 2],
2222
"USE_PLACEHOLDERS": settings.DEBUG,
2323
"QUEUE_NAME": "pictures",
24+
"BACKEND": "default",
2425
"PICTURE_CLASS": "pictures.models.PillowPicture",
2526
"PROCESSOR": "pictures.tasks.process_picture",
2627
**getattr(settings, "PICTURES", {}),

pictures/tasks.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
import warnings
34
from typing import Protocol
45

6+
import django
57
from django.db import transaction
68
from PIL import Image
79

@@ -46,12 +48,12 @@ def _process_picture(
4648

4749

4850
try:
49-
import dramatiq
51+
from dramatiq import actor
5052
except ImportError:
5153
pass
5254
else:
5355

54-
@dramatiq.actor(queue_name=conf.get_settings().QUEUE_NAME)
56+
@actor(queue_name=conf.get_settings().QUEUE_NAME)
5557
def process_picture_with_dramatiq(
5658
storage: tuple[str, list, dict],
5759
file_name: str,
@@ -60,12 +62,19 @@ def process_picture_with_dramatiq(
6062
) -> None:
6163
_process_picture(storage, file_name, new, old)
6264

63-
def process_picture( # noqa: F811
65+
def dramatiq_process_picture( # noqa: F811
6466
storage: tuple[str, list, dict],
6567
file_name: str,
6668
new: list[tuple[str, list, dict]] | None = None,
6769
old: list[tuple[str, list, dict]] | None = None,
6870
) -> None:
71+
if django.VERSION >= (6, 0):
72+
warnings.warn(
73+
"The `dramatiq_process_picture`-processor deprecated in favor of Django's tasks framework."
74+
" Deletion is scheduled with Django 5.2 version support.",
75+
PendingDeprecationWarning,
76+
stacklevel=2,
77+
)
6978
transaction.on_commit(
7079
lambda: process_picture_with_dramatiq.send(
7180
storage=storage,
@@ -75,6 +84,8 @@ def process_picture( # noqa: F811
7584
)
7685
)
7786

87+
process_picture = dramatiq_process_picture # type: ignore[assignment]
88+
7889

7990
try:
8091
from celery import shared_task
@@ -94,12 +105,19 @@ def process_picture_with_celery(
94105
) -> None:
95106
_process_picture(storage, file_name, new, old)
96107

97-
def process_picture( # noqa: F811
108+
def celery_process_picture( # noqa: F811
98109
storage: tuple[str, list, dict],
99110
file_name: str,
100111
new: list[tuple[str, list, dict]] | None = None,
101112
old: list[tuple[str, list, dict]] | None = None,
102113
) -> None:
114+
if django.VERSION >= (6, 0):
115+
warnings.warn(
116+
"The `celery_process_picture`-processor deprecated in favor of Django's tasks framework."
117+
" Deletion is scheduled with Django 5.2 version support.",
118+
PendingDeprecationWarning,
119+
stacklevel=2,
120+
)
103121
transaction.on_commit(
104122
lambda: process_picture_with_celery.apply_async(
105123
kwargs=dict(
@@ -112,6 +130,8 @@ def process_picture( # noqa: F811
112130
)
113131
)
114132

133+
process_picture = celery_process_picture # type: ignore[assignment]
134+
115135

116136
try:
117137
from django_rq import job
@@ -128,12 +148,19 @@ def process_picture_with_django_rq(
128148
) -> None:
129149
_process_picture(storage, file_name, new, old)
130150

131-
def process_picture( # noqa: F811
151+
def rq_process_picture( # noqa: F811
132152
storage: tuple[str, list, dict],
133153
file_name: str,
134154
new: list[tuple[str, list, dict]] | None = None,
135155
old: list[tuple[str, list, dict]] | None = None,
136156
) -> None:
157+
if django.VERSION >= (6, 0):
158+
warnings.warn(
159+
"The `rq_process_picture`-processor deprecated in favor of Django's tasks framework."
160+
" Deletion is scheduled with Django 5.2 version support.",
161+
PendingDeprecationWarning,
162+
stacklevel=2,
163+
)
137164
transaction.on_commit(
138165
lambda: process_picture_with_django_rq.delay(
139166
storage=storage,
@@ -142,3 +169,46 @@ def process_picture( # noqa: F811
142169
old=old,
143170
)
144171
)
172+
173+
process_picture = rq_process_picture # type: ignore[assignment]
174+
175+
176+
try:
177+
from django.tasks import exceptions, task
178+
except ImportError:
179+
pass
180+
else:
181+
try:
182+
183+
@task(
184+
backend=conf.get_settings().BACKEND,
185+
queue_name=conf.get_settings().QUEUE_NAME,
186+
)
187+
def process_picture_with_django_tasks(
188+
storage: tuple[str, list, dict],
189+
file_name: str,
190+
new: list[tuple[str, list, dict]] | None = None,
191+
old: list[tuple[str, list, dict]] | None = None,
192+
) -> None:
193+
_process_picture(storage, file_name, new, old)
194+
195+
def process_picture( # noqa: F811
196+
storage: tuple[str, list, dict],
197+
file_name: str,
198+
new: list[tuple[str, list, dict]] | None = None,
199+
old: list[tuple[str, list, dict]] | None = None,
200+
) -> None:
201+
transaction.on_commit(
202+
lambda: process_picture_with_django_tasks.enqueue(
203+
storage=storage,
204+
file_name=file_name,
205+
new=new,
206+
old=old,
207+
)
208+
)
209+
210+
except exceptions.InvalidTask as e:
211+
raise exceptions.ImproperlyConfigured(
212+
"Pictures are processed on a separate queue by default,"
213+
" please `TASKS` settings in accordance with Django-Pictures documentation."
214+
) from e

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ classifiers = [
3030
"Programming Language :: Python :: 3.14",
3131
"Framework :: Django",
3232
"Framework :: Django :: 4.2",
33-
"Framework :: Django :: 5.1",
3433
"Framework :: Django :: 5.2",
34+
"Framework :: Django :: 6.0",
3535
]
3636
requires-python = ">=3.10"
3737
dependencies = ["django>=4.2.0", "pillow>=11.3.0"]

tests/test_tasks.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import importlib
12
from unittest.mock import Mock
23

34
import pytest
5+
from django.core.exceptions import ImproperlyConfigured
46

57
from pictures import tasks
68
from tests.testapp.models import SimpleModel
@@ -24,3 +26,21 @@ def test_process_picture__file_cannot_be_reopened(image_upload_file):
2426

2527
def test_noop():
2628
tasks.noop() # does nothing
29+
30+
31+
def test_django_tasks_misconfiguration(settings):
32+
pytest.importorskip(
33+
"django", minversion="6.0", reason="Django tasks introduced in 6.0"
34+
)
35+
settings.TASKS = {
36+
"default": {
37+
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
38+
"QUEUES": ["default"],
39+
}
40+
}
41+
with pytest.raises(ImproperlyConfigured) as e:
42+
importlib.reload(tasks)
43+
assert str(e.value) == (
44+
"Pictures are processed on a separate queue by default,"
45+
" please `TASKS` settings in accordance with Django-Pictures documentation."
46+
)

tests/testapp/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,16 @@
140140
}
141141

142142

143+
# Django tasks (6.0+)
144+
145+
TASKS = {
146+
"default": {
147+
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
148+
"QUEUES": ["default", "pictures"],
149+
}
150+
}
151+
152+
143153
# Dramatiq
144154
try:
145155
import dramatiq # NoQA

0 commit comments

Comments
 (0)