Skip to content

Commit

Permalink
Resolve #194 -- Add support to run Pictures for a image CDN (#195)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
codingjoe authored Dec 17, 2024
1 parent 9ebc0f6 commit 9aaa603
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 122 deletions.
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,47 @@ Note that the `media` keys are only included, if you have specified breakpoints.
`PictureField` is compatible with [Django Cleanup](https://github.com/un1t/django-cleanup),
which automatically deletes its file and corresponding `SimplePicture` files.

### external image processing (via CDNs)

This package is designed to accommodate growth, allowing you to start small and scale up as needed.
Should you use a CDN, or some other external image processing service, you can
set this up in two simple steps:

1. Override `PICTURES["PROCESSOR"]` to disable the default processing.
2. Override `PICTURES["PICTURE_CLASS"]` implement any custom behavior.

```python
# settings.py
PICTURES = {
"PROCESSOR": "pictures.tasks.noop", # disable default processing and do nothing
"PICTURE_CLASS": "path.to.MyPicture", # override the default picture class
}
```

The `MyPicture`class should implement the url property, which returns the URL
of the image. You may use the `Picture` class as a base class.

Available attributes are:
* `parent_name` - name of the source file uploaded to the `PictureField`
* `aspect_ratio` - aspect ratio of the output image
* `width` - width of the output image
* `file_type` - file type of the output image

```python
# path/to.py
from pathlib import Path
from pictures.models import Picture


class MyPicture(Picture):
@property
def url(self):
return (
f"https://cdn.example.com/{Path(self.parent_name).stem}"
f"_{self.aspect_ratio}_{self.width}w.{self.file_type.lower()}"
)
```

[drf]: https://www.django-rest-framework.org/
[celery]: https://docs.celeryproject.org/en/stable/
[dramatiq]: https://dramatiq.io/
Expand Down
1 change: 1 addition & 0 deletions pictures/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def get_settings():
"PIXEL_DENSITIES": [1, 2],
"USE_PLACEHOLDERS": settings.DEBUG,
"QUEUE_NAME": "pictures",
"PICTURE_CLASS": "pictures.models.PillowPicture",
"PROCESSOR": "pictures.tasks.process_picture",
**getattr(settings, "PICTURES", {}),
},
Expand Down
4 changes: 2 additions & 2 deletions pictures/contrib/rest_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
__all__ = ["PictureField"]

from pictures import utils
from pictures.models import PictureFieldFile, SimplePicture
from pictures.models import Picture, PictureFieldFile


def default(obj):
if isinstance(obj, SimplePicture):
if isinstance(obj, Picture):
return obj.url
raise TypeError(f"Type '{type(obj).__name__}' not serializable")

Expand Down
68 changes: 41 additions & 27 deletions pictures/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import abc
import dataclasses
import io
import math
Expand All @@ -12,20 +13,23 @@
from django.db.models import ImageField
from django.db.models.fields.files import ImageFieldFile
from django.urls import reverse
from PIL import Image, ImageOps

__all__ = ["PictureField", "PictureFieldFile"]

from django.utils.module_loading import import_string
from PIL import Image, ImageOps

from pictures import conf, utils

__all__ = ["PictureField", "PictureFieldFile", "Picture"]

RGB_FORMATS = ["JPEG"]


@dataclasses.dataclass
class SimplePicture:
"""A simple picture class similar to Django's image class."""
class Picture(abc.ABC):
"""
An abstract picture class similar to Django's image class.
Subclasses will need to implement the `url` property.
"""

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

def __hash__(self):
return hash(self.name)
return hash(self.url)

def __eq__(self, other):
if not isinstance(other, type(self)):
return NotImplemented
return self.deconstruct() == other.deconstruct()

def deconstruct(self):
return (
f"{self.__class__.__module__}.{self.__class__.__qualname__}",
(
self.parent_name,
self.file_type,
str(self.aspect_ratio) if self.aspect_ratio else None,
self.storage.deconstruct(),
self.width,
),
{},
)

@property
@abc.abstractmethod
def url(self) -> str:
"""Return the URL of the picture."""


class PillowPicture(Picture):
"""Use the Pillow library to process images."""

@property
def url(self) -> str:
if conf.get_settings().USE_PLACEHOLDERS:
Expand Down Expand Up @@ -78,7 +104,7 @@ def name(self) -> str:
def path(self) -> Path:
return Path(self.storage.path(self.name))

def process(self, image) -> Image:
def process(self, image) -> "Image":
image = ImageOps.exif_transpose(image) # crates a copy
height = self.height or self.width / Fraction(*image.size)
size = math.floor(self.width), math.floor(height)
Expand All @@ -101,24 +127,11 @@ def save(self, image):
def delete(self):
self.storage.delete(self.name)

def deconstruct(self):
return (
f"{self.__class__.__module__}.{self.__class__.__qualname__}",
(
self.parent_name,
self.file_type,
str(self.aspect_ratio) if self.aspect_ratio else None,
self.storage.deconstruct(),
self.width,
),
{},
)


class PictureFieldFile(ImageFieldFile):

def __xor__(self, other) -> tuple[set[SimplePicture], set[SimplePicture]]:
"""Return the new and obsolete :class:`SimpleFile` instances."""
def __xor__(self, other) -> tuple[set[Picture], set[Picture]]:
"""Return the new and obsolete :class:`Picture` instances."""
if not isinstance(other, PictureFieldFile):
return NotImplemented
new = self.get_picture_files_list() - other.get_picture_files_list()
Expand Down Expand Up @@ -179,7 +192,7 @@ def height(self):
return self._get_image_dimensions()[1]

@property
def aspect_ratios(self) -> {Fraction | None: {str: {int: SimplePicture}}}:
def aspect_ratios(self) -> {Fraction | None: {str: {int: Picture}}}:
self._require_file()
return self.get_picture_files(
file_name=self.name,
Expand All @@ -197,11 +210,12 @@ def get_picture_files(
img_height: int,
storage: Storage,
field: PictureField,
) -> {Fraction | None: {str: {int: SimplePicture}}}:
) -> {Fraction | None: {str: {int: Picture}}}:
PictureClass = import_string(conf.get_settings().PICTURE_CLASS)
return {
ratio: {
file_type: {
width: SimplePicture(file_name, file_type, ratio, storage, width)
width: PictureClass(file_name, file_type, ratio, storage, width)
for width in utils.source_set(
(img_width, img_height),
ratio=ratio,
Expand All @@ -214,7 +228,7 @@ def get_picture_files(
for ratio in field.aspect_ratios
}

def get_picture_files_list(self) -> set[SimplePicture]:
def get_picture_files_list(self) -> set[Picture]:
return {
picture
for sources in self.aspect_ratios.values()
Expand Down
4 changes: 4 additions & 0 deletions pictures/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
from pictures import conf, utils


def noop(*args, **kwargs) -> None:
"""Do nothing. You will need to set up your own image processing (like a CDN)."""


class PictureProcessor(Protocol):

def __call__(
Expand Down
12 changes: 9 additions & 3 deletions tests/contrib/test_rest_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest
from django.core.files.storage import default_storage

from pictures.models import SimplePicture
from pictures.models import Picture
from tests.testapp import models

serializers = pytest.importorskip("rest_framework.serializers")
Expand Down Expand Up @@ -31,19 +31,25 @@ class Meta:
fields = ["image_invalid"]


class TestPicture(Picture):
@property
def url(self):
return f"/media/{self.parent_name}"


def test_default(settings):
settings.PICTURES["USE_PLACEHOLDERS"] = False
assert (
rest_framework.default(
obj=SimplePicture(
obj=TestPicture(
parent_name="testapp/simplemodel/image.jpg",
file_type="WEBP",
aspect_ratio=Fraction("4/3"),
storage=default_storage,
width=800,
)
)
== "/media/testapp/simplemodel/image/4_3/800w.webp"
== "/media/testapp/simplemodel/image.jpg"
)


Expand Down
Loading

0 comments on commit 9aaa603

Please sign in to comment.