Skip to content

Commit 9aaa603

Browse files
authored
Resolve #194 -- Add support to run Pictures for a image CDN (#195)
Add support documented support to run an external image processor like Cloudinary or AWS lambda. This does not remove the need to have Pillow installed, as Django requires Pillow to read the image dimensions.
1 parent 9ebc0f6 commit 9aaa603

File tree

9 files changed

+199
-122
lines changed

9 files changed

+199
-122
lines changed

README.md

+41
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,47 @@ Note that the `media` keys are only included, if you have specified breakpoints.
296296
`PictureField` is compatible with [Django Cleanup](https://github.com/un1t/django-cleanup),
297297
which automatically deletes its file and corresponding `SimplePicture` files.
298298

299+
### external image processing (via CDNs)
300+
301+
This package is designed to accommodate growth, allowing you to start small and scale up as needed.
302+
Should you use a CDN, or some other external image processing service, you can
303+
set this up in two simple steps:
304+
305+
1. Override `PICTURES["PROCESSOR"]` to disable the default processing.
306+
2. Override `PICTURES["PICTURE_CLASS"]` implement any custom behavior.
307+
308+
```python
309+
# settings.py
310+
PICTURES = {
311+
"PROCESSOR": "pictures.tasks.noop", # disable default processing and do nothing
312+
"PICTURE_CLASS": "path.to.MyPicture", # override the default picture class
313+
}
314+
```
315+
316+
The `MyPicture`class should implement the url property, which returns the URL
317+
of the image. You may use the `Picture` class as a base class.
318+
319+
Available attributes are:
320+
* `parent_name` - name of the source file uploaded to the `PictureField`
321+
* `aspect_ratio` - aspect ratio of the output image
322+
* `width` - width of the output image
323+
* `file_type` - file type of the output image
324+
325+
```python
326+
# path/to.py
327+
from pathlib import Path
328+
from pictures.models import Picture
329+
330+
331+
class MyPicture(Picture):
332+
@property
333+
def url(self):
334+
return (
335+
f"https://cdn.example.com/{Path(self.parent_name).stem}"
336+
f"_{self.aspect_ratio}_{self.width}w.{self.file_type.lower()}"
337+
)
338+
```
339+
299340
[drf]: https://www.django-rest-framework.org/
300341
[celery]: https://docs.celeryproject.org/en/stable/
301342
[dramatiq]: https://dramatiq.io/

pictures/conf.py

+1
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+
"PICTURE_CLASS": "pictures.models.PillowPicture",
2425
"PROCESSOR": "pictures.tasks.process_picture",
2526
**getattr(settings, "PICTURES", {}),
2627
},

pictures/contrib/rest_framework.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
__all__ = ["PictureField"]
77

88
from pictures import utils
9-
from pictures.models import PictureFieldFile, SimplePicture
9+
from pictures.models import Picture, PictureFieldFile
1010

1111

1212
def default(obj):
13-
if isinstance(obj, SimplePicture):
13+
if isinstance(obj, Picture):
1414
return obj.url
1515
raise TypeError(f"Type '{type(obj).__name__}' not serializable")
1616

pictures/models.py

+41-27
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import abc
34
import dataclasses
45
import io
56
import math
@@ -12,20 +13,23 @@
1213
from django.db.models import ImageField
1314
from django.db.models.fields.files import ImageFieldFile
1415
from django.urls import reverse
15-
from PIL import Image, ImageOps
16-
17-
__all__ = ["PictureField", "PictureFieldFile"]
18-
1916
from django.utils.module_loading import import_string
17+
from PIL import Image, ImageOps
2018

2119
from pictures import conf, utils
2220

21+
__all__ = ["PictureField", "PictureFieldFile", "Picture"]
22+
2323
RGB_FORMATS = ["JPEG"]
2424

2525

2626
@dataclasses.dataclass
27-
class SimplePicture:
28-
"""A simple picture class similar to Django's image class."""
27+
class Picture(abc.ABC):
28+
"""
29+
An abstract picture class similar to Django's image class.
30+
31+
Subclasses will need to implement the `url` property.
32+
"""
2933

3034
parent_name: str
3135
file_type: str
@@ -37,13 +41,35 @@ def __post_init__(self):
3741
self.aspect_ratio = Fraction(self.aspect_ratio) if self.aspect_ratio else None
3842

3943
def __hash__(self):
40-
return hash(self.name)
44+
return hash(self.url)
4145

4246
def __eq__(self, other):
4347
if not isinstance(other, type(self)):
4448
return NotImplemented
4549
return self.deconstruct() == other.deconstruct()
4650

51+
def deconstruct(self):
52+
return (
53+
f"{self.__class__.__module__}.{self.__class__.__qualname__}",
54+
(
55+
self.parent_name,
56+
self.file_type,
57+
str(self.aspect_ratio) if self.aspect_ratio else None,
58+
self.storage.deconstruct(),
59+
self.width,
60+
),
61+
{},
62+
)
63+
64+
@property
65+
@abc.abstractmethod
66+
def url(self) -> str:
67+
"""Return the URL of the picture."""
68+
69+
70+
class PillowPicture(Picture):
71+
"""Use the Pillow library to process images."""
72+
4773
@property
4874
def url(self) -> str:
4975
if conf.get_settings().USE_PLACEHOLDERS:
@@ -78,7 +104,7 @@ def name(self) -> str:
78104
def path(self) -> Path:
79105
return Path(self.storage.path(self.name))
80106

81-
def process(self, image) -> Image:
107+
def process(self, image) -> "Image":
82108
image = ImageOps.exif_transpose(image) # crates a copy
83109
height = self.height or self.width / Fraction(*image.size)
84110
size = math.floor(self.width), math.floor(height)
@@ -101,24 +127,11 @@ def save(self, image):
101127
def delete(self):
102128
self.storage.delete(self.name)
103129

104-
def deconstruct(self):
105-
return (
106-
f"{self.__class__.__module__}.{self.__class__.__qualname__}",
107-
(
108-
self.parent_name,
109-
self.file_type,
110-
str(self.aspect_ratio) if self.aspect_ratio else None,
111-
self.storage.deconstruct(),
112-
self.width,
113-
),
114-
{},
115-
)
116-
117130

118131
class PictureFieldFile(ImageFieldFile):
119132

120-
def __xor__(self, other) -> tuple[set[SimplePicture], set[SimplePicture]]:
121-
"""Return the new and obsolete :class:`SimpleFile` instances."""
133+
def __xor__(self, other) -> tuple[set[Picture], set[Picture]]:
134+
"""Return the new and obsolete :class:`Picture` instances."""
122135
if not isinstance(other, PictureFieldFile):
123136
return NotImplemented
124137
new = self.get_picture_files_list() - other.get_picture_files_list()
@@ -179,7 +192,7 @@ def height(self):
179192
return self._get_image_dimensions()[1]
180193

181194
@property
182-
def aspect_ratios(self) -> {Fraction | None: {str: {int: SimplePicture}}}:
195+
def aspect_ratios(self) -> {Fraction | None: {str: {int: Picture}}}:
183196
self._require_file()
184197
return self.get_picture_files(
185198
file_name=self.name,
@@ -197,11 +210,12 @@ def get_picture_files(
197210
img_height: int,
198211
storage: Storage,
199212
field: PictureField,
200-
) -> {Fraction | None: {str: {int: SimplePicture}}}:
213+
) -> {Fraction | None: {str: {int: Picture}}}:
214+
PictureClass = import_string(conf.get_settings().PICTURE_CLASS)
201215
return {
202216
ratio: {
203217
file_type: {
204-
width: SimplePicture(file_name, file_type, ratio, storage, width)
218+
width: PictureClass(file_name, file_type, ratio, storage, width)
205219
for width in utils.source_set(
206220
(img_width, img_height),
207221
ratio=ratio,
@@ -214,7 +228,7 @@ def get_picture_files(
214228
for ratio in field.aspect_ratios
215229
}
216230

217-
def get_picture_files_list(self) -> set[SimplePicture]:
231+
def get_picture_files_list(self) -> set[Picture]:
218232
return {
219233
picture
220234
for sources in self.aspect_ratios.values()

pictures/tasks.py

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
from pictures import conf, utils
99

1010

11+
def noop(*args, **kwargs) -> None:
12+
"""Do nothing. You will need to set up your own image processing (like a CDN)."""
13+
14+
1115
class PictureProcessor(Protocol):
1216

1317
def __call__(

tests/contrib/test_rest_framework.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from django.core.files.storage import default_storage
55

6-
from pictures.models import SimplePicture
6+
from pictures.models import Picture
77
from tests.testapp import models
88

99
serializers = pytest.importorskip("rest_framework.serializers")
@@ -31,19 +31,25 @@ class Meta:
3131
fields = ["image_invalid"]
3232

3333

34+
class TestPicture(Picture):
35+
@property
36+
def url(self):
37+
return f"/media/{self.parent_name}"
38+
39+
3440
def test_default(settings):
3541
settings.PICTURES["USE_PLACEHOLDERS"] = False
3642
assert (
3743
rest_framework.default(
38-
obj=SimplePicture(
44+
obj=TestPicture(
3945
parent_name="testapp/simplemodel/image.jpg",
4046
file_type="WEBP",
4147
aspect_ratio=Fraction("4/3"),
4248
storage=default_storage,
4349
width=800,
4450
)
4551
)
46-
== "/media/testapp/simplemodel/image/4_3/800w.webp"
52+
== "/media/testapp/simplemodel/image.jpg"
4753
)
4854

4955

0 commit comments

Comments
 (0)