diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07799f2..9ad71f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,26 +8,15 @@ on: jobs: - analyze: - name: CodeQL - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2.3.4 - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: python - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 - msgcheck: runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v2.2.2 - - uses: actions/checkout@v2.3.4 - - run: sudo apt install -y gettext aspell libenchant-dev - - uses: actions/cache@v2.1.6 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + - uses: actions/checkout@v4 + - run: sudo apt install -y gettext aspell libenchant-2-dev + - uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('lint-requirements.txt') }} @@ -48,9 +37,11 @@ jobs: - "pydocstyle ." runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v2.2.2 - - uses: actions/checkout@v2.3.4 - - uses: actions/cache@v2.1.6 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + - uses: actions/checkout@v4 + - uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('lint-requirements.txt') }} @@ -59,18 +50,18 @@ jobs: - run: python -m pip install -r lint-requirements.txt - run: ${{ matrix.lint-command }} - dist: runs-on: ubuntu-latest steps: - - name: Install gettext - run: sudo apt install gettext -y - - uses: actions/setup-python@v2.2.2 - - run: python -m pip install --upgrade pip setuptools wheel twine readme-renderer - - uses: actions/checkout@v2.3.4 - - run: python setup.py sdist bdist_wheel + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + - run: sudo apt install gettext -y + - run: python -m pip install --upgrade pip build wheel twine readme-renderer + - run: python -m build --sdist --wheel - run: python -m twine check dist/* - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 with: path: dist/* @@ -82,27 +73,25 @@ jobs: strategy: matrix: python-version: - - "3.7" - "3.8" - "3.9" + - "3.10" django-version: - - "2.2" - - "3.1" - - "3.2rc1" + - "3.2" + - "4.0" extra: - - "" - - "progressbar" + - "test" + - "test,progressbar" steps: - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - run: sudo apt install gettext -y - - uses: actions/checkout@v2.3.4 - - run: python -m pip install --upgrade pip setuptools codecov wheel - - run: python -m pip install .[${{ matrix.extra }}] + - uses: actions/checkout@v4 + - run: python -m pip install --upgrade pip codecov + - run: python -m pip install -e .[${{ matrix.extra }}] if: ${{ matrix.extra }} - - run: python -m pip install django~=${{ matrix.django-version }} - - name: Test with pytest - run: python setup.py test + - run: python -m pip install django~=${{ matrix.django-version }}a + - run: python -m pytest - run: codecov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30eb296..a71d600 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,22 @@ -name: PyPi Release +name: Release -on: [release] +on: + release: + types: [published] jobs: - build: + PyPi: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.3.4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2.2.2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} - - name: Install gettext - run: sudo apt-get install gettext -y - - name: Install dependencies - run: python -m pip install --upgrade pip setuptools wheel twine - - name: Build dist packages - run: python setup.py sdist bdist_wheel - - name: Upload packages - run: python -m twine upload dist/* + python-version: "3.10" + - run: sudo apt-get install gettext -y + - run: python -m pip install --upgrade pip build wheel twine + - run: python -m build --sdist --wheel + - run: python -m twine upload dist/* env: - TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} diff --git a/README.md b/README.md index cc70313..77fe5f6 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,110 @@ # Django Standardized Image Field -Django Field that implement the following features: +This package has been deprecated in favor of [django-pictures][django-pictures]. -* Django-Storages compatible (S3) -* Resize images to different sizes +## Migration Instructions + +First, make sure you understand the differences between the two packages and +how to serve images in a modern web application via the [picture][picture-tag]-Element. + +Next, follow the setup instructions for [django-pictures][django-pictures]. + +Once you are set up, change your models to use the new `PictureField` and provide the + `aspect_ratios` you'd like to serve. Do create migrations just yet. + +This step should be followed by changing your templates and frontend. +The new placeholders feature for local development should help you +to do this almost effortlessly. + +Finally, run `makemigrations` and replace the `AlterField` operation with +`AlterPictureField`. + +We highly recommend to use Django's `image_width` and `image_height` fields, to avoid +unnecessary IO. If you can add these fields to your model, you can use the following +snippet to populate them: + +```python +import django.core.files.storage +from django.db import migrations, models +import pictures.models +from pictures.migrations import AlterPictureField + +def forward(apps, schema_editor): + for obj in apps.get_model("my-app.MyModel").objects.all().iterator(): + obj.image_width = obj.logo.width + obj.image_height = obj.logo.height + obj.save(update_fields=["image_height", "image_width"]) + +def backward(apps, schema_editor): + apps.get_model("my-app.MyModel").objects.all().update( + image_width=None, + image_height=None, + ) + +class Migration(migrations.Migration): + dependencies = [ + ('my-app', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name="mymodel", + name="image_height", + field=models.PositiveIntegerField(editable=False, null=True), + ), + migrations.AddField( + model_name="mymodel", + name="image_width", + field=models.PositiveIntegerField(editable=False, null=True), + ), + migrations.RunPython(forward, backward), + AlterPictureField( + model_name="mymodel", + name="image", + field=pictures.models.PictureField( + aspect_ratios=["3/2", "3/1"], + breakpoints={"desktop": 1024, "mobile": 576}, + container_width=1200, + file_types=["WEBP"], + grid_columns=12, + height_field="image_height", + pixel_densities=[1, 2], + storage=django.core.files.storage.FileSystemStorage(), + upload_to="pictures/", + verbose_name="image", + width_field="image_width", + ), + ), + ] +``` + +[django-pictures]: https://github.com/codingjoe/django-pictures +[picture-tag]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture + +## Why would I want this? + +This is a drop-in replacement for the [Django ImageField](https://docs.djangoproject.com/en/1.8/ref/models/fields/#django.db.models.ImageField) that provides a standardized way to handle image uploads. +It is designed to be as easy to use as possible, and to provide a consistent interface for all image fields. +It allows images to be presented in various size variants (eg:thumbnails, mid, and hi-res versions) +and it provides a way to handle images that are too large with validators. + + +## Features + +Django Standardized Image Field implements the following features: + +* [Django-Storages](https://django-storages.readthedocs.io/en/latest/) compatible (eg: S3, Azure, Google Cloud Storage, etc) +* Resizes images to different sizes * Access thumbnails on model level, no template tags required -* Preserves original image -* Asynchronous rendering (Celery & Co) -* Restrict accepted image dimensions -* Rename files to a standardized name (using a callable upload_to) +* Preserves original images +* Can be rendered asynchronously (ie as a [Celery job](https://realpython.com/asynchronous-tasks-with-django-and-celery/)) +* Restricts acceptable image dimensions +* Renames a file to a standardized name format (using a callable `upload_to` function, see below) ## Installation -Simply install the latest stable package using the command +Simply install the latest stable package using the following command: ```bash pip install django-stdimage @@ -28,11 +119,13 @@ and add `'stdimage'` to `INSTALLED_APP`s in your settings.py, that's it! ## Usage +Now it's instally you can use either: `StdImageField` or `JPEGField`. + `StdImageField` works just like Django's own [ImageField](https://docs.djangoproject.com/en/dev/ref/models/fields/#imagefield) -except that you can specify different sized variations. +except that you can specify different size variations. -The `JPEGField` works similar to the `StdImageField` but all size variations are +The `JPEGField` is identical to the `StdImageField` but all images are converted to JPEGs, no matter what type the original file is. ### Variations @@ -58,7 +151,7 @@ class MyModel(models.Model): # is the same as dictionary-style call image = StdImageField(upload_to='path/to/img', variations={'thumbnail': (100, 75)}) - # variations are converted to JPEGs + # JPEGField variations are converted to JPEGs. jpeg = JPEGField( upload_to='path/to/img', variations={'full': (None, None), 'thumbnail': (100, 75)}, @@ -77,7 +170,7 @@ class MyModel(models.Model): }, delete_orphans=True) ``` -For using generated variations in templates use `myimagefield.variation_name`. +To use these variations in templates use `myimagefield.variation_name`. Example: @@ -85,15 +178,32 @@ Example: ``` -### Utils +### Upload to function -Since version 4 the custom `upload_to` utils have been dropped in favor of -[Django Dynamic Filenames][dynamic_filenames]. +You can use a function for the `upload_to` argument. Using [Django Dynamic Filenames][dynamic_filenames].[dynamic_filenames]: https://github.com/codingjoe/django-dynamic-filenames -[dynamic_filenames]: https://github.com/codingjoe/django-dynamic-filenames +This allows images to be given unique paths and filenames based on the model instance. + +Example + +```python +from django.db import models +from stdimage import StdImageField +from dynamic_filenames import FilePattern + +upload_to_pattern = FilePattern( + filename_pattern='my_model/{app_label:.25}/{model_name:.30}/{uuid:base32}{ext}', +) + + +class MyModel(models.Model): + # works just like django's ImageField + image = StdImageField(upload_to=upload_to_pattern) +``` ### Validators -The `StdImageField` doesn't implement any size validation. Validation can be specified using the validator attribute +The `StdImageField` doesn't implement any size validation out-of-the-box. +However, Validation can be specified using the validator attribute and using a set of validators shipped with this package. Validators can be used for both Forms and Models. @@ -120,9 +230,9 @@ Django [dropped support](https://docs.djangoproject.com/en/dev/releases/1.3/#del for automated deletions in version 1.3. Since version 5, this package supports a `delete_orphans` argument. It will delete -orphaned files, should a file be delete or replaced via Django form or and object with -a `StdImageField` be deleted. It will not be deleted if the field value is changed or -reassigned programatically. In those rare cases, you will need to handle proper deletion +orphaned files, should a file be deleted or replaced via a Django form and the object with +the `StdImageField` be deleted. It will not delete files if the field value is changed or +reassigned programatically. In these rare cases, you will need to handle proper deletion yourself. ```python @@ -141,10 +251,10 @@ class MyModel(models.Model): ### Async image processing Tools like celery allow to execute time-consuming tasks outside of the request. If you don't want -to wait for your variations to be rendered in request, StdImage provides your the option to pass a -async keyword and a util. -Note that the callback is not transaction save, but the file will be there. -This example is based on celery. +to wait for your variations to be rendered in request, StdImage provides you the option to pass an +async keyword and a 'render_variations' function that triggers the async task. +Note that the callback is not transaction save, but the file variations will be present. +The example below is based on celery. `tasks.py`: ```python @@ -177,19 +287,18 @@ def image_processor(file_name, variations, storage): class AsyncImageModel(models.Model): image = StdImageField( # above task definition can only handle one model object per image filename - upload_to='path/to/file/', + upload_to='path/to/file/', # or use a function render_variations=image_processor # pass boolean or callable ) processed = models.BooleanField(default=False) # flag that could be used for view querysets ``` ### Re-rendering variations -You might want to add new variations to a field. That means you need to render new variations for missing fields. +You might have added or changed variations to an existing field. That means you will need to render new variations. This can be accomplished using a management command. ```bash python manage.py rendervariations 'app_name.model_name.field_name' [--replace] [-i/--ignore-missing] ``` The `replace` option will replace all existing files. -The `ignore-missing` option will suspend missing source file errors and keep -rendering variations for other files. Othervise command will stop on first -missing file. +The `ignore-missing` option will suspend 'missing source file' errors and keep +rendering variations for other files. Otherwise, the command will stop on first missing file. diff --git a/lint-requirements.txt b/lint-requirements.txt index a8978cc..e0936be 100644 --- a/lint-requirements.txt +++ b/lint-requirements.txt @@ -1,6 +1,6 @@ -bandit==1.7.0 -black==21.5b1 -flake8==3.9.2 -isort==5.8.0 -msgcheck==3.1 -pydocstyle==6.1.1 +bandit==1.7.5 +black==24.3.0 +flake8==6.1.0 +isort==5.12.0 +msgcheck==4.0.0 +pydocstyle==6.3.0 diff --git a/setup.cfg b/setup.cfg index 696d7c0..5262f42 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ url = https://github.com/codingjoe/django-stdimage license = MIT license_file = LICENSE classifier = - Development Status :: 5 - Production/Stable + Development Status :: 7 - Inactive Environment :: Web Environment Framework :: Django Topic :: Multimedia :: Graphics :: Graphics Conversion @@ -20,15 +20,14 @@ classifier = Topic :: Software Development Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Framework :: Django - Framework :: Django :: 2.2 - Framework :: Django :: 3.1 Framework :: Django :: 3.2 + Framework :: Django :: 4.0 -python_requires = >=3.7 +python_requires = >=3.8 [options] include_package_data = True @@ -39,11 +38,6 @@ install_requires = setup_requires = setuptools_scm - pytest-runner -tests_require = - pytest - pytest-cov - pytest-django [options.package_data] * = *.txt, *.rst, *.html, *.po @@ -53,20 +47,23 @@ exclude = tests [options.extras_require] +test = + pytest + pytest-cov + pytest-django progressbar = progressbar2>=3.0.0 [bdist_wheel] universal = 1 -[aliases] -test = pytest - [tool:pytest] +testpaths = + tests norecursedirs=venv env .eggs DJANGO_SETTINGS_MODULE=tests.settings addopts = --cov=stdimage --nomigrations --tb=short filterwarnings = - error + ignore::DeprecationWarning [coverage:run] source = . diff --git a/stdimage/models.py b/stdimage/models.py index 0eb6943..dd429d8 100644 --- a/stdimage/models.py +++ b/stdimage/models.py @@ -1,5 +1,6 @@ import logging import os +import warnings from io import BytesIO from django.core.files.base import ContentFile @@ -11,12 +12,21 @@ ImageFileDescriptor, ) from PIL import Image, ImageFile, ImageOps +from PIL.Image import Resampling from .validators import MinSizeValidator logger = logging.getLogger() +warnings.warn( + "The django-stdimage is deprecated in favor of django-pictures.\n" + "Migration instructions are available in the README:\n" + "https://github.com/codingjoe/django-stdimage#migration-instructions", + DeprecationWarning, +) + + class StdImageFileDescriptor(ImageFileDescriptor): """The variation property of the field is accessible in instance cases.""" @@ -144,6 +154,25 @@ def delete_variations(self): variation_name = self.get_variation_name(self.name, variation) self.storage.delete(variation_name) + def __getstate__(self): + state = super().__getstate__() + state["variations"] = {} + for variation_name in self.field.variations: + if variation := getattr(self, variation_name, None): + variation_state = variation.__getstate__() + state["variations"][variation_name] = variation_state + return state + + def __setstate__(self, state): + variations = state["variations"] + state.pop("variations") + super().__setstate__(state) + for key, value in variations.items(): + cls = ImageFieldFile + field = cls.__new__(cls) + setattr(self, key, field) + getattr(self, key).__setstate__(value) + class StdImageField(ImageField): """ @@ -167,7 +196,7 @@ class StdImageField(ImageField): "width": None, "height": None, "crop": False, - "resample": Image.ANTIALIAS, + "resample": Resampling.LANCZOS, } def __init__( @@ -178,7 +207,7 @@ def __init__( render_variations=True, force_min_size=False, delete_orphans=False, - **kwargs + **kwargs, ): """ Standardized ImageField for Django. @@ -235,6 +264,9 @@ def __init__( super().__init__(verbose_name=verbose_name, name=name, **kwargs) + # The attribute name of the old file to use on the model object + self._old_attname = "_old_%s" % name + def add_variation(self, name, params): variation = self.def_variation.copy() variation["kwargs"] = {} @@ -284,9 +316,32 @@ def save_form_data(self, instance, data): if self.delete_orphans and (data is False or data is not None): file = getattr(instance, self.name) if file and file._committed and file != data: - file.delete(save=False) + # Store the old file which should be deleted if the new one is valid + setattr(instance, self._old_attname, file) super().save_form_data(instance, data) + def pre_save(self, model_instance, add): + if hasattr(model_instance, self._old_attname): + # Delete the old file and its variations from the storage + old_file = getattr(model_instance, self._old_attname) + old_file.delete_variations() + old_file.storage.delete(old_file.name) + delattr(model_instance, self._old_attname) + return super().pre_save(model_instance, add) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + return ( + name, + path, + args, + { + **kwargs, + "variations": self._variations, + "force_min_size": self.force_min_size, + }, + ) + class JPEGFieldFile(StdImageFieldFile): @classmethod diff --git a/tests/forms.py b/tests/forms.py index c70347d..ff3927f 100644 --- a/tests/forms.py +++ b/tests/forms.py @@ -7,3 +7,9 @@ class ThumbnailModelForm(forms.ModelForm): class Meta: model = models.ThumbnailModel fields = "__all__" + + +class MinSizeModelForm(forms.ModelForm): + class Meta: + model = models.MinSizeModel + fields = "__all__" diff --git a/tests/models.py b/tests/models.py index 68a702f..4e91627 100644 --- a/tests/models.py +++ b/tests/models.py @@ -95,7 +95,11 @@ class MaxSizeModel(models.Model): class MinSizeModel(models.Model): - image = StdImageField(upload_to=upload_to, validators=[MinSizeValidator(200, 200)]) + image = StdImageField( + upload_to=upload_to, + delete_orphans=True, + validators=[MinSizeValidator(200, 200)], + ) class ForceMinSizeModel(models.Model): diff --git a/tests/settings.py b/tests/settings.py index 03ebbd9..480a744 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -45,4 +45,4 @@ SECRET_KEY = "foobar" -USE_L10N = True +USE_TZ = True diff --git a/tests/test_forms.py b/tests/test_forms.py index 81b3360..03629b0 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -45,3 +45,16 @@ def test_save_form_data__none(self, db): obj = form.save() assert obj.image assert os.path.exists(org_path) + + def test_save_form_data__invalid(self, db): + instance = models.MinSizeModel.objects.create( + image=self.fixtures["600x400.jpg"] + ) + org_path = instance.image.path + assert os.path.exists(org_path) + form = forms.MinSizeModelForm( + files={"image": self.fixtures["100.gif"]}, + instance=instance, + ) + assert not form.is_valid() + assert os.path.exists(org_path) diff --git a/tests/test_models.py b/tests/test_models.py index 772737d..e234521 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,13 +1,17 @@ import io import os import time +from copy import deepcopy import pytest from django.conf import settings from django.core.files.storage import default_storage from django.core.files.uploadedfile import SimpleUploadedFile +from django.db.models.fields.files import ImageFieldFile from PIL import Image +from stdimage.models import StdImageFieldFile + from . import models from .models import ( AdminDeleteModel, @@ -170,6 +174,61 @@ def test_defer(self, db, django_assert_num_queries): deferred.image assert instance.image.thumbnail == deferred.image.thumbnail + @pytest.mark.django_db + def test_variations_deepcopy_unsaved(self): + instance_original = ResizeModel(image=self.fixtures["600x400.jpg"]) + instance = deepcopy(instance_original) + assert isinstance(instance.image, StdImageFieldFile) + assert instance.image == instance_original.image + + @pytest.mark.django_db + def test_variations_deepcopy_without_image(self): + instance_original = ThumbnailModel.objects.create(image=None) + instance = deepcopy(instance_original) + assert isinstance(instance.image, StdImageFieldFile) + assert instance.image == instance_original.image + + @pytest.mark.django_db + def test_variations_deepcopy(self): + """Tests test_variations() with a deep copied object""" + instance_original = ResizeModel.objects.create( + image=self.fixtures["600x400.jpg"] + ) + instance = deepcopy(instance_original) + assert isinstance(instance.image, StdImageFieldFile) + + assert hasattr(instance.image, "thumbnail") + assert hasattr(instance.image, "medium") + + assert isinstance(instance.image.thumbnail, ImageFieldFile) + assert isinstance(instance.image.medium, ImageFieldFile) + + source_file = self.fixtures["600x400.jpg"] + + assert os.path.exists(os.path.join(IMG_DIR, "600x400.jpg")) + assert instance.image.width == 600 + assert instance.image.height == 400 + path = os.path.join(IMG_DIR, "600x400.jpg") + + with open(path, "rb") as f: + source_file.seek(0) + assert source_file.read() == f.read() + + path = os.path.join(IMG_DIR, "600x400.medium.jpg") + assert os.path.exists(path) + assert instance.image.medium.width == 400 + assert instance.image.medium.height <= 400 + with open(os.path.join(IMG_DIR, "600x400.medium.jpg"), "rb") as f: + source_file.seek(0) + assert source_file.read() != f.read() + + assert os.path.exists(os.path.join(IMG_DIR, "600x400.thumbnail.jpg")) + assert instance.image.thumbnail.width == 100 + assert instance.image.thumbnail.height <= 75 + with open(os.path.join(IMG_DIR, "600x400.thumbnail.jpg"), "rb") as f: + source_file.seek(0) + assert source_file.read() != f.read() + class TestUtils(TestStdImage): """Tests Utils""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 2ab2c68..7180204 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ import os import pytest -from PIL import Image +from PIL.Image import Resampling from stdimage.utils import render_variations from tests.models import ManualVariationsModel @@ -24,7 +24,7 @@ def test_render_variations(self, image_upload_file): "width": 150, "height": 150, "crop": True, - "resample": Image.ANTIALIAS, + "resample": Resampling.LANCZOS, } }, )