diff --git a/.checks.yml b/.checks.yml deleted file mode 100644 index 288e631..0000000 --- a/.checks.yml +++ /dev/null @@ -1,3 +0,0 @@ -- bandit -- flake8 -- pydocstyle diff --git a/.fussyfox.yml b/.fussyfox.yml deleted file mode 100644 index 600df19..0000000 --- a/.fussyfox.yml +++ /dev/null @@ -1,4 +0,0 @@ -- bandit -- flake8 -- isort -- pydocstyle diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 71607d0..d232a23 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,9 @@ version: 2 updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily - package-ecosystem: github-actions directory: "/" schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9ad71f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + + msgcheck: + runs-on: ubuntu-latest + steps: + - 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') }} + restore-keys: | + ${{ runner.os }}-pip- + - run: python -m pip install -r lint-requirements.txt + - run: msgcheck -n stdimage/locale/*/LC_MESSAGES/*.po + + lint: + strategy: + fail-fast: false + matrix: + lint-command: + - "bandit -r . -x ./tests" + - "black --check --diff ." + - "flake8 ." + - "isort --check-only --diff ." + - "pydocstyle ." + runs-on: ubuntu-latest + steps: + - 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') }} + restore-keys: | + ${{ runner.os }}-pip- + - run: python -m pip install -r lint-requirements.txt + - run: ${{ matrix.lint-command }} + + dist: + runs-on: ubuntu-latest + steps: + - 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@v3 + with: + path: dist/* + + pytest: + runs-on: ubuntu-latest + needs: + - lint + - msgcheck + strategy: + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + django-version: + - "3.2" + - "4.0" + extra: + - "test" + - "test,progressbar" + steps: + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - run: sudo apt install gettext -y + - 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 }}a + - run: python -m pytest + - run: codecov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02fcd4c..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@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + - 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/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 026496f..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: CI - -on: - push: - branches: - - master - pull_request: - -jobs: - - dist: - runs-on: ubuntu-latest - steps: - - name: Install gettext - run: sudo apt-get install gettext -y - - uses: actions/setup-python@v2 - - run: python -m pip install --upgrade pip setuptools wheel twine readme-renderer - - uses: actions/checkout@v2 - - run: python setup.py sdist bdist_wheel - - run: python -m twine check dist/* - - pytest: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: - - "3.7" - - "3.8" - - "3.9" - django-version: - - "2.2" - - "3.1" - steps: - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - uses: actions/checkout@v1 - - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools codecov - pip install django~=${{ matrix.django-version }} - - name: Test with pytest - run: python setup.py test - - run: codecov diff --git a/MANIFEST.in b/MANIFEST.in index 6f39718..8fb7fc9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include stdimage/locale/*/LC_MESSAGES/django.mo prune tests prune .github exclude .* +exclude lint-requirements.txt 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 new file mode 100644 index 0000000..e0936be --- /dev/null +++ b/lint-requirements.txt @@ -0,0 +1,6 @@ +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 4255691..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,7 +20,14 @@ classifier = Topic :: Software Development Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Framework :: Django + Framework :: Django :: 3.2 + Framework :: Django :: 4.0 + +python_requires = >=3.8 [options] include_package_data = True @@ -28,14 +35,9 @@ packages = stdimage install_requires = Django>=2.2 pillow>=2.5 - progressbar2>=3.0.0 + setup_requires = setuptools_scm - pytest-runner -tests_require = - pytest - pytest-cov - pytest-django [options.package_data] * = *.txt, *.rst, *.html, *.po @@ -44,36 +46,24 @@ tests_require = 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 --cov-report xml --tb=short -rxs --nomigrations - -[tox:tox] -envlist = py{36,37}-dj{111,22,master} - -[testenv] -deps = - dj111: https://github.com/django/django/archive/stable/1.11.x.tar.gz#egg=django - dj22: https://github.com/django/django/archive/stable/2.2.x.tar.gz#egg=django - djmaster: https://github.com/django/django/archive/master.tar.gz#egg=django -commands = python setup.py test - -[flake8] -max-line-length = 88 -statistics = true -show-source = true -exclude = */migrations/*,docs/*,env/*,venv/*,.tox/*,.eggs - -[pydocstyle] -add-ignore = D1 -match-dir = (?!tests|env|docs|\.).* +addopts = --cov=stdimage --nomigrations --tb=short +filterwarnings = + ignore::DeprecationWarning [coverage:run] source = . @@ -87,10 +77,22 @@ omit = ignore_errors = True show_missing = True +[flake8] +max_line_length=88 +select = C,E,F,W,B,B950 +ignore = E203, E501, W503 + +[pydocstyle] +add-ignore = D1 +match-dir = (?!tests|env|docs|\.).* + [isort] atomic = true line_length = 88 known_first_party = stdimage, tests include_trailing_comma = True +multi_line_output = 3 +force_grid_wrap = 0 +use_parentheses = True default_section=THIRDPARTY combine_as_imports = true diff --git a/setup.py b/setup.py index abf1228..e19500b 100755 --- a/setup.py +++ b/setup.py @@ -5,53 +5,54 @@ import subprocess # nosec from distutils.cmd import Command from distutils.command.build import build as _build +from distutils.command.install import install as _install from setuptools import setup -from setuptools.command.install_lib import install_lib as _install_lib BASE_DIR = os.path.dirname((os.path.abspath(__file__))) class compile_translations(Command): - description = 'Compile i18n translations using gettext.' + description = "Compile i18n translations using gettext." user_options = [] def initialize_options(self): - pass + self.build_lib = None def finalize_options(self): - pass + self.set_undefined_options("build", ("build_lib", "build_lib")) def run(self): - pattern = 'stdimage/locale/*/LC_MESSAGES/django.po' + pattern = "stdimage/locale/*/LC_MESSAGES/django.po" for file in glob.glob(pattern): - cmd = ['msgfmt', '-c'] name, ext = os.path.splitext(file) - - cmd += ['-o', '%s.mo' % name] - cmd += ['%s.po' % name] + cmd = ["msgfmt", "-c", "-o", f"{self.build_lib}/{name}.mo", file] self.announce( - 'running command: %s' % ' '.join(cmd), - level=distutils.log.INFO) + "running command: %s" % " ".join(cmd), level=distutils.log.INFO + ) subprocess.check_call(cmd, cwd=BASE_DIR) # nosec class build(_build): - sub_commands = [('compile_translations', None)] + _build.sub_commands + sub_commands = [ + *_build.sub_commands, + ("compile_translations", None), + ] -class install_lib(_install_lib): - def run(self): - self.run_command('compile_translations') - _install_lib.run(self) +class install(_install): + sub_commands = [ + *_install.sub_commands, + ("compile_translations", None), + ] setup( - name='django-stdimage', + name="django-stdimage", use_scm_version=True, cmdclass={ - 'build': build, - 'install_lib': install_lib, - 'compile_translations': compile_translations, + "build": build, + "install": install, + "compile_translations": compile_translations, }, ) diff --git a/stdimage/management/commands/rendervariations.py b/stdimage/management/commands/rendervariations.py index 2ba3df3..bcc852e 100644 --- a/stdimage/management/commands/rendervariations.py +++ b/stdimage/management/commands/rendervariations.py @@ -1,4 +1,3 @@ -import progressbar from django.apps import apps from django.core.files.storage import get_storage_class from django.core.management import BaseCommand, CommandError @@ -7,44 +6,48 @@ class Command(BaseCommand): - help = 'Renders all variations of a StdImageField.' - args = '' + help = "Renders all variations of a StdImageField." + args = "" def add_arguments(self, parser): - parser.add_argument('field_path', - nargs='+', - type=str, - help='') - parser.add_argument('--replace', - action='store_true', - dest='replace', - default=False, - help='Replace existing files.') + parser.add_argument( + "field_path", nargs="+", type=str, help="" + ) + parser.add_argument( + "--replace", + action="store_true", + dest="replace", + default=False, + help="Replace existing files.", + ) - parser.add_argument('-i', '--ignore-missing', - action='store_true', - dest='ignore_missing', - default=False, - help='Ignore missing source file error and ' - 'skip render for that file') + parser.add_argument( + "-i", + "--ignore-missing", + action="store_true", + dest="ignore_missing", + default=False, + help="Ignore missing source file error and " "skip render for that file", + ) def handle(self, *args, **options): - replace = options.get('replace', False) - ignore_missing = options.get('ignore_missing', False) - routes = options.get('field_path', []) + replace = options.get("replace", False) + ignore_missing = options.get("ignore_missing", False) + routes = options.get("field_path", []) for route in routes: try: - app_label, model_name, field_name = route.rsplit('.') + app_label, model_name, field_name = route.rsplit(".") except ValueError: - raise CommandError("Error parsing field_path '{}'. Use format " - "." - .format(route)) + raise CommandError( + "Error parsing field_path '{}'. Use format " + ".".format(route) + ) model_class = apps.get_model(app_label, model_name) field = model_class._meta.get_field(field_name) - queryset = model_class._default_manager \ - .exclude(**{'%s__isnull' % field_name: True}) \ - .exclude(**{field_name: ''}) + queryset = model_class._default_manager.exclude( + **{"%s__isnull" % field_name: True} + ).exclude(**{field_name: ""}) obj = queryset.first() do_render = True if obj: @@ -53,11 +56,9 @@ def handle(self, *args, **options): images = queryset.values_list(field_name, flat=True).iterator() count = queryset.count() - self.render(field, images, count, replace, ignore_missing, - do_render) + self.render(field, images, count, replace, ignore_missing, do_render) - @staticmethod - def render(field, images, count, replace, ignore_missing, do_render): + def render(self, field, images, count, replace, ignore_missing, do_render): kwargs_list = ( dict( file_name=file_name, @@ -70,28 +71,43 @@ def render(field, images, count, replace, ignore_missing, do_render): ) for file_name in images ) - with progressbar.ProgressBar(max_value=count, widgets=( - progressbar.RotatingMarker(), - ' | ', progressbar.AdaptiveETA(), - ' | ', progressbar.Percentage(), - ' ', progressbar.Bar(), - )) as bar: - for _ in map(render_field_variations, kwargs_list): - bar += 1 + try: + import progressbar + except ImportError: + for file_name in map(render_field_variations, kwargs_list): + self.stdout.write(f"Processing: {file_name}", self.style.NOTICE) + else: + with progressbar.ProgressBar( + max_value=count, + widgets=( + progressbar.RotatingMarker(), + " | ", + progressbar.AdaptiveETA(), + " | ", + progressbar.Percentage(), + " ", + progressbar.Bar(), + ), + ) as bar: + for _ in map(render_field_variations, kwargs_list): + bar += 1 def render_field_variations(kwargs): - kwargs['storage'] = get_storage_class(kwargs['storage'])() - ignore_missing = kwargs.pop('ignore_missing') - do_render = kwargs.pop('do_render') + kwargs["storage"] = get_storage_class(kwargs["storage"])() + ignore_missing = kwargs.pop("ignore_missing") + do_render = kwargs.pop("do_render") try: if callable(do_render): - kwargs.pop('field_class') + kwargs.pop("field_class") do_render = do_render(**kwargs) if do_render: render_variations(**kwargs) except FileNotFoundError as e: if not ignore_missing: + print(ignore_missing) raise CommandError( - 'Source file was not found, terminating. ' - 'Use -i/--ignore-missing to skip this error.') from e + "Source file was not found, terminating. " + "Use -i/--ignore-missing to skip this error." + ) from e + return kwargs["file_name"] diff --git a/stdimage/models.py b/stdimage/models.py index e1528b6..dd429d8 100644 --- a/stdimage/models.py +++ b/stdimage/models.py @@ -1,19 +1,32 @@ import logging import os +import warnings from io import BytesIO from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.db.models import signals -from django.db.models.fields.files import (ImageField, ImageFieldFile, - ImageFileDescriptor,) +from django.db.models.fields.files import ( + ImageField, + ImageFieldFile, + 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.""" @@ -37,16 +50,15 @@ def save(self, name, content, save=True): if not isinstance(render_variations, bool): msg = ( '"render_variations" callable expects a boolean return value,' - ' but got %s' - ) % type(render_variations) + " but got %s" + ) % type(render_variations) raise TypeError(msg) if render_variations: self.render_variations() @staticmethod def is_smaller(img, variation): - return img.size[0] > variation['width'] \ - or img.size[1] > variation['height'] + return img.size[0] > variation["width"] or img.size[1] > variation["height"] def render_variations(self, replace=True): """Render all image variations and saves them to the storage.""" @@ -54,11 +66,12 @@ def render_variations(self, replace=True): self.render_variation(self.name, variation, replace, self.storage) @classmethod - def render_variation(cls, file_name, variation, replace=True, - storage=default_storage): + def render_variation( + cls, file_name, variation, replace=True, storage=default_storage + ): """Render an image variation and saves it to the storage.""" - variation_name = cls.get_variation_name(file_name, variation['name']) - file_overwrite = getattr(storage, 'file_overwrite', False) + variation_name = cls.get_variation_name(file_name, variation["name"]) + file_overwrite = getattr(storage, "file_overwrite", False) if not replace and storage.exists(variation_name): logger.info('File "%s" already exists.', variation_name) return variation_name @@ -83,47 +96,38 @@ def process_variation(cls, variation, image): """Process variation before actual saving.""" save_kargs = {} file_format = image.format - save_kargs['format'] = file_format + save_kargs["format"] = file_format - resample = variation['resample'] + resample = variation["resample"] if cls.is_smaller(image, variation): factor = 1 - while image.size[0] / factor \ - > 2 * variation['width'] \ - and image.size[1] * 2 / factor \ - > 2 * variation['height']: + while ( + image.size[0] / factor > 2 * variation["width"] + and image.size[1] * 2 / factor > 2 * variation["height"] + ): factor *= 2 if factor > 1: image.thumbnail( - (int(image.size[0] / factor), - int(image.size[1] / factor)), - resample=resample + (int(image.size[0] / factor), int(image.size[1] / factor)), + resample=resample, ) - size = variation['width'], variation['height'] - size = tuple(int(i) if i is not None else i - for i in size) + size = variation["width"], variation["height"] + size = tuple(int(i) if i is not None else i for i in size) - if file_format == 'JPEG': + if file_format == "JPEG": # http://stackoverflow.com/a/21669827 - image = image.convert('RGB') - save_kargs['optimize'] = True - save_kargs['quality'] = 'web_high' + image = image.convert("RGB") + save_kargs["optimize"] = True + save_kargs["quality"] = "web_high" if size[0] * size[1] > 10000: # roughly <10kb - save_kargs['progressive'] = True + save_kargs["progressive"] = True - if variation['crop']: - image = ImageOps.fit( - image, - size, - method=resample - ) + if variation["crop"]: + image = ImageOps.fit(image, size, method=resample) else: - image.thumbnail( - size, - resample=resample - ) + image.thumbnail(size, resample=resample) return image, save_kargs @@ -132,11 +136,13 @@ def get_variation_name(cls, file_name, variation_name): """Return the variation file name based on the variation.""" path, ext = os.path.splitext(file_name) path, file_name = os.path.split(path) - file_name = '{file_name}.{variation_name}{extension}'.format(**{ - 'file_name': file_name, - 'variation_name': variation_name, - 'extension': ext, - }) + file_name = "{file_name}.{variation_name}{extension}".format( + **{ + "file_name": file_name, + "variation_name": variation_name, + "extension": ext, + } + ) return os.path.join(path, file_name) def delete(self, save=True): @@ -148,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): """ @@ -168,15 +193,22 @@ class StdImageField(ImageField): descriptor_class = StdImageFileDescriptor attr_class = StdImageFieldFile def_variation = { - 'width': None, - 'height': None, - 'crop': False, - 'resample': Image.ANTIALIAS, + "width": None, + "height": None, + "crop": False, + "resample": Resampling.LANCZOS, } - def __init__(self, verbose_name=None, name=None, variations=None, - render_variations=True, force_min_size=False, delete_orphans=False, - **kwargs): + def __init__( + self, + verbose_name=None, + name=None, + variations=None, + render_variations=True, + force_min_size=False, + delete_orphans=False, + **kwargs, + ): """ Standardized ImageField for Django. @@ -207,13 +239,12 @@ def __init__(self, verbose_name=None, name=None, variations=None, if not variations: variations = {} if not isinstance(variations, dict): - msg = ('"variations" expects a dict,' - ' but got %s') % type(variations) + msg = ('"variations" expects a dict,' " but got %s") % type(variations) raise TypeError(msg) - if not (isinstance(render_variations, bool) or - callable(render_variations)): - msg = ('"render_variations" excepts a boolean or callable,' - ' but got %s') % type(render_variations) + if not (isinstance(render_variations, bool) or callable(render_variations)): + msg = ( + '"render_variations" excepts a boolean or callable,' " but got %s" + ) % type(render_variations) raise TypeError(msg) self._variations = variations @@ -227,14 +258,15 @@ def __init__(self, verbose_name=None, name=None, variations=None, if self.variations and self.force_min_size: self.min_size = ( - max(self.variations.values(), - key=lambda x: x["width"])["width"], - max(self.variations.values(), - key=lambda x: x["height"])["height"] + max(self.variations.values(), key=lambda x: x["width"])["width"], + max(self.variations.values(), key=lambda x: x["height"])["height"], ) 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"] = {} @@ -260,12 +292,9 @@ def set_variations(self, instance=None, **kwargs): if field._committed: for name, variation in list(self.variations.items()): variation_name = self.attr_class.get_variation_name( - field.name, - variation['name'] + field.name, variation["name"] ) - variation_field = ImageFieldFile(instance, - self, - variation_name) + variation_field = ImageFieldFile(instance, self, variation_name) setattr(field, name, variation_field) def post_delete_callback(self, sender, instance, **kwargs): @@ -284,73 +313,83 @@ def validate(self, value, model_instance): MinSizeValidator(self.min_size[0], self.min_size[1])(value) def save_form_data(self, instance, data): - if self.delete_orphans and self.blank and (data is False or data is not None): + 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): +class JPEGFieldFile(StdImageFieldFile): @classmethod def get_variation_name(cls, file_name, variation_name): path = super().get_variation_name(file_name, variation_name) path, ext = os.path.splitext(path) - return '%s.jpeg' % path + return "%s.jpeg" % path @classmethod def process_variation(cls, variation, image): """Process variation before actual saving.""" save_kargs = {} - file_format = 'JPEG' - save_kargs['format'] = file_format - - resample = variation['resample'] + file_format = "JPEG" + save_kargs["format"] = file_format - if variation['width'] is None: - variation['width'] = image.size[0] + resample = variation["resample"] - if variation['height'] is None: - variation['height'] = image.size[1] + width = image.size[0] if variation["width"] is None else variation["width"] + height = image.size[1] if variation["height"] is None else variation["height"] factor = 1 - while image.size[0] / factor \ - > 2 * variation['width'] \ - and image.size[1] * 2 / factor \ - > 2 * variation['height']: + while ( + image.size[0] / factor > 2 * width + and image.size[1] * 2 / factor > 2 * height + ): factor *= 2 if factor > 1: image.thumbnail( - (int(image.size[0] / factor), - int(image.size[1] / factor)), - resample=resample + (int(image.size[0] / factor), int(image.size[1] / factor)), + resample=resample, ) - size = variation['width'], variation['height'] - size = tuple(int(i) if i is not None else i - for i in size) + size = width, height + size = tuple(int(i) if i is not None else i for i in size) # http://stackoverflow.com/a/21669827 - image = image.convert('RGB') - save_kargs['optimize'] = True - save_kargs['quality'] = 'web_high' + image = image.convert("RGB") + save_kargs["optimize"] = True + save_kargs["quality"] = "web_high" if size[0] * size[1] > 10000: # roughly <10kb - save_kargs['progressive'] = True + save_kargs["progressive"] = True - if variation['crop']: - image = ImageOps.fit( - image, - size, - method=resample - ) + if variation["crop"]: + image = ImageOps.fit(image, size, method=resample) else: - image.thumbnail( - size, - resample=resample - ) + image.thumbnail(size, resample=resample) - save_kargs.update(variation['kwargs']) + save_kargs.update(variation["kwargs"]) return image, save_kargs diff --git a/stdimage/utils.py b/stdimage/utils.py index 667ab74..183289a 100644 --- a/stdimage/utils.py +++ b/stdimage/utils.py @@ -3,10 +3,13 @@ from .models import StdImageFieldFile -def render_variations(file_name, variations, replace=False, - storage=default_storage, field_class=StdImageFieldFile): +def render_variations( + file_name, + variations, + replace=False, + storage=default_storage, + field_class=StdImageFieldFile, +): """Render all variations for a given field.""" for key, variation in variations.items(): - field_class.render_variation( - file_name, variation, replace, storage - ) + field_class.render_variation(file_name, variation, replace, storage) diff --git a/stdimage/validators.py b/stdimage/validators.py index b609c55..257374a 100644 --- a/stdimage/validators.py +++ b/stdimage/validators.py @@ -13,14 +13,14 @@ def compare(self, x): return True def __init__(self, width, height): - self.limit_value = width or float('inf'), height or float('inf') + self.limit_value = width or float("inf"), height or float("inf") def __call__(self, value): cleaned = self.clean(value) if self.compare(cleaned, self.limit_value): params = { - 'width': self.limit_value[0], - 'height': self.limit_value[1], + "width": self.limit_value[0], + "height": self.limit_value[1], } raise ValidationError(self.message, code=self.code, params=params) @@ -42,10 +42,13 @@ class MaxSizeValidator(BaseSizeValidator): def compare(self, img_size, max_size): return img_size[0] > max_size[0] or img_size[1] > max_size[1] - message = _('The image you uploaded is too large.' - ' The required maximum resolution is:' - ' %(width)sx%(height)s px.') - code = 'max_resolution' + + message = _( + "The image you uploaded is too large." + " The required maximum resolution is:" + " %(width)sx%(height)s px." + ) + code = "max_resolution" class MinSizeValidator(BaseSizeValidator): @@ -57,6 +60,9 @@ class MinSizeValidator(BaseSizeValidator): def compare(self, img_size, min_size): return img_size[0] < min_size[0] or img_size[1] < min_size[1] - message = _('The image you uploaded is too small.' - ' The required minimum resolution is:' - ' %(width)sx%(height)s px.') + + message = _( + "The image you uploaded is too small." + " The required minimum resolution is:" + " %(width)sx%(height)s px." + ) diff --git a/tests/admin.py b/tests/admin.py index 0abab8b..da8f70a 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -3,6 +3,7 @@ from . import models admin.site.register(models.AdminDeleteModel) +admin.site.register(models.AdminUpdateModel) admin.site.register(models.ResizeCropModel) admin.site.register(models.ResizeModel) admin.site.register(models.SimpleModel) diff --git a/tests/conftest.py b/tests/conftest.py index 33ebae0..bcce969 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,17 +7,14 @@ @pytest.fixture def imagedata(): - img = Image.new('RGB', (250, 250), (255, 55, 255)) + img = Image.new("RGB", (250, 250), (255, 55, 255)) output = io.BytesIO() - img.save(output, format='JPEG') + img.save(output, format="JPEG") return output @pytest.fixture def image_upload_file(imagedata): - return SimpleUploadedFile( - 'image.jpg', - imagedata.getvalue() - ) + return SimpleUploadedFile("image.jpg", imagedata.getvalue()) diff --git a/tests/forms.py b/tests/forms.py index 0e660fd..ff3927f 100644 --- a/tests/forms.py +++ b/tests/forms.py @@ -4,7 +4,12 @@ class ThumbnailModelForm(forms.ModelForm): - class Meta: model = models.ThumbnailModel - fields = '__all__' + fields = "__all__" + + +class MinSizeModelForm(forms.ModelForm): + class Meta: + model = models.MinSizeModel + fields = "__all__" diff --git a/tests/models.py b/tests/models.py index 34a4255..4e91627 100644 --- a/tests/models.py +++ b/tests/models.py @@ -10,93 +10,109 @@ from stdimage.utils import render_variations from stdimage.validators import MaxSizeValidator, MinSizeValidator -upload_to = 'img/' +upload_to = "img/" class SimpleModel(models.Model): """works as ImageField""" + image = StdImageField(upload_to=upload_to) class AdminDeleteModel(models.Model): """can be deleted through admin""" + image = StdImageField( upload_to=upload_to, variations={ - 'thumbnail': (100, 75), + "thumbnail": (100, 75), }, blank=True, delete_orphans=True, ) +class AdminUpdateModel(models.Model): + """can be updated through admin, image not optional""" + + image = StdImageField( + upload_to=upload_to, + variations={ + "thumbnail": (100, 75), + }, + blank=False, + delete_orphans=True, + ) + + class ResizeModel(models.Model): """resizes image to maximum size to fit a 640x480 area""" + image = StdImageField( upload_to=upload_to, variations={ - 'medium': {'width': 400, 'height': 400}, - 'thumbnail': (100, 75), - } + "medium": {"width": 400, "height": 400}, + "thumbnail": (100, 75), + }, ) class ResizeCropModel(models.Model): """resizes image to 640x480 cropping if necessary""" + image = StdImageField( - upload_to=upload_to, - variations={'thumbnail': (150, 150, True)} + upload_to=upload_to, variations={"thumbnail": (150, 150, True)} ) class ThumbnailModel(models.Model): """creates a thumbnail resized to maximum size to fit a 100x75 area""" + image = StdImageField( upload_to=upload_to, blank=True, - variations={'thumbnail': (100, 75)}, + variations={"thumbnail": (100, 75)}, delete_orphans=True, ) class JPEGModel(models.Model): """creates a thumbnail resized to maximum size to fit a 100x75 area""" + image = JPEGField( upload_to=upload_to, blank=True, variations={ - 'full': (None, None), - 'thumbnail': (100, 75, True), - }, + "full": (None, None), + "thumbnail": (100, 75, True), + }, delete_orphans=True, ) class MaxSizeModel(models.Model): - image = StdImageField( - upload_to=upload_to, - validators=[MaxSizeValidator(16, 16)] - ) + image = StdImageField(upload_to=upload_to, validators=[MaxSizeValidator(16, 16)]) class MinSizeModel(models.Model): image = StdImageField( upload_to=upload_to, - validators=[MinSizeValidator(200, 200)] + delete_orphans=True, + validators=[MinSizeValidator(200, 200)], ) class ForceMinSizeModel(models.Model): """creates a thumbnail resized to maximum size to fit a 100x75 area""" + image = StdImageField( - upload_to=upload_to, - force_min_size=True, - variations={'thumbnail': (600, 600)} + upload_to=upload_to, force_min_size=True, variations={"thumbnail": (600, 600)} ) class CustomManager(models.Manager): """Just like Django's default, but a different class.""" + pass @@ -109,18 +125,20 @@ class Meta: class ManualVariationsModel(CustomManagerModel): """delays creation of 150x150 thumbnails until it is called manually""" + image = StdImageField( upload_to=upload_to, - variations={'thumbnail': (150, 150, True)}, - render_variations=False + variations={"thumbnail": (150, 150, True)}, + render_variations=False, ) class MyStorageModel(CustomManagerModel): """delays creation of 150x150 thumbnails until it is called manually""" + image = StdImageField( upload_to=upload_to, - variations={'thumbnail': (150, 150, True)}, + variations={"thumbnail": (150, 150, True)}, storage=FileSystemStorage(), ) @@ -132,18 +150,20 @@ def render_job(**kwargs): class UtilVariationsModel(models.Model): """delays creation of 150x150 thumbnails until it is called manually""" + image = StdImageField( upload_to=upload_to, - variations={'thumbnail': (150, 150, True)}, - render_variations=render_job + variations={"thumbnail": (150, 150, True)}, + render_variations=render_job, ) class ThumbnailWithoutDirectoryModel(models.Model): """Save into a generated filename that does not contain any '/' char""" + image = StdImageField( - upload_to=lambda instance, filename: 'custom.gif', - variations={'thumbnail': {'width': 150, 'height': 150}}, + upload_to=lambda instance, filename: "custom.gif", + variations={"thumbnail": {"width": 150, "height": 150}}, ) @@ -151,8 +171,7 @@ def custom_render_variations(file_name, variations, storage, replace=False): """Resize image to 100x100.""" for _, variation in variations.items(): variation_name = StdImageFieldFile.get_variation_name( - file_name, - variation['name'] + file_name, variation["name"] ) if storage.exists(variation_name): storage.delete(variation_name) @@ -163,7 +182,7 @@ def custom_render_variations(file_name, variations, storage, replace=False): img = img.resize(size) with BytesIO() as file_buffer: - img.save(file_buffer, 'JPEG') + img.save(file_buffer, "JPEG") f = ContentFile(file_buffer.getvalue()) storage.save(variation_name, f) @@ -175,6 +194,6 @@ class CustomRenderVariationsModel(models.Model): image = StdImageField( upload_to=upload_to, - variations={'thumbnail': (150, 150)}, + variations={"thumbnail": (150, 150)}, render_variations=custom_render_variations, ) diff --git a/tests/settings.py b/tests/settings.py index 81d782a..480a744 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -7,42 +7,42 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'stdimage', - 'tests' + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "stdimage", + "tests", ) -DEFAULT_FILE_STORAGE = 'tests.storage.MyFileSystemStorage' +DEFAULT_FILE_STORAGE = "tests.storage.MyFileSystemStorage" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'APP_DIRS': True, + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, } ] MIDDLEWARE = MIDDLEWARE_CLASSES = ( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", ) MEDIA_ROOT = tempfile.mkdtemp() SITE_ID = 1 -ROOT_URLCONF = 'tests.urls' +ROOT_URLCONF = "tests.urls" -SECRET_KEY = 'foobar' +SECRET_KEY = "foobar" -USE_L10N = True +USE_TZ = True diff --git a/tests/test_commands.py b/tests/test_commands.py index 1e0b8b0..0ef69b1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -11,45 +11,30 @@ @pytest.mark.django_db class TestRenderVariations: - @pytest.fixture(autouse=True) def _swap_concurrent_executor(self, monkeypatch): """Use ThreadPoolExecutor for coverage reports.""" monkeypatch.setattr( - 'concurrent.futures.ProcessPoolExecutor', + "concurrent.futures.ProcessPoolExecutor", ThreadPoolExecutor, ) def test_no_options(self, image_upload_file): - obj = ThumbnailModel.objects.create( - image=image_upload_file - ) + obj = ThumbnailModel.objects.create(image=image_upload_file) file_path = obj.image.thumbnail.path obj.image.delete_variations() - call_command( - 'rendervariations', - 'tests.ThumbnailModel.image' - ) + call_command("rendervariations", "tests.ThumbnailModel.image") assert os.path.exists(file_path) def test_multiprocessing(self, image_upload_file): objs = [ - ThumbnailModel.objects.create( - image=image_upload_file - ) - for _ in range(100) + ThumbnailModel.objects.create(image=image_upload_file) for _ in range(100) ] - file_names = [ - obj.image.thumbnail.path - for obj in objs - ] + file_names = [obj.image.thumbnail.path for obj in objs] for obj in objs: obj.image.delete_variations() assert not any([os.path.exists(f) for f in file_names]) - call_command( - 'rendervariations', - 'tests.ThumbnailModel.image' - ) + call_command("rendervariations", "tests.ThumbnailModel.image") assert any([os.path.exists(f) for f in file_names]) def test_no_replace(self, image_upload_file): @@ -59,8 +44,8 @@ def test_no_replace(self, image_upload_file): before = os.path.getmtime(file_path) time.sleep(0.1) call_command( - 'rendervariations', - 'tests.ThumbnailModel.image', + "rendervariations", + "tests.ThumbnailModel.image", ) assert os.path.exists(file_path) after = os.path.getmtime(file_path) @@ -72,11 +57,7 @@ def test_replace(self, image_upload_file): assert os.path.exists(file_path) before = os.path.getmtime(file_path) time.sleep(0.1) - call_command( - 'rendervariations', - 'tests.ThumbnailModel.image', - replace=True - ) + call_command("rendervariations", "tests.ThumbnailModel.image", replace=True) assert os.path.exists(file_path) after = os.path.getmtime(file_path) assert before != after @@ -89,9 +70,9 @@ def test_ignore_missing(self, image_upload_file): assert not os.path.exists(file_path) time.sleep(0.1) call_command( - 'rendervariations', - 'tests.ThumbnailModel.image', - '--ignore-missing', + "rendervariations", + "tests.ThumbnailModel.image", + "--ignore-missing", replace=True, ) @@ -103,9 +84,9 @@ def test_short_ignore_missing(self, image_upload_file): assert not os.path.exists(file_path) time.sleep(0.1) call_command( - 'rendervariations', - 'tests.ThumbnailModel.image', - '-i', + "rendervariations", + "tests.ThumbnailModel.image", + "-i", replace=True, ) @@ -118,48 +99,40 @@ def test_no_ignore_missing(self, image_upload_file): time.sleep(0.1) with pytest.raises(CommandError): call_command( - 'rendervariations', - 'tests.ThumbnailModel.image', + "rendervariations", + "tests.ThumbnailModel.image", replace=True, ) def test_none_default_storage(self, image_upload_file): - obj = MyStorageModel.customer_manager.create( - image=image_upload_file - ) + obj = MyStorageModel.customer_manager.create(image=image_upload_file) file_path = obj.image.thumbnail.path obj.image.delete_variations() - call_command( - 'rendervariations', - 'tests.MyStorageModel.image' - ) + call_command("rendervariations", "tests.MyStorageModel.image") assert os.path.exists(file_path) def test_invalid_field_path(self): with pytest.raises(CommandError) as exc_info: - call_command( - 'rendervariations', - 'MyStorageModel.image' - ) + call_command("rendervariations", "MyStorageModel.image") - error_message = "Error parsing field_path 'MyStorageModel.image'. "\ - "Use format ." + error_message = ( + "Error parsing field_path 'MyStorageModel.image'. " + "Use format ." + ) assert str(exc_info.value) == error_message def test_custom_render_variations(self, image_upload_file): - obj = CustomRenderVariationsModel.objects.create( - image=image_upload_file - ) + obj = CustomRenderVariationsModel.objects.create(image=image_upload_file) file_path = obj.image.thumbnail.path assert os.path.exists(file_path) - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: before = hashlib.md5(f.read()).hexdigest() call_command( - 'rendervariations', - 'tests.CustomRenderVariationsModel.image', + "rendervariations", + "tests.CustomRenderVariationsModel.image", replace=True, ) assert os.path.exists(file_path) - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: after = hashlib.md5(f.read()).hexdigest() assert before == after diff --git a/tests/test_forms.py b/tests/test_forms.py index 1420d2c..03629b0 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -6,27 +6,26 @@ class TestStdImageField(TestStdImage): - def test_save_form_data__new(self, db): - instance = models.ThumbnailModel.objects.create(image=self.fixtures['100.gif']) + instance = models.ThumbnailModel.objects.create(image=self.fixtures["100.gif"]) org_path = instance.image.path assert os.path.exists(org_path) form = forms.ThumbnailModelForm( - files=dict(image=self.fixtures['600x400.jpg']), + files=dict(image=self.fixtures["600x400.jpg"]), instance=instance, ) assert form.is_valid() obj = form.save() - assert obj.image.name == 'img/600x400.jpg' + assert obj.image.name == "img/600x400.jpg" assert os.path.exists(instance.image.path) assert not os.path.exists(org_path) def test_save_form_data__false(self, db): - instance = models.ThumbnailModel.objects.create(image=self.fixtures['100.gif']) + instance = models.ThumbnailModel.objects.create(image=self.fixtures["100.gif"]) org_path = instance.image.path assert os.path.exists(org_path) form = forms.ThumbnailModelForm( - data={'image-clear': '1'}, + data={"image-clear": "1"}, instance=instance, ) assert form.is_valid() @@ -35,14 +34,27 @@ def test_save_form_data__false(self, db): assert not os.path.exists(org_path) def test_save_form_data__none(self, db): - instance = models.ThumbnailModel.objects.create(image=self.fixtures['100.gif']) + instance = models.ThumbnailModel.objects.create(image=self.fixtures["100.gif"]) org_path = instance.image.path assert os.path.exists(org_path) form = forms.ThumbnailModelForm( - data={'image': None}, + data={"image": None}, instance=instance, ) assert form.is_valid() 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 f681e7a..e234521 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,25 +1,37 @@ 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 . import models -from .models import (AdminDeleteModel, CustomRenderVariationsModel, ResizeCropModel, - ResizeModel, SimpleModel, ThumbnailModel, - ThumbnailWithoutDirectoryModel, UtilVariationsModel,) +from stdimage.models import StdImageFieldFile -IMG_DIR = os.path.join(settings.MEDIA_ROOT, 'img') +from . import models +from .models import ( + AdminDeleteModel, + AdminUpdateModel, + CustomRenderVariationsModel, + ResizeCropModel, + ResizeModel, + SimpleModel, + ThumbnailModel, + ThumbnailWithoutDirectoryModel, + UtilVariationsModel, +) + +IMG_DIR = os.path.join(settings.MEDIA_ROOT, "img") FIXTURES = [ - ('100.gif', 'GIF', 100, 100), - ('600x400.gif', 'GIF', 600, 400), - ('600x400.jpg', 'JPEG', 600, 400), - ('600x400.jpg', 'PNG', 600, 400), + ("100.gif", "GIF", 100, 100), + ("600x400.gif", "GIF", 600, 400), + ("600x400.jpg", "JPEG", 600, 400), + ("600x400.jpg", "PNG", 600, 400), ] @@ -30,7 +42,7 @@ class TestStdImage: def setup(self): for fixture_filename, img_format, width, height in FIXTURES: with io.BytesIO() as f: - img = Image.new('RGB', (width, height), (255, 55, 255)) + img = Image.new("RGB", (width, height), (255, 55, 255)) img.save(f, format=img_format) suf = SimpleUploadedFile(fixture_filename, f.getvalue()) self.fixtures[fixture_filename] = suf @@ -49,105 +61,100 @@ class TestModel(TestStdImage): def test_simple(self, db): """Tests if Field behaves just like Django's ImageField.""" - instance = SimpleModel.objects.create(image=self.fixtures['100.gif']) - target_file = os.path.join(IMG_DIR, '100.gif') - source_file = self.fixtures['100.gif'] + instance = SimpleModel.objects.create(image=self.fixtures["100.gif"]) + target_file = os.path.join(IMG_DIR, "100.gif") + source_file = self.fixtures["100.gif"] assert SimpleModel.objects.count() == 1 assert SimpleModel.objects.get(pk=1) == instance assert os.path.exists(target_file) - with open(target_file, 'rb') as f: + with open(target_file, "rb") as f: source_file.seek(0) assert source_file.read() == f.read() def test_variations(self, db): """Adds image and checks filesystem as well as width and height.""" - instance = ResizeModel.objects.create( - image=self.fixtures['600x400.jpg'] - ) + instance = ResizeModel.objects.create(image=self.fixtures["600x400.jpg"]) - source_file = self.fixtures['600x400.jpg'] + source_file = self.fixtures["600x400.jpg"] - assert os.path.exists(os.path.join(IMG_DIR, '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') + path = os.path.join(IMG_DIR, "600x400.jpg") - with open(path, 'rb') as f: + 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') + 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: + 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 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: + with open(os.path.join(IMG_DIR, "600x400.thumbnail.jpg"), "rb") as f: source_file.seek(0) assert source_file.read() != f.read() def test_cropping(self, db): - instance = ResizeCropModel.objects.create( - image=self.fixtures['600x400.jpg'] - ) + instance = ResizeCropModel.objects.create(image=self.fixtures["600x400.jpg"]) assert instance.image.thumbnail.width == 150 assert instance.image.thumbnail.height == 150 def test_variations_override(self, db): - source_file = self.fixtures['600x400.jpg'] - target_file = os.path.join(IMG_DIR, 'image.thumbnail.jpg') + source_file = self.fixtures["600x400.jpg"] + target_file = os.path.join(IMG_DIR, "image.thumbnail.jpg") os.mkdir(IMG_DIR) default_storage.save(target_file, source_file) - ResizeModel.objects.create( - image=self.fixtures['600x400.jpg'] - ) - thumbnail_path = os.path.join(IMG_DIR, 'image.thumbnail.jpg') + ResizeModel.objects.create(image=self.fixtures["600x400.jpg"]) + thumbnail_path = os.path.join(IMG_DIR, "image.thumbnail.jpg") assert os.path.exists(thumbnail_path) - thumbnail_path = os.path.join(IMG_DIR, 'image.thumbnail_1.jpg') + thumbnail_path = os.path.join(IMG_DIR, "image.thumbnail_1.jpg") assert not os.path.exists(thumbnail_path) def test_delete_thumbnail(self, db): """Delete an image with thumbnail""" - obj = ThumbnailModel.objects.create( - image=self.fixtures['100.gif'] - ) + obj = ThumbnailModel.objects.create(image=self.fixtures["100.gif"]) obj.image.delete() - path = os.path.join(IMG_DIR, 'image.gif') + path = os.path.join(IMG_DIR, "image.gif") assert not os.path.exists(path) - path = os.path.join(IMG_DIR, 'image.thumbnail.gif') + path = os.path.join(IMG_DIR, "image.thumbnail.gif") assert not os.path.exists(path) def test_fore_min_size(self, admin_client): - admin_client.post('/admin/tests/forceminsizemodel/add/', { - 'image': self.fixtures['100.gif'], - }) - path = os.path.join(IMG_DIR, 'image.gif') + admin_client.post( + "/admin/tests/forceminsizemodel/add/", + { + "image": self.fixtures["100.gif"], + }, + ) + path = os.path.join(IMG_DIR, "image.gif") assert not os.path.exists(path) def test_thumbnail_save_without_directory(self, db): obj = ThumbnailWithoutDirectoryModel.objects.create( - image=self.fixtures['100.gif'] + image=self.fixtures["100.gif"] ) obj.save() # Our model saves the images directly into the MEDIA_ROOT directory # not IMG_DIR, under a custom name - original = os.path.join(settings.MEDIA_ROOT, 'custom.gif') - thumbnail = os.path.join(settings.MEDIA_ROOT, 'custom.thumbnail.gif') + original = os.path.join(settings.MEDIA_ROOT, "custom.gif") + thumbnail = os.path.join(settings.MEDIA_ROOT, "custom.thumbnail.gif") assert os.path.exists(original) assert os.path.exists(thumbnail) def test_custom_render_variations(self, db): instance = CustomRenderVariationsModel.objects.create( - image=self.fixtures['600x400.jpg'] + image=self.fixtures["600x400.jpg"] ) # Image size must be 100x100 despite variations settings assert instance.image.thumbnail.width == 100 @@ -160,54 +167,123 @@ def test_defer(self, db, django_assert_num_queries): Accessing a deferred field would cause Django to do a second implicit database query. """ - instance = ResizeModel.objects.create(image=self.fixtures['100.gif']) + instance = ResizeModel.objects.create(image=self.fixtures["100.gif"]) with django_assert_num_queries(1): - deferred = ResizeModel.objects.only('pk').get(pk=instance.pk) + deferred = ResizeModel.objects.only("pk").get(pk=instance.pk) with django_assert_num_queries(1): 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""" def test_deletion_singnal_receiver(self, db): - obj = AdminDeleteModel.objects.create( - image=self.fixtures['100.gif'] - ) + obj = AdminDeleteModel.objects.create(image=self.fixtures["100.gif"]) path = obj.image.path obj.delete() assert not os.path.exists(path) def test_deletion_singnal_receiver_many(self, db): - obj = AdminDeleteModel.objects.create( - image=self.fixtures['100.gif'] - ) + obj = AdminDeleteModel.objects.create(image=self.fixtures["100.gif"]) path = obj.image.path AdminDeleteModel.objects.all().delete() assert not os.path.exists(path) def test_pre_save_delete_callback_clear(self, admin_client): - obj = AdminDeleteModel.objects.create( - image=self.fixtures['100.gif'] - ) + obj = AdminDeleteModel.objects.create(image=self.fixtures["100.gif"]) path = obj.image.path - admin_client.post('/admin/tests/admindeletemodel/1/change/', { - 'image-clear': 'checked', - }) + admin_client.post( + "/admin/tests/admindeletemodel/1/change/", + { + "image-clear": "checked", + }, + ) assert not os.path.exists(path) def test_pre_save_delete_callback_new(self, admin_client): - AdminDeleteModel.objects.create( - image=self.fixtures['100.gif'] + obj = AdminDeleteModel.objects.create(image=self.fixtures["100.gif"]) + path = obj.image.path + assert os.path.exists(path) + admin_client.post( + "/admin/tests/admindeletemodel/1/change/", + { + "image": self.fixtures["600x400.jpg"], + }, ) - admin_client.post('/admin/tests/admindeletemodel/1/change/', { - 'image': self.fixtures['600x400.jpg'], - }) - assert not os.path.exists(os.path.join(IMG_DIR, 'image.gif')) + assert not os.path.exists(path) + assert os.path.exists(os.path.join(IMG_DIR, "600x400.jpg")) + + def test_pre_save_delete_callback_update(self, admin_client): + obj = AdminUpdateModel.objects.create(image=self.fixtures["100.gif"]) + path = obj.image.path + assert os.path.exists(path) + admin_client.post( + "/admin/tests/adminupdatemodel/1/change/", + { + "image": self.fixtures["600x400.jpg"], + }, + ) + assert not os.path.exists(path) + assert os.path.exists(os.path.join(IMG_DIR, "600x400.jpg")) def test_render_variations_callback(self, db): - obj = UtilVariationsModel.objects.create(image=self.fixtures['100.gif']) + obj = UtilVariationsModel.objects.create(image=self.fixtures["100.gif"]) file_path = obj.image.thumbnail.path assert os.path.exists(file_path) @@ -227,23 +303,39 @@ def test_render_variations_overwrite(self, db, image_upload_file): class TestValidators(TestStdImage): def test_max_size_validator(self, admin_client): - response = admin_client.post('/admin/tests/maxsizemodel/add/', { - 'image': self.fixtures['600x400.jpg'], - }) - assert 'too large' in response.context['adminform'].form.errors['image'][0] - assert not os.path.exists(os.path.join(IMG_DIR, '800x600.jpg')) + response = admin_client.post( + "/admin/tests/maxsizemodel/add/", + { + "image": self.fixtures["600x400.jpg"], + }, + ) + assert "too large" in response.context["adminform"].form.errors["image"][0] + assert not os.path.exists(os.path.join(IMG_DIR, "800x600.jpg")) def test_min_size_validator(self, admin_client): - response = admin_client.post('/admin/tests/minsizemodel/add/', { - 'image': self.fixtures['100.gif'], - }) - assert 'too small' in response.context['adminform'].form.errors['image'][0] - assert not os.path.exists(os.path.join(IMG_DIR, '100.gif')) + response = admin_client.post( + "/admin/tests/minsizemodel/add/", + { + "image": self.fixtures["100.gif"], + }, + ) + assert "too small" in response.context["adminform"].form.errors["image"][0] + assert not os.path.exists(os.path.join(IMG_DIR, "100.gif")) class TestJPEGField(TestStdImage): def test_convert(self, db): - obj = models.JPEGModel.objects.create(image=self.fixtures['100.gif']) - assert obj.image.thumbnail.path.endswith('img/100.thumbnail.jpeg') + obj = models.JPEGModel.objects.create(image=self.fixtures["100.gif"]) + assert obj.image.thumbnail.path.endswith("img/100.thumbnail.jpeg") assert obj.image.full.width == 100 assert obj.image.full.height == 100 + + def test_convert_multiple(self, db): + large = models.JPEGModel.objects.create(image=self.fixtures["600x400.gif"]) + small = models.JPEGModel.objects.create(image=self.fixtures["100.gif"]) + + assert large.image.field._variations["full"] == (None, None) + assert small.image.field._variations["full"] == (None, None) + + assert large.image.full.width == 600 + assert small.image.full.width == 100 diff --git a/tests/test_utils.py b/tests/test_utils.py index 047a651..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 @@ -14,18 +14,18 @@ def test_render_variations(self, image_upload_file): instance = ManualVariationsModel.customer_manager.create( image=image_upload_file ) - path = os.path.join(IMG_DIR, 'image.thumbnail.jpg') + path = os.path.join(IMG_DIR, "image.thumbnail.jpg") assert not os.path.exists(path) render_variations( file_name=instance.image.name, variations={ - 'thumbnail': { - 'name': 'thumbnail', - 'width': 150, - 'height': 150, - 'crop': True, - 'resample': Image.ANTIALIAS + "thumbnail": { + "name": "thumbnail", + "width": 150, + "height": 150, + "crop": True, + "resample": Resampling.LANCZOS, } - } + }, ) assert os.path.exists(path) diff --git a/tests/test_validators.py b/tests/test_validators.py index 4a3b4ac..5ccdd93 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -4,7 +4,8 @@ class TestBaseSizeValidator: def test_init__none(self): assert validators.MinSizeValidator(None, None).limit_value == ( - float('inf'), float('inf') + float("inf"), + float("inf"), ) diff --git a/tests/urls.py b/tests/urls.py index d69c573..f8aa21b 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,8 +1,8 @@ -from django.conf.urls import url from django.contrib import admin +from django.urls import path admin.autodiscover() urlpatterns = [ - url(r'^admin/', admin.site.urls), + path("admin/", admin.site.urls), ]