Skip to content

Commit

Permalink
Resolve #194 -- Add support to run Pictures for a image CDN
Browse files Browse the repository at this point in the history
  • Loading branch information
codingjoe committed Dec 15, 2024
1 parent 9ebc0f6 commit e025438
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 120 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ jobs:
strategy:
matrix:
extras:
- "nopillow"
- "celery"
- "dramatiq"
- "django-rq"
Expand Down
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 built with growth in mind. You can start small and grow big.
Should you use a CDN, or some other external image processing service, you can
you set up can be complete 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, that returns the
URL of the image. You can 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/{self.aspect_ratio}/"
f"{Path(self.parent_name).stem}_{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
60 changes: 35 additions & 25 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,19 +13,18 @@
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:
class Picture(abc.ABC):
"""A simple picture class similar to Django's image class."""

parent_name: str
Expand All @@ -37,13 +37,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):
"""A simple picture class similar to Django's image class."""

@property
def url(self) -> str:
if conf.get_settings().USE_PLACEHOLDERS:
Expand Down Expand Up @@ -78,7 +100,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,23 +123,10 @@ 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]]:
def __xor__(self, other) -> tuple[set[Picture], set[Picture]]:
"""Return the new and obsolete :class:`SimpleFile` instances."""
if not isinstance(other, PictureFieldFile):
return NotImplemented
Expand Down Expand Up @@ -179,7 +188,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 +206,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 +224,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
5 changes: 5 additions & 0 deletions pictures/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
from pictures import conf, utils


def noop(*args, **kwargs) -> None:
"""Do not process the picture, but rely on some other service to do so."""
pass


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 e025438

Please sign in to comment.