diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5a5e2e7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - 2.7 +install: pip install tox --use-mirrors +script: tox -e py27-django13,py27-django12,py26-django13,py27-django12 +notifications: + irc: "irc.freenode.org#imagekit" diff --git a/AUTHORS b/AUTHORS index d20dfee..24968f0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,7 +22,9 @@ Contributors * `Alexander Bohn`_ * `Timothée Peignier`_ * `Madis Väin`_ - +* `Jan Sagemüller`_ +* `Clay McClure`_ +* `Jannis Leidel`_ .. _Justin Driscoll: http://github.com/jdriscoll .. _HZDG: http://hzdg.com @@ -39,3 +41,6 @@ Contributors .. _Alexander Bohn: http://github.com/fish2000 .. _Timothée Peignier: http://github.com/cyberdelia .. _Madis Väin: http://github.com/madisvain +.. _Jan Sagemüller: https://github.com/version2 +.. _Clay McClure: https://github.com/claymation +.. _Jannis Leidel: https://github.com/jezdez diff --git a/README.rst b/README.rst index c968bd7..041d02d 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,12 @@ - ImageKit is a Django app that helps you to add variations of uploaded images to your models. These variations are called "specs" and can include things like different sizes (e.g. thumbnails) and black and white versions. -For the full documentation, see `ImageKit on RTD`_. +**For the complete documentation on the latest stable version of ImageKit, see** +`ImageKit on RTD`_. Our `changelog is also available`_. .. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org +.. _`changelog is also available`: http://django-imagekit.readthedocs.org/en/latest/changelog.html Installation @@ -30,10 +31,12 @@ Adding Specs to a Model ----------------------- Much like ``django.db.models.ImageField``, Specs are defined as properties -of a model class:: +of a model class: + +.. code-block:: python from django.db import models - from imagekit.models.fields import ImageSpecField + from imagekit.models import ImageSpecField class Photo(models.Model): original_image = models.ImageField(upload_to='photos') @@ -42,13 +45,28 @@ of a model class:: Accessing the spec through a model instance will create the image and return an ImageFile-like object (just like with a normal -``django.db.models.ImageField``):: +``django.db.models.ImageField``): + +.. code-block:: python photo = Photo.objects.all()[0] photo.original_image.url # > '/media/photos/birthday.tiff' photo.formatted_image.url # > '/media/cache/photos/birthday_formatted_image.jpeg' -Check out ``imagekit.models.fields.ImageSpecField`` for more information. +Check out ``imagekit.models.ImageSpecField`` for more information. + +If you only want to save the processed image (without maintaining the original), +you can use a ``ProcessedImageField``: + +.. code-block:: python + + from django.db import models + from imagekit.models.fields import ProcessedImageField + + class Photo(models.Model): + processed_image = ProcessedImageField(format='JPEG', options={'quality': 90}) + +See the class documentation for details. Processors @@ -56,19 +74,23 @@ Processors The real power of ImageKit comes from processors. Processors take an image, do something to it, and return the result. By providing a list of processors to -your spec, you can expose different versions of the original image:: +your spec, you can expose different versions of the original image: + +.. code-block:: python from django.db import models - from imagekit.models.fields import ImageSpecField - from imagekit.processors import resize, Adjust + from imagekit.models import ImageSpecField + from imagekit.processors import ResizeToFill, Adjust class Photo(models.Model): original_image = models.ImageField(upload_to='photos') thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), - resize.Fill(50, 50)], image_field='original_image', + ResizeToFill(50, 50)], image_field='original_image', format='JPEG', options={'quality': 90}) -The ``thumbnail`` property will now return a cropped image:: +The ``thumbnail`` property will now return a cropped image: + +.. code-block:: python photo = Photo.objects.all()[0] photo.thumbnail.url # > '/media/cache/photos/birthday_thumbnail.jpeg' @@ -76,13 +98,16 @@ The ``thumbnail`` property will now return a cropped image:: photo.original_image.width # > 1000 The original image is not modified; ``thumbnail`` is a new file that is the -result of running the ``imagekit.processors.resize.Fill`` processor on the -original. +result of running the ``imagekit.processors.ResizeToFill`` processor on the +original. (If you only need to save the processed image, and not the original, +pass processors to a ``ProcessedImageField`` instead of an ``ImageSpecField``.) The ``imagekit.processors`` module contains processors for many common image manipulations, like resizing, rotating, and color adjustments. However, if they aren't up to the task, you can create your own. All you have to do is -implement a ``process()`` method:: +implement a ``process()`` method: + +.. code-block:: python class Watermark(object): def process(self, image): @@ -101,7 +126,9 @@ Admin ImageKit also contains a class named ``imagekit.admin.AdminThumbnail`` for displaying specs (or even regular ImageFields) in the `Django admin change list`_. AdminThumbnail is used as a property on -Django admin classes:: +Django admin classes: + +.. code-block:: python from django.contrib import admin from imagekit.admin import AdminThumbnail @@ -119,3 +146,60 @@ AdminThumbnail can even use a custom template. For more information, see ``imagekit.admin.AdminThumbnail``. .. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list + + +Image Cache Backends +-------------------- + +Whenever you access properties like ``url``, ``width`` and ``height`` of an +``ImageSpecField``, its cached image is validated; whenever you save a new image +to the ``ImageField`` your spec uses as a source, the spec image is invalidated. +The default way to validate a cache image is to check to see if the file exists +and, if not, generate a new one; the default way to invalidate the cache is to +delete the image. This is a very simple and straightforward way to handle cache +validation, but it has its drawbacks—for example, checking to see if the image +exists means frequently hitting the storage backend. + +Because of this, ImageKit allows you to define custom image cache backends. To +be a valid image cache backend, a class must implement three methods: +``validate``, ``invalidate``, and ``clear`` (which is called when the image is +no longer needed in any form, i.e. the model is deleted). Each of these methods +must accept a file object, but the internals are up to you. For example, you +could store the state (valid, invalid) of the cache in a database to avoid +filesystem access. You can then specify your image cache backend on a per-field +basis: + +.. code-block:: python + + class Photo(models.Model): + ... + thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend()) + +Or in your ``settings.py`` file if you want to use it as the default: + +.. code-block:: python + + IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend' + + +Community +--------- + +Please use `the GitHub issue tracker `_ +to report bugs with django-imagekit. `A mailing list `_ +also exists to discuss the project and ask questions, as well as the official +`#imagekit `_ channel on Freenode. + + +Contributing +------------ + +We love contributions! And you don't have to be an expert with the library—or +even Django—to contribute either: ImageKit's processors are standalone classes +that are completely separate from the more intimidating internals of Django's +ORM. If you've written a processor that you think might be useful to other +people, open a pull request so we can take a look! + +ImageKit's image cache backends are also fairly isolated from the ImageKit guts. +If you've fine-tuned one to work perfectly for a popular file storage backend, +let us take a look! Maybe other people could use it. diff --git a/docs/apireference.rst b/docs/apireference.rst index cbaca49..d4a2ed8 100644 --- a/docs/apireference.rst +++ b/docs/apireference.rst @@ -5,7 +5,7 @@ API Reference :mod:`models` Module -------------------- -.. automodule:: imagekit.models +.. automodule:: imagekit.models.fields :members: @@ -13,13 +13,13 @@ API Reference ------------------------ .. automodule:: imagekit.processors - :members: + :members: .. automodule:: imagekit.processors.resize - :members: + :members: .. automodule:: imagekit.processors.crop -:members: + :members: :mod:`admin` Module diff --git a/docs/changelog.rst b/docs/changelog.rst index 742ad29..34d3ef7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,76 @@ Changelog ========= +v2.0.1 +------ + +- Fixed a file descriptor leak in the `utils.quiet()` context manager. + + +v2.0.0 +------ + +- Added the concept of image cache backends. Image cache backends assume + control of validating and invalidating the cached images from `ImageSpec` in + versions past. The default backend maintins the current behavior: invalidating + an image deletes it, while validating checks whether the file exists and + creates the file if it doesn't. One can create custom image cache backends to + control how their images are cached (e.g., Celery, etc.). + + ImageKit ships with three built-in backends: + + - ``imagekit.imagecache.PessimisticImageCacheBackend`` - A very safe image + cache backend. Guarantees that files will always be available, but at the + cost of hitting the storage backend. + - ``imagekit.imagecache.NonValidatingImageCacheBackend`` - A backend that is + super optimistic about the existence of spec files. It will hit your file + storage much less frequently than the pessimistic backend, but it is + technically possible for a cache file to be missing after validation. + - ``imagekit.imagecache.celery.CeleryImageCacheBackend`` - A pessimistic cache + state backend that uses celery to generate its spec images. Like + ``PessimisticCacheStateBackend``, this one checks to see if the file + exists on validation, so the storage is hit fairly frequently, but an + image is guaranteed to exist. However, while validation guarantees the + existence of *an* image, it does not necessarily guarantee that you will + get the correct image, as the spec may be pending regeneration. In other + words, while there are ``generate`` tasks in the queue, it is possible to + get a stale spec image. The tradeoff is that calling ``invalidate()`` + won't block to interact with file storage. + +- Some of the processors have been renamed and several new ones have been added: + + - ``imagekit.processors.ResizeToFill`` - (previously + ``imagekit.processors.resize.Crop``) Scales the image to fill the provided + dimensions and then trims away the excess. + - ``imagekit.processors.ResizeToFit`` - (previously + ``imagekit.processors.resize.Fit``) Scale to fit the provided dimensions. + - ``imagekit.processors.SmartResize`` - Like ``ResizeToFill``, but crops using + entroy (``SmartCrop``) instead of an anchor argument. + - ``imagekit.processors.BasicCrop`` - Crop using provided box. + - ``imagekit.processors.SmartCrop`` - (previously + ``imagekit.processors.resize.SmartCrop``) Crop to provided size, trimming + based on entropy. + - ``imagekit.processors.TrimBorderColor`` - Trim the specified color from the + specified sides. + - ``imagekit.processors.AddBorder`` - Add a border of specific color and + thickness to an image. + - ``imagekit.processors.Resize`` - Scale to the provided dimensions (can distort). + - ``imagekit.processors.ResizeToCover`` - Scale to the smallest size that will + cover the specified dimensions. Used internally by ``Fill`` and + ``SmartFill``. + - ``imagekit.processors.ResizeCanvas`` - Takes an image an resizes the canvas, + using a specific background color if the new size is larger than the current + image. + +- ``mat_color`` has been added as an arguemnt to ``ResizeToFit``. If set, the + the target image size will be enforced and the specified color will be + used as background color to pad the image. + +- We now use `Tox`_ to automate testing. + +.. _`Tox`: http://pypi.python.org/pypi/tox + + v1.1.0 ------ @@ -15,6 +85,7 @@ v1.1.0 - The private ``_Resize`` class has been removed. + v1.0.3 ------ @@ -30,6 +101,7 @@ v1.0.3 - Fixed PIL zeroing out files when write mode is enabled. + v1.0.2 ------ @@ -42,6 +114,7 @@ v1.0.2 - Fixed a regression from the 0.4.x series in which ImageKit was unable to convert a PNG file in ``P`` or "palette" mode to JPEG. + v1.0.1 ------ @@ -51,6 +124,7 @@ v1.0.1 - Fixed the included admin template not being found when ImageKit was and the packaging of the included admin templates. + v1.0 ---- diff --git a/docs/conf.py b/docs/conf.py index 274e849..9305a5c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,8 +16,9 @@ import sys, os # 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 # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('..')) sys.path.append(os.path.abspath('_themes')) +os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' # -- General configuration ----------------------------------------------------- @@ -49,9 +50,9 @@ copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & # built documents. # # The short X.Y version. -version = '1.1.0' +version = '2.0.1' # The full version, including alpha/beta/rc tags. -release = '1.1.0' +release = '2.0.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -121,7 +122,7 @@ html_theme_path = ['_themes'] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/docs/index.rst b/docs/index.rst index 5e78c02..2c06385 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,9 @@ Getting Started Commands -------- -.. automodule:: imagekit.management.commands.ikflush +.. automodule:: imagekit.management.commands.ikcacheinvalidate + +.. automodule:: imagekit.management.commands.ikcachevalidate Authors @@ -16,6 +18,14 @@ Authors .. include:: ../AUTHORS +Community +--------- + +The official Freenode channel for ImageKit is `#imagekit `_. +You should always find some fine people to answer your questions +about ImageKit there. + + Digging Deeper -------------- diff --git a/imagekit/__init__.py b/imagekit/__init__.py index 1b2a4a3..55a5d04 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,4 +1,34 @@ __title__ = 'django-imagekit' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' -__version__ = (1, 1, 0, 'final', 0) +__version__ = (2, 0, 1, 'final', 0) __license__ = 'BSD' + + +def get_version(version=None): + """Derives a PEP386-compliant version number from VERSION.""" + if version is None: + version = __version__ + assert len(version) == 5 + assert version[3] in ('alpha', 'beta', 'rc', 'final') + + # Now build the two parts of the version number: + # main = X.Y[.Z] + # sub = .devN - for pre-alpha releases + # | {a|b|c}N - for alpha, beta and rc releases + + parts = 2 if version[2] == 0 else 3 + main = '.'.join(str(x) for x in version[:parts]) + + sub = '' + if version[3] == 'alpha' and version[4] == 0: + # At the toplevel, this would cause an import loop. + from django.utils.version import get_svn_revision + svn_revision = get_svn_revision()[4:] + if svn_revision != 'unknown': + sub = '.dev%s' % svn_revision + + elif version[3] != 'final': + mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'} + sub = mapping[version[3]] + str(version[4]) + + return main + sub diff --git a/imagekit/admin.py b/imagekit/admin.py index f764d3e..4466e6e 100644 --- a/imagekit/admin.py +++ b/imagekit/admin.py @@ -21,11 +21,14 @@ class AdminThumbnail(object): self.template = template def __call__(self, obj): - try: - thumbnail = getattr(obj, self.image_field) - except AttributeError: - raise Exception('The property %s is not defined on %s.' % \ - (self.image_field, obj.__class__.__name__)) + if callable(self.image_field): + thumbnail = self.image_field(obj) + else: + try: + thumbnail = getattr(obj, self.image_field) + except AttributeError: + raise Exception('The property %s is not defined on %s.' % \ + (self.image_field, obj.__class__.__name__)) original_image = getattr(thumbnail, 'source_file', None) or thumbnail template = self.template or 'imagekit/admin/thumbnail.html' diff --git a/imagekit/conf.py b/imagekit/conf.py new file mode 100644 index 0000000..51ddf55 --- /dev/null +++ b/imagekit/conf.py @@ -0,0 +1,5 @@ +from appconf import AppConf + + +class ImageKitConf(AppConf): + DEFAULT_IMAGE_CACHE_BACKEND = 'imagekit.imagecache.PessimisticImageCacheBackend' diff --git a/imagekit/generators.py b/imagekit/generators.py index bf012e1..046f0c0 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -1,20 +1,16 @@ import os -from StringIO import StringIO - -from django.core.files.base import ContentFile - -from .processors import ProcessorPipeline, AutoConvert -from .utils import img_to_fobj, open_image, \ - format_to_extension, extension_to_format, UnknownFormatError, \ - UnknownExtensionError +from .lib import StringIO +from .processors import ProcessorPipeline +from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format, + UnknownExtensionError) class SpecFileGenerator(object): - def __init__(self, processors=None, format=None, options={}, + def __init__(self, processors=None, format=None, options=None, autoconvert=True, storage=None): self.processors = processors self.format = format - self.options = options + self.options = options or {} self.autoconvert = autoconvert self.storage = storage @@ -32,7 +28,7 @@ class SpecFileGenerator(object): # Determine the format. format = self.format - if not format: + if filename and not format: # Try to guess the format from the extension. extension = os.path.splitext(filename)[1].lower() if extension: @@ -42,39 +38,10 @@ class SpecFileGenerator(object): pass format = format or img.format or original_format or 'JPEG' - # Run the AutoConvert processor - if self.autoconvert: - autoconvert_processor = AutoConvert(format) - img = autoconvert_processor.process(img) - options = dict(autoconvert_processor.save_kwargs.items() + \ - options.items()) - imgfile = img_to_fobj(img, format, **options) - content = ContentFile(imgfile.read()) + content = IKContentFile(filename, imgfile.read(), format=format) return img, content - def suggest_extension(self, name): - original_extension = os.path.splitext(name)[1] - try: - suggested_extension = format_to_extension(self.format) - except UnknownFormatError: - extension = original_extension - else: - if suggested_extension.lower() == original_extension.lower(): - extension = original_extension - else: - try: - original_format = extension_to_format(original_extension) - except UnknownExtensionError: - extension = suggested_extension - else: - # If the formats match, give precedence to the original extension. - if self.format.lower() == original_format.lower(): - extension = original_extension - else: - extension = suggested_extension - return extension - def generate_file(self, filename, source_file, save=True): """ Generates a new image file by processing the source file and returns diff --git a/imagekit/imagecache/__init__.py b/imagekit/imagecache/__init__.py new file mode 100644 index 0000000..cf98a9d --- /dev/null +++ b/imagekit/imagecache/__init__.py @@ -0,0 +1,34 @@ +from django.core.exceptions import ImproperlyConfigured +from django.utils.importlib import import_module + +from imagekit.imagecache.base import InvalidImageCacheBackendError, PessimisticImageCacheBackend, NonValidatingImageCacheBackend + +_default_image_cache_backend = None + + +def get_default_image_cache_backend(): + """ + Get the default image cache backend. Uses the same method as + django.core.file.storage.get_storage_class + + """ + global _default_image_cache_backend + if not _default_image_cache_backend: + from django.conf import settings + import_path = settings.IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND + try: + dot = import_path.rindex('.') + except ValueError: + raise ImproperlyConfigured("%s isn't an image cache backend module." % \ + import_path) + module, classname = import_path[:dot], import_path[dot + 1:] + try: + mod = import_module(module) + except ImportError, e: + raise ImproperlyConfigured('Error importing image cache backend module %s: "%s"' % (module, e)) + try: + cls = getattr(mod, classname) + _default_image_cache_backend = cls() + except AttributeError: + raise ImproperlyConfigured('Image cache backend module "%s" does not define a "%s" class.' % (module, classname)) + return _default_image_cache_backend diff --git a/imagekit/imagecache.py b/imagekit/imagecache/base.py similarity index 55% rename from imagekit/imagecache.py rename to imagekit/imagecache/base.py index 3784957..f06c9b5 100644 --- a/imagekit/imagecache.py +++ b/imagekit/imagecache/base.py @@ -1,5 +1,8 @@ from django.core.exceptions import ImproperlyConfigured -from django.utils.importlib import import_module + + +class InvalidImageCacheBackendError(ImproperlyConfigured): + pass class PessimisticImageCacheBackend(object): @@ -55,33 +58,3 @@ class NonValidatingImageCacheBackend(object): def clear(self, file): file.delete(save=False) - - -_default_image_cache_backend = None - - -def get_default_image_cache_backend(): - """ - Get the default image cache backend. Uses the same method as - django.core.file.storage.get_storage_class - - """ - global _default_image_cache_backend - if not _default_image_cache_backend: - from .settings import DEFAULT_IMAGE_CACHE_BACKEND as import_path - try: - dot = import_path.rindex('.') - except ValueError: - raise ImproperlyConfigured("%s isn't an image cache backend module." % \ - import_path) - module, classname = import_path[:dot], import_path[dot+1:] - try: - mod = import_module(module) - except ImportError, e: - raise ImproperlyConfigured('Error importing image cache backend module %s: "%s"' % (module, e)) - try: - cls = getattr(mod, classname) - _default_image_cache_backend = cls() - except AttributeError: - raise ImproperlyConfigured('Image cache backend module "%s" does not define a "%s" class.' % (module, classname)) - return _default_image_cache_backend diff --git a/imagekit/imagecache/celery.py b/imagekit/imagecache/celery.py new file mode 100644 index 0000000..9dee5ca --- /dev/null +++ b/imagekit/imagecache/celery.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from imagekit.imagecache import PessimisticImageCacheBackend, InvalidImageCacheBackendError + + +def generate(model, pk, attr): + try: + instance = model._default_manager.get(pk=pk) + except model.DoesNotExist: + pass # The model was deleted since the task was scheduled. NEVER MIND! + else: + field_file = getattr(instance, attr) + field_file.delete(save=False) + field_file.generate(save=True) + + +class CeleryImageCacheBackend(PessimisticImageCacheBackend): + """ + A pessimistic cache state backend that uses celery to generate its spec + images. Like PessimisticCacheStateBackend, this one checks to see if the + file exists on validation, so the storage is hit fairly frequently, but an + image is guaranteed to exist. However, while validation guarantees the + existence of *an* image, it does not necessarily guarantee that you will get + the correct image, as the spec may be pending regeneration. In other words, + while there are `generate` tasks in the queue, it is possible to get a + stale spec image. The tradeoff is that calling `invalidate()` won't block + to interact with file storage. + + """ + def __init__(self): + try: + from celery.task import task + except: + raise InvalidImageCacheBackendError("Celery image cache backend requires the 'celery' library") + if not getattr(CeleryImageCacheBackend, '_task', None): + CeleryImageCacheBackend._task = task(generate) + + def invalidate(self, file): + self._task.delay(file.instance.__class__, file.instance.pk, file.attname) + + def clear(self, file): + file.delete(save=False) diff --git a/imagekit/lib.py b/imagekit/lib.py index efacb79..574e587 100644 --- a/imagekit/lib.py +++ b/imagekit/lib.py @@ -15,3 +15,8 @@ except ImportError: 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 cStringIO import StringIO +except ImportError: + from StringIO import StringIO diff --git a/imagekit/models/__init__.py b/imagekit/models/__init__.py index 97798e1..4207987 100644 --- a/imagekit/models/__init__.py +++ b/imagekit/models/__init__.py @@ -1,3 +1,4 @@ +from .. import conf from .fields import ImageSpecField, ProcessedImageField import warnings @@ -5,6 +6,6 @@ import warnings class ImageSpec(ImageSpecField): def __init__(self, *args, **kwargs): warnings.warn('ImageSpec has been moved to' - ' imagekit.models.fields.ImageSpecField. Please use that' - ' instead.', DeprecationWarning) + ' imagekit.models.ImageSpecField. Please use that instead.', + DeprecationWarning) super(ImageSpec, self).__init__(*args, **kwargs) diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index f7c5237..3e11e69 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -1,10 +1,16 @@ +import os + from django.db import models -from django.db.models.signals import post_init, post_save, post_delete from ...imagecache import get_default_image_cache_backend from ...generators import SpecFileGenerator from .files import ImageSpecFieldFile, ProcessedImageFieldFile -from .utils import ImageSpecFieldDescriptor, ImageKitMeta, BoundImageKitMeta +from ..receivers import configure_receivers +from .utils import ImageSpecFileDescriptor, ImageKitMeta, BoundImageKitMeta +from ...utils import suggest_extension + + +configure_receivers() class ImageSpecField(object): @@ -13,7 +19,7 @@ class ImageSpecField(object): variants of uploaded images to your models. """ - def __init__(self, processors=None, format=None, options={}, + def __init__(self, processors=None, format=None, options=None, image_field=None, pre_cache=None, storage=None, cache_to=None, autoconvert=True, image_cache_backend=None): """ @@ -45,8 +51,8 @@ class ImageSpecField(object): based on that format. if not, the extension of the original file will be passed. You do not have to use this extension, it's only a recommendation. - :param autoconvert: Specifies whether the AutoConvert processor - should be run before saving. + :param autoconvert: Specifies whether automatic conversion using + ``prepare_image()`` should be performed prior to saving. :param image_cache_backend: An object responsible for managing the state of cached files. Defaults to an instance of IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND @@ -73,59 +79,27 @@ class ImageSpecField(object): get_default_image_cache_backend() def contribute_to_class(self, cls, name): - setattr(cls, name, ImageSpecFieldDescriptor(self, name)) + setattr(cls, name, ImageSpecFileDescriptor(self, name)) try: - ik = getattr(cls, '_ik') - except AttributeError: - ik = ImageKitMeta() + # Make sure we don't modify an inherited ImageKitMeta instance + ik = cls.__dict__['ik'] + except KeyError: + try: + base = getattr(cls, '_ik') + except AttributeError: + ik = ImageKitMeta() + else: + # Inherit all the spec fields. + ik = ImageKitMeta(base.spec_fields) setattr(cls, '_ik', ik) ik.spec_fields.append(name) - # Connect to the signals only once for this class. - uid = '%s.%s' % (cls.__module__, cls.__name__) - post_init.connect(ImageSpecField._post_init_receiver, sender=cls, - dispatch_uid=uid) - post_save.connect(ImageSpecField._post_save_receiver, sender=cls, - dispatch_uid=uid) - post_delete.connect(ImageSpecField._post_delete_receiver, sender=cls, - dispatch_uid=uid) - # Register the field with the image_cache_backend try: self.image_cache_backend.register_field(cls, self, name) except AttributeError: pass - @staticmethod - def _post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs): - if not raw: - old_hashes = instance._ik._source_hashes.copy() - new_hashes = ImageSpecField._update_source_hashes(instance) - for attname in instance._ik.spec_fields: - if old_hashes[attname] != new_hashes[attname]: - getattr(instance, attname).invalidate() - - @staticmethod - def _update_source_hashes(instance): - """ - Stores hashes of the source image files so that they can be compared - later to see whether the source image has changed (and therefore whether - the spec file needs to be regenerated). - - """ - instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \ - for f in instance._ik.spec_files) - return instance._ik._source_hashes - - @staticmethod - def _post_delete_receiver(sender, instance=None, **kwargs): - for spec_file in instance._ik.spec_files: - spec_file.clear() - - @staticmethod - def _post_init_receiver(sender, instance, **kwargs): - ImageSpecField._update_source_hashes(instance) - class ProcessedImageField(models.ImageField): """ @@ -137,14 +111,14 @@ class ProcessedImageField(models.ImageField): """ attr_class = ProcessedImageFieldFile - def __init__(self, processors=None, format=None, options={}, + def __init__(self, processors=None, format=None, options=None, verbose_name=None, name=None, width_field=None, height_field=None, autoconvert=True, **kwargs): """ The ProcessedImageField constructor accepts all of the arguments that the :class:`django.db.models.ImageField` constructor accepts, as well as the ``processors``, ``format``, and ``options`` arguments of - :class:`imagekit.models.fields.ImageSpecField`. + :class:`imagekit.models.ImageSpecField`. """ if 'quality' in kwargs: @@ -160,8 +134,8 @@ class ProcessedImageField(models.ImageField): filename = os.path.normpath(self.storage.get_valid_name( os.path.basename(filename))) name, ext = os.path.splitext(filename) - ext = self.generator.suggest_extension(filename) - return '%s%s' % (name, ext) + ext = suggest_extension(filename, self.generator.format) + return u'%s%s' % (name, ext) try: diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 7e964bc..d6f3dc2 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -1,15 +1,16 @@ import os import datetime -from django.db.models.fields.files import ImageFieldFile +from django.db.models.fields.files import ImageField, ImageFieldFile from django.utils.encoding import force_unicode, smart_str +from ...utils import suggest_extension + class ImageSpecFieldFile(ImageFieldFile): def __init__(self, instance, field, attname): - ImageFieldFile.__init__(self, instance, field, None) + super(ImageSpecFieldFile, self).__init__(instance, field, None) self.attname = attname - self.storage = self.field.storage or self.source_file.storage @property def source_file(self): @@ -19,7 +20,7 @@ class ImageSpecFieldFile(ImageFieldFile): else: image_fields = [getattr(self.instance, f.attname) for f in \ self.instance.__class__._meta.fields if \ - isinstance(f, models.ImageField)] + isinstance(f, ImageField)] if len(image_fields) == 0: raise Exception('%s does not define any ImageFields, so your' \ ' %s ImageSpecField has no image to act on.' % \ @@ -36,12 +37,8 @@ class ImageSpecFieldFile(ImageFieldFile): def _require_file(self): if not self.source_file: raise ValueError("The '%s' attribute's image_field has no file associated with it." % self.attname) - - def _get_file(self): - self.validate() - return super(ImageFieldFile, self).file - - file = property(_get_file, ImageFieldFile._set_file, ImageFieldFile._del_file) + else: + self.validate() def clear(self): return self.field.image_cache_backend.clear(self) @@ -61,11 +58,6 @@ class ImageSpecFieldFile(ImageFieldFile): return self.field.generator.generate_file(self.name, self.source_file, save) - @property - def url(self): - self.validate() - return super(ImageFieldFile, self).url - def delete(self, save=False): """ Pulled almost verbatim from ``ImageFieldFile.delete()`` and @@ -107,7 +99,7 @@ class ImageSpecFieldFile(ImageFieldFile): filepath, basename = os.path.split(path) filename = os.path.splitext(basename)[0] new_name = '%s_%s%s' % (filename, specname, extension) - return os.path.join(os.path.join('cache', filepath), new_name) + return os.path.join('cache', filepath, new_name) @property def name(self): @@ -127,9 +119,8 @@ class ImageSpecFieldFile(ImageFieldFile): raise Exception('No cache_to or default_cache_to value' ' specified') if callable(cache_to): - suggested_extension = \ - self.field.generator.suggest_extension( - self.source_file.name) + suggested_extension = suggest_extension( + self.source_file.name, self.field.generator.format) new_filename = force_unicode( datetime.datetime.now().strftime( smart_str(cache_to(self.instance, @@ -153,6 +144,25 @@ class ImageSpecFieldFile(ImageFieldFile): # it at least that one time. pass + @property + def storage(self): + return getattr(self, '_storage', None) or self.field.storage or self.source_file.storage + + @storage.setter + def storage(self, storage): + self._storage = storage + + def __getstate__(self): + return dict( + attname=self.attname, + instance=self.instance, + ) + + def __setstate__(self, state): + self.attname = state['attname'] + self.instance = state['instance'] + self.field = getattr(self.instance.__class__, self.attname) + class ProcessedImageFieldFile(ImageFieldFile): def save(self, name, content, save=True): diff --git a/imagekit/models/fields/utils.py b/imagekit/models/fields/utils.py index ce40bbb..1b3ccaa 100644 --- a/imagekit/models/fields/utils.py +++ b/imagekit/models/fields/utils.py @@ -13,7 +13,7 @@ class BoundImageKitMeta(object): class ImageKitMeta(object): def __init__(self, spec_fields=None): - self.spec_fields = spec_fields or [] + self.spec_fields = list(spec_fields) if spec_fields else [] def __get__(self, instance, owner): if instance is None: @@ -24,7 +24,7 @@ class ImageKitMeta(object): return ik -class ImageSpecFieldDescriptor(object): +class ImageSpecFileDescriptor(object): def __init__(self, field, attname): self.attname = attname self.field = field @@ -35,5 +35,8 @@ class ImageSpecFieldDescriptor(object): else: img_spec_file = ImageSpecFieldFile(instance, self.field, self.attname) - setattr(instance, self.attname, img_spec_file) + instance.__dict__[self.attname] = img_spec_file return img_spec_file + + def __set__(self, instance, value): + instance.__dict__[self.attname] = value diff --git a/imagekit/models/receivers.py b/imagekit/models/receivers.py new file mode 100644 index 0000000..da93a69 --- /dev/null +++ b/imagekit/models/receivers.py @@ -0,0 +1,48 @@ +from django.db.models.signals import post_init, post_save, post_delete +from ..utils import ik_model_receiver + + +def update_source_hashes(instance): + """ + Stores hashes of the source image files so that they can be compared + later to see whether the source image has changed (and therefore whether + the spec file needs to be regenerated). + + """ + instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \ + for f in instance._ik.spec_files) + return instance._ik._source_hashes + + +@ik_model_receiver +def post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs): + if not raw: + old_hashes = instance._ik._source_hashes.copy() + new_hashes = update_source_hashes(instance) + for attname in instance._ik.spec_fields: + if old_hashes[attname] != new_hashes[attname]: + getattr(instance, attname).invalidate() + + +@ik_model_receiver +def post_delete_receiver(sender, instance=None, **kwargs): + for spec_file in instance._ik.spec_files: + spec_file.clear() + + +@ik_model_receiver +def post_init_receiver(sender, instance, **kwargs): + update_source_hashes(instance) + + +def configure_receivers(): + # Connect the signals. We have to listen to every model (not just those + # with IK fields) and filter in our receivers because of a Django issue with + # abstract base models. + # Related: + # https://github.com/jdriscoll/django-imagekit/issues/126 + # https://code.djangoproject.com/ticket/9318 + uid = 'ik_spec_field_receivers' + post_init.connect(post_init_receiver, dispatch_uid=uid) + post_save.connect(post_save_receiver, dispatch_uid=uid) + post_delete.connect(post_delete_receiver, dispatch_uid=uid) diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py index c3be66c..c2c9320 100644 --- a/imagekit/processors/__init__.py +++ b/imagekit/processors/__init__.py @@ -7,260 +7,7 @@ should be limited to image manipulations--they should be completely decoupled from both the filesystem and the ORM. """ -from imagekit.lib import Image, ImageColor, ImageEnhance -from imagekit.processors import resize, crop - -RGBA_TRANSPARENCY_FORMATS = ['PNG'] -PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] - - -class ProcessorPipeline(list): - """ - A :class:`list` of other processors. This class allows any object that - knows how to deal with a single processor to deal with a list of them. - For example:: - - processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image) - - """ - def process(self, img): - for proc in self: - img = proc.process(img) - return img - - -class Adjust(object): - """ - Performs color, brightness, contrast, and sharpness enhancements on the - image. See :mod:`PIL.ImageEnhance` for more imformation. - - """ - def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0): - """ - :param color: A number between 0 and 1 that specifies the saturation - of the image. 0 corresponds to a completely desaturated image - (black and white) and 1 to the original color. - See :class:`PIL.ImageEnhance.Color` - :param brightness: A number representing the brightness; 0 results in - a completely black image whereas 1 corresponds to the brightness - of the original. See :class:`PIL.ImageEnhance.Brightness` - :param contrast: A number representing the contrast; 0 results in a - completely gray image whereas 1 corresponds to the contrast of - the original. See :class:`PIL.ImageEnhance.Contrast` - :param sharpness: A number representing the sharpness; 0 results in a - blurred image; 1 corresponds to the original sharpness; 2 - results in a sharpened image. See - :class:`PIL.ImageEnhance.Sharpness` - - """ - self.color = color - self.brightness = brightness - self.contrast = contrast - self.sharpness = sharpness - - def process(self, img): - original = img = img.convert('RGBA') - for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: - factor = getattr(self, name.lower()) - if factor != 1.0: - try: - img = getattr(ImageEnhance, name)(img).enhance(factor) - except ValueError: - pass - else: - # PIL's Color and Contrast filters both convert the image - # to L mode, losing transparency info, so we put it back. - # See https://github.com/jdriscoll/django-imagekit/issues/64 - if name in ('Color', 'Contrast'): - img = Image.merge('RGBA', img.split()[:3] + - original.split()[3:4]) - return img - - -class Reflection(object): - """ - Creates an image with a reflection. - - """ - background_color = '#FFFFFF' - size = 0.0 - opacity = 0.6 - - def process(self, img): - # Convert bgcolor string to RGB value. - background_color = ImageColor.getrgb(self.background_color) - # Handle palleted images. - img = img.convert('RGB') - # Copy orignial image and flip the orientation. - reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM) - # Create a new image filled with the bgcolor the same size. - background = Image.new("RGB", img.size, background_color) - # Calculate our alpha mask. - start = int(255 - (255 * self.opacity)) # The start of our gradient. - steps = int(255 * self.size) # The number of intermedite values. - increment = (255 - start) / float(steps) - mask = Image.new('L', (1, 255)) - for y in range(255): - if y < steps: - val = int(y * increment + start) - else: - val = 255 - mask.putpixel((0, y), val) - alpha_mask = mask.resize(img.size) - # Merge the reflection onto our background color using the alpha mask. - reflection = Image.composite(background, reflection, alpha_mask) - # Crop the reflection. - reflection_height = int(img.size[1] * self.size) - reflection = reflection.crop((0, 0, img.size[0], reflection_height)) - # Create new image sized to hold both the original image and - # the reflection. - composite = Image.new("RGB", (img.size[0], img.size[1] + reflection_height), background_color) - # Paste the orignal image and the reflection into the composite image. - composite.paste(img, (0, 0)) - composite.paste(reflection, (0, img.size[1])) - # Return the image complete with reflection effect. - return composite - - -class Transpose(object): - """ - Rotates or flips the image. - - """ - AUTO = 'auto' - FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT - FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM - ROTATE_90 = Image.ROTATE_90 - ROTATE_180 = Image.ROTATE_180 - ROTATE_270 = Image.ROTATE_270 - - methods = [AUTO] - _EXIF_ORIENTATION_STEPS = { - 1: [], - 2: [FLIP_HORIZONTAL], - 3: [ROTATE_180], - 4: [FLIP_VERTICAL], - 5: [ROTATE_270, FLIP_HORIZONTAL], - 6: [ROTATE_270], - 7: [ROTATE_90, FLIP_HORIZONTAL], - 8: [ROTATE_90], - } - - def __init__(self, *args): - """ - Possible arguments: - - Transpose.AUTO - - Transpose.FLIP_HORIZONTAL - - Transpose.FLIP_VERTICAL - - Transpose.ROTATE_90 - - Transpose.ROTATE_180 - - Transpose.ROTATE_270 - - The order of the arguments dictates the order in which the - Transposition steps are taken. - - If Transpose.AUTO is present, all other arguments are ignored, and - the processor will attempt to rotate the image according to the - EXIF Orientation data. - - """ - super(Transpose, self).__init__() - if args: - self.methods = args - - def process(self, img): - if self.AUTO in self.methods: - try: - orientation = img._getexif()[0x0112] - ops = self._EXIF_ORIENTATION_STEPS[orientation] - except (KeyError, TypeError, AttributeError): - ops = [] - else: - ops = self.methods - for method in ops: - img = img.transpose(method) - return img - - -class AutoConvert(object): - """A processor that does some common-sense conversions based on the target - format. This includes things like preserving transparency and quantizing. - This processors is used automatically by ``ImageSpecField`` and - ``ProcessedImageField`` immediately before saving the image unless you - specify ``autoconvert=False``. - - """ - - def __init__(self, format): - self.format = format - - def process(self, img): - matte = False - self.save_kwargs = {} - if img.mode == 'RGBA': - if self.format in RGBA_TRANSPARENCY_FORMATS: - pass - elif self.format in PALETTE_TRANSPARENCY_FORMATS: - # If you're going from a format with alpha transparency to one - # with palette transparency, transparency values will be - # snapped: pixels that are more opaque than not will become - # fully opaque; pixels that are more transparent than not will - # become fully transparent. This will not produce a good-looking - # result if your image contains varying levels of opacity; in - # that case, you'll probably want to use a processor to matte - # the image on a solid color. The reason we don't matte by - # default is because not doing so allows processors to treat - # RGBA-format images as a super-type of P-format images: if you - # have an RGBA-format image with only a single transparent - # color, and save it as a GIF, it will retain its transparency. - # In other words, a P-format image converted to an - # RGBA-formatted image by a processor and then saved as a - # P-format image will give the expected results. - alpha = img.split()[-1] - mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) - img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, - colors=255) - img.paste(255, mask) - self.save_kwargs['transparency'] = 255 - else: - # Simply converting an RGBA-format image to an RGB one creates a - # gross result, so we matte the image on a white background. If - # that's not what you want, that's fine: use a processor to deal - # with the transparency however you want. This is simply a - # sensible default that will always produce something that looks - # good. Or at least, it will look better than just a straight - # conversion. - matte = True - elif img.mode == 'P': - if self.format in PALETTE_TRANSPARENCY_FORMATS: - try: - self.save_kwargs['transparency'] = img.info['transparency'] - except KeyError: - pass - elif self.format in RGBA_TRANSPARENCY_FORMATS: - # Currently PIL doesn't support any RGBA-mode formats that - # aren't also P-mode formats, so this will never happen. - img = img.convert('RGBA') - else: - matte = True - else: - img = img.convert('RGB') - - # GIFs are always going to be in palette mode, so we can do a little - # optimization. Note that the RGBA sources also use adaptive - # quantization (above). Images that are already in P mode don't need - # any quantization because their colors are already limited. - if self.format == 'GIF': - img = img.convert('P', palette=Image.ADAPTIVE) - - if matte: - img = img.convert('RGBA') - bg = Image.new('RGBA', img.size, (255, 255, 255)) - bg.paste(img, img) - img = bg.convert('RGB') - - if self.format == 'JPEG': - self.save_kwargs['optimize'] = True - - return img +from .base import * +from .crop import * +from .resize import * diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py new file mode 100644 index 0000000..61c0e30 --- /dev/null +++ b/imagekit/processors/base.py @@ -0,0 +1,209 @@ +from imagekit.lib import Image, ImageColor, ImageEnhance + + +class ProcessorPipeline(list): + """ + A :class:`list` of other processors. This class allows any object that + knows how to deal with a single processor to deal with a list of them. + For example:: + + processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image) + + """ + def process(self, img): + for proc in self: + img = proc.process(img) + return img + + +class Adjust(object): + """ + Performs color, brightness, contrast, and sharpness enhancements on the + image. See :mod:`PIL.ImageEnhance` for more imformation. + + """ + def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0): + """ + :param color: A number between 0 and 1 that specifies the saturation + of the image. 0 corresponds to a completely desaturated image + (black and white) and 1 to the original color. + See :class:`PIL.ImageEnhance.Color` + :param brightness: A number representing the brightness; 0 results in + a completely black image whereas 1 corresponds to the brightness + of the original. See :class:`PIL.ImageEnhance.Brightness` + :param contrast: A number representing the contrast; 0 results in a + completely gray image whereas 1 corresponds to the contrast of + the original. See :class:`PIL.ImageEnhance.Contrast` + :param sharpness: A number representing the sharpness; 0 results in a + blurred image; 1 corresponds to the original sharpness; 2 + results in a sharpened image. See + :class:`PIL.ImageEnhance.Sharpness` + + """ + self.color = color + self.brightness = brightness + self.contrast = contrast + self.sharpness = sharpness + + def process(self, img): + original = img = img.convert('RGBA') + for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: + factor = getattr(self, name.lower()) + if factor != 1.0: + try: + img = getattr(ImageEnhance, name)(img).enhance(factor) + except ValueError: + pass + else: + # PIL's Color and Contrast filters both convert the image + # to L mode, losing transparency info, so we put it back. + # See https://github.com/jdriscoll/django-imagekit/issues/64 + if name in ('Color', 'Contrast'): + img = Image.merge('RGBA', img.split()[:3] + + original.split()[3:4]) + return img + + +class Reflection(object): + """ + Creates an image with a reflection. + + """ + def __init__(self, background_color='#FFFFFF', size=0.0, opacity=0.6): + self.background_color = background_color + self.size = size + self.opacity = opacity + + def process(self, img): + # Convert bgcolor string to RGB value. + background_color = ImageColor.getrgb(self.background_color) + # Handle palleted images. + img = img.convert('RGBA') + # Copy orignial image and flip the orientation. + reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM) + # Create a new image filled with the bgcolor the same size. + background = Image.new("RGBA", img.size, background_color) + # Calculate our alpha mask. + start = int(255 - (255 * self.opacity)) # The start of our gradient. + steps = int(255 * self.size) # The number of intermedite values. + increment = (255 - start) / float(steps) + mask = Image.new('L', (1, 255)) + for y in range(255): + if y < steps: + val = int(y * increment + start) + else: + val = 255 + mask.putpixel((0, y), val) + alpha_mask = mask.resize(img.size) + # Merge the reflection onto our background color using the alpha mask. + reflection = Image.composite(background, reflection, alpha_mask) + # Crop the reflection. + reflection_height = int(img.size[1] * self.size) + reflection = reflection.crop((0, 0, img.size[0], reflection_height)) + # Create new image sized to hold both the original image and + # the reflection. + composite = Image.new("RGBA", (img.size[0], img.size[1] + reflection_height), background_color) + # Paste the orignal image and the reflection into the composite image. + composite.paste(img, (0, 0)) + composite.paste(reflection, (0, img.size[1])) + # Return the image complete with reflection effect. + return composite + + +class Transpose(object): + """ + Rotates or flips the image. + + """ + AUTO = 'auto' + FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT + FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM + ROTATE_90 = Image.ROTATE_90 + ROTATE_180 = Image.ROTATE_180 + ROTATE_270 = Image.ROTATE_270 + + methods = [AUTO] + _EXIF_ORIENTATION_STEPS = { + 1: [], + 2: [FLIP_HORIZONTAL], + 3: [ROTATE_180], + 4: [FLIP_VERTICAL], + 5: [ROTATE_270, FLIP_HORIZONTAL], + 6: [ROTATE_270], + 7: [ROTATE_90, FLIP_HORIZONTAL], + 8: [ROTATE_90], + } + + def __init__(self, *args): + """ + Possible arguments: + - Transpose.AUTO + - Transpose.FLIP_HORIZONTAL + - Transpose.FLIP_VERTICAL + - Transpose.ROTATE_90 + - Transpose.ROTATE_180 + - Transpose.ROTATE_270 + + The order of the arguments dictates the order in which the + Transposition steps are taken. + + If Transpose.AUTO is present, all other arguments are ignored, and + the processor will attempt to rotate the image according to the + EXIF Orientation data. + + """ + super(Transpose, self).__init__() + if args: + self.methods = args + + def process(self, img): + if self.AUTO in self.methods: + try: + orientation = img._getexif()[0x0112] + ops = self._EXIF_ORIENTATION_STEPS[orientation] + except (KeyError, TypeError, AttributeError): + ops = [] + else: + ops = self.methods + for method in ops: + img = img.transpose(method) + return img + + +class Anchor(object): + """ + Defines all the anchor points needed by the various processor classes. + + """ + TOP_LEFT = 'tl' + TOP = 't' + TOP_RIGHT = 'tr' + BOTTOM_LEFT = 'bl' + BOTTOM = 'b' + BOTTOM_RIGHT = 'br' + CENTER = 'c' + LEFT = 'l' + RIGHT = 'r' + + _ANCHOR_PTS = { + TOP_LEFT: (0, 0), + TOP: (0.5, 0), + TOP_RIGHT: (1, 0), + LEFT: (0, 0.5), + CENTER: (0.5, 0.5), + RIGHT: (1, 0.5), + BOTTOM_LEFT: (0, 1), + BOTTOM: (0.5, 1), + BOTTOM_RIGHT: (1, 1), + } + + @staticmethod + def get_tuple(anchor): + """Normalizes anchor values (strings or tuples) to tuples. + + """ + # If the user passed in one of the string values, convert it to a + # percentage tuple. + if anchor in Anchor._ANCHOR_PTS.keys(): + anchor = Anchor._ANCHOR_PTS[anchor] + return anchor diff --git a/imagekit/processors/crop.py b/imagekit/processors/crop.py index 304297d..da5c0fb 100644 --- a/imagekit/processors/crop.py +++ b/imagekit/processors/crop.py @@ -1,5 +1,6 @@ -from ..lib import Image, ImageChops, ImageDraw, ImageStat +from .base import Anchor from .utils import histogram_entropy +from ..lib import Image, ImageChops, ImageDraw, ImageStat class Side(object): @@ -71,101 +72,31 @@ class TrimBorderColor(object): return img -class BasicCrop(object): - """Crops an image to the specified rectangular region. - - """ - def __init__(self, x, y, width, height): - """ - :param x: The x position of the clipping box, in pixels. - :param y: The y position of the clipping box, in pixels. - :param width: The width position of the clipping box, in pixels. - :param height: The height position of the clipping box, in pixels. - - """ - self.x = x - self.y = y - self.width = width - self.height = height - - def process(self, img): - box = (self.x, self.y, self.x + self.width, self.y + self.height) - return img.crop(box) - - class Crop(object): """ - Crops an image , cropping it to the specified width and height - relative to the anchor. + Crops an image, cropping it to the specified width and height. You may + optionally provide either an anchor or x and y coordinates. This processor + functions exactly the same as ``ResizeCanvas`` except that it will never + enlarge the image. """ - TOP_LEFT = 'tl' - TOP = 't' - TOP_RIGHT = 'tr' - BOTTOM_LEFT = 'bl' - BOTTOM = 'b' - BOTTOM_RIGHT = 'br' - CENTER = 'c' - LEFT = 'l' - RIGHT = 'r' - _ANCHOR_PTS = { - TOP_LEFT: (0, 0), - TOP: (0.5, 0), - TOP_RIGHT: (1, 0), - LEFT: (0, 0.5), - CENTER: (0.5, 0.5), - RIGHT: (1, 0.5), - BOTTOM_LEFT: (0, 1), - BOTTOM: (0.5, 1), - BOTTOM_RIGHT: (1, 1), - } - - def __init__(self, width=None, height=None, anchor=None): - """ - :param width: The target width, in pixels. - :param height: The target height, in pixels. - :param anchor: Specifies which part of the image should be retained - when cropping. Valid values are: - - - Crop.TOP_LEFT - - Crop.TOP - - Crop.TOP_RIGHT - - Crop.LEFT - - Crop.CENTER - - Crop.RIGHT - - Crop.BOTTOM_LEFT - - Crop.BOTTOM - - Crop.BOTTOM_RIGHT - - You may also pass a tuple that indicates the percentages of excess - to be trimmed from each dimension. For example, ``(0, 0)`` - corresponds to "top left", ``(0.5, 0.5)`` to "center" and ``(1, 1)`` - to "bottom right". This is basically the same as using percentages - in CSS background positions. - - """ + def __init__(self, width=None, height=None, anchor=None, x=None, y=None): self.width = width self.height = height self.anchor = anchor + self.x = x + self.y = y def process(self, img): + from .resize import ResizeCanvas + original_width, original_height = img.size new_width, new_height = min(original_width, self.width), \ min(original_height, self.height) - trim_x, trim_y = original_width - new_width, \ - original_height - new_height - # If the user passed in one of the string values, convert it to a - # percentage tuple. - anchor = self.anchor or Crop.CENTER - if anchor in Crop._ANCHOR_PTS.keys(): - anchor = Crop._ANCHOR_PTS[anchor] - - x = int(float(trim_x) * float(anchor[0])) - y = int(float(trim_y) * float(anchor[1])) - - return BasicCrop(x, y, new_width, new_height).process(img) + return ResizeCanvas(new_width, new_height, anchor=self.anchor, + x=self.x, y=self.y).process(img) class SmartCrop(object): diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 1838e73..e4b747a 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -1,9 +1,9 @@ from imagekit.lib import Image -from . import crop import warnings +from .base import Anchor -class BasicResize(object): +class Resize(object): """ Resizes an image to the specified width and height. @@ -21,11 +21,11 @@ class BasicResize(object): return img.resize((self.width, self.height), Image.ANTIALIAS) -class Cover(object): +class ResizeToCover(object): """ Resizes the image to the smallest possible size that will entirely cover the provided dimensions. You probably won't be using this processor directly, - but it's used internally by ``Fill`` and ``SmartFill``. + but it's used internally by ``ResizeToFill`` and ``SmartResize``. """ def __init__(self, width, height): @@ -42,59 +42,39 @@ class Cover(object): float(self.height) / original_height) new_width, new_height = (int(original_width * ratio), int(original_height * ratio)) - return BasicResize(new_width, new_height).process(img) + return Resize(new_width, new_height).process(img) -class Fill(object): +class ResizeToFill(object): """ - Resizes an image , cropping it to the exact specified width and height. + Resizes an image, cropping it to the exact specified width and height. """ - TOP_LEFT = crop.Crop.TOP_LEFT - TOP = crop.Crop.TOP - TOP_RIGHT = crop.Crop.TOP_RIGHT - BOTTOM_LEFT = crop.Crop.BOTTOM_LEFT - BOTTOM = crop.Crop.BOTTOM - BOTTOM_RIGHT = crop.Crop.BOTTOM_RIGHT - CENTER = crop.Crop.CENTER - LEFT = crop.Crop.LEFT - RIGHT = crop.Crop.RIGHT def __init__(self, width=None, height=None, anchor=None): """ :param width: The target width, in pixels. :param height: The target height, in pixels. :param anchor: Specifies which part of the image should be retained - when cropping. Valid values are: - - - Fill.TOP_LEFT - - Fill.TOP - - Fill.TOP_RIGHT - - Fill.LEFT - - Fill.CENTER - - Fill.RIGHT - - Fill.BOTTOM_LEFT - - Fill.BOTTOM - - Fill.BOTTOM_RIGHT - + when cropping. """ self.width = width self.height = height self.anchor = anchor def process(self, img): - img = Cover(self.width, self.height).process(img) - return crop.Crop(self.width, self.height, + from .crop import Crop + img = ResizeToCover(self.width, self.height).process(img) + return Crop(self.width, self.height, anchor=self.anchor).process(img) -class SmartFill(object): +class SmartResize(object): """ - The ``SmartFill`` processor is identical to ``Fill``, except that it uses - entropy to crop the image instead of a user-specified anchor point. - Internally, it simply runs the ``resize.Cover`` and ``crop.SmartCrop`` + The ``SmartResize`` processor is identical to ``ResizeToFill``, except that + it uses entropy to crop the image instead of a user-specified anchor point. + Internally, it simply runs the ``ResizeToCover`` and ``SmartCrop`` processors in series. - """ def __init__(self, width, height): """ @@ -105,23 +85,104 @@ class SmartFill(object): self.width, self.height = width, height def process(self, img): - img = Cover(self.width, self.height).process(img) - return crop.SmartCrop(self.width, self.height).process(img) + from .crop import SmartCrop + img = ResizeToCover(self.width, self.height).process(img) + return SmartCrop(self.width, self.height).process(img) -class Crop(Fill): - def __init__(self, *args, **kwargs): - warnings.warn('`imagekit.processors.resize.Crop` has been renamed to' - '`imagekit.processors.resize.Fill`.', DeprecationWarning) - super(Crop, self).__init__(*args, **kwargs) +class ResizeCanvas(object): + """ + Resizes the canvas, using the provided background color if the new size is + larger than the current image. + + """ + def __init__(self, width, height, color=None, anchor=None, x=None, y=None): + """ + :param width: The target width, in pixels. + :param height: The target height, in pixels. + :param color: The background color to use for padding. + :param anchor: Specifies the position of the original image on the new + canvas. Valid values are: + + - Anchor.TOP_LEFT + - Anchor.TOP + - Anchor.TOP_RIGHT + - Anchor.LEFT + - Anchor.CENTER + - Anchor.RIGHT + - Anchor.BOTTOM_LEFT + - Anchor.BOTTOM + - Anchor.BOTTOM_RIGHT + + You may also pass a tuple that indicates the position in + percentages. For example, ``(0, 0)`` corresponds to "top left", + ``(0.5, 0.5)`` to "center" and ``(1, 1)`` to "bottom right". This is + basically the same as using percentages in CSS background positions. + + """ + if x is not None or y is not None: + if anchor: + raise Exception('You may provide either an anchor or x and y' + ' coordinate, but not both.') + else: + self.x, self.y = x or 0, y or 0 + self.anchor = None + else: + self.anchor = anchor or Anchor.CENTER + self.x = self.y = None + + self.width = width + self.height = height + self.color = color or (255, 255, 255, 0) + + def process(self, img): + original_width, original_height = img.size + + if self.anchor: + anchor = Anchor.get_tuple(self.anchor) + trim_x, trim_y = self.width - original_width, \ + self.height - original_height + x = int(float(trim_x) * float(anchor[0])) + y = int(float(trim_y) * float(anchor[1])) + else: + x, y = self.x, self.y + + new_img = Image.new('RGBA', (self.width, self.height), self.color) + new_img.paste(img, (x, y)) + return new_img -class Fit(object): +class AddBorder(object): + """ + Add a border of specific color and size to an image. + + """ + def __init__(self, thickness, color=None): + """ + :param color: Color to use for the border + :param thickness: Thickness of the border. Can be either an int or + a 4-tuple of ints of the form (top, right, bottom, left). + """ + self.color = color + if isinstance(thickness, int): + self.top = self.right = self.bottom = self.left = thickness + else: + self.top, self.right, self.bottom, self.left = thickness + + def process(self, img): + new_width = img.size[0] + self.left + self.right + new_height = img.size[1] + self.top + self.bottom + return ResizeCanvas(new_width, new_height, color=self.color, + x=self.left, y=self.top).process(img) + + +class ResizeToFit(object): """ Resizes an image to fit within the specified dimensions. """ - def __init__(self, width=None, height=None, upscale=None, mat_color=None): + + def __init__(self, width=None, height=None, upscale=None, mat_color=None, anchor=Anchor.CENTER): """ :param width: The maximum width of the desired image. :param height: The maximum height of the desired image. @@ -136,6 +197,7 @@ class Fit(object): self.height = height self.upscale = upscale self.mat_color = mat_color + self.anchor = anchor def process(self, img): cur_width, cur_height = img.size @@ -151,18 +213,8 @@ class Fit(object): int(round(cur_height * ratio))) if (cur_width > new_dimensions[0] or cur_height > new_dimensions[1]) or \ self.upscale: - img = BasicResize(new_dimensions[0], + img = Resize(new_dimensions[0], new_dimensions[1]).process(img) if self.mat_color: - new_img = Image.new('RGBA', (self.width, self.height), self.mat_color) - new_img.paste(img, ((self.width - img.size[0]) / 2, (self.height - img.size[1]) / 2)) - img = new_img + img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img) return img - - -class SmartCrop(crop.SmartCrop): - def __init__(self, *args, **kwargs): - warnings.warn('The SmartCrop processor has been moved to' - ' `imagekit.processors.crop.SmartCrop`, where it belongs.', - DeprecationWarning) - super(SmartCrop, self).__init__(*args, **kwargs) diff --git a/imagekit/settings.py b/imagekit/settings.py deleted file mode 100644 index d84030d..0000000 --- a/imagekit/settings.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.conf import settings - -DEFAULT_IMAGE_CACHE_BACKEND = getattr(settings, - 'IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND', - 'imagekit.imagecache.PessimisticImageCacheBackend') diff --git a/imagekit/utils.py b/imagekit/utils.py index 4f695cf..173f152 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,29 +1,49 @@ -import tempfile +import os +import mimetypes +import sys import types +from django.core.files.base import ContentFile from django.db.models.loading import cache from django.utils.functional import wraps +from django.utils.encoding import smart_str, smart_unicode from .imagecache import get_default_image_cache_backend from .lib import Image, ImageFile +from .lib import Image, ImageFile, StringIO -def img_to_fobj(img, format, **kwargs): - tmp = tempfile.TemporaryFile() - try: - img.save(tmp, format, **kwargs) - except IOError: - # PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough, - # So if we have a problem saving, we temporarily increase it. See - # http://github.com/jdriscoll/django-imagekit/issues/50 - old_maxblock = ImageFile.MAXBLOCK - ImageFile.MAXBLOCK = img.size[0] * img.size[1] - try: - img.save(tmp, format, **kwargs) - finally: - ImageFile.MAXBLOCK = old_maxblock - tmp.seek(0) - return tmp +RGBA_TRANSPARENCY_FORMATS = ['PNG'] +PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] + + +class IKContentFile(ContentFile): + """ + Wraps a ContentFile in a file-like object with a filename and a + content_type. A PIL image format can be optionally be provided as a content + type hint. + + """ + def __init__(self, filename, content, format=None): + self.file = ContentFile(content) + self.file.name = filename + mimetype = getattr(self.file, 'content_type', None) + if format and not mimetype: + mimetype = format_to_mimetype(format) + if not mimetype: + ext = os.path.splitext(filename or '')[1] + mimetype = extension_to_mimetype(ext) + self.file.content_type = mimetype + + def __str__(self): + return smart_str(self.file.name or '') + + def __unicode__(self): + return smart_unicode(self.file.name or u'') + + +def img_to_fobj(img, format, autoconvert=True, **options): + return save_image(img, StringIO(), format, options, autoconvert) def get_spec_files(instance): @@ -108,6 +128,19 @@ def _format_to_extension(format): return None +def extension_to_mimetype(ext): + try: + filename = 'a%s' % (ext or '') # guess_type requires a full filename, not just an extension + mimetype = mimetypes.guess_type(filename)[0] + except IndexError: + mimetype = None + return mimetype + + +def format_to_mimetype(format): + return extension_to_mimetype(format_to_extension(format)) + + def extension_to_format(extension): """Returns the format that corresponds to the provided extension. @@ -168,6 +201,188 @@ def validate_app_cache(apps, force_revalidation=False): f.validate() +def suggest_extension(name, format): + original_extension = os.path.splitext(name)[1] + try: + suggested_extension = format_to_extension(format) + except UnknownFormatError: + extension = original_extension + else: + if suggested_extension.lower() == original_extension.lower(): + extension = original_extension + else: + try: + original_format = extension_to_format(original_extension) + except UnknownExtensionError: + extension = suggested_extension + else: + # If the formats match, give precedence to the original extension. + if format.lower() == original_format.lower(): + extension = original_extension + else: + extension = suggested_extension + return extension + + +def save_image(img, outfile, format, options=None, autoconvert=True): + """ + Wraps PIL's ``Image.save()`` method. There are two main benefits of using + this function over PIL's: + + 1. It gracefully handles the infamous "Suspension not allowed here" errors. + 2. It prepares the image for saving using ``prepare_image()``, which will do + some common-sense processing given the target format. + + """ + options = options or {} + + if autoconvert: + img, save_kwargs = prepare_image(img, format) + options = dict(save_kwargs.items() + options.items()) + + # Attempt to reset the file pointer. + try: + outfile.seek(0) + except AttributeError: + pass + + try: + with quiet(): + img.save(outfile, format, **options) + except IOError: + # PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough, + # So if we have a problem saving, we temporarily increase it. See + # http://github.com/jdriscoll/django-imagekit/issues/50 + old_maxblock = ImageFile.MAXBLOCK + ImageFile.MAXBLOCK = img.size[0] * img.size[1] + try: + img.save(outfile, format, **options) + finally: + ImageFile.MAXBLOCK = old_maxblock + + try: + outfile.seek(0) + except AttributeError: + pass + + return outfile + + +class quiet(object): + """ + A context manager for suppressing the stderr activity of PIL's C libraries. + Based on http://stackoverflow.com/a/978264/155370 + + """ + def __enter__(self): + self.stderr_fd = sys.__stderr__.fileno() + self.null_fd = os.open(os.devnull, os.O_RDWR) + self.old = os.dup(self.stderr_fd) + os.dup2(self.null_fd, self.stderr_fd) + + def __exit__(self, *args, **kwargs): + os.dup2(self.old, self.stderr_fd) + os.close(self.null_fd) + os.close(self.old) + + +def prepare_image(img, format): + """ + Prepares the image for saving to the provided format by doing some + common-sense conversions. This includes things like preserving transparency + and quantizing. This function is used automatically by ``save_image()`` + (and classes like ``ImageSpecField`` and ``ProcessedImageField``) + immediately before saving unless you specify ``autoconvert=False``. It is + provided as a utility for those doing their own processing. + + :param img: The image to prepare for saving. + :param format: The format that the image will be saved to. + + """ + matte = False + save_kwargs = {} + + if img.mode == 'RGBA': + if format in RGBA_TRANSPARENCY_FORMATS: + pass + elif format in PALETTE_TRANSPARENCY_FORMATS: + # If you're going from a format with alpha transparency to one + # with palette transparency, transparency values will be + # snapped: pixels that are more opaque than not will become + # fully opaque; pixels that are more transparent than not will + # become fully transparent. This will not produce a good-looking + # result if your image contains varying levels of opacity; in + # that case, you'll probably want to use a processor to matte + # the image on a solid color. The reason we don't matte by + # default is because not doing so allows processors to treat + # RGBA-format images as a super-type of P-format images: if you + # have an RGBA-format image with only a single transparent + # color, and save it as a GIF, it will retain its transparency. + # In other words, a P-format image converted to an + # RGBA-formatted image by a processor and then saved as a + # P-format image will give the expected results. + + # Work around a bug in PIL: split() doesn't check to see if + # img is loaded. + img.load() + + alpha = img.split()[-1] + mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) + img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, + colors=255) + img.paste(255, mask) + save_kwargs['transparency'] = 255 + else: + # Simply converting an RGBA-format image to an RGB one creates a + # gross result, so we matte the image on a white background. If + # that's not what you want, that's fine: use a processor to deal + # with the transparency however you want. This is simply a + # sensible default that will always produce something that looks + # good. Or at least, it will look better than just a straight + # conversion. + matte = True + elif img.mode == 'P': + if format in PALETTE_TRANSPARENCY_FORMATS: + try: + save_kwargs['transparency'] = img.info['transparency'] + except KeyError: + pass + elif format in RGBA_TRANSPARENCY_FORMATS: + # Currently PIL doesn't support any RGBA-mode formats that + # aren't also P-mode formats, so this will never happen. + img = img.convert('RGBA') + else: + matte = True + else: + img = img.convert('RGB') + + # GIFs are always going to be in palette mode, so we can do a little + # optimization. Note that the RGBA sources also use adaptive + # quantization (above). Images that are already in P mode don't need + # any quantization because their colors are already limited. + if format == 'GIF': + img = img.convert('P', palette=Image.ADAPTIVE) + + if matte: + img = img.convert('RGBA') + bg = Image.new('RGBA', img.size, (255, 255, 255)) + bg.paste(img, img) + img = bg.convert('RGB') + + if format == 'JPEG': + save_kwargs['optimize'] = True + + return img, save_kwargs + + +def ik_model_receiver(fn): + @wraps(fn) + def receiver(sender, **kwargs): + if getattr(sender, '_ik', None): + fn(sender, **kwargs) + return receiver + + def autodiscover(): """ Auto-discover INSTALLED_APPS imagespecs.py modules and fail silently when diff --git a/requirements.txt b/requirements.txt index 6a3296e..5161716 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -Django >= 1.3.1 +Django>=1.3.1 +django-appconf>=0.5 +PIL>=1.1.7 diff --git a/setup.py b/setup.py index 46aef9a..6f662f9 100644 --- a/setup.py +++ b/setup.py @@ -11,9 +11,12 @@ if 'publish' in sys.argv: read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read() +# Dynamically calculate the version based on imagekit.VERSION. +version = __import__('imagekit').get_version() + setup( name='django-imagekit', - version=':versiontools:imagekit:', + version=version, description='Automated image processing for Django models.', long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')), author='Justin Driscoll', @@ -25,8 +28,11 @@ setup( packages=find_packages(), zip_safe=False, include_package_data=True, + install_requires=[ + 'django-appconf>=0.5', + ], classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', @@ -37,7 +43,4 @@ setup( 'Programming Language :: Python :: 2.7', 'Topic :: Utilities' ], - setup_requires=[ - 'versiontools >= 1.8', - ], ) diff --git a/tests/core/models.py b/tests/core/models.py index e69de29..2c3d8e4 100644 --- a/tests/core/models.py +++ b/tests/core/models.py @@ -0,0 +1,34 @@ +from django.db import models + +from imagekit.models import ImageSpecField +from imagekit.processors import Adjust +from imagekit.processors import ResizeToFill +from imagekit.processors import SmartCrop + + +class Photo(models.Model): + original_image = models.ImageField(upload_to='photos') + + thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), + ResizeToFill(50, 50)], image_field='original_image', format='JPEG', + options={'quality': 90}) + + smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2, + sharpness=1.1), SmartCrop(50, 50)], image_field='original_image', + format='JPEG', options={'quality': 90}) + + +class AbstractImageModel(models.Model): + original_image = models.ImageField(upload_to='photos') + abstract_class_spec = ImageSpecField() + + class Meta: + abstract = True + + +class ConcreteImageModel1(AbstractImageModel): + first_spec = ImageSpecField() + + +class ConcreteImageModel2(AbstractImageModel): + second_spec = ImageSpecField() diff --git a/tests/core/tests.py b/tests/core/tests.py index 6ccc977..b4884c8 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -1,73 +1,25 @@ from __future__ import with_statement import os -import tempfile -from django.core.files.base import ContentFile -from django.db import models from django.test import TestCase from imagekit import utils -from imagekit.lib import Image -from imagekit.models.fields import ImageSpecField -from imagekit.processors import Adjust -from imagekit.processors.resize import Fill -from imagekit.processors.crop import SmartCrop - - -class Photo(models.Model): - original_image = models.ImageField(upload_to='photos') - - thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), - Fill(50, 50)], image_field='original_image', format='JPEG', - options={'quality': 90}) - - smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2, - sharpness=1.1), SmartCrop(50, 50)], image_field='original_image', - format='JPEG', options={'quality': 90}) +from .models import (Photo, AbstractImageModel, ConcreteImageModel1, + ConcreteImageModel2) +from .testutils import create_photo, pickleback class IKTest(TestCase): - def generate_image(self): - tmp = tempfile.TemporaryFile() - Image.new('RGB', (800, 600)).save(tmp, 'JPEG') - tmp.seek(0) - return tmp - - def generate_lenna(self): - """ - See also: - - http://en.wikipedia.org/wiki/Lenna - http://sipi.usc.edu/database/database.php?volume=misc&image=12 - - """ - tmp = tempfile.TemporaryFile() - lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg') - with open(lennapath, "r+b") as lennafile: - Image.open(lennafile).save(tmp, 'JPEG') - tmp.seek(0) - return tmp - - def create_photo(self, name): - photo = Photo() - img = self.generate_lenna() - file = ContentFile(img.read()) - photo.original_image = file - photo.original_image.save(name, file) - photo.save() - img.close() - return photo def setUp(self): - self.photo = self.create_photo('test.jpg') + self.photo = create_photo('test.jpg') def test_nodelete(self): """Don't delete the spec file when the source image hasn't changed. """ filename = self.photo.thumbnail.file.name - thumbnail_timestamp = os.path.getmtime(filename) self.photo.save() self.assertTrue(self.photo.thumbnail.storage.exists(filename)) @@ -109,3 +61,27 @@ class IKUtilsTest(TestCase): with self.assertRaises(utils.UnknownFormatError): utils.format_to_extension('TXT') + + +class PickleTest(TestCase): + def test_model(self): + ph = pickleback(create_photo('pickletest.jpg')) + + # This isn't supposed to error. + ph.thumbnail.source_file + + def test_field(self): + thumbnail = pickleback(create_photo('pickletest2.jpg').thumbnail) + + # This isn't supposed to error. + thumbnail.source_file + + +class InheritanceTest(TestCase): + def test_abstract_base(self): + self.assertEqual(set(AbstractImageModel._ik.spec_fields), + set(['abstract_class_spec'])) + self.assertEqual(set(ConcreteImageModel1._ik.spec_fields), + set(['abstract_class_spec', 'first_spec'])) + self.assertEqual(set(ConcreteImageModel2._ik.spec_fields), + set(['abstract_class_spec', 'second_spec'])) diff --git a/tests/core/testutils.py b/tests/core/testutils.py new file mode 100644 index 0000000..4acc13c --- /dev/null +++ b/tests/core/testutils.py @@ -0,0 +1,46 @@ +import os +import tempfile + +from django.core.files.base import ContentFile + +from imagekit.lib import Image, StringIO +from .models import Photo +import pickle + + +def generate_lenna(): + """ + See also: + + http://en.wikipedia.org/wiki/Lenna + http://sipi.usc.edu/database/database.php?volume=misc&image=12 + + """ + tmp = tempfile.TemporaryFile() + lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg') + with open(lennapath, "r+b") as lennafile: + Image.open(lennafile).save(tmp, 'JPEG') + tmp.seek(0) + return tmp + + +def create_instance(model_class, image_name): + instance = model_class() + img = generate_lenna() + file = ContentFile(img.read()) + instance.original_image = file + instance.original_image.save(image_name, file) + instance.save() + img.close() + return instance + + +def create_photo(name): + return create_instance(Photo, name) + + +def pickleback(obj): + pickled = StringIO() + pickle.dump(obj, pickled) + pickled.seek(0) + return pickle.load(pickled) diff --git a/tox.ini b/tox.ini index 8f2eadc..72bf206 100644 --- a/tox.ini +++ b/tox.ini @@ -1,31 +1,45 @@ [tox] envlist = - py27-django13, - py27-django12, - py26-django13, - py26-django12 + py27-django14, py27-django13, py27-django12, + py26-django14, py26-django13, py26-django12 [testenv] changedir = tests setenv = PYTHONPATH = {toxinidir}/tests commands = django-admin.py test core --settings=settings +[testenv:py27-django14] +basepython = python2.7 +deps = + Django>=1.4 + Pillow + [testenv:py27-django13] +basepython = python2.7 deps = Django>=1.3,<=1.4 Pillow [testenv:py27-django12] +basepython = python2.7 deps = Django>=1.2,<=1.3 Pillow +[testenv:py26-django14] +basepython = python2.6 +deps = + Django>=1.4 + Pillow + [testenv:py26-django13] +basepython = python2.6 deps = Django>=1.3,<=1.4 Pillow [testenv:py26-django12] +basepython = python2.6 deps = Django>=1.2,<=1.3 Pillow