diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 05e22765..5feffc1a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -13,7 +13,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 diff --git a/docs/_themes/README.rst b/docs/_themes/README.rst index e8179f96..26172c76 100755 --- a/docs/_themes/README.rst +++ b/docs/_themes/README.rst @@ -2,7 +2,7 @@ krTheme Sphinx Style ==================== This repository contains sphinx styles Kenneth Reitz uses in most of -his projects. It is a drivative of Mitsuhiko's themes for Flask and Flask related +his projects. It is a derivative of Mitsuhiko's themes for Flask and Flask related projects. To use this style in your Sphinx documentation, follow this guide: diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py index 33f47449..436c5685 100755 --- a/docs/_themes/flask_theme_support.py +++ b/docs/_themes/flask_theme_support.py @@ -1,7 +1,8 @@ # flasky extensions. flasky pygments style based on tango style from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal +from pygments.token import (Comment, Error, Generic, Keyword, Literal, Name, + Number, Operator, Other, Punctuation, String, + Whitespace) class FlaskyStyle(Style): diff --git a/docs/caching.rst b/docs/caching.rst index 6b576fcc..30e93089 100644 --- a/docs/caching.rst +++ b/docs/caching.rst @@ -117,7 +117,7 @@ differently and for example do not cache values if timeout is ``None``. If you clear your cache durring deployment or some other reason probably you do not want to lose the cache for generated images especcialy if you are using some slow remote storage (like Amazon S3). Then you can configure -seprate cache (for example redis) in your ``CACHES`` config and tell ImageKit +separate cache (for example redis) in your ``CACHES`` config and tell ImageKit to use it instead of the default cache by setting ``IMAGEKIT_CACHE_BACKEND``. @@ -180,7 +180,7 @@ Or, in Python: If you are using an "async" backend in combination with the "optimistic" cache file strategy (see `Removing Safeguards`_ below), checking for - thruthiness as described above will not work. The "optimistic" backend is + truthiness as described above will not work. The "optimistic" backend is very optimistic so to say, and removes the check. Create and use the following strategy to a) have images only created on save, and b) retain the ability to check whether the images have already been created:: @@ -211,7 +211,7 @@ operation. However, if the state isn't cached, ImageKit will need to query the storage backend. For those who aren't willing to accept that cost (and who never want ImageKit -to generate images in the request-responce cycle), there's the "optimistic" +to generate images in the request-response cycle), there's the "optimistic" cache file strategy. This strategy only generates a new image when a spec's source image is created or changed. Unlike with the "just in time" strategy, accessing the file won't cause it to be generated, ImageKit will just assume diff --git a/docs/conf.py b/docs/conf.py index c8672a85..06e90660 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # ImageKit documentation build configuration file, created by # sphinx-quickstart on Sun Sep 25 17:05:55 2011. @@ -11,7 +10,9 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import re, sys, os +import os +import re +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -42,8 +43,8 @@ master_doc = 'index' # General information about the project. -project = u'ImageKit' -copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter' +project = 'ImageKit' +copyright = '2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter' pkgmeta = {} execfile(os.path.join(os.path.dirname(__file__), '..', 'imagekit', @@ -189,8 +190,8 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'ImageKit.tex', u'ImageKit Documentation', - u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett \\& Matthew Tretter', 'manual'), + ('index', 'ImageKit.tex', 'ImageKit Documentation', + 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett \\& Matthew Tretter', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -219,8 +220,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'imagekit', u'ImageKit Documentation', - [u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'], 1) + ('index', 'imagekit', 'ImageKit Documentation', + ['Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'], 1) ] # If true, show URL addresses after external links. @@ -233,7 +234,7 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'ImageKit', u'ImageKit Documentation', u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter', + ('index', 'ImageKit', 'ImageKit Documentation', 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter', 'ImageKit', 'One line description of project.', 'Miscellaneous'), ] diff --git a/docs/configuration.rst b/docs/configuration.rst index 236191e3..84636cee 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -21,12 +21,23 @@ Settings :default: ``None`` + Starting with Django 4.2, if you defined ``settings.STORAGES``: + the Django storage backend alias to retrieve the storage instance defined + in your settings, as described in the `Django file storage documentation`_. + If no value is provided for ``IMAGEKIT_DEFAULT_FILE_STORAGE``, + and none is specified by the spec definition, the ``default`` file storage + will be used. + + Before Django 4.2, or if ``settings.STORAGES`` is not defined: The qualified class name of a Django storage backend to use to save the cached images. If no value is provided for ``IMAGEKIT_DEFAULT_FILE_STORAGE``, and none is specified by the spec definition, `your default file storage`__ will be used. +.. _`Django file storage documentation`: https://docs.djangoproject.com/en/dev/ref/files/storage/ + + .. attribute:: IMAGEKIT_DEFAULT_CACHEFILE_BACKEND :default: ``'imagekit.cachefiles.backends.Simple'`` @@ -52,7 +63,7 @@ Settings The cache is then used to store information like the state of cached images (i.e. validated or not). -.. _`Django cache section`: https://docs.djangoproject.com/en/1.8/topics/cache/#accessing-the-cache +.. _`Django cache section`: https://docs.djangoproject.com/en/dev/topics/cache/#accessing-the-cache .. attribute:: IMAGEKIT_CACHE_TIMEOUT diff --git a/imagekit/__init__.py b/imagekit/__init__.py index 6e92fb34..1f0ffc4a 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,6 +1,9 @@ -# flake8: noqa -from . import conf -from . import generatorlibrary -from .specs import ImageSpec +from . import conf, generatorlibrary from .pkgmeta import * from .registry import register, unregister +from .specs import ImageSpec + +__all__ = [ + 'ImageSpec', 'conf', 'generatorlibrary', 'register', 'unregister', + '__title__', '__author__', '__version__', '__license__' +] diff --git a/imagekit/admin.py b/imagekit/admin.py index 9d73d9c0..9e171a97 100644 --- a/imagekit/admin.py +++ b/imagekit/admin.py @@ -1,14 +1,8 @@ -from django import VERSION -if VERSION[0] < 2: - # ugettext is an alias for gettext() since Django 2.0, - # and deprecated as of Django 3.0. - from django.utils.translation import ugettext_lazy as _ -else: - from django.utils.translation import gettext_lazy as _ from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ -class AdminThumbnail(object): +class AdminThumbnail: """ A convenience utility for adding thumbnails to Django's admin change list. diff --git a/imagekit/cachefiles/__init__.py b/imagekit/cachefiles/__init__.py index 30ea755e..3d036fd1 100644 --- a/imagekit/cachefiles/__init__.py +++ b/imagekit/cachefiles/__init__.py @@ -1,13 +1,18 @@ +import os.path from copy import copy + from django.conf import settings from django.core.files import File from django.core.files.images import ImageFile -from django.utils.functional import SimpleLazyObject from django.utils.encoding import smart_str +from django.utils.functional import SimpleLazyObject + from ..files import BaseIKFile from ..registry import generator_registry from ..signals import content_required, existence_required -from ..utils import get_logger, get_singleton, generate, get_by_qname +from ..utils import ( + generate, get_by_qname, get_logger, get_singleton, get_storage +) class ImageCacheFile(BaseIKFile, ImageFile): @@ -41,8 +46,7 @@ def __init__(self, generator, name=None, storage=None, cachefile_backend=None, c self.name = name storage = (callable(storage) and storage()) or storage or \ - getattr(generator, 'cachefile_storage', None) or get_singleton( - settings.IMAGEKIT_DEFAULT_FILE_STORAGE, 'file storage backend') + getattr(generator, 'cachefile_storage', None) or get_storage() self.cachefile_backend = ( cachefile_backend or getattr(generator, 'cachefile_backend', None) @@ -55,7 +59,7 @@ def __init__(self, generator, name=None, storage=None, cachefile_backend=None, c 'cache file strategy') ) - super(ImageCacheFile, self).__init__(storage=storage) + super().__init__(storage=storage) def _require_file(self): if getattr(self, '_file', None) is None: @@ -100,7 +104,8 @@ def _generate(self): actual_name = self.storage.save(self.name, content) # We're going to reuse the generated file, so we need to reset the pointer. - content.seek(0) + if not hasattr(content, "seekable") or content.seekable(): + content.seek(0) # Store the generated file. If we don't do this, the next time the # "file" attribute is accessed, it will result in a call to the storage @@ -108,7 +113,13 @@ def _generate(self): # contents of the file, what would the point of that be? self.file = File(content) - if actual_name != self.name: + # ``actual_name`` holds the output of ``self.storage.save()`` that + # by default returns filenames with forward slashes, even on windows. + # On the other hand, ``self.name`` holds OS-specific paths results + # from applying path normalizers like ``os.path.normpath()`` in the + # ``namer``. So, the filenames should be normalized before their + # equality checking. + if os.path.normpath(actual_name) != os.path.normpath(self.name): get_logger().warning( 'The storage backend %s did not save the file with the' ' requested name ("%s") and instead used "%s". This may be' @@ -146,26 +157,16 @@ def __getstate__(self): # remove storage from state as some non-FileSystemStorage can't be # pickled - settings_storage = get_singleton( - settings.IMAGEKIT_DEFAULT_FILE_STORAGE, - 'file storage backend' - ) + settings_storage = get_storage() if state['storage'] == settings_storage: state.pop('storage') return state def __setstate__(self, state): if 'storage' not in state: - state['storage'] = get_singleton( - settings.IMAGEKIT_DEFAULT_FILE_STORAGE, - 'file storage backend' - ) + state['storage'] = get_storage() self.__dict__.update(state) - def __nonzero__(self): - # Python 2 compatibility - return self.__bool__() - def __repr__(self): return smart_str("<%s: %s>" % ( self.__class__.__name__, self if self.name else "None") @@ -177,7 +178,7 @@ def __init__(self, generator_id, *args, **kwargs): def setup(): generator = generator_registry.get(generator_id, *args, **kwargs) return ImageCacheFile(generator) - super(LazyImageCacheFile, self).__init__(setup) + super().__init__(setup) def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, str(self) or 'None') diff --git a/imagekit/cachefiles/backends.py b/imagekit/cachefiles/backends.py index c1d28ae1..e8423d32 100644 --- a/imagekit/cachefiles/backends.py +++ b/imagekit/cachefiles/backends.py @@ -1,11 +1,13 @@ -from ..utils import get_singleton, get_cache, sanitize_cache_key import warnings from copy import copy -from django.core.exceptions import ImproperlyConfigured + from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from ..utils import get_cache, get_singleton, sanitize_cache_key -class CacheFileState(object): +class CacheFileState: EXISTS = 'exists' GENERATING = 'generating' DOES_NOT_EXIST = 'does_not_exist' @@ -25,7 +27,7 @@ class InvalidFileBackendError(ImproperlyConfigured): pass -class AbstractCacheFileBackend(object): +class AbstractCacheFileBackend: """ An abstract cache file backend. This isn't used by any internal classes and is included simply to illustrate the minimum interface of a cache file @@ -39,7 +41,7 @@ def exists(self, file): raise NotImplementedError -class CachedFileBackend(object): +class CachedFileBackend: existence_check_timeout = 5 """ The number of seconds to wait before rechecking to see if the file exists. @@ -157,7 +159,7 @@ def __init__(self, *args, **kwargs): except ImportError: raise ImproperlyConfigured('You must install celery to use' ' imagekit.cachefiles.backends.Celery.') - super(Celery, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def schedule_generation(self, file, force=False): _celery_task.delay(self, file, force=force) @@ -168,7 +170,7 @@ class Async(Celery): def __init__(self, *args, **kwargs): message = '{path}.Async is deprecated. Use {path}.Celery instead.' warnings.warn(message.format(path=__name__), DeprecationWarning) - super(Async, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) try: @@ -189,7 +191,7 @@ def __init__(self, *args, **kwargs): except ImportError: raise ImproperlyConfigured('You must install django-rq to use' ' imagekit.cachefiles.backends.RQ.') - super(RQ, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def schedule_generation(self, file, force=False): _rq_job.delay(self, file, force=force) @@ -213,7 +215,7 @@ def __init__(self, *args, **kwargs): except ImportError: raise ImproperlyConfigured('You must install django-dramatiq to use' ' imagekit.cachefiles.backends.Dramatiq.') - super(Dramatiq, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def schedule_generation(self, file, force=False): _dramatiq_actor.send(self, file, force=force) diff --git a/imagekit/cachefiles/namers.py b/imagekit/cachefiles/namers.py index d6bc95a3..52469402 100644 --- a/imagekit/cachefiles/namers.py +++ b/imagekit/cachefiles/namers.py @@ -5,8 +5,10 @@ """ -from django.conf import settings import os + +from django.conf import settings + from ..utils import format_to_extension, suggest_extension diff --git a/imagekit/cachefiles/strategies.py b/imagekit/cachefiles/strategies.py index bcd8211e..8af85bdd 100644 --- a/imagekit/cachefiles/strategies.py +++ b/imagekit/cachefiles/strategies.py @@ -1,11 +1,7 @@ -import six - -from django.utils.functional import LazyObject -from ..lib import force_text from ..utils import get_singleton -class JustInTime(object): +class JustInTime: """ A strategy that ensures the file exists right before it's needed. @@ -18,7 +14,7 @@ def on_content_required(self, file): file.generate() -class Optimistic(object): +class Optimistic: """ A strategy that acts immediately when the source file changes and assumes that the cache files will not be removed (i.e. it doesn't ensure the @@ -33,14 +29,14 @@ def should_verify_existence(self, file): return False -class DictStrategy(object): +class DictStrategy: def __init__(self, callbacks): for k, v in callbacks.items(): setattr(self, k, v) def load_strategy(strategy): - if isinstance(strategy, six.string_types): + if isinstance(strategy, str): strategy = get_singleton(strategy, 'cache file strategy') elif isinstance(strategy, dict): strategy = DictStrategy(strategy) diff --git a/imagekit/compat.py b/imagekit/compat.py deleted file mode 100644 index f26e8b87..00000000 --- a/imagekit/compat.py +++ /dev/null @@ -1,161 +0,0 @@ -# flake8: noqa -""" -This module contains code from django.template.base -(sha 90d3af380e8efec0301dd91600c6686232de3943). Bundling this code allows us to -support older versions of Django that did not contain it (< 1.4). - - -Copyright (c) Django Software Foundation and individual contributors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - 3. Neither the name of Django nor the names of its contributors may be used - to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -""" - -from django.template import TemplateSyntaxError -import re - - -# Regex for token keyword arguments -kwarg_re = re.compile(r"(?:(\w+)=)?(.+)") - - -def token_kwargs(bits, parser, support_legacy=False): - """ - A utility method for parsing token keyword arguments. - - :param bits: A list containing remainder of the token (split by spaces) - that is to be checked for arguments. Valid arguments will be removed - from this list. - - :param support_legacy: If set to true ``True``, the legacy format - ``1 as foo`` will be accepted. Otherwise, only the standard ``foo=1`` - format is allowed. - - :returns: A dictionary of the arguments retrieved from the ``bits`` token - list. - - There is no requirement for all remaining token ``bits`` to be keyword - arguments, so the dictionary will be returned as soon as an invalid - argument format is reached. - """ - if not bits: - return {} - match = kwarg_re.match(bits[0]) - kwarg_format = match and match.group(1) - if not kwarg_format: - if not support_legacy: - return {} - if len(bits) < 3 or bits[1] != 'as': - return {} - - kwargs = {} - while bits: - if kwarg_format: - match = kwarg_re.match(bits[0]) - if not match or not match.group(1): - return kwargs - key, value = match.groups() - del bits[:1] - else: - if len(bits) < 3 or bits[1] != 'as': - return kwargs - key, value = bits[2], bits[0] - del bits[:3] - kwargs[key] = parser.compile_filter(value) - if bits and not kwarg_format: - if bits[0] != 'and': - return kwargs - del bits[:1] - return kwargs - - -def parse_bits(parser, bits, params, varargs, varkw, defaults, - takes_context, name): - """ - Parses bits for template tag helpers (simple_tag, include_tag and - assignment_tag), in particular by detecting syntax errors and by - extracting positional and keyword arguments. - """ - if takes_context: - if params[0] == 'context': - params = params[1:] - else: - raise TemplateSyntaxError( - "'%s' is decorated with takes_context=True so it must " - "have a first argument of 'context'" % name) - args = [] - kwargs = {} - unhandled_params = list(params) - for bit in bits: - # First we try to extract a potential kwarg from the bit - kwarg = token_kwargs([bit], parser) - if kwarg: - # The kwarg was successfully extracted - param, value = list(kwarg.items())[0] - if param not in params and varkw is None: - # An unexpected keyword argument was supplied - raise TemplateSyntaxError( - "'%s' received unexpected keyword argument '%s'" % - (name, param)) - elif param in kwargs: - # The keyword argument has already been supplied once - raise TemplateSyntaxError( - "'%s' received multiple values for keyword argument '%s'" % - (name, param)) - else: - # All good, record the keyword argument - kwargs[str(param)] = value - if param in unhandled_params: - # If using the keyword syntax for a positional arg, then - # consume it. - unhandled_params.remove(param) - else: - if kwargs: - raise TemplateSyntaxError( - "'%s' received some positional argument(s) after some " - "keyword argument(s)" % name) - else: - # Record the positional argument - args.append(parser.compile_filter(bit)) - try: - # Consume from the list of expected positional arguments - unhandled_params.pop(0) - except IndexError: - if varargs is None: - raise TemplateSyntaxError( - "'%s' received too many positional arguments" % - name) - if defaults is not None: - # Consider the last n params handled, where n is the - # number of defaults. - unhandled_params = unhandled_params[:-len(defaults)] - if unhandled_params: - # Some positional arguments were not supplied - raise TemplateSyntaxError( - "'%s' did not receive value(s) for the argument(s): %s" % - (name, ", ".join(["'%s'" % p for p in unhandled_params]))) - return args, kwargs diff --git a/imagekit/conf.py b/imagekit/conf.py index 9d2ca1f3..786aeb34 100644 --- a/imagekit/conf.py +++ b/imagekit/conf.py @@ -36,5 +36,10 @@ def configure_cache_timeout(self, value): def configure_default_file_storage(self, value): if value is None: - value = settings.DEFAULT_FILE_STORAGE + try: + from django.conf import DEFAULT_STORAGE_ALIAS + except ImportError: # Django < 4.2 + return settings.DEFAULT_FILE_STORAGE + else: + return DEFAULT_STORAGE_ALIAS return value diff --git a/imagekit/files.py b/imagekit/files.py index f64cf697..8823c589 100644 --- a/imagekit/files.py +++ b/imagekit/files.py @@ -1,10 +1,8 @@ -from __future__ import unicode_literals import os -from django.core.files.base import File, ContentFile -from django.utils.encoding import smart_str -from .lib import smart_text -from .utils import format_to_mimetype, extension_to_mimetype +from django.core.files.base import ContentFile, File + +from .utils import extension_to_mimetype, format_to_mimetype class BaseIKFile(File): @@ -59,7 +57,7 @@ def open(self, mode='rb'): try: self.file.open(mode) except ValueError: - # if the underlaying file can't be reopened + # if the underlying file can't be reopened # then we will use the storage to try to open it again if self.file.closed: # clear cached file instance @@ -103,8 +101,4 @@ def name(self): return self.file.name def __str__(self): - return smart_str(self.file.name or '') - - def __unicode__(self): - # Python 2 - return smart_text(self.file.name or '') + return str(self.file.name or '') diff --git a/imagekit/forms/fields.py b/imagekit/forms/fields.py index cf3ccba6..cd155466 100644 --- a/imagekit/forms/fields.py +++ b/imagekit/forms/fields.py @@ -1,4 +1,5 @@ from django.forms import ImageField + from ..specs import SpecHost from ..utils import generate @@ -17,16 +18,16 @@ def __init__(self, processors=None, format=None, options=None, SpecHost.__init__(self, processors=processors, format=format, options=options, autoconvert=autoconvert, spec=spec, spec_id=spec_id) - super(ProcessedImageField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def clean(self, data, initial=None): - data = super(ProcessedImageField, self).clean(data, initial) + data = super().clean(data, initial) if data and data != initial: spec = self.get_spec(source=data) f = generate(spec) # Name is required in Django 1.4. When we drop support for it - # then we can dirrectly return the result from `generate(spec)` + # then we can directly return the result from `generate(spec)` f.name = data.name return f diff --git a/imagekit/generatorlibrary.py b/imagekit/generatorlibrary.py index f07e606d..59f71ef6 100644 --- a/imagekit/generatorlibrary.py +++ b/imagekit/generatorlibrary.py @@ -1,5 +1,5 @@ -from .registry import register from .processors import Thumbnail as ThumbnailProcessor +from .registry import register from .specs import ImageSpec @@ -7,7 +7,7 @@ class Thumbnail(ImageSpec): def __init__(self, width=None, height=None, anchor=None, crop=None, upscale=None, **kwargs): self.processors = [ThumbnailProcessor(width, height, anchor=anchor, crop=crop, upscale=upscale)] - super(Thumbnail, self).__init__(**kwargs) + super().__init__(**kwargs) register.generator('imagekit:thumbnail', Thumbnail) diff --git a/imagekit/hashers.py b/imagekit/hashers.py index 12825bda..9b9de6e9 100644 --- a/imagekit/hashers.py +++ b/imagekit/hashers.py @@ -1,12 +1,7 @@ from copy import copy from hashlib import md5 -from pickle import MARK, DICT -try: - from pickle import _Pickler -except ImportError: - # Python 2 compatible - from pickle import Pickler as _Pickler -from .lib import StringIO +from io import BytesIO +from pickle import DICT, MARK, _Pickler class CanonicalizingPickler(_Pickler): @@ -30,6 +25,6 @@ def save_dict(self, obj): def pickle(obj): - file = StringIO() + file = BytesIO() CanonicalizingPickler(file, 0).dump(obj) return md5(file.getvalue()).hexdigest() diff --git a/imagekit/lib.py b/imagekit/lib.py deleted file mode 100644 index bdc51c13..00000000 --- a/imagekit/lib.py +++ /dev/null @@ -1,58 +0,0 @@ -# flake8: noqa -import sys - -# Required PIL classes may or may not be available from the root namespace -# depending on the installation method used. -try: - from PIL import Image, ImageColor, ImageChops, ImageEnhance, ImageFile, \ - ImageFilter, ImageDraw, ImageStat -except ImportError: - try: - import Image - import ImageColor - import ImageChops - import ImageEnhance - import ImageFile - import ImageFilter - import ImageDraw - import ImageStat - except ImportError: - raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.') - -try: - from io import BytesIO as StringIO -except: - try: - from cStringIO import StringIO - except ImportError: - from StringIO import StringIO - -try: - from logging import NullHandler -except ImportError: - from logging import Handler - - class NullHandler(Handler): - def emit(self, record): - pass - -# Try to import `force_text` available from Django 1.5 -# This function will replace `unicode` used in the code -# If Django version is under 1.5 then use `force_unicde` -# It is used for compatibility between Python 2 and Python 3 -if sys.version_info >= (3, 0): - from django.utils.encoding import (force_bytes, - force_str as force_text, - smart_str as smart_text) -else: - try: - from django.utils.encoding import force_text, force_bytes, smart_text - except ImportError: - # Django < 1.5 - from django.utils.encoding import (force_unicode as force_text, - smart_str as force_bytes, - smart_unicode as smart_text) - -__all__ = ['Image', 'ImageColor', 'ImageChops', 'ImageEnhance', 'ImageFile', - 'ImageFilter', 'ImageDraw', 'ImageStat', 'StringIO', 'NullHandler', - 'force_text', 'force_bytes', 'smart_text'] diff --git a/imagekit/management/commands/generateimages.py b/imagekit/management/commands/generateimages.py index 2addfd31..e6e63982 100644 --- a/imagekit/management/commands/generateimages.py +++ b/imagekit/management/commands/generateimages.py @@ -1,7 +1,9 @@ -from django.core.management.base import BaseCommand import re -from ...registry import generator_registry, cachefile_registry + +from django.core.management.base import BaseCommand + from ...exceptions import MissingSource +from ...registry import cachefile_registry, generator_registry class Command(BaseCommand): diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index 767374f2..0da1427e 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -1,13 +1,11 @@ -from __future__ import unicode_literals - -from django.conf import settings from django.db import models from django.db.models.signals import class_prepared -from .files import ProcessedImageFieldFile -from .utils import ImageSpecFileDescriptor + +from ...registry import register from ...specs import SpecHost from ...specs.sourcegroups import ImageFieldSourceGroup -from ...registry import register +from .files import ProcessedImageFieldFile +from .utils import ImageSpecFileDescriptor class SpecHostField(SpecHost): @@ -22,7 +20,7 @@ def _set_spec_id(self, cls, name): # Register the spec with the id. This allows specs to be overridden # later, from outside of the model definition. - super(SpecHostField, self).set_spec_id(spec_id) + super().set_spec_id(spec_id) class ImageSpecField(SpecHostField): @@ -113,14 +111,4 @@ def __init__(self, processors=None, format=None, options=None, def contribute_to_class(self, cls, name): self._set_spec_id(cls, name) - return super(ProcessedImageField, self).contribute_to_class(cls, name) - - -# If the project does not use south, then we will not try to add introspection -if 'south' in settings.INSTALLED_APPS: - try: - from south.modelsinspector import add_introspection_rules - except ImportError: - pass - else: - add_introspection_rules([], [r'^imagekit\.models\.fields\.ProcessedImageField$']) + return super().contribute_to_class(cls, name) diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 0fbbad63..d208c5f8 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -1,6 +1,8 @@ -from django.db.models.fields.files import ImageFieldFile import os -from ...utils import suggest_extension, generate + +from django.db.models.fields.files import ImageFieldFile + +from ...utils import generate, suggest_extension class ProcessedImageFieldFile(ImageFieldFile): @@ -10,4 +12,4 @@ def save(self, name, content, save=True): ext = suggest_extension(name, spec.format) new_name = '%s%s' % (filename, ext) content = generate(spec) - return super(ProcessedImageFieldFile, self).save(new_name, content, save) + return super().save(new_name, content, save) diff --git a/imagekit/models/fields/utils.py b/imagekit/models/fields/utils.py index 6cd74a07..90e00321 100644 --- a/imagekit/models/fields/utils.py +++ b/imagekit/models/fields/utils.py @@ -1,7 +1,7 @@ from ...cachefiles import ImageCacheFile -class ImageSpecFileDescriptor(object): +class ImageSpecFileDescriptor: def __init__(self, field, attname, source_field_name): self.attname = attname self.field = field diff --git a/imagekit/pkgmeta.py b/imagekit/pkgmeta.py index 4745e7bd..a38778cc 100644 --- a/imagekit/pkgmeta.py +++ b/imagekit/pkgmeta.py @@ -1,5 +1,5 @@ __title__ = 'django-imagekit' __author__ = 'Matthew Tretter, Venelin Stoykov, Eric Eldredge, Bryan Veloso, Greg Newman, Chris Drackett, Justin Driscoll' -__version__ = '4.1.0' +__version__ = '5.0.0' __license__ = 'BSD' __all__ = ['__title__', '__author__', '__version__', '__license__'] diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py index 7c3532f0..b0195d9e 100644 --- a/imagekit/processors/__init__.py +++ b/imagekit/processors/__init__.py @@ -1,12 +1,12 @@ from pilkit.processors import * __all__ = [ - # Base - 'ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose', - 'Anchor', 'MakeOpaque', - # Crop - 'TrimBorderColor', 'Crop', 'SmartCrop', - # Resize - 'Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize', - 'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail' + # Base + 'ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose', + 'Anchor', 'MakeOpaque', + # Crop + 'TrimBorderColor', 'Crop', 'SmartCrop', + # Resize + 'Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize', + 'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail' ] diff --git a/imagekit/registry.py b/imagekit/registry.py index 685b094c..f7a5bca6 100644 --- a/imagekit/registry.py +++ b/imagekit/registry.py @@ -3,7 +3,7 @@ from .utils import autodiscover, call_strategy_method -class GeneratorRegistry(object): +class GeneratorRegistry: """ An object for registering generators. This registry provides a convenient way for a distributable app to define default generators @@ -61,7 +61,7 @@ def _receive(self, file, callback): call_strategy_method(file, callback) -class SourceGroupRegistry(object): +class SourceGroupRegistry: """ The source group registry is responsible for listening to source_* signals on source groups, and relaying them to the image generated file strategies @@ -116,7 +116,7 @@ def source_group_receiver(self, sender, source, signal, **kwargs): call_strategy_method(file, callback_name) -class CacheFileRegistry(object): +class CacheFileRegistry: """ An object for registering generated files with image generators. The two are associated with each other via a string id. We do this (as opposed to @@ -152,11 +152,10 @@ def unregister(self, generator_id, cachefiles): def get(self, generator_id): for k, v in self._cachefiles.items(): if generator_id in v: - for file in k(): - yield file + yield from k() -class Register(object): +class Register: """ Register generators and generated files. @@ -179,7 +178,7 @@ def source_group(self, generator_id, source_group): source_group_registry.register(generator_id, source_group) -class Unregister(object): +class Unregister: """ Unregister generators and generated files. diff --git a/imagekit/signals.py b/imagekit/signals.py index 4bca574c..5430748c 100644 --- a/imagekit/signals.py +++ b/imagekit/signals.py @@ -1,6 +1,5 @@ from django.dispatch import Signal - # Generated file signals content_required = Signal() existence_required = Signal() diff --git a/imagekit/specs/__init__.py b/imagekit/specs/__init__.py index d9339f37..d06cf888 100644 --- a/imagekit/specs/__init__.py +++ b/imagekit/specs/__init__.py @@ -1,15 +1,17 @@ from copy import copy + from django.conf import settings from django.db.models.fields.files import ImageFieldFile + +from .. import hashers from ..cachefiles.backends import get_default_cachefile_backend from ..cachefiles.strategies import load_strategy -from .. import hashers from ..exceptions import AlreadyRegistered, MissingSource -from ..utils import open_image, get_by_qname, process_image from ..registry import generator_registry, register +from ..utils import get_by_qname, open_image, process_image -class BaseImageSpec(object): +class BaseImageSpec: """ An object that defines how an new image should be generated from a source image. @@ -85,7 +87,7 @@ class ImageSpec(BaseImageSpec): def __init__(self, source): self.source = source - super(ImageSpec, self).__init__() + super().__init__() @property def cachefile_name(self): @@ -121,7 +123,7 @@ def __getstate__(self): # yet, preventing us from accessing the source field. # (This is issue #234.) if isinstance(self.source, ImageFieldFile): - field = getattr(self.source, 'field') + field = self.source.field state['_field_data'] = { 'instance': getattr(self.source, 'instance', None), 'attname': getattr(field, 'name', None), @@ -144,7 +146,8 @@ def generate(self): " with it." % self) # TODO: Move into a generator base class - # TODO: Factor out a generate_image function so you can create a generator and only override the PIL.Image creating part. (The tricky part is how to deal with original_format since generator base class won't have one.) + # TODO: Factor out a generate_image function so you can create a generator and only override the PIL.Image creating part. + # (The tricky part is how to deal with original_format since generator base class won't have one.) closed = self.source.closed if closed: @@ -193,7 +196,7 @@ def create_spec(class_attrs, state): return instance -class SpecHost(object): +class SpecHost: """ An object that ostensibly has a spec attribute but really delegates to the spec registry. @@ -201,7 +204,7 @@ class SpecHost(object): """ def __init__(self, spec=None, spec_id=None, **kwargs): - spec_attrs = dict((k, v) for k, v in kwargs.items() if v is not None) + spec_attrs = {k: v for k, v in kwargs.items() if v is not None} if spec_attrs: if spec: diff --git a/imagekit/specs/sourcegroups.py b/imagekit/specs/sourcegroups.py index 03a33224..2e3e7c4a 100644 --- a/imagekit/specs/sourcegroups.py +++ b/imagekit/specs/sourcegroups.py @@ -10,9 +10,11 @@ """ +import inspect + from django.db.models.signals import post_init, post_save from django.utils.functional import wraps -import inspect + from ..cachefiles import LazyImageCacheFile from ..signals import source_saved from ..utils import get_nonabstract_descendants @@ -38,7 +40,7 @@ def receiver(self, sender, **kwargs): return receiver -class ModelSignalRouter(object): +class ModelSignalRouter: """ Normally, ``ImageFieldSourceGroup`` would be directly responsible for watching for changes on the model field it represents. However, Django does @@ -72,9 +74,9 @@ def update_source_hashes(self, instance): """ self.init_instance(instance) - instance._ik['source_hashes'] = dict( - (attname, hash(getattr(instance, attname))) - for attname in self.get_source_fields(instance)) + instance._ik['source_hashes'] = { + attname: hash(getattr(instance, attname)) + for attname in self.get_source_fields(instance)} return instance._ik['source_hashes'] def get_source_fields(self, instance): @@ -82,9 +84,10 @@ def get_source_fields(self, instance): Returns a list of the source fields for the given instance. """ - return set(src.image_field - for src in self._source_groups - if isinstance(instance, src.model_class)) + return { + src.image_field + for src in self._source_groups + if isinstance(instance, src.model_class)} @ik_model_receiver def post_save_receiver(self, sender, instance=None, created=False, update_fields=None, raw=False, **kwargs): @@ -105,12 +108,13 @@ def post_save_receiver(self, sender, instance=None, created=False, update_fields def post_init_receiver(self, sender, instance=None, **kwargs): self.init_instance(instance) source_fields = self.get_source_fields(instance) - local_fields = dict((field.name, field) - for field in instance._meta.local_fields - if field.name in source_fields) - instance._ik['source_hashes'] = dict( - (attname, hash(file_field)) - for attname, file_field in local_fields.items()) + local_fields = { + field.name: field + for field in instance._meta.local_fields + if field.name in source_fields} + instance._ik['source_hashes'] = { + attname: hash(file_field) + for attname, file_field in local_fields.items()} def dispatch_signal(self, signal, file, model_class, instance, attname): """ @@ -124,9 +128,9 @@ def dispatch_signal(self, signal, file, model_class, instance, attname): signal.send(sender=source_group, source=file) -class ImageFieldSourceGroup(object): +class ImageFieldSourceGroup: """ - A source group that repesents a particular field across all instances of a + A source group that represents a particular field across all instances of a model and its subclasses. """ @@ -147,7 +151,7 @@ def files(self): yield getattr(instance, self.image_field) -class SourceGroupFilesGenerator(object): +class SourceGroupFilesGenerator: """ A Python generator that yields cache file objects for source groups. diff --git a/imagekit/templatetags/imagekit.py b/imagekit/templatetags/imagekit.py index 5a69c915..b2bbfe72 100644 --- a/imagekit/templatetags/imagekit.py +++ b/imagekit/templatetags/imagekit.py @@ -1,14 +1,11 @@ -from __future__ import unicode_literals - from django import template +from django.template.library import parse_bits +from django.utils.encoding import force_str from django.utils.html import escape from django.utils.safestring import mark_safe -from ..compat import parse_bits from ..cachefiles import ImageCacheFile from ..registry import generator_registry -from ..lib import force_text - register = template.Library() @@ -20,7 +17,7 @@ def get_cachefile(context, generator_id, generator_kwargs, source=None): generator_id = generator_id.resolve(context) - kwargs = dict((k, v.resolve(context)) for k, v in generator_kwargs.items()) + kwargs = {k: v.resolve(context) for k, v in generator_kwargs.items()} generator = generator_registry.get(generator_id, **kwargs) return ImageCacheFile(generator) @@ -33,7 +30,7 @@ def parse_dimensions(dimensions): """ width, height = [d.strip() and int(d) or None for d in dimensions.split('x')] - return dict(width=width, height=height) + return {'width': width, 'height': height} class GenerateImageAssignmentNode(template.Node): @@ -44,7 +41,7 @@ def __init__(self, variable_name, generator_id, generator_kwargs): self._variable_name = variable_name def get_variable_name(self, context): - return force_text(self._variable_name) + return force_str(self._variable_name) def render(self, context): variable_name = self.get_variable_name(context) @@ -63,12 +60,11 @@ def __init__(self, generator_id, generator_kwargs, html_attrs): def render(self, context): file = get_cachefile(context, self._generator_id, self._generator_kwargs) - attrs = dict((k, v.resolve(context)) for k, v in - self._html_attrs.items()) + attrs = {k: v.resolve(context) for k, v in self._html_attrs.items()} # Only add width and height if neither is specified (to allow for # proportional in-browser scaling). - if not 'width' in attrs and not 'height' in attrs: + if 'width' not in attrs and 'height' not in attrs: attrs.update(width=file.width, height=file.height) attrs['src'] = file.url @@ -87,16 +83,20 @@ def __init__(self, variable_name, generator_id, dimensions, source, generator_kw self._generator_kwargs = generator_kwargs def get_variable_name(self, context): - return force_text(self._variable_name) + return force_str(self._variable_name) def render(self, context): variable_name = self.get_variable_name(context) generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR - kwargs = dict((k, v.resolve(context)) for k, v in - self._generator_kwargs.items()) + kwargs = {k: v.resolve(context) for k, v in self._generator_kwargs.items()} kwargs['source'] = self._source.resolve(context) kwargs.update(parse_dimensions(self._dimensions.resolve(context))) + if kwargs.get('anchor'): + # ImageKit uses pickle at protocol 0, which throws infinite + # recursion errors when anchor is set to a SafeString instance. + # This converts the SafeString into a str instance. + kwargs['anchor'] = kwargs['anchor'][:] generator = generator_registry.get(generator_id, **kwargs) context[variable_name] = ImageCacheFile(generator) @@ -116,20 +116,23 @@ def __init__(self, generator_id, dimensions, source, generator_kwargs, html_attr def render(self, context): generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR dimensions = parse_dimensions(self._dimensions.resolve(context)) - kwargs = dict((k, v.resolve(context)) for k, v in - self._generator_kwargs.items()) + kwargs = {k: v.resolve(context) for k, v in self._generator_kwargs.items()} kwargs['source'] = self._source.resolve(context) kwargs.update(dimensions) + if kwargs.get('anchor'): + # ImageKit uses pickle at protocol 0, which throws infinite + # recursion errors when anchor is set to a SafeString instance. + # This converts the SafeString into a str instance. + kwargs['anchor'] = kwargs['anchor'][:] generator = generator_registry.get(generator_id, **kwargs) file = ImageCacheFile(generator) - attrs = dict((k, v.resolve(context)) for k, v in - self._html_attrs.items()) + attrs = {k: v.resolve(context) for k, v in self._html_attrs.items()} # Only add width and height if neither is specified (to allow for # proportional in-browser scaling). - if not 'width' in attrs and not 'height' in attrs: + if 'width' not in attrs and 'height' not in attrs: attrs.update(width=file.width, height=file.height) attrs['src'] = file.url @@ -169,7 +172,7 @@ def parse_ik_tag_bits(parser, bits): ' setting html attributes.' % HTML_ATTRS_DELIMITER) args, html_attrs = parse_bits(parser, html_bits, [], 'args', - 'kwargs', None, False, tag_name) + 'kwargs', None, [], None, False, tag_name) if len(args): raise template.TemplateSyntaxError('All "%s" tag arguments after' ' the "%s" token must be named.' % (tag_name, @@ -178,7 +181,7 @@ def parse_ik_tag_bits(parser, bits): return (tag_name, bits, html_attrs, varname) -#@register.tag +@register.tag def generateimage(parser, token): """ Creates an image based on the provided arguments. @@ -211,7 +214,7 @@ def generateimage(parser, token): tag_name, bits, html_attrs, varname = parse_ik_tag_bits(parser, bits) args, kwargs = parse_bits(parser, bits, ['generator_id'], 'args', 'kwargs', - None, False, tag_name) + None, [], None, False, tag_name) if len(args) != 1: raise template.TemplateSyntaxError('The "%s" tag requires exactly one' @@ -225,7 +228,7 @@ def generateimage(parser, token): return GenerateImageTagNode(generator_id, kwargs, html_attrs) -#@register.tag +@register.tag def thumbnail(parser, token): """ A convenient shortcut syntax for generating a thumbnail. The following:: @@ -259,7 +262,7 @@ def thumbnail(parser, token): tag_name, bits, html_attrs, varname = parse_ik_tag_bits(parser, bits) args, kwargs = parse_bits(parser, bits, [], 'args', 'kwargs', - None, False, tag_name) + None, [], None, False, tag_name) if len(args) < 2: raise template.TemplateSyntaxError('The "%s" tag requires at least two' @@ -279,7 +282,3 @@ def thumbnail(parser, token): else: return ThumbnailImageTagNode(generator_id, dimensions, source, kwargs, html_attrs) - - -generateimage = register.tag(generateimage) -thumbnail = register.tag(thumbnail) diff --git a/imagekit/utils.py b/imagekit/utils.py index ca6727ae..08f4aac2 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,31 +1,24 @@ -from __future__ import unicode_literals import logging import re -from tempfile import NamedTemporaryFile from hashlib import md5 +from importlib import import_module from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.files import File -try: - from importlib import import_module -except ImportError: - from django.utils.importlib import import_module from pilkit.utils import * -from .lib import NullHandler, force_bytes - bad_memcached_key_chars = re.compile('[\u0000-\u001f\\s]+') _autodiscovered = False + def get_nonabstract_descendants(model): """ Returns all non-abstract descendants of the model. """ if not model._meta.abstract: yield model for s in model.__subclasses__(): - for m in get_nonabstract_descendants(s): - yield m + yield from get_nonabstract_descendants(s) def get_by_qname(path, desc): @@ -72,55 +65,15 @@ def autodiscover(): if _autodiscovered: return - try: - from django.utils.module_loading import autodiscover_modules - except ImportError: - # Django<1.7 - _autodiscover_modules_fallback() - else: - autodiscover_modules('imagegenerators') + from django.utils.module_loading import autodiscover_modules + autodiscover_modules('imagegenerators') _autodiscovered = True -def _autodiscover_modules_fallback(): - """ - Auto-discover INSTALLED_APPS imagegenerators.py modules and fail silently - when not present. This forces an import on them to register any admin bits - they may want. - - Copied from django.contrib.admin - - Used for Django versions < 1.7 - """ - from django.conf import settings - try: - from importlib import import_module - except ImportError: - from django.utils.importlib import import_module - from django.utils.module_loading import module_has_submodule - - for app in settings.INSTALLED_APPS: - # As of Django 1.7, settings.INSTALLED_APPS may contain classes instead of modules, hence the try/except - # See here: https://docs.djangoproject.com/en/dev/releases/1.7/#introspecting-applications - try: - mod = import_module(app) - # Attempt to import the app's admin module. - try: - import_module('%s.imagegenerators' % app) - except: - # Decide whether to bubble up this error. If the app just - # doesn't have an imagegenerators module, we can ignore the error - # attempting to import it, otherwise we want it to bubble up. - if module_has_submodule(mod, 'imagegenerators'): - raise - except ImportError: - pass - - def get_logger(logger_name='imagekit', add_null_handler=True): logger = logging.getLogger(logger_name) if add_null_handler: - logger.addHandler(NullHandler()) + logger.addHandler(logging.NullHandler()) return logger @@ -167,16 +120,27 @@ def call_strategy_method(file, method_name): def get_cache(): - try: - from django.core.cache import caches - except ImportError: - # Django < 1.7 - from django.core.cache import get_cache - return get_cache(settings.IMAGEKIT_CACHE_BACKEND) + from django.core.cache import caches return caches[settings.IMAGEKIT_CACHE_BACKEND] +def get_storage(): + try: + from django.core.files.storage import storages, InvalidStorageError + except ImportError: # Django < 4.2 + return get_singleton( + settings.IMAGEKIT_DEFAULT_FILE_STORAGE, 'file storage backend' + ) + else: + try: + return storages[settings.IMAGEKIT_DEFAULT_FILE_STORAGE] + except InvalidStorageError: + return get_singleton( + settings.IMAGEKIT_DEFAULT_FILE_STORAGE, 'file storage backend' + ) + + def sanitize_cache_key(key): if settings.IMAGEKIT_USE_MEMCACHED_SAFE_CACHE_KEY: # Memcached keys can't contain whitespace or control characters. @@ -185,7 +149,7 @@ def sanitize_cache_key(key): # The also can't be > 250 chars long. Since we don't know what the # user's cache ``KEY_FUNCTION`` setting is like, we'll limit it to 200. if len(new_key) >= 200: - new_key = '%s:%s' % (new_key[:200-33], md5(force_bytes(key)).hexdigest()) + new_key = '%s:%s' % (new_key[:200 - 33], md5(key.encode('utf-8')).hexdigest()) key = new_key return key diff --git a/setup.py b/setup.py index c018b750..e1024819 100644 --- a/setup.py +++ b/setup.py @@ -1,33 +1,32 @@ #!/usr/bin/env python import codecs import os -from setuptools import setup, find_packages import sys - -# Workaround for multiprocessing/nose issue. See http://bugs.python.org/msg170215 -try: - import multiprocessing # NOQA -except ImportError: - pass +from setuptools import find_packages, setup if 'publish' in sys.argv: - os.system('python setup.py sdist bdist_wheel upload') + os.system('python3 -m build') + os.system('python3 -m twine upload --repository django_imagekit dist/*') sys.exit() -read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read() +def read(filepath): + with codecs.open(filepath, 'r', 'utf-8') as f: + return f.read() def exec_file(filepath, globalz=None, localz=None): - exec(read(filepath), globalz, localz) + exec(read(filepath), globalz, localz) # Load package meta from the pkgmeta module without loading imagekit. pkgmeta = {} -exec_file(os.path.join(os.path.dirname(__file__), - 'imagekit', 'pkgmeta.py'), pkgmeta) +exec_file( + os.path.join(os.path.dirname(__file__), 'imagekit', 'pkgmeta.py'), + pkgmeta +) setup( @@ -37,18 +36,16 @@ def exec_file(filepath, globalz=None, localz=None): long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')), author='Matthew Tretter', author_email='m@tthewwithanm.com', - maintainer='Bryan Veloso', - maintainer_email='bryan@revyver.com', + maintainer='Venelin Stoykov', + maintainer_email='venelin.stoykov@industria.tech', license='BSD', url='http://github.com/matthewwithanm/django-imagekit/', packages=find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']), zip_safe=False, include_package_data=True, install_requires=[ - "django-appconf>=0.5,<1.0.4; python_version<'3'", - "django-appconf; python_version>'3'", - 'pilkit>=0.2.0', - 'six', + 'django-appconf', + 'pilkit', ], extras_require={ 'async': ['django-celery>=3.0'], @@ -62,17 +59,15 @@ def exec_file(filepath, globalz=None, localz=None): 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Utilities' ], ) diff --git a/tests/imagegenerators.py b/tests/imagegenerators.py index 11ac5552..d6edb356 100644 --- a/tests/imagegenerators.py +++ b/tests/imagegenerators.py @@ -9,7 +9,7 @@ class TestSpec(ImageSpec): class ResizeTo1PixelSquare(ImageSpec): def __init__(self, width=None, height=None, anchor=None, crop=None, **kwargs): self.processors = [ResizeToFill(1, 1)] - super(ResizeTo1PixelSquare, self).__init__(**kwargs) + super().__init__(**kwargs) register.generator('testspec', TestSpec) diff --git a/tests/models.py b/tests/models.py index 0f6bd0c4..f4892cfd 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,8 +1,7 @@ from django.db import models from imagekit import ImageSpec -from imagekit.models import ProcessedImageField -from imagekit.models import ImageSpecField +from imagekit.models import ImageSpecField, ProcessedImageField from imagekit.processors import Adjust, ResizeToFill, SmartCrop @@ -38,7 +37,7 @@ class ProcessedImageFieldWithSpecModel(models.Model): processed = ProcessedImageField(spec=Thumbnail, upload_to='p') -class CountingCacheFileStrategy(object): +class CountingCacheFileStrategy: def __init__(self): self.on_existence_required_count = 0 self.on_content_required_count = 0 diff --git a/tests/settings.py b/tests/settings.py index 872dacd4..ed59d4bc 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -8,12 +8,6 @@ MEDIA_ROOT = os.path.normpath(os.path.join(BASE_PATH, 'media')) -# Django <= 1.2 -DATABASE_ENGINE = 'sqlite3' -DATABASE_NAME = 'imagekit.db' -TEST_DATABASE_NAME = 'imagekit-test.db' - -# Django >= 1.3 DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -32,7 +26,6 @@ CACHE_BACKEND = 'locmem://' -# Django >= 1.8 TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', diff --git a/tests/test_abstract_models.py b/tests/test_abstract_models.py index e16f73ae..e0f88ca3 100644 --- a/tests/test_abstract_models.py +++ b/tests/test_abstract_models.py @@ -1,6 +1,7 @@ from imagekit.utils import get_nonabstract_descendants -from . models import (AbstractImageModel, ConcreteImageModel, - ConcreteImageModelSubclass) + +from .models import (AbstractImageModel, ConcreteImageModel, + ConcreteImageModelSubclass) def test_nonabstract_descendants_generator(): diff --git a/tests/test_cachefiles.py b/tests/test_cachefiles.py index 7e6b622e..fa911deb 100644 --- a/tests/test_cachefiles.py +++ b/tests/test_cachefiles.py @@ -1,19 +1,16 @@ -import pytest - -try: - from unittest import mock -except ImportError: - import mock +from hashlib import md5 +from unittest import mock +import pytest from django.conf import settings -from hashlib import md5 + from imagekit.cachefiles import ImageCacheFile, LazyImageCacheFile from imagekit.cachefiles.backends import Simple -from imagekit.lib import force_bytes + from .imagegenerators import TestSpec -from .utils import (assert_file_is_truthy, assert_file_is_falsy, - DummyAsyncCacheFileBackend, get_unique_image_file, - get_image_file) +from .utils import (DummyAsyncCacheFileBackend, assert_file_is_falsy, + assert_file_is_truthy, get_image_file, + get_unique_image_file) def test_no_source_falsiness(): @@ -86,7 +83,7 @@ def test_memcached_cache_key(): """ - class MockFile(object): + class MockFile: def __init__(self, name): self.name = name @@ -104,7 +101,7 @@ def __init__(self, name): assert backend.get_key(file) == '%s%s:%s' % ( settings.IMAGEKIT_CACHE_PREFIX, '1' * (200 - len(':') - 32 - len(settings.IMAGEKIT_CACHE_PREFIX)), - md5(force_bytes('%s%s-state' % (settings.IMAGEKIT_CACHE_PREFIX, filename))).hexdigest()) + md5(('%s%s-state' % (settings.IMAGEKIT_CACHE_PREFIX, filename)).encode('utf-8')).hexdigest()) def test_lazyfile_stringification(): @@ -112,8 +109,8 @@ def test_lazyfile_stringification(): assert str(file) == '' assert repr(file) == '' - source_file = get_image_file() - file = LazyImageCacheFile('testspec', source=source_file) + with get_image_file() as source_file: + file = LazyImageCacheFile('testspec', source=source_file) file.name = 'a.jpg' assert str(file) == 'a.jpg' assert repr(file) == '' diff --git a/tests/test_fields.py b/tests/test_fields.py index f243d101..47ba0d93 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,23 +1,23 @@ import pytest - from django import forms from django.core.files.base import File from django.core.files.uploadedfile import SimpleUploadedFile + from imagekit import forms as ikforms from imagekit.processors import SmartCrop + from . import imagegenerators # noqa -from .models import (ProcessedImageFieldModel, - ProcessedImageFieldWithSpecModel, - ImageModel) +from .models import (ImageModel, ProcessedImageFieldModel, + ProcessedImageFieldWithSpecModel) from .utils import get_image_file @pytest.mark.django_db(transaction=True) def test_model_processedimagefield(): instance = ProcessedImageFieldModel() - file = File(get_image_file()) - instance.processed.save('whatever.jpeg', file) - instance.save() + with File(get_image_file()) as file: + instance.processed.save('whatever.jpeg', file) + instance.save() assert instance.processed.width == 50 assert instance.processed.height == 50 @@ -26,9 +26,9 @@ def test_model_processedimagefield(): @pytest.mark.django_db(transaction=True) def test_model_processedimagefield_with_spec(): instance = ProcessedImageFieldWithSpecModel() - file = File(get_image_file()) - instance.processed.save('whatever.jpeg', file) - instance.save() + with File(get_image_file()) as file: + instance.processed.save('whatever.jpeg', file) + instance.save() assert instance.processed.width == 100 assert instance.processed.height == 60 @@ -38,15 +38,19 @@ def test_model_processedimagefield_with_spec(): def test_form_processedimagefield(): class TestForm(forms.ModelForm): image = ikforms.ProcessedImageField(spec_id='tests:testform_image', - processors=[SmartCrop(50, 50)], format='JPEG') + processors=[SmartCrop(50, 50)], + format='JPEG') class Meta: model = ImageModel fields = 'image', - upload_file = get_image_file() - file_dict = {'image': SimpleUploadedFile('abc.jpg', upload_file.read())} - form = TestForm({}, file_dict) + with get_image_file() as upload_file: + files = { + 'image': SimpleUploadedFile('abc.jpg', upload_file.read()) + } + + form = TestForm({}, files) instance = form.save() assert instance.image.width == 50 diff --git a/tests/test_generateimage_tag.py b/tests/test_generateimage_tag.py index 4ab795d9..eb2b7ad4 100644 --- a/tests/test_generateimage_tag.py +++ b/tests/test_generateimage_tag.py @@ -1,15 +1,15 @@ import pytest - from django.template import TemplateSyntaxError + from . import imagegenerators # noqa -from .utils import render_tag, get_html_attrs, clear_imagekit_cache +from .utils import clear_imagekit_cache, get_html_attrs, render_tag def test_img_tag(): ttag = r"""{% generateimage 'testspec' source=img %}""" clear_imagekit_cache() attrs = get_html_attrs(ttag) - expected_attrs = set(['src', 'width', 'height']) + expected_attrs = {'src', 'width', 'height'} assert set(attrs.keys()) == expected_attrs for k in expected_attrs: assert attrs[k].strip() != '' diff --git a/tests/test_no_extra_queries.py b/tests/test_no_extra_queries.py index 0a2dffb7..612d027e 100644 --- a/tests/test_no_extra_queries.py +++ b/tests/test_no_extra_queries.py @@ -1,7 +1,4 @@ -try: - from unittest.mock import Mock, PropertyMock, patch -except: - from mock import Mock, PropertyMock, patch +from unittest.mock import Mock, PropertyMock, patch from .models import Photo diff --git a/tests/test_optimistic_strategy.py b/tests/test_optimistic_strategy.py index 04407da6..7b321232 100644 --- a/tests/test_optimistic_strategy.py +++ b/tests/test_optimistic_strategy.py @@ -1,17 +1,15 @@ -from imagekit.cachefiles import ImageCacheFile - -try: - from unittest.mock import Mock -except: - from mock import Mock +from unittest.mock import Mock -from .utils import create_image from django.core.files.storage import FileSystemStorage + +from imagekit.cachefiles import ImageCacheFile from imagekit.cachefiles.backends import Simple as SimpleCFBackend from imagekit.cachefiles.strategies import Optimistic as OptimisticStrategy +from .utils import create_image + -class ImageGenerator(object): +class ImageGenerator: def generate(self): return create_image() @@ -32,7 +30,7 @@ def get_image_cache_file(): def test_no_io_on_bool(): """ When checking the truthiness of an ImageCacheFile, the storage shouldn't - peform IO operations. + perform IO operations. """ file = get_image_cache_file() diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 08cbc77a..aa2bc120 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -6,8 +6,10 @@ import pytest from imagekit.cachefiles import ImageCacheFile + from .imagegenerators import TestSpec -from .utils import create_photo, pickleback, get_unique_image_file, clear_imagekit_cache +from .utils import (clear_imagekit_cache, create_photo, get_unique_image_file, + pickleback) @pytest.mark.django_db(transaction=True) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..2cf49301 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,74 @@ +import django +from django.test import override_settings +import pytest +from imagekit.conf import ImageKitConf, settings +from imagekit.utils import get_storage + + +@pytest.mark.skipif( + django.VERSION < (4, 2), + reason="STORAGES was introduced in Django 4.2", +) +def test_custom_storages(): + with override_settings( + STORAGES={ + "default": { + "BACKEND": "tests.utils.CustomStorage", + } + }, + ): + conf = ImageKitConf() + assert conf.configure_default_file_storage(None) == "default" + + +@pytest.mark.skipif( + django.VERSION >= (5, 1), + reason="DEFAULT_FILE_STORAGE is removed in Django 5.1.", +) +def test_custom_default_file_storage(): + with override_settings(DEFAULT_FILE_STORAGE="tests.utils.CustomStorage"): + # If we don’t remove this, Django 4.2 will keep the old value. + del settings.STORAGES + conf = ImageKitConf() + + if django.VERSION >= (4, 2): + assert conf.configure_default_file_storage(None) == "default" + else: + assert ( + conf.configure_default_file_storage(None) == "tests.utils.CustomStorage" + ) + + +def test_get_storage_default(): + from django.core.files.storage import FileSystemStorage + + assert isinstance(get_storage(), FileSystemStorage) + + +@pytest.mark.skipif( + django.VERSION >= (5, 1), + reason="DEFAULT_FILE_STORAGE is removed in Django 5.1.", +) +def test_get_storage_custom_path(): + from tests.utils import CustomStorage + + with override_settings(IMAGEKIT_DEFAULT_FILE_STORAGE="tests.utils.CustomStorage"): + assert isinstance(get_storage(), CustomStorage) + + +@pytest.mark.skipif( + django.VERSION < (4, 2), + reason="STORAGES was introduced in Django 4.2", +) +def test_get_storage_custom_key(): + from tests.utils import CustomStorage + + with override_settings( + STORAGES={ + "custom": { + "BACKEND": "tests.utils.CustomStorage", + } + }, + IMAGEKIT_DEFAULT_FILE_STORAGE="custom", + ): + assert isinstance(get_storage(), CustomStorage) diff --git a/tests/test_sourcegroups.py b/tests/test_sourcegroups.py index 317715a1..56174179 100644 --- a/tests/test_sourcegroups.py +++ b/tests/test_sourcegroups.py @@ -1,9 +1,10 @@ import pytest - from django.core.files import File + from imagekit.signals import source_saved from imagekit.specs.sourcegroups import ImageFieldSourceGroup -from . models import AbstractImageModel, ImageModel, ConcreteImageModel + +from .models import AbstractImageModel, ConcreteImageModel, ImageModel from .utils import get_image_file @@ -25,7 +26,8 @@ def test_source_saved_signal(): source_group = ImageFieldSourceGroup(ImageModel, 'image') receiver = make_counting_receiver(source_group) source_saved.connect(receiver) - ImageModel.objects.create(image=File(get_image_file(), name='reference.png')) + with File(get_image_file(), name='reference.png') as image: + ImageModel.objects.create(image=image) assert receiver.count == 1 @@ -55,5 +57,6 @@ def test_abstract_model_signals(): source_group = ImageFieldSourceGroup(AbstractImageModel, 'original_image') receiver = make_counting_receiver(source_group) source_saved.connect(receiver) - ConcreteImageModel.objects.create(original_image=File(get_image_file(), name='reference.png')) + with File(get_image_file(), name='reference.png') as image: + ConcreteImageModel.objects.create(original_image=image) assert receiver.count == 1 diff --git a/tests/test_thumbnail_tag.py b/tests/test_thumbnail_tag.py index 31a0116e..f3bdfda6 100644 --- a/tests/test_thumbnail_tag.py +++ b/tests/test_thumbnail_tag.py @@ -1,15 +1,25 @@ import pytest - from django.template import TemplateSyntaxError + from . import imagegenerators # noqa -from .utils import render_tag, get_html_attrs, clear_imagekit_cache +from .utils import clear_imagekit_cache, get_html_attrs, render_tag def test_img_tag(): ttag = r"""{% thumbnail '100x100' img %}""" clear_imagekit_cache() attrs = get_html_attrs(ttag) - expected_attrs = set(['src', 'width', 'height']) + expected_attrs = {'src', 'width', 'height'} + assert set(attrs.keys()) == expected_attrs + for k in expected_attrs: + assert attrs[k].strip() != '' + + +def test_img_tag_anchor(): + ttag = r"""{% thumbnail '100x100' img anchor='c' %}""" + clear_imagekit_cache() + attrs = get_html_attrs(ttag) + expected_attrs = {'src', 'width', 'height'} assert set(attrs.keys()) == expected_attrs for k in expected_attrs: assert attrs[k].strip() != '' @@ -58,6 +68,13 @@ def test_assignment_tag(): assert html != '' +def test_assignment_tag_anchor(): + ttag = r"""{% thumbnail '100x100' img anchor='c' as th %}{{ th.url }}""" + clear_imagekit_cache() + html = render_tag(ttag) + assert html != '' + + def test_single_dimension(): ttag = r"""{% thumbnail '100x' img as th %}{{ th.width }}""" clear_imagekit_cache() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..0bac5dc8 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,42 @@ +import django +from django.test import override_settings +import pytest +from imagekit.utils import get_storage + + +def test_get_storage_default(): + from django.core.files.storage import default_storage + + if django.VERSION >= (4, 2): + assert get_storage() == default_storage + else: + assert isinstance(get_storage(), type(default_storage._wrapped)) + + +@pytest.mark.skipif( + django.VERSION >= (5, 1), + reason="DEFAULT_FILE_STORAGE is removed in Django 5.1.", +) +def test_get_storage_custom_import_path(): + from tests.utils import CustomStorage + + with override_settings(IMAGEKIT_DEFAULT_FILE_STORAGE="tests.utils.CustomStorage"): + assert isinstance(get_storage(), CustomStorage) + + +@pytest.mark.skipif( + django.VERSION < (4, 2), + reason="STORAGES was introduced in Django 4.2", +) +def test_get_storage_custom_key(): + from tests.utils import CustomStorage + + with override_settings( + STORAGES={ + "custom": { + "BACKEND": "tests.utils.CustomStorage", + } + }, + IMAGEKIT_DEFAULT_FILE_STORAGE="custom", + ): + assert isinstance(get_storage(), CustomStorage) diff --git a/tests/utils.py b/tests/utils.py index 61ce882b..1909772e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,14 +1,19 @@ -from bs4 import BeautifulSoup import os +import pickle import shutil +from io import BytesIO +from tempfile import NamedTemporaryFile + +from bs4 import BeautifulSoup from django.core.files import File +from django.core.files.storage import FileSystemStorage from django.template import Context, Template -from imagekit.cachefiles.backends import Simple, CacheFileState +from PIL import Image + +from imagekit.cachefiles.backends import Simple from imagekit.conf import settings -from imagekit.lib import Image, StringIO from imagekit.utils import get_cache -import pickle -from tempfile import NamedTemporaryFile + from .models import Photo @@ -27,7 +32,8 @@ def get_image_file(): def get_unique_image_file(): file = NamedTemporaryFile() - file.write(get_image_file().read()) + with get_image_file() as image: + file.write(image.read()) return file @@ -49,17 +55,17 @@ def create_photo(name): def pickleback(obj): - pickled = StringIO() + pickled = BytesIO() pickle.dump(obj, pickled) pickled.seek(0) return pickle.load(pickled) def render_tag(ttag): - img = get_image_file() - template = Template('{%% load imagekit %%}%s' % ttag) - context = Context({'img': img}) - return template.render(context) + with get_image_file() as img: + template = Template('{%% load imagekit %%}%s' % ttag) + context = Context({'img': img}) + return template.render(context) def get_html_attrs(ttag): @@ -74,6 +80,10 @@ def assert_file_is_truthy(file): assert bool(file), 'File is not truthy' +class CustomStorage(FileSystemStorage): + pass + + class DummyAsyncCacheFileBackend(Simple): """ A cache file backend meant to simulate async generation. diff --git a/tox.ini b/tox.ini index f71b83cc..bcf55e8e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,27 @@ [tox] envlist = - py27-django{111,18} - py{36,37,38,39,310}-django{22,31,32}, - py310-djangomain, + py37-django{32} + py38-django{42, 41, 32} + py39-django{42, 41, 32} + py310-django{42, 41, 32} + py311-django{42, 41} + py311-djangomain, coverage-report [gh-actions] python = - 2.7: py27 - 3.6: py36 3.7: py37 3.8: py38 3.9: py39 - 3.10: py310, coverage-report + 3.10: py310 + 3.11: py311, coverage-report [testenv] deps = -r test-requirements.txt - django18: django~=1.8.0 - django111: django~=1.11.0 - django22: django~=2.2.0 - django31: django~=3.1.0 django32: django~=3.2.0 + django41: django~=4.1.0 + django42: django~=4.2.0 djangomain: https://github.com/django/django/archive/refs/heads/main.zip setenv = COVERAGE_FILE=.coverage.{envname}