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/README.rst b/README.rst index e1730b6..041d02d 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,3 @@ - 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. @@ -32,7 +31,9 @@ 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 import ImageSpecField @@ -44,7 +45,9 @@ 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' @@ -53,13 +56,15 @@ an ImageFile-like object (just like with a normal 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``:: +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 = ImageSpecField(format='JPEG', options={'quality': 90}) + processed_image = ProcessedImageField(format='JPEG', options={'quality': 90}) See the class documentation for details. @@ -69,7 +74,9 @@ 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 import ImageSpecField @@ -81,7 +88,9 @@ your spec, you can expose different versions of the 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' @@ -96,7 +105,9 @@ 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): @@ -115,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 @@ -154,17 +167,30 @@ 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:: +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:: +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 ------------ diff --git a/docs/apireference.rst b/docs/apireference.rst index df4b5c7..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: diff --git a/docs/changelog.rst b/docs/changelog.rst index 34d3ef7..072d8e1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,17 @@ Changelog ========= +v2.0.2 +------ + +- Fixed the pickling of ImageSpecFieldFile. +- Signals are now connected without specifying the class and non-IK models + are filitered out in the receivers. This is necessary beacuse of a bug + with how Django handles abstract models. +- Fixed a `ZeroDivisionError` in the Reflection processor. +- `cStringIO` is now used if it's available. +- Reflections on images now use RGBA instead of RGB. + v2.0.1 ------ diff --git a/docs/conf.py b/docs/conf.py index 580401c..e0913b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ 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' @@ -50,9 +50,9 @@ copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & # built documents. # # The short X.Y version. -version = '2.0.1' +version = '2.0.2' # The full version, including alpha/beta/rc tags. -release = '2.0.1' +release = '2.0.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/index.rst b/docs/index.rst index 5634f21..2c06385 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,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 55a5d04..9dd4ffe 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,6 +1,6 @@ __title__ = 'django-imagekit' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' -__version__ = (2, 0, 1, 'final', 0) +__version__ = (2, 0, 2, 'final', 0) __license__ = 'BSD' 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 50d04ac..046f0c0 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -1,6 +1,5 @@ import os -from StringIO import StringIO - +from .lib import StringIO from .processors import ProcessorPipeline from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format, UnknownExtensionError) diff --git a/imagekit/imagecache/__init__.py b/imagekit/imagecache/__init__.py index 3c582c8..cf98a9d 100644 --- a/imagekit/imagecache/__init__.py +++ b/imagekit/imagecache/__init__.py @@ -14,7 +14,8 @@ def get_default_image_cache_backend(): """ global _default_image_cache_backend if not _default_image_cache_backend: - from ..settings import DEFAULT_IMAGE_CACHE_BACKEND as import_path + from django.conf import settings + import_path = settings.IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND try: dot = import_path.rindex('.') except ValueError: 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 c5ef221..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 diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index b2cceda..3e11e69 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -1,15 +1,18 @@ 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 ..receivers import configure_receivers from .utils import ImageSpecFileDescriptor, ImageKitMeta, BoundImageKitMeta from ...utils import suggest_extension +configure_receivers() + + class ImageSpecField(object): """ The heart and soul of the ImageKit library, ImageSpecField allows you to add @@ -91,51 +94,12 @@ class ImageSpecField(object): 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): """ diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 153c69f..d6f3dc2 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -11,7 +11,6 @@ class ImageSpecFieldFile(ImageFieldFile): def __init__(self, instance, field, attname): 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): @@ -100,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): @@ -145,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/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/base.py b/imagekit/processors/base.py index 06239e2..61c0e30 100644 --- a/imagekit/processors/base.py +++ b/imagekit/processors/base.py @@ -69,19 +69,20 @@ class Reflection(object): Creates an image with a reflection. """ - background_color = '#FFFFFF' - size = 0.0 - opacity = 0.6 + 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('RGB') + 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("RGB", img.size, background_color) + 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. @@ -101,7 +102,7 @@ class Reflection(object): 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) + 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])) 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 4e680fe..d1d607e 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,6 +1,5 @@ import os import mimetypes -from StringIO import StringIO import sys import types @@ -9,7 +8,7 @@ from django.db.models.loading import cache from django.utils.functional import wraps from django.utils.encoding import smart_str, smart_unicode -from .lib import Image, ImageFile +from .lib import Image, ImageFile, StringIO RGBA_TRANSPARENCY_FORMATS = ['PNG'] @@ -372,3 +371,11 @@ def prepare_image(img, format): 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 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 6fdb8fb..6f662f9 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,9 @@ setup( packages=find_packages(), zip_safe=False, include_package_data=True, + install_requires=[ + 'django-appconf>=0.5', + ], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', diff --git a/tests/core/tests.py b/tests/core/tests.py index d3039e4..a73915e 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -1,23 +1,16 @@ from __future__ import with_statement import os -import pickle -from StringIO import StringIO from django.test import TestCase from imagekit import utils from .models import (Photo, AbstractImageModel, ConcreteImageModel1, ConcreteImageModel2) -from .testutils import generate_lenna, create_photo +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 setUp(self): self.photo = create_photo('test.jpg') @@ -27,7 +20,6 @@ class IKTest(TestCase): """ filename = self.photo.thumbnail.file.name - thumbnail_timestamp = os.path.getmtime(filename) self.photo.save() self.assertTrue(self.photo.thumbnail.storage.exists(filename)) @@ -60,27 +52,29 @@ class IKUtilsTest(TestCase): self.assertEqual(utils.extension_to_format('.jpeg'), 'JPEG') self.assertEqual(utils.extension_to_format('.rgba'), 'SGI') - with self.assertRaises(utils.UnknownExtensionError): - utils.extension_to_format('.txt') + self.assertRaises(utils.UnknownExtensionError, + lambda: utils.extension_to_format('.txt')) def test_format_to_extension_no_init(self): self.assertEqual(utils.format_to_extension('PNG'), '.png') self.assertEqual(utils.format_to_extension('ICO'), '.ico') - with self.assertRaises(utils.UnknownFormatError): - utils.format_to_extension('TXT') + self.assertRaises(utils.UnknownFormatError, + lambda: utils.format_to_extension('TXT')) class PickleTest(TestCase): - def test_source_file(self): - ph = create_photo('pickletest.jpg') - pickled_model = StringIO() - pickle.dump(ph, pickled_model) - pickled_model.seek(0) - unpickled_model = pickle.load(pickled_model) + def test_model(self): + ph = pickleback(create_photo('pickletest.jpg')) # This isn't supposed to error. - unpickled_model.thumbnail.source_file + 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): diff --git a/tests/core/testutils.py b/tests/core/testutils.py index 27e0f52..4acc13c 100644 --- a/tests/core/testutils.py +++ b/tests/core/testutils.py @@ -3,8 +3,9 @@ import tempfile from django.core.files.base import ContentFile -from imagekit.lib import Image +from imagekit.lib import Image, StringIO from .models import Photo +import pickle def generate_lenna(): @@ -36,3 +37,10 @@ def create_instance(model_class, image_name): 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..1ad1957 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-django13] +[testenv:py27-django14] +basepython = python2.7 deps = - Django>=1.3,<=1.4 + Django>=1.4,<1.5 + 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 + Django>=1.2,<1.3 + Pillow + +[testenv:py26-django14] +basepython = python2.6 +deps = + Django>=1.4,<1.5 Pillow [testenv:py26-django13] +basepython = python2.6 deps = - Django>=1.3,<=1.4 + Django>=1.3,<1.4 Pillow [testenv:py26-django12] +basepython = python2.6 deps = - Django>=1.2,<=1.3 + Django>=1.2,<1.3 Pillow