diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 00000000..aa8ce2ad
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,11 @@
+[run]
+branch = true
+parallel = true
+
+[report]
+show_missing = true
+skip_empty = true
+skip_covered = true
+precision = 2
+omit =
+ tests/*
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
new file mode 100644
index 00000000..10c10bbe
--- /dev/null
+++ b/.github/workflows/python.yml
@@ -0,0 +1,31 @@
+name: Python CI
+
+on:
+ push:
+ branches: [ develop ]
+ pull_request:
+ branches: [ develop ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+ strategy:
+ max-parallel: 4
+ matrix:
+ python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14.0-rc.2']
+
+ steps:
+ - uses: actions/checkout@v5
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install tox tox-gh-actions
+
+ - name: Run tests
+ run: tox
diff --git a/.gitignore b/.gitignore
index f013ec3c..c4c4606e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,17 @@
*.pyc
.DS_Store
.tox
+.idea
+.vscode
+.coverage
+.coverage.*
+.venv
MANIFEST
build
dist
-/tests/media
+/tests/media/*
+!/tests/media/reference.png
+/venv
+/venv3
+/.env
+/tags
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 00000000..33295726
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,31 @@
+version: 2
+
+# Set the OS, Python version and other tools you might need
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+ # You can also specify other tool versions:
+ # nodejs: "20"
+ # rust: "1.70"
+ # golang: "1.20"
+
+# Build documentation in the "docs/" directory with Sphinx
+sphinx:
+ configuration: docs/conf.py
+ # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
+ # builder: "dirhtml"
+ # Fail on all warnings to avoid broken references
+ # fail_on_warning: true
+
+# Optionally build your docs in additional formats such as PDF and ePub
+# formats:
+# - pdf
+# - epub
+
+# Optional but recommended, declare the Python requirements required
+# to build your documentation
+# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+# python:
+# install:
+# - requirements: docs/requirements.txt
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 5a5e2e7d..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-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 24968f01..1e516114 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,17 +1,19 @@
ImageKit was originally written by `Justin Driscoll`_.
-The field-based API was written by the bright minds at HZDG_.
+The field-based API and other post-1.0 stuff was written by the bright people at
+HZDG_.
Maintainers
-~~~~~~~~~~~
+-----------
-* `Bryan Veloso`_
+* `Venelin Stoykov`_
* `Matthew Tretter`_
+* `Bryan Veloso`_
* `Chris Drackett`_
* `Greg Newman`_
Contributors
-~~~~~~~~~~~~
+------------
* `Josh Ourisman`_
* `Jonathan Slenders`_
@@ -25,6 +27,9 @@ Contributors
* `Jan Sagemüller`_
* `Clay McClure`_
* `Jannis Leidel`_
+* `Sean Bell`_
+* `Saul Shanabrook`_
+* `Jaap Roes`_
.. _Justin Driscoll: http://github.com/jdriscoll
.. _HZDG: http://hzdg.com
@@ -44,3 +49,7 @@ Contributors
.. _Jan Sagemüller: https://github.com/version2
.. _Clay McClure: https://github.com/claymation
.. _Jannis Leidel: https://github.com/jezdez
+.. _Sean Bell: https://github.com/seanbell
+.. _Saul Shanabrook: https://github.com/saulshanabrook
+.. _Venelin Stoykov: https://github.com/vstoykov
+.. _Jaap Roes: https://github.com/jaap3
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
new file mode 100644
index 00000000..d70a2d6b
--- /dev/null
+++ b/CONTRIBUTING.rst
@@ -0,0 +1,24 @@
+Contributing
+------------
+
+We love contributions! These guidelines will help make sure we can get your
+contributions merged as quickly as possible:
+
+1. Write `good commit messages`__!
+2. If you want to add a new feature, talk to us on the `mailing list`__ or
+ `IRC`__ first. We might already have plans, or be able to offer some advice.
+3. Make sure your code passes the tests that ImageKit already has. To run the
+ tests, first install tox, ``pip install tox``, then use ``tox``. This will let you know about any errors or style
+ issues.
+4. While we're talking about tests, creating new ones for your code makes it
+ much easier for us to merge your code quickly. ImageKit uses pytest_, so
+ writing tests is painless. Check out `ours`__ for examples.
+5. It's a good idea to do your work in a branch; that way, you can work on more
+ than one contribution at a time without making them interdependent.
+
+
+__ http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
+__ https://groups.google.com/forum/#!forum/django-imagekit
+__ irc://irc.freenode.net/imagekit
+.. _pytest: https://docs.pytest.org/en/latest/
+__ https://github.com/matthewwithanm/django-imagekit/tree/develop/tests
diff --git a/MANIFEST.in b/MANIFEST.in
index 0adbc507..934606c2 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,5 +1,20 @@
include AUTHORS
include LICENSE
include README.rst
-recursive-include docs *
-recursive-include imagekit/templates *
+include pytest.ini
+include test-requirements.txt
+include testrunner.py
+include setup.cfg
+include tests/*.py
+include tests/assets/Lenna.png
+include tests/assets/lenna-*.jpg
+include tests/media/lenna.png
+prune tests/media/CACHE
+prune tests/media/b
+prune tests/media/photos
+include docs/Makefile
+include docs/conf.py
+include docs/make.bat
+include docs/*.rst
+recursive-include docs/_themes LICENSE README.rst flask_theme_support.py theme.conf *.css_t *.css *.html
+recursive-include imagekit/templates *.html
diff --git a/README.rst b/README.rst
index 041d02d0..e82f10e4 100644
--- a/README.rst
+++ b/README.rst
@@ -1,111 +1,353 @@
-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.
+|Build Status|_
+
+.. |Build Status| image:: https://github.com/matthewwithanm/django-imagekit/actions/workflows/python.yml/badge.svg?branch=develop
+.. _Build Status: https://github.com/matthewwithanm/django-imagekit/actions/workflows/python.yml
+
+ImageKit is a Django app for processing images. Need a thumbnail? A
+black-and-white version of a user-uploaded image? ImageKit will make them for
+you. If you need to programmatically generate one image from another, you need
+ImageKit.
+
+ImageKit comes with a bunch of image processors for common tasks like resizing
+and cropping, but you can also create your own. For an idea of what's possible,
+check out the `Instakit`__ project.
**For the complete documentation on the latest stable version of ImageKit, see**
-`ImageKit on RTD`_. Our `changelog is also available`_.
+`ImageKit on RTD`_.
.. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org
-.. _`changelog is also available`: http://django-imagekit.readthedocs.org/en/latest/changelog.html
+__ https://github.com/fish2000/instakit
Installation
-------------
+============
-1. Install `PIL`_ or `Pillow`_. If you're using an ``ImageField`` in Django,
- you should have already done this.
+1. Install `Pillow`_. (If you're using an ``ImageField`` in Django,
+ you should have already done this.)
2. ``pip install django-imagekit``
- (or clone the source and put the imagekit module on your path)
-3. Add ``'imagekit'`` to your ``INSTALLED_APPS`` list in your project's settings.py
-
-.. note:: If you've never seen Pillow before, it considers itself a
- more-frequently updated "friendly" fork of PIL that's compatible with
- setuptools. As such, it shares the same namespace as PIL does and is a
- drop-in replacement.
+3. Add ``'imagekit'`` to your ``INSTALLED_APPS`` list in your project's ``settings.py``
-.. _`PIL`: http://pypi.python.org/pypi/PIL
.. _`Pillow`: http://pypi.python.org/pypi/Pillow
-Adding Specs to a Model
------------------------
+Usage Overview
+==============
+
+.. _specs:
+
+Specs
+-----
+
+You have one image and you want to do something to it to create another image.
+But how do you tell ImageKit what to do? By defining an image spec.
-Much like ``django.db.models.ImageField``, Specs are defined as properties
-of a model class:
+An **image spec** is a type of **image generator** that generates a new image
+from a source image.
+
+
+Defining Specs In Models
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+The easiest way to use define an image spec is by using an ImageSpecField on
+your model class:
.. code-block:: python
from django.db import models
from imagekit.models import ImageSpecField
+ from imagekit.processors import ResizeToFill
+
+ class Profile(models.Model):
+ avatar = models.ImageField(upload_to='avatars')
+ avatar_thumbnail = ImageSpecField(source='avatar',
+ processors=[ResizeToFill(100, 50)],
+ format='JPEG',
+ options={'quality': 60})
+
+ profile = Profile.objects.all()[0]
+ print(profile.avatar_thumbnail.url) # > /media/CACHE/images/982d5af84cddddfd0fbf70892b4431e4.jpg
+ print(profile.avatar_thumbnail.width) # > 100
+
+As you can probably tell, ImageSpecFields work a lot like Django's
+ImageFields. The difference is that they're automatically generated by
+ImageKit based on the instructions you give. In the example above, the avatar
+thumbnail is a resized version of the avatar image, saved as a JPEG with a
+quality of 60.
+
+Sometimes, however, you don't need to keep the original image (the avatar in
+the above example); when the user uploads an image, you just want to process it
+and save the result. In those cases, you can use the ``ProcessedImageField``
+class:
+
+.. code-block:: python
+
+ from django.db import models
+ from imagekit.models import ProcessedImageField
+ from imagekit.processors import ResizeToFill
- class Photo(models.Model):
- original_image = models.ImageField(upload_to='photos')
- formatted_image = ImageSpecField(image_field='original_image', format='JPEG',
- options={'quality': 90})
+ class Profile(models.Model):
+ avatar_thumbnail = ProcessedImageField(upload_to='avatars',
+ processors=[ResizeToFill(100, 50)],
+ format='JPEG',
+ options={'quality': 60})
-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``):
+ profile = Profile.objects.all()[0]
+ print(profile.avatar_thumbnail.url) # > /media/avatars/MY-avatar.jpg
+ print(profile.avatar_thumbnail.width) # > 100
+
+This is pretty similar to our previous example. We don't need to specify a
+"source" any more since we're not processing another image field, but we do need
+to pass an "upload_to" argument. This behaves exactly as it does for Django
+ImageFields.
+
+.. note::
+
+ You might be wondering why we didn't need an "upload_to" argument for our
+ ImageSpecField. The reason is that ProcessedImageFields really are just like
+ ImageFields—they save the file path in the database and you need to run
+ syncdb (or create a migration) when you add one to your model.
+
+ ImageSpecFields, on the other hand, are virtual—they add no fields to your
+ database and don't require a database. This is handy for a lot of reasons,
+ but it means that the path to the image file needs to be programmatically
+ constructed based on the source image and the spec.
+
+
+Defining Specs Outside of Models
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Defining specs as models fields is one very convenient way to process images,
+but it isn't the only way. Sometimes you can't (or don't want to) add fields to
+your models, and that's okay. You can define image spec classes and use them
+directly. This can be especially useful for doing image processing in views—
+particularly when the processing being done depends on user input.
.. 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'
+ from imagekit import ImageSpec
+ from imagekit.processors import ResizeToFill
-Check out ``imagekit.models.ImageSpecField`` for more information.
+ class Thumbnail(ImageSpec):
+ processors = [ResizeToFill(100, 50)]
+ format = 'JPEG'
+ options = {'quality': 60}
-If you only want to save the processed image (without maintaining the original),
-you can use a ``ProcessedImageField``:
+It's probably not surprising that this class is capable of processing an image
+in the exact same way as our ImageSpecField above. However, unlike with the
+image spec model field, this class doesn't define what source the spec is acting
+on, or what should be done with the result; that's up to you:
.. code-block:: python
- from django.db import models
- from imagekit.models.fields import ProcessedImageField
+ source_file = open('/path/to/myimage.jpg', 'rb')
+ image_generator = Thumbnail(source=source_file)
+ result = image_generator.generate()
- class Photo(models.Model):
- processed_image = ProcessedImageField(format='JPEG', options={'quality': 90})
+.. note::
-See the class documentation for details.
+ You don't have to use ``open``! You can use whatever File-like object you
+ want—including a model's ``ImageField``.
+The result of calling ``generate()`` on an image spec is a file-like object
+containing our resized image, with which you can do whatever you want. For
+example, if you wanted to save it to disk:
-Processors
-----------
+.. code-block:: python
+
+ dest = open('/path/to/dest.jpg', 'wb')
+ dest.write(result.read())
+ dest.close()
+
+
+Using Specs In Templates
+^^^^^^^^^^^^^^^^^^^^^^^^
-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:
+If you have a model with an ImageSpecField or ProcessedImageField, you can
+easily use those processed image just as you would a normal image field:
+
+.. code-block:: html
+
+
+
+(This is assuming you have a view that's setting a context variable named
+"profile" to an instance of our Profile model.)
+
+But you can also generate processed image files directly in your template—from
+any image—without adding anything to your model. In order to do this, you'll
+first have to define an image generator class (remember, specs are a type of
+generator) in your app somewhere, just as we did in the last section. You'll
+also need a way of referring to the generator in your template, so you'll need
+to register it.
.. code-block:: python
- from django.db import models
- from imagekit.models import ImageSpecField
- from imagekit.processors import ResizeToFill, Adjust
+ from imagekit import ImageSpec, register
+ from imagekit.processors import ResizeToFill
+
+ class Thumbnail(ImageSpec):
+ processors = [ResizeToFill(100, 50)]
+ format = 'JPEG'
+ options = {'quality': 60}
+
+ register.generator('myapp:thumbnail', Thumbnail)
+
+.. note::
+
+ You can register your generator with any id you want, but choose wisely!
+ If you pick something too generic, you could have a conflict with another
+ third-party app you're using. For this reason, it's a good idea to prefix
+ your generator ids with the name of your app. Also, ImageKit recognizes
+ colons as separators when doing pattern matching (e.g. in the generateimages
+ management command), so it's a good idea to use those too!
+
+.. warning::
+
+ This code can go in any file you want—but you need to make sure it's loaded!
+ In order to keep things simple, ImageKit will automatically try to load an
+ module named "imagegenerators" in each of your installed apps. So why don't
+ you just save yourself the headache and put your image specs in there?
+
+Now that we've created an image generator class and registered it with ImageKit,
+we can use it in our templates!
+
+
+generateimage
+"""""""""""""
+
+The most generic template tag that ImageKit gives you is called "generateimage".
+It requires at least one argument: the id of a registered image generator.
+Additional keyword-style arguments are passed to the registered generator class.
+As we saw above, image spec constructors expect a source keyword argument, so
+that's what we need to pass to use our thumbnail spec:
+
+.. code-block:: html
+
+ {% load imagekit %}
+
+ {% generateimage 'myapp:thumbnail' source=source_file %}
+
+This will output the following HTML:
+
+.. code-block:: html
+
+
+
+You can also add additional HTML attributes; just separate them from your
+keyword args using two dashes:
+
+.. code-block:: html
+
+ {% load imagekit %}
+
+ {% generateimage 'myapp:thumbnail' source=source_file -- alt="A picture of Me" id="mypicture" %}
+
+Not generating HTML image tags? No problem. The tag also functions as an
+assignment tag, providing access to the underlying file object:
+
+.. code-block:: html
- 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})
+ {% load imagekit %}
-The ``thumbnail`` property will now return a cropped image:
+ {% generateimage 'myapp:thumbnail' source=source_file as th %}
+ Click to download a cool {{ th.width }} x {{ th.height }} image!
+
+
+thumbnail
+"""""""""
+
+Because it's such a common use case, ImageKit also provides a "thumbnail"
+template tag:
+
+.. code-block:: html
+
+ {% load imagekit %}
+
+ {% thumbnail '100x50' source_file %}
+
+Like the generateimage tag, the thumbnail tag outputs an
tag:
+
+.. code-block:: html
+
+
+
+Comparing this syntax to the generateimage tag above, you'll notice a few
+differences.
+
+First, we didn't have to specify an image generator id; unless we tell it
+otherwise, thumbnail tag uses the generator registered with the id
+"imagekit:thumbnail". **It's important to note that this tag is *not* using the
+Thumbnail spec class we defined earlier**; it's using the generator registered
+with the id "imagekit:thumbnail" which, by default, is
+``imagekit.generatorlibrary.Thumbnail``.
+
+Second, we're passing two positional arguments (the dimensions and the source
+image) as opposed to the keyword arguments we used with the generateimage tag.
+
+Like with the generateimage tag, you can also specify additional HTML attributes
+for the thumbnail tag, or use it as an assignment tag:
+
+.. code-block:: html
+
+ {% load imagekit %}
+
+ {% thumbnail '100x50' source_file -- alt="A picture of Me" id="mypicture" %}
+ {% thumbnail '100x50' source_file as th %}
+
+
+Using Specs in Forms
+^^^^^^^^^^^^^^^^^^^^
+
+In addition to the model field above, there's also a form field version of the
+``ProcessedImageField`` class. The functionality is basically the same (it
+processes an image once and saves the result), but it's used in a form class:
+
+.. code-block:: python
+
+ from django import forms
+ from imagekit.forms import ProcessedImageField
+ from imagekit.processors import ResizeToFill
+
+ class ProfileForm(forms.Form):
+ avatar_thumbnail = ProcessedImageField(spec_id='myapp:profile:avatar_thumbnail',
+ processors=[ResizeToFill(100, 50)],
+ format='JPEG',
+ options={'quality': 60})
+
+The benefit of using ``imagekit.forms.ProcessedImageField`` (as opposed to
+``imagekit.models.ProcessedImageField`` above) is that it keeps the logic for
+creating the image outside of your model (in which you would use a normal Django
+ImageField). You can even create multiple forms, each with their own
+ProcessedImageField, that all store their results in the same image field.
+
+
+Processors
+----------
+
+So far, we've only seen one processor: ``imagekit.processors.ResizeToFill``. But
+ImageKit is capable of far more than just resizing images, and that power comes
+from its processors.
+
+Processors take a PIL image object, do something to it, and return a new one.
+A spec can make use of as many processors as you'd like, which will all be run
+in order.
.. code-block:: python
- photo = Photo.objects.all()[0]
- photo.thumbnail.url # > '/media/cache/photos/birthday_thumbnail.jpeg'
- photo.thumbnail.width # > 50
- photo.original_image.width # > 1000
+ from imagekit import ImageSpec
+ from imagekit.processors import TrimBorderColor, Adjust
-The original image is not modified; ``thumbnail`` is a new file that is the
-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``.)
+ class MySpec(ImageSpec):
+ processors = [
+ TrimBorderColor(),
+ Adjust(contrast=1.2, sharpness=1.1),
+ ]
+ format = 'JPEG'
+ options = {'quality': 60}
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:
+define a class that implements a ``process()`` method:
.. code-block:: python
@@ -114,10 +356,29 @@ implement a ``process()`` method:
# Code for adding the watermark goes here.
return image
- class Photo(models.Model):
- original_image = models.ImageField(upload_to='photos')
- watermarked_image = ImageSpecField([Watermark()], image_field='original_image',
- format='JPEG', options={'quality': 90})
+That's all there is to it! To use your fancy new custom processor, just include
+it in your spec's ``processors`` list:
+
+.. code-block:: python
+
+ from imagekit import ImageSpec
+ from imagekit.processors import TrimBorderColor, Adjust
+ from myapp.processors import Watermark
+
+ class MySpec(ImageSpec):
+ processors = [
+ TrimBorderColor(),
+ Adjust(contrast=1.2, sharpness=1.1),
+ Watermark(),
+ ]
+ format = 'JPEG'
+ options = {'quality': 60}
+
+Note that when you import a processor from ``imagekit.processors``, imagekit
+in turn imports the processor from `PILKit`_. So if you are looking for
+available processors, look at PILKit.
+
+.. _`PILKit`: https://github.com/matthewwithanm/pilkit
Admin
@@ -134,65 +395,68 @@ Django admin classes:
from imagekit.admin import AdminThumbnail
from .models import Photo
-
class PhotoAdmin(admin.ModelAdmin):
list_display = ('__str__', 'admin_thumbnail')
admin_thumbnail = AdminThumbnail(image_field='thumbnail')
-
admin.site.register(Photo, PhotoAdmin)
-AdminThumbnail can even use a custom template. For more information, see
-``imagekit.admin.AdminThumbnail``.
+To use specs defined outside of models:
-.. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list
+.. code-block:: python
+
+ from django.contrib import admin
+ from imagekit.admin import AdminThumbnail
+ from imagekit import ImageSpec
+ from imagekit.processors import ResizeToFill
+ from imagekit.cachefiles import ImageCacheFile
+ from .models import Photo
-Image Cache Backends
---------------------
+ class AdminThumbnailSpec(ImageSpec):
+ processors = [ResizeToFill(100, 30)]
+ format = 'JPEG'
+ options = {'quality': 60 }
-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.
+ def cached_admin_thumb(instance):
+ # `image` is the name of the image field on the model
+ cached = ImageCacheFile(AdminThumbnailSpec(instance.image))
+ # only generates the first time, subsequent calls use cache
+ cached.generate()
+ return cached
-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:
+ class PhotoAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'admin_thumbnail')
+ admin_thumbnail = AdminThumbnail(image_field=cached_admin_thumb)
-.. code-block:: python
+ admin.site.register(Photo, PhotoAdmin)
- class Photo(models.Model):
- ...
- thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend())
+
+AdminThumbnail can even use a custom template. For more information, see
+``imagekit.admin.AdminThumbnail``.
-Or in your ``settings.py`` file if you want to use it as the default:
+.. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list
-.. code-block:: python
- IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend'
+Management Commands
+-------------------
+
+ImageKit has one management command—``generateimages``—which will generate cache
+files for all of your registered image generators. You can also pass it a list
+of generator ids in order to generate images selectively.
Community
----------
+=========
-Please use `the GitHub issue tracker `_
+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
@@ -200,6 +464,11 @@ 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.
+You can also check out our list of `open, contributor-friendly issues`__ for
+ideas.
+
+Check out our `contributing guidelines`_ for more information about pitching in
+with ImageKit.
+
+__ https://github.com/matthewwithanm/django-imagekit/issues?labels=contributor-friendly&state=open
+.. _`contributing guidelines`: https://github.com/matthewwithanm/django-imagekit/blob/develop/CONTRIBUTING.rst
diff --git a/docs/_themes/README.rst b/docs/_themes/README.rst
index e8179f96..26172c76 100755
--- a/docs/_themes/README.rst
+++ b/docs/_themes/README.rst
@@ -2,7 +2,7 @@ krTheme Sphinx Style
====================
This repository contains sphinx styles Kenneth Reitz uses in most of
-his projects. It is a drivative of Mitsuhiko's themes for Flask and Flask related
+his projects. It is a derivative of Mitsuhiko's themes for Flask and Flask related
projects. To use this style in your Sphinx documentation, follow
this guide:
diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py
index 33f47449..436c5685 100755
--- a/docs/_themes/flask_theme_support.py
+++ b/docs/_themes/flask_theme_support.py
@@ -1,7 +1,8 @@
# flasky extensions. flasky pygments style based on tango style
from pygments.style import Style
-from pygments.token import Keyword, Name, Comment, String, Error, \
- Number, Operator, Generic, Whitespace, Punctuation, Other, Literal
+from pygments.token import (Comment, Error, Generic, Keyword, Literal, Name,
+ Number, Operator, Other, Punctuation, String,
+ Whitespace)
class FlaskyStyle(Style):
diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst
new file mode 100644
index 00000000..f04a3945
--- /dev/null
+++ b/docs/advanced_usage.rst
@@ -0,0 +1,182 @@
+Advanced Usage
+**************
+
+
+Models
+======
+
+
+The ``ImageSpecField`` Shorthand Syntax
+---------------------------------------
+
+If you've read the README, you already know what an ``ImageSpecField`` is and
+the basics of defining one:
+
+.. code-block:: python
+
+ from django.db import models
+ from imagekit.models import ImageSpecField
+ from imagekit.processors import ResizeToFill
+
+ class Profile(models.Model):
+ avatar = models.ImageField(upload_to='avatars')
+ avatar_thumbnail = ImageSpecField(source='avatar',
+ processors=[ResizeToFill(100, 50)],
+ format='JPEG',
+ options={'quality': 60})
+
+This will create an ``avatar_thumbnail`` field which is a resized version of the
+image stored in the ``avatar`` image field. But this is actually just shorthand
+for creating an ``ImageSpec``, registering it, and associating it with an
+``ImageSpecField``:
+
+.. code-block:: python
+
+ from django.db import models
+ from imagekit import ImageSpec, register
+ from imagekit.models import ImageSpecField
+ from imagekit.processors import ResizeToFill
+
+ class AvatarThumbnail(ImageSpec):
+ processors = [ResizeToFill(100, 50)]
+ format = 'JPEG'
+ options = {'quality': 60}
+
+ register.generator('myapp:profile:avatar_thumbnail', AvatarThumbnail)
+
+ class Profile(models.Model):
+ avatar = models.ImageField(upload_to='avatars')
+ avatar_thumbnail = ImageSpecField(source='avatar',
+ id='myapp:profile:avatar_thumbnail')
+
+Obviously, the shorthand version is a lot, well…shorter. So why would you ever
+want to go through the trouble of using the long form? The answer is that the
+long form—creating an image spec class and registering it—gives you a lot more
+power over the generated image.
+
+.. _dynamic-specs:
+
+Specs That Change
+-----------------
+
+As you'll remember from the README, an image spec is just a type of image
+generator that generates a new image from a source image. How does the image
+spec get access to the source image? Simple! It's passed to the constructor as
+a keyword argument and stored as an attribute of the spec. Normally, we don't
+have to concern ourselves with this; the ``ImageSpec`` knows what to do with the
+source image and we're happy to let it do its thing. However, having access to
+the source image in our spec class can be very useful…
+
+Often, when using an ``ImageSpecField``, you may want the spec to vary based on
+properties of a model. (For example, you might want to store image dimensions on
+the model and then use them to generate your thumbnail.) Now that we know how to
+access the source image from our spec, it's a simple matter to extract its model
+and use it to create our processors list. In fact, ImageKit includes a utility
+for getting this information.
+
+.. code-block:: python
+ :emphasize-lines: 11-14
+
+ from django.db import models
+ from imagekit import ImageSpec, register
+ from imagekit.models import ImageSpecField
+ from imagekit.processors import ResizeToFill
+ from imagekit.utils import get_field_info
+
+ class AvatarThumbnail(ImageSpec):
+ format = 'JPEG'
+ options = {'quality': 60}
+
+ @property
+ def processors(self):
+ model, field_name = get_field_info(self.source)
+ return [ResizeToFill(model.thumbnail_width, model.thumbnail_height)]
+
+ register.generator('myapp:profile:avatar_thumbnail', AvatarThumbnail)
+
+ class Profile(models.Model):
+ avatar = models.ImageField(upload_to='avatars')
+ avatar_thumbnail = ImageSpecField(source='avatar',
+ id='myapp:profile:avatar_thumbnail')
+ thumbnail_width = models.PositiveIntegerField()
+ thumbnail_height = models.PositiveIntegerField()
+
+Now each avatar thumbnail will be resized according to the dimensions stored on
+the model!
+
+Of course, processors aren't the only thing that can vary based on the model of
+the source image; spec behavior can change in any way you want.
+
+
+.. _source-groups:
+
+Source Groups
+=============
+
+When you run the ``generateimages`` management command, how does ImageKit know
+which source images to use with which specs? Obviously, when you define an
+ImageSpecField, the source image is being connected to a spec, but what's going
+on underneath the hood?
+
+The answer is that, when you define an ImageSpecField, ImageKit automatically
+creates and registers an object called a *source group*. Source groups are
+responsible for two things:
+
+1. They dispatch signals when a source is saved, and
+2. They expose a generator method that enumerates source files.
+
+When these objects are registered (using ``imagekit.register.source_group()``),
+their signals will trigger callbacks on the cache file strategies associated
+with image specs that use the source. (So, for example, you can chose to
+generate a file every time the source image changes.) In addition, the generator
+method is used (indirectly) to create the list of files to generate with the
+``generateimages`` management command.
+
+Currently, there is only one source group class bundled with ImageKit—the one
+used by ImageSpecFields. This source group
+(``imagekit.specs.sourcegroups.ImageFieldSourceGroup``) represents an ImageField
+on every instance of a particular model. In terms of the above description, the
+instance ``ImageFieldSourceGroup(Profile, 'avatar')`` 1) dispatches a signal
+every time the image in Profile's avatar ImageField changes, and 2) exposes a
+generator method that iterates over every Profile's "avatar" image.
+
+Chances are, this is the only source group you will ever need to use, however,
+ImageKit lets you define and register custom source groups easily. This may be
+useful, for example, if you're using the template tags "generateimage" and
+"thumbnail" and the optimistic cache file strategy. Again, the purpose is
+to tell ImageKit which specs are used with which sources (so the
+"generateimages" management command can generate those files) and when the
+source image has been created or changed (so that the strategy has the
+opportunity to act on it).
+
+A simple example of a custom source group class is as follows:
+
+.. code-block:: python
+
+ import glob
+ import os
+
+ class JpegsInADirectory(object):
+ def __init__(self, dir):
+ self.dir = dir
+
+ def files(self):
+ os.chdir(self.dir)
+ for name in glob.glob('*.jpg'):
+ yield open(name, 'rb')
+
+Instances of this class could then be registered with one or more spec id:
+
+.. code-block:: python
+
+ from imagekit import register
+
+ register.source_group('myapp:profile:avatar_thumbnail', JpegsInADirectory('/path/to/some/pics'))
+
+Running the "generateimages" management command would now cause thumbnails to be
+generated (using the "myapp:profile:avatar_thumbnail" spec) for each of the
+JPEGs in `/path/to/some/pics`.
+
+Note that, since this source group doesnt send the `source_saved` signal, the
+corresponding cache file strategy callbacks would not be called for them.
+
diff --git a/docs/apireference.rst b/docs/apireference.rst
deleted file mode 100644
index d4a2ed83..00000000
--- a/docs/apireference.rst
+++ /dev/null
@@ -1,29 +0,0 @@
-API Reference
-=============
-
-
-:mod:`models` Module
---------------------
-
-.. automodule:: imagekit.models.fields
- :members:
-
-
-:mod:`processors` Module
-------------------------
-
-.. automodule:: imagekit.processors
- :members:
-
-.. automodule:: imagekit.processors.resize
- :members:
-
-.. automodule:: imagekit.processors.crop
- :members:
-
-
-:mod:`admin` Module
---------------------
-
-.. automodule:: imagekit.admin
- :members:
diff --git a/docs/caching.rst b/docs/caching.rst
new file mode 100644
index 00000000..30e93089
--- /dev/null
+++ b/docs/caching.rst
@@ -0,0 +1,256 @@
+Caching
+*******
+
+
+Default Backend Workflow
+========================
+
+
+``ImageSpec``
+-------------
+
+At the heart of ImageKit are image generators. These are classes with a
+``generate()`` method which returns an image file. An image spec is a type of
+image generator. The thing that makes specs special is that they accept a source
+image. So an image spec is just an image generator that makes an image from some
+other image.
+
+
+``ImageCacheFile``
+------------------
+
+However, an image spec by itself would be vastly inefficient. Every time an
+an image was accessed in some way, it would have be regenerated and saved.
+Most of the time, you want to re-use a previously generated image, based on the
+input image and spec, instead of generating a new one. That's where
+``ImageCacheFile`` comes in. ``ImageCacheFile`` is a File-like object that
+wraps an image generator. They look and feel just like regular file
+objects, but they've got a little trick up their sleeve: they represent files
+that may not actually exist!
+
+
+.. _cache-file-strategy:
+
+Cache File Strategy
+-------------------
+
+Each ``ImageCacheFile`` has a cache file strategy, which abstracts away when
+image is actually generated. It can implement the following three methods:
+
+* ``on_content_required`` - called by ``ImageCacheFile`` when it requires the
+ contents of the generated image. For example, when you call ``read()`` or
+ try to access information contained in the file.
+* ``on_existence_required`` - called by ``ImageCacheFile`` when it requires the
+ generated image to exist but may not be concerned with its contents. For
+ example, when you access its ``url`` or ``path`` attribute.
+* ``on_source_saved`` - called when the source of a spec is saved
+
+The default strategy only defines the first two of these, as follows:
+
+.. code-block:: python
+
+ class JustInTime(object):
+ def on_content_required(self, file):
+ file.generate()
+
+ def on_existence_required(self, file):
+ file.generate()
+
+
+.. _cache-file-backend:
+
+Cache File Backend
+------------------
+
+The ``generate`` method on the ``ImageCacheFile`` is further delegated to the
+cache file backend, which abstracts away how an image is generated.
+
+The cache file backend defaults to the setting
+``IMAGEKIT_DEFAULT_CACHEFILE_BACKEND`` and can be set explicitly on a spec with
+the ``cachefile_backend`` attribute.
+
+The default works like this:
+
+* Checks the file storage to see if a file exists
+ * If not, caches that information for 5 seconds
+ * If it does, caches that information in the ``IMAGEKIT_CACHE_BACKEND``
+
+If file doesn't exist, generates it immediately and synchronously
+
+
+That pretty much covers the architecture of the caching layer, and its default
+behavior. I like the default behavior. When will an image be regenerated?
+Whenever it needs to be! When will your storage backend get hit? Depending on
+your ``IMAGEKIT_CACHE_BACKEND`` settings, as little as twice per file (once for the
+existence check and once to save the generated file). What if you want to change
+a spec? The generated file name (which is used as part of the cache keys) vary
+with the source file name and spec attributes, so if you change any of those, a
+new file will be generated. The default behavior is easy!
+
+.. note::
+
+ Like regular Django ImageFields, IK doesn't currently cache width and height
+ values, so accessing those will always result in a read. That will probably
+ change soon though.
+
+
+Optimizing
+==========
+
+There are several ways to improve the performance (reduce I/O operations) of
+ImageKit. Each has its own pros and cons.
+
+
+Caching Data About Generated Files
+----------------------------------
+
+Generally, once a file is generated, you will never be removing it, so by
+default ImageKit will use default cache to cache the state of generated
+files "forever" (or only 5 minutes when ``DEBUG = True``).
+
+The time for which ImageKit will cache state is configured with
+``IMAGEKIT_CACHE_TIMEOUT``. If set to ``None`` this means "never expire"
+(default when ``DEBUG = False``). You can reduce this timeout if you want
+or set it to some numeric value in seconds if your cache backend behaves
+differently and for example do not cache values if timeout is ``None``.
+
+If you clear your cache durring deployment or some other reason probably
+you do not want to lose the cache for generated images especcialy if you
+are using some slow remote storage (like Amazon S3). Then you can configure
+separate cache (for example redis) in your ``CACHES`` config and tell ImageKit
+to use it instead of the default cache by setting ``IMAGEKIT_CACHE_BACKEND``.
+
+
+Pre-Generating Images
+---------------------
+
+The default cache file backend generates images immediately and synchronously.
+If you don't do anything special, that will be when they are first requested—as
+part of request-response cycle. This means that the first visitor to your page
+will have to wait for the file to be created before they see any HTML.
+
+This can be mitigated, though, by simply generating the images ahead of time, by
+running the ``generateimages`` management command.
+
+.. note::
+
+ If using with template tags, be sure to read :ref:`source-groups`.
+
+
+Deferring Image Generation
+--------------------------
+
+As mentioned above, image generation is normally done synchronously. through
+the default cache file backend. However, you can also take advantage of
+deferred generation. In order to do this, you'll need to do two things:
+
+1) install `celery`__ (or `django-celery`__ if you are bound to Celery<3.1)
+2) tell ImageKit to use the async cachefile backend.
+ To do this for all specs, set the ``IMAGEKIT_DEFAULT_CACHEFILE_BACKEND`` in
+ your settings
+
+.. code-block:: python
+
+ IMAGEKIT_DEFAULT_CACHEFILE_BACKEND = 'imagekit.cachefiles.backends.Async'
+
+Images will now be generated asynchronously. But watch out! Asynchrounous
+generation means you'll have to account for images that haven't been generated
+yet. You can do this by checking the truthiness of your files; if an image
+hasn't been generated, it will be falsy:
+
+.. code-block:: html
+
+ {% if not profile.avatar_thumbnail %}
+
+ {% else %}
+
+ {% endif %}
+
+Or, in Python:
+
+.. code-block:: python
+
+ profile = Profile.objects.all()[0]
+ if profile.avatar_thumbnail:
+ url = profile.avatar_thumbnail.url
+ else:
+ url = '/path/to/placeholder.jpg'
+
+.. note::
+
+ If you are using an "async" backend in combination with the "optimistic"
+ cache file strategy (see `Removing Safeguards`_ below), checking for
+ truthiness as described above will not work. The "optimistic" backend is
+ very optimistic so to say, and removes the check. Create and use the
+ following strategy to a) have images only created on save, and b) retain
+ the ability to check whether the images have already been created::
+
+ class ImagekitOnSaveStrategy(object):
+ def on_source_saved(self, file):
+ file.generate()
+
+.. note::
+
+ If you use custom storage backend for some specs,
+ (storage passed to the field different than configured one)
+ it's required the storage to be pickleable
+
+
+__ https://pypi.python.org/pypi/django-celery
+__ http://www.celeryproject.org
+
+
+Removing Safeguards
+-------------------
+
+Even with pre-generating images, ImageKit will still try to ensure that your
+image exists when you access it by default. This is for your benefit: if you
+forget to generate your images, ImageKit will see that and generate it for you.
+If the state of the file is cached (see above), this is a pretty cheap
+operation. However, if the state isn't cached, ImageKit will need to query the
+storage backend.
+
+For those who aren't willing to accept that cost (and who never want ImageKit
+to generate images in the request-response cycle), there's the "optimistic"
+cache file strategy. This strategy only generates a new image when a spec's
+source image is created or changed. Unlike with the "just in time" strategy,
+accessing the file won't cause it to be generated, ImageKit will just assume
+that it already exists.
+
+To use this cache file strategy for all specs, set the
+``IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY`` in your settings:
+
+.. code-block:: python
+
+ IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = 'imagekit.cachefiles.strategies.Optimistic'
+
+If you have specs that :ref:`change based on attributes of the source
+`, that's not going to cut it, though; the file will also need to
+be generated when those attributes change. Likewise, image generators that don't
+have sources (i.e. generators that aren't specs) won't cause files to be
+generated automatically when using the optimistic strategy. (ImageKit can't know
+when those need to be generated, if not on access.) In both cases, you'll have
+to trigger the file generation yourself—either by generating the file in code
+when necessary, or by periodically running the ``generateimages`` management
+command. Luckily, ImageKit makes this pretty easy:
+
+.. code-block:: python
+
+ from imagekit.cachefiles import LazyImageCacheFile
+
+ file = LazyImageCacheFile('myapp:profile:avatar_thumbnail', source=source_file)
+ file.generate()
+
+One final situation in which images won't be generated automatically when using
+the optimistic strategy is when you use a spec with a source that hasn't been
+registered with it. Unlike the previous two examples, this situation cannot be
+rectified by running the ``generateimages`` management command, for the simple
+reason that the command has no way of knowing it needs to generate a file for
+that spec from that source. Typically, this situation would arise when using the
+template tags. Unlike ImageSpecFields, which automatically register all the
+possible source images with the spec you define, the template tags
+("generateimage" and "thumbnail") let you use any spec with any source.
+Therefore, in order to generate the appropriate files using the
+``generateimages`` management command, you'll need to first register a source
+group that represents all of the sources you wish to use with the corresponding
+specs. See :ref:`source-groups` for more information.
diff --git a/docs/changelog.rst b/docs/changelog.rst
deleted file mode 100644
index 072d8e17..00000000
--- a/docs/changelog.rst
+++ /dev/null
@@ -1,142 +0,0 @@
-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
-------
-
-- 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
-------
-
-- A ``SmartCrop`` resize processor was added. This allows an image to be
- cropped based on the amount of entropy in the target image's histogram.
-
-- The ``quality`` argument was removed in favor of an ``options`` dictionary.
- This is a more general solution which grants access to PIL's format-specific
- options (including "quality", "progressive", and "optimize" for JPEGs).
-
-- The ``TrimColor`` processor was renamed to ``TrimBorderColor``.
-
-- The private ``_Resize`` class has been removed.
-
-
-v1.0.3
-------
-
-- ``ImageSpec._create()`` was renamed ``ImageSpec.generate()`` and is now
- available in the public API.
-
-- Added an ``AutoConvert`` processor to encapsulate the transparency
- handling logic.
-
-- Refactored transparency handling to be smarter, handling a lot more of
- the situations in which one would convert to or from formats that support
- transparency.
-
-- Fixed PIL zeroing out files when write mode is enabled.
-
-
-v1.0.2
-------
-
-- Added this changelog.
-
-- Enhanced extension detection, format detection, and conversion between the
- two. This eliminates the reliance on an image being loaded into memory
- beforehand in order to detect said image's extension.
-
-- 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
-------
-
-- Minor fixes related to the rendering of ``README.rst`` as a reStructured
- text file.
-
-- Fixed the included admin template not being found when ImageKit was and
- the packaging of the included admin templates.
-
-
-v1.0
-----
-
-- Initial release of the *new* field-based ImageKit API.
diff --git a/docs/conf.py b/docs/conf.py
index e0913b92..3fba84ea 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
#
# ImageKit documentation build configuration file, created by
# sphinx-quickstart on Sun Sep 25 17:05:55 2011.
@@ -11,7 +10,9 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import sys, os
+import os
+import re
+import sys
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
@@ -42,17 +43,22 @@
master_doc = 'index'
# General information about the project.
-project = u'ImageKit'
-copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'
+project = 'ImageKit'
+copyright = '2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Venelin Stoykov & contributors'
+
+pkgmeta = {}
+pkgmeta_file = os.path.join(os.path.dirname(__file__), '..', 'imagekit', 'pkgmeta.py')
+with open(pkgmeta_file, 'r') as f:
+ exec(f.read(), pkgmeta)
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
-version = '2.0.2'
+version = re.match(r'\d+\.\d+', pkgmeta['__version__']).group()
# The full version, including alpha/beta/rc tags.
-release = '2.0.2'
+release = pkgmeta['__version__']
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@@ -185,8 +191,8 @@
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
- ('index', 'ImageKit.tex', u'ImageKit Documentation',
- u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett \\& Matthew Tretter', 'manual'),
+ ('index', 'ImageKit.tex', 'ImageKit Documentation',
+ 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett \\& Matthew Tretter', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@@ -215,8 +221,8 @@
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
- ('index', 'imagekit', u'ImageKit Documentation',
- [u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'], 1)
+ ('index', 'imagekit', 'ImageKit Documentation',
+ ['Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'], 1)
]
# If true, show URL addresses after external links.
@@ -229,7 +235,7 @@
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
- ('index', 'ImageKit', u'ImageKit Documentation', u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter',
+ ('index', 'ImageKit', 'ImageKit Documentation', 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter',
'ImageKit', 'One line description of project.', 'Miscellaneous'),
]
diff --git a/docs/configuration.rst b/docs/configuration.rst
new file mode 100644
index 00000000..89299280
--- /dev/null
+++ b/docs/configuration.rst
@@ -0,0 +1,117 @@
+.. _settings:
+
+Configuration
+=============
+
+
+Settings
+--------
+
+.. currentmodule:: django.conf.settings
+
+
+.. attribute:: IMAGEKIT_DEFAULT_THUMBNAIL_FORMAT
+
+ :default: ``None``
+
+ The output format of the images generated by the ``thumbnail`` template tag.
+
+
+.. attribute:: IMAGEKIT_CACHEFILE_DIR
+
+ :default: ``'CACHE/images'``
+
+ The directory to which image files will be cached.
+
+
+.. attribute:: IMAGEKIT_DEFAULT_THUMBNAIL_SRCSET_SCALES
+
+ :default: ``None``
+
+ A list of scale factors, for example ``[2, 3]``. If specified, every
+ ``
`` generated by the ``thumbnail`` template tag will have a ``srcset``
+ attribute with the given scales. To prevent this, set ``srcset=None``.
+
+
+.. attribute:: IMAGEKIT_DEFAULT_FILE_STORAGE
+
+ :default: ``None``
+
+ Starting with Django 4.2, if you defined ``settings.STORAGES``:
+ the Django storage backend alias to retrieve the storage instance defined
+ in your settings, as described in the `Django file storage documentation`_.
+ If no value is provided for ``IMAGEKIT_DEFAULT_FILE_STORAGE``,
+ and none is specified by the spec definition, the ``default`` file storage
+ will be used.
+
+ Before Django 4.2, or if ``settings.STORAGES`` is not defined:
+ The qualified class name of a Django storage backend to use to save the
+ cached images. If no value is provided for ``IMAGEKIT_DEFAULT_FILE_STORAGE``,
+ and none is specified by the spec definition, `your default file storage`__
+ will be used.
+
+
+.. _`Django file storage documentation`: https://docs.djangoproject.com/en/dev/ref/files/storage/
+
+
+.. attribute:: IMAGEKIT_DEFAULT_CACHEFILE_BACKEND
+
+ :default: ``'imagekit.cachefiles.backends.Simple'``
+
+ Specifies the class that will be used to validate cached image files.
+
+
+.. attribute:: IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY
+
+ :default: ``'imagekit.cachefiles.strategies.JustInTime'``
+
+ The class responsible for specifying how and when cache files are
+ generated.
+
+
+.. attribute:: IMAGEKIT_CACHE_BACKEND
+
+ :default: ``'default'``
+
+ The Django cache backend alias to retrieve the shared cache instance defined
+ in your settings, as described in the `Django cache section`_.
+
+ The cache is then used to store information like the state of cached
+ images (i.e. validated or not).
+
+.. _`Django cache section`: https://docs.djangoproject.com/en/dev/topics/cache/#accessing-the-cache
+
+
+.. attribute:: IMAGEKIT_CACHE_TIMEOUT
+
+ :default: ``None``
+
+ Use when you need to override the timeout used to cache file state.
+ By default it is "cache forever".
+ It's highly recommended that you use a very high timeout.
+
+
+.. attribute:: IMAGEKIT_CACHE_PREFIX
+
+ :default: ``'imagekit:'``
+
+ A cache prefix to be used when values are stored in ``IMAGEKIT_CACHE_BACKEND``
+
+
+.. attribute:: IMAGEKIT_CACHEFILE_NAMER
+
+ :default: ``'imagekit.cachefiles.namers.hash'``
+
+ A function responsible for generating file names for non-spec cache files.
+
+
+.. attribute:: IMAGEKIT_SPEC_CACHEFILE_NAMER
+
+ :default: ``'imagekit.cachefiles.namers.source_name_as_path'``
+
+ A function responsible for generating file names for cache files that
+ correspond to image specs. Since you will likely want to base the name of
+ your cache files on the name of the source, this extra setting is provided.
+
+
+__ https://docs.djangoproject.com/en/dev/ref/settings/#default-file-storage
diff --git a/docs/index.rst b/docs/index.rst
index 2c063856..192ace0d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,43 +1,24 @@
-Getting Started
-===============
-
.. include:: ../README.rst
-Commands
---------
-
-.. automodule:: imagekit.management.commands.ikcacheinvalidate
-
-.. automodule:: imagekit.management.commands.ikcachevalidate
-
-
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
---------------
-
-.. toctree::
-
- apireference
- changelog
-
-
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
+
+.. toctree::
+ :glob:
+ :maxdepth: 2
+
+ configuration
+ advanced_usage
+ caching
+ upgrading
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
new file mode 100644
index 00000000..15eab6d1
--- /dev/null
+++ b/docs/upgrading.rst
@@ -0,0 +1,117 @@
+Upgrading from 2.x
+==================
+
+ImageKit 3.0 introduces new APIs and tools that augment, improve, and in some
+cases entirely replace old IK workflows. Below, you'll find some useful guides
+for migrating your ImageKit 2.0 apps over to the shiny new IK3.
+
+
+Model Specs
+-----------
+
+IK3 is chock full of new features and better tools for even the most
+sophisticated use cases. Despite this, not too much has changed when it
+comes to the most common of use cases: processing an ``ImageField`` on a model.
+
+In IK2, you may have used an ``ImageSpecField`` on a model to process an
+existing ``ImageField``:
+
+.. code-block:: python
+
+ class Profile(models.Model):
+ avatar = models.ImageField(upload_to='avatars')
+ avatar_thumbnail = ImageSpecField(image_field='avatar',
+ processors=[ResizeToFill(100, 50)],
+ format='JPEG',
+ options={'quality': 60})
+
+In IK3, things look much the same:
+
+.. code-block:: python
+
+ class Profile(models.Model):
+ avatar = models.ImageField(upload_to='avatars')
+ avatar_thumbnail = ImageSpecField(source='avatar',
+ processors=[ResizeToFill(100, 50)],
+ format='JPEG',
+ options={'quality': 60})
+
+The major difference is that ``ImageSpecField`` no longer takes an
+``image_field`` kwarg. Instead, you define a ``source``.
+
+
+Image Cache Backends
+--------------------
+
+In IK2, you could gain some control over how your cached images were generated
+by providing an ``image_cache_backend``:
+
+.. code-block:: python
+
+ class Photo(models.Model):
+ ...
+ thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend())
+
+This gave you great control over *how* your images are generated and stored,
+but it could be difficult to control *when* they were generated and stored.
+
+IK3 retains the image cache backend concept (now called cache file backends),
+but separates the 'when' control out to cache file strategies:
+
+.. code-block:: python
+
+ class Photo(models.Model):
+ ...
+ thumbnail = ImageSpecField(...,
+ cachefile_backend=MyCacheFileBackend(),
+ cachefile_strategy=MyCacheFileStrategy())
+
+If you are using the IK2 default image cache backend setting:
+
+.. code-block:: python
+
+ IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend'
+
+IK3 provides analogous settings for cache file backends and strategies:
+
+.. code-block:: python
+
+ IMAGEKIT_DEFAULT_CACHEFILE_BACKEND = 'path.to.MyCacheFileBackend'
+ IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = 'path.to.MyCacheFileStrategy'
+
+See the documentation on :ref:`cache file backends ` and :ref:`cache file strategies `
+for more details.
+
+
+Conditional model ``processors``
+--------------------------------
+
+In IK2, an ``ImageSpecField`` could take a ``processors`` callable instead of
+an iterable, which allowed processing decisions to made based on other
+properties of the model. IK3 does away with this feature for consistency's sake
+(if one kwarg could be callable, why not all?), but provides a much more robust
+solution: the custom ``spec``. See the :doc:`advanced usage ` documentation for more.
+
+
+Conditonal ``cache_to`` file names
+----------------------------------
+
+IK2 provided a means of specifying custom cache file names for your
+image specs by passing a ``cache_to`` callable to an ``ImageSpecField``.
+IK3 does away with this feature, again, for consistency.
+
+There is a way to achieve custom file names by overriding your spec's
+``cachefile_name``, but it is not recommended, as the spec's default
+behavior is to hash the combination of ``source``, ``processors``, ``format``,
+and other spec options to ensure that changes to the spec always result in
+unique file names. See the documentation on :ref:`specs` for more.
+
+
+Processors have moved to PILKit
+-------------------------------
+
+Processors have moved to a separate project: `PILKit`_. You should not have to
+make any changes to an IK2 project to use PILKit--it should be installed with
+IK3, and importing from ``imagekit.processors`` will still work.
+
+.. _`PILKit`: https://github.com/matthewwithanm/pilkit
diff --git a/imagekit/__init__.py b/imagekit/__init__.py
index 9dd4ffe1..1f0ffc4a 100644
--- a/imagekit/__init__.py
+++ b/imagekit/__init__.py
@@ -1,34 +1,9 @@
-__title__ = 'django-imagekit'
-__author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge'
-__version__ = (2, 0, 2, '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
+from . import conf, generatorlibrary
+from .pkgmeta import *
+from .registry import register, unregister
+from .specs import ImageSpec
+
+__all__ = [
+ 'ImageSpec', 'conf', 'generatorlibrary', 'register', 'unregister',
+ '__title__', '__author__', '__version__', '__license__'
+]
diff --git a/imagekit/admin.py b/imagekit/admin.py
index 4466e6ec..9e171a97 100644
--- a/imagekit/admin.py
+++ b/imagekit/admin.py
@@ -1,8 +1,8 @@
-from django.utils.translation import ugettext_lazy as _
from django.template.loader import render_to_string
+from django.utils.translation import gettext_lazy as _
-class AdminThumbnail(object):
+class AdminThumbnail:
"""
A convenience utility for adding thumbnails to Django's admin change list.
@@ -27,10 +27,10 @@ def __call__(self, obj):
try:
thumbnail = getattr(obj, self.image_field)
except AttributeError:
- raise Exception('The property %s is not defined on %s.' % \
+ 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
+ original_image = getattr(thumbnail, 'source', None) or thumbnail
template = self.template or 'imagekit/admin/thumbnail.html'
return render_to_string(template, {
diff --git a/imagekit/cachefiles/__init__.py b/imagekit/cachefiles/__init__.py
new file mode 100644
index 00000000..6717cb9a
--- /dev/null
+++ b/imagekit/cachefiles/__init__.py
@@ -0,0 +1,184 @@
+import os.path
+from copy import copy
+
+from django.conf import settings
+from django.core.files import File
+from django.core.files.images import ImageFile
+from django.utils.encoding import smart_str
+from django.utils.functional import SimpleLazyObject
+
+from ..files import BaseIKFile
+from ..registry import generator_registry
+from ..signals import content_required, existence_required
+from ..utils import (
+ generate, get_by_qname, get_logger, get_singleton, get_storage
+)
+
+
+class ImageCacheFile(BaseIKFile, ImageFile):
+ """
+ A file that represents the result of a generator. Creating an instance of
+ this class is not enough to trigger the generation of the file. In fact,
+ one of the main points of this class is to allow the creation of the file
+ to be deferred until the time that the cache file strategy requires it.
+
+ """
+ def __init__(self, generator, name=None, storage=None, cachefile_backend=None, cachefile_strategy=None):
+ """
+ :param generator: The object responsible for generating a new image.
+ :param name: The filename
+ :param storage: A Django storage object, or a callable which returns a
+ storage object that will be used to save the file.
+ :param cachefile_backend: The object responsible for managing the
+ state of the file.
+ :param cachefile_strategy: The object responsible for handling events
+ for this file.
+
+ """
+ self.generator = generator
+
+ if not name:
+ try:
+ name = generator.cachefile_name
+ except AttributeError:
+ fn = get_by_qname(settings.IMAGEKIT_CACHEFILE_NAMER, 'namer')
+ name = fn(generator)
+ self.name = name
+
+ storage = (callable(storage) and storage()) or storage or \
+ getattr(generator, 'cachefile_storage', None) or get_storage()
+ self.cachefile_backend = (
+ cachefile_backend
+ or getattr(generator, 'cachefile_backend', None)
+ or get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_BACKEND,
+ 'cache file backend'))
+ self.cachefile_strategy = (
+ cachefile_strategy
+ or getattr(generator, 'cachefile_strategy', None)
+ or get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY,
+ 'cache file strategy')
+ )
+
+ super().__init__(storage=storage)
+
+ def _require_file(self):
+ if getattr(self, '_file', None) is None:
+ content_required.send(sender=self, file=self)
+ self._file = self.storage.open(self.name, 'rb')
+
+ # The ``path`` and ``url`` properties are overridden so as to not call
+ # ``_require_file``, which is only meant to be called when the file object
+ # will be directly interacted with (e.g. when using ``read()``). These only
+ # require the file to exist; they do not need its contents to work. This
+ # distinction gives the user the flexibility to create a cache file
+ # strategy that assumes the existence of a file, but can still make the file
+ # available when its contents are required.
+
+ def _storage_attr(self, attr):
+ if getattr(self, '_file', None) is None:
+ existence_required.send(sender=self, file=self)
+ fn = getattr(self.storage, attr)
+ return fn(self.name)
+
+ @property
+ def path(self):
+ return self._storage_attr('path')
+
+ @property
+ def url(http://github.com/self):
+ return self._storage_attr('url')
+
+ def generate(self, force=False):
+ """
+ Generate the file. If ``force`` is ``True``, the file will be generated
+ whether the file already exists or not.
+
+ """
+ if force or getattr(self, '_file', None) is None:
+ self.cachefile_backend.generate(self, force)
+
+ def _generate(self):
+ # Generate the file
+ content = generate(self.generator)
+
+ actual_name = self.storage.save(self.name, content)
+
+ # We're going to reuse the generated file, so we need to reset the pointer.
+ if not hasattr(content, "seekable") or content.seekable():
+ content.seek(0)
+
+ # Store the generated file. If we don't do this, the next time the
+ # "file" attribute is accessed, it will result in a call to the storage
+ # backend (in ``BaseIKFile._get_file``). Since we already have the
+ # contents of the file, what would the point of that be?
+ self.file = File(content)
+
+ # ``actual_name`` holds the output of ``self.storage.save()`` that
+ # by default returns filenames with forward slashes, even on windows.
+ # On the other hand, ``self.name`` holds OS-specific paths results
+ # from applying path normalizers like ``os.path.normpath()`` in the
+ # ``namer``. So, the filenames should be normalized before their
+ # equality checking.
+ if os.path.normpath(actual_name) != os.path.normpath(self.name):
+ get_logger().warning(
+ 'The storage backend %s did not save the file with the'
+ ' requested name ("%s") and instead used "%s". This may be'
+ ' because a file already existed with the requested name. If'
+ ' so, you may have meant to call generate() instead of'
+ ' generate(force=True), or there may be a race condition in the'
+ ' file backend %s. The saved file will not be used.',
+ self.storage,
+ self.name,
+ actual_name,
+ self.cachefile_backend
+ )
+
+ def __bool__(self):
+ if not self.name:
+ return False
+
+ # Dispatch the existence_required signal before checking to see if the
+ # file exists. This gives the strategy a chance to create the file.
+ existence_required.send(sender=self, file=self)
+
+ try:
+ check = self.cachefile_strategy.should_verify_existence(self)
+ except AttributeError:
+ # All synchronous backends should have created the file as part of
+ # `existence_required` if they wanted to.
+ check = getattr(self.cachefile_backend, 'is_async', False)
+ return self.cachefile_backend.exists(self) if check else True
+
+ def __getstate__(self):
+ state = copy(self.__dict__)
+
+ # file is hidden link to "file" attribute
+ state.pop('_file', None)
+
+ # remove storage from state as some non-FileSystemStorage can't be
+ # pickled
+ settings_storage = get_storage()
+ if state['storage'] == settings_storage:
+ state.pop('storage')
+ return state
+
+ def __setstate__(self, state):
+ if 'storage' not in state:
+ state['storage'] = get_storage()
+ self.__dict__.update(state)
+
+ def __repr__(self):
+ return smart_str("<%s: %s>" % (
+ self.__class__.__name__, self if self.name else "None")
+ )
+
+
+class LazyImageCacheFile(SimpleLazyObject):
+ def __init__(self, generator_id, *args, **kwargs):
+ def setup():
+ generator = generator_registry.get(generator_id, *args, **kwargs)
+ return ImageCacheFile(generator)
+ super().__init__(setup)
+
+ def __repr__(self):
+ return '<%s: %s>' % (self.__class__.__name__, str(self) or 'None')
diff --git a/imagekit/cachefiles/backends.py b/imagekit/cachefiles/backends.py
new file mode 100644
index 00000000..e8423d32
--- /dev/null
+++ b/imagekit/cachefiles/backends.py
@@ -0,0 +1,221 @@
+import warnings
+from copy import copy
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+
+from ..utils import get_cache, get_singleton, sanitize_cache_key
+
+
+class CacheFileState:
+ EXISTS = 'exists'
+ GENERATING = 'generating'
+ DOES_NOT_EXIST = 'does_not_exist'
+
+
+def get_default_cachefile_backend():
+ """
+ Get the default file backend.
+
+ """
+ from django.conf import settings
+ return get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_BACKEND,
+ 'file backend')
+
+
+class InvalidFileBackendError(ImproperlyConfigured):
+ pass
+
+
+class AbstractCacheFileBackend:
+ """
+ An abstract cache file backend. This isn't used by any internal classes and
+ is included simply to illustrate the minimum interface of a cache file
+ backend for users who wish to implement their own.
+
+ """
+ def generate(self, file, force=False):
+ raise NotImplementedError
+
+ def exists(self, file):
+ raise NotImplementedError
+
+
+class CachedFileBackend:
+ existence_check_timeout = 5
+ """
+ The number of seconds to wait before rechecking to see if the file exists.
+ If the image is found to exist, that information will be cached using the
+ timeout specified in your CACHES setting (which should be very high).
+ However, when the file does not exist, you probably want to check again
+ in a relatively short amount of time. This attribute allows you to do that.
+
+ """
+
+ @property
+ def cache(self):
+ if not getattr(self, '_cache', None):
+ self._cache = get_cache()
+ return self._cache
+
+ def get_key(self, file):
+ from django.conf import settings
+ return sanitize_cache_key('%s%s-state' %
+ (settings.IMAGEKIT_CACHE_PREFIX, file.name))
+
+ def get_state(self, file, check_if_unknown=True):
+ key = self.get_key(file)
+ state = self.cache.get(key)
+ if state is None and check_if_unknown:
+ exists = self._exists(file)
+ state = CacheFileState.EXISTS if exists else CacheFileState.DOES_NOT_EXIST
+ self.set_state(file, state)
+ return state
+
+ def set_state(self, file, state):
+ key = self.get_key(file)
+ if state == CacheFileState.DOES_NOT_EXIST:
+ self.cache.set(key, state, self.existence_check_timeout)
+ else:
+ self.cache.set(key, state, settings.IMAGEKIT_CACHE_TIMEOUT)
+
+ def __getstate__(self):
+ state = copy(self.__dict__)
+ # Don't include the cache when pickling. It'll be reconstituted based
+ # on the settings.
+ state.pop('_cache', None)
+ return state
+
+ def exists(self, file):
+ return self.get_state(file) == CacheFileState.EXISTS
+
+ def generate(self, file, force=False):
+ raise NotImplementedError
+
+ def generate_now(self, file, force=False):
+ if force or self.get_state(file) not in (CacheFileState.GENERATING, CacheFileState.EXISTS):
+ self.set_state(file, CacheFileState.GENERATING)
+ file._generate()
+ self.set_state(file, CacheFileState.EXISTS)
+ file.close()
+
+
+class Simple(CachedFileBackend):
+ """
+ The most basic file backend. The storage is consulted to see if the file
+ exists. Files are generated synchronously.
+
+ """
+
+ def generate(self, file, force=False):
+ self.generate_now(file, force=force)
+
+ def _exists(self, file):
+ return bool(getattr(file, '_file', None)
+ or (file.name and file.storage.exists(file.name)))
+
+
+def _generate_file(backend, file, force=False):
+ backend.generate_now(file, force=force)
+
+
+class BaseAsync(Simple):
+ """
+ Base class for cache file backends that generate files asynchronously.
+ """
+ is_async = True
+
+ def generate(self, file, force=False):
+ # Schedule the file for generation, unless we know for sure we don't
+ # need to. If an already-generated file sneaks through, that's okay;
+ # ``generate_now`` will catch it. We just want to make sure we don't
+ # schedule anything we know is unnecessary--but we also don't want to
+ # force a costly existence check.
+ state = self.get_state(file, check_if_unknown=False)
+ if state not in (CacheFileState.GENERATING, CacheFileState.EXISTS):
+ self.schedule_generation(file, force=force)
+
+ def schedule_generation(self, file, force=False):
+ # overwrite this to have the file generated in the background,
+ # e. g. in a worker queue.
+ raise NotImplementedError
+
+
+try:
+ from celery import shared_task as task
+except ImportError:
+ pass
+else:
+ _celery_task = task(ignore_result=True, serializer='pickle')(_generate_file)
+
+
+class Celery(BaseAsync):
+ """
+ A backend that uses Celery to generate the images.
+ """
+ def __init__(self, *args, **kwargs):
+ try:
+ import celery # noqa
+ except ImportError:
+ raise ImproperlyConfigured('You must install celery to use'
+ ' imagekit.cachefiles.backends.Celery.')
+ super().__init__(*args, **kwargs)
+
+ def schedule_generation(self, file, force=False):
+ _celery_task.delay(self, file, force=force)
+
+
+# Stub class to preserve backwards compatibility and issue a warning
+class Async(Celery):
+ def __init__(self, *args, **kwargs):
+ message = '{path}.Async is deprecated. Use {path}.Celery instead.'
+ warnings.warn(message.format(path=__name__), DeprecationWarning)
+ super().__init__(*args, **kwargs)
+
+
+try:
+ from django_rq import job
+except ImportError:
+ pass
+else:
+ _rq_job = job('default', result_ttl=0)(_generate_file)
+
+
+class RQ(BaseAsync):
+ """
+ A backend that uses RQ to generate the images.
+ """
+ def __init__(self, *args, **kwargs):
+ try:
+ import django_rq # noqa
+ except ImportError:
+ raise ImproperlyConfigured('You must install django-rq to use'
+ ' imagekit.cachefiles.backends.RQ.')
+ super().__init__(*args, **kwargs)
+
+ def schedule_generation(self, file, force=False):
+ _rq_job.delay(self, file, force=force)
+
+
+try:
+ from dramatiq import actor
+except ImportError:
+ pass
+else:
+ _dramatiq_actor = actor()(_generate_file)
+
+
+class Dramatiq(BaseAsync):
+ """
+ A backend that uses Dramatiq to generate the images.
+ """
+ def __init__(self, *args, **kwargs):
+ try:
+ import dramatiq # noqa
+ except ImportError:
+ raise ImproperlyConfigured('You must install django-dramatiq to use'
+ ' imagekit.cachefiles.backends.Dramatiq.')
+ super().__init__(*args, **kwargs)
+
+ def schedule_generation(self, file, force=False):
+ _dramatiq_actor.send(self, file, force=force)
diff --git a/imagekit/cachefiles/namers.py b/imagekit/cachefiles/namers.py
new file mode 100644
index 00000000..52469402
--- /dev/null
+++ b/imagekit/cachefiles/namers.py
@@ -0,0 +1,93 @@
+"""
+Functions responsible for returning filenames for the given image generator.
+Users are free to define their own functions; these are just some some sensible
+choices.
+
+"""
+
+import os
+
+from django.conf import settings
+
+from ..utils import format_to_extension, suggest_extension
+
+
+def source_name_as_path(generator):
+ """
+ A namer that, given the following source file name::
+
+ photos/thumbnails/bulldog.jpg
+
+ will generate a name like this::
+
+ /path/to/generated/images/photos/thumbnails/bulldog/5ff3233527c5ac3e4b596343b440ff67.jpg
+
+ where "/path/to/generated/images/" is the value specified by the
+ ``IMAGEKIT_CACHEFILE_DIR`` setting.
+
+ """
+ source_filename = getattr(generator.source, 'name', None)
+
+ if source_filename is None or os.path.isabs(source_filename):
+ # Generally, we put the file right in the cache file directory.
+ dir = settings.IMAGEKIT_CACHEFILE_DIR
+ else:
+ # For source files with relative names (like Django media files),
+ # use the source's name to create the new filename.
+ dir = os.path.join(settings.IMAGEKIT_CACHEFILE_DIR,
+ os.path.splitext(source_filename)[0])
+
+ ext = suggest_extension(source_filename or '', generator.format)
+ return os.path.normpath(os.path.join(dir,
+ '%s%s' % (generator.get_hash(), ext)))
+
+
+def source_name_dot_hash(generator):
+ """
+ A namer that, given the following source file name::
+
+ photos/thumbnails/bulldog.jpg
+
+ will generate a name like this::
+
+ /path/to/generated/images/photos/thumbnails/bulldog.5ff3233527c5.jpg
+
+ where "/path/to/generated/images/" is the value specified by the
+ ``IMAGEKIT_CACHEFILE_DIR`` setting.
+
+ """
+ source_filename = getattr(generator.source, 'name', None)
+
+ if source_filename is None or os.path.isabs(source_filename):
+ # Generally, we put the file right in the cache file directory.
+ dir = settings.IMAGEKIT_CACHEFILE_DIR
+ else:
+ # For source files with relative names (like Django media files),
+ # use the source's name to create the new filename.
+ dir = os.path.join(settings.IMAGEKIT_CACHEFILE_DIR,
+ os.path.dirname(source_filename))
+
+ ext = suggest_extension(source_filename or '', generator.format)
+ basename = os.path.basename(source_filename)
+ return os.path.normpath(os.path.join(dir, '%s.%s%s' % (
+ os.path.splitext(basename)[0], generator.get_hash()[:12], ext)))
+
+
+def hash(generator):
+ """
+ A namer that, given the following source file name::
+
+ photos/thumbnails/bulldog.jpg
+
+ will generate a name like this::
+
+ /path/to/generated/images/5ff3233527c5ac3e4b596343b440ff67.jpg
+
+ where "/path/to/generated/images/" is the value specified by the
+ ``IMAGEKIT_CACHEFILE_DIR`` setting.
+
+ """
+ format = getattr(generator, 'format', None)
+ ext = format_to_extension(format) if format else ''
+ return os.path.normpath(os.path.join(settings.IMAGEKIT_CACHEFILE_DIR,
+ '%s%s' % (generator.get_hash(), ext)))
diff --git a/imagekit/cachefiles/strategies.py b/imagekit/cachefiles/strategies.py
new file mode 100644
index 00000000..8af85bdd
--- /dev/null
+++ b/imagekit/cachefiles/strategies.py
@@ -0,0 +1,45 @@
+from ..utils import get_singleton
+
+
+class JustInTime:
+ """
+ A strategy that ensures the file exists right before it's needed.
+
+ """
+
+ def on_existence_required(self, file):
+ file.generate()
+
+ def on_content_required(self, file):
+ file.generate()
+
+
+class Optimistic:
+ """
+ A strategy that acts immediately when the source file changes and assumes
+ that the cache files will not be removed (i.e. it doesn't ensure the
+ cache file exists when it's accessed).
+
+ """
+
+ def on_source_saved(self, file):
+ file.generate()
+
+ def should_verify_existence(self, file):
+ return False
+
+
+class DictStrategy:
+ def __init__(self, callbacks):
+ for k, v in callbacks.items():
+ setattr(self, k, v)
+
+
+def load_strategy(strategy):
+ if isinstance(strategy, str):
+ strategy = get_singleton(strategy, 'cache file strategy')
+ elif isinstance(strategy, dict):
+ strategy = DictStrategy(strategy)
+ elif callable(strategy):
+ strategy = strategy()
+ return strategy
diff --git a/imagekit/conf.py b/imagekit/conf.py
index b429ff2e..786aeb34 100644
--- a/imagekit/conf.py
+++ b/imagekit/conf.py
@@ -1,6 +1,45 @@
from appconf import AppConf
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
class ImageKitConf(AppConf):
- DEFAULT_IMAGE_CACHE_BACKEND = 'imagekit.imagecache.PessimisticImageCacheBackend'
+ CACHEFILE_NAMER = 'imagekit.cachefiles.namers.hash'
+ SPEC_CACHEFILE_NAMER = 'imagekit.cachefiles.namers.source_name_as_path'
+ CACHEFILE_DIR = 'CACHE/images'
+ DEFAULT_CACHEFILE_BACKEND = 'imagekit.cachefiles.backends.Simple'
+ DEFAULT_CACHEFILE_STRATEGY = 'imagekit.cachefiles.strategies.JustInTime'
+
DEFAULT_FILE_STORAGE = None
+
+ CACHE_BACKEND = None
+ CACHE_PREFIX = 'imagekit:'
+ CACHE_TIMEOUT = None
+ USE_MEMCACHED_SAFE_CACHE_KEY = True
+
+ def configure_cache_backend(self, value):
+ if value is None:
+ from django.core.cache import DEFAULT_CACHE_ALIAS
+ return DEFAULT_CACHE_ALIAS
+
+ if value not in settings.CACHES:
+ raise ImproperlyConfigured("{0} is not present in settings.CACHES".format(value))
+
+ return value
+
+ def configure_cache_timeout(self, value):
+ if value is None and settings.DEBUG:
+ # If value is not configured and is DEBUG set it to 5 minutes
+ return 300
+ # Otherwise leave it as is. If it is None then valies will never expire
+ return value
+
+ def configure_default_file_storage(self, value):
+ if value is None:
+ try:
+ from django.conf import DEFAULT_STORAGE_ALIAS
+ except ImportError: # Django < 4.2
+ return settings.DEFAULT_FILE_STORAGE
+ else:
+ return DEFAULT_STORAGE_ALIAS
+ return value
diff --git a/imagekit/exceptions.py b/imagekit/exceptions.py
new file mode 100644
index 00000000..be790443
--- /dev/null
+++ b/imagekit/exceptions.py
@@ -0,0 +1,22 @@
+from pilkit.exceptions import UnknownExtension, UnknownFormat
+
+
+class AlreadyRegistered(Exception):
+ pass
+
+
+class NotRegistered(Exception):
+ pass
+
+
+class MissingGeneratorId(Exception):
+ pass
+
+
+class MissingSource(ValueError):
+ silent_variable_failure = True
+
+
+# Aliases for backwards compatibility
+UnknownExtensionError = UnknownExtension
+UnknownFormatError = UnknownFormat
diff --git a/imagekit/files.py b/imagekit/files.py
new file mode 100644
index 00000000..8823c589
--- /dev/null
+++ b/imagekit/files.py
@@ -0,0 +1,104 @@
+import os
+
+from django.core.files.base import ContentFile, File
+
+from .utils import extension_to_mimetype, format_to_mimetype
+
+
+class BaseIKFile(File):
+ """
+ This class contains all of the methods we need from
+ django.db.models.fields.files.FieldFile, but with the model stuff ripped
+ out. It's only extended by one class, but we keep it separate for
+ organizational reasons.
+
+ """
+
+ def __init__(self, storage):
+ self.storage = storage
+
+ def _require_file(self):
+ if not self:
+ raise ValueError()
+
+ def _get_file(self):
+ self._require_file()
+ if not hasattr(self, '_file') or self._file is None:
+ self._file = self.storage.open(self.name, 'rb')
+ return self._file
+
+ def _set_file(self, file):
+ self._file = file
+
+ def _del_file(self):
+ del self._file
+
+ file = property(_get_file, _set_file, _del_file)
+
+ def _get_path(self):
+ self._require_file()
+ return self.storage.path(self.name)
+ path = property(_get_path)
+
+ def _get_url(http://github.com/self):
+ self._require_file()
+ return self.storage.url(http://github.com/self.name)
+ url = property(_get_url)
+
+ def _get_size(self):
+ self._require_file()
+ if not getattr(self, '_committed', False):
+ return self.file.size
+ return self.storage.size(self.name)
+ size = property(_get_size)
+
+ def open(self, mode='rb'):
+ self._require_file()
+ try:
+ self.file.open(mode)
+ except ValueError:
+ # if the underlying file can't be reopened
+ # then we will use the storage to try to open it again
+ if self.file.closed:
+ # clear cached file instance
+ del self.file
+ # Because file is a property we can acces it after
+ # we deleted it
+ return self.file.open(mode)
+ raise
+
+ def _get_closed(self):
+ file = getattr(self, '_file', None)
+ return file is None or file.closed
+ closed = property(_get_closed)
+
+ def close(self):
+ file = getattr(self, '_file', None)
+ if file is not None:
+ file.close()
+
+
+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
+
+ @property
+ def name(self):
+ return self.file.name
+
+ def __str__(self):
+ return str(self.file.name or '')
diff --git a/imagekit/forms/__init__.py b/imagekit/forms/__init__.py
new file mode 100644
index 00000000..f7310d18
--- /dev/null
+++ b/imagekit/forms/__init__.py
@@ -0,0 +1,3 @@
+# flake8: noqa
+
+from .fields import ProcessedImageField
diff --git a/imagekit/forms/fields.py b/imagekit/forms/fields.py
new file mode 100644
index 00000000..cd155466
--- /dev/null
+++ b/imagekit/forms/fields.py
@@ -0,0 +1,34 @@
+from django.forms import ImageField
+
+from ..specs import SpecHost
+from ..utils import generate
+
+
+class ProcessedImageField(ImageField, SpecHost):
+
+ def __init__(self, processors=None, format=None, options=None,
+ autoconvert=True, spec_id=None, spec=None, *args, **kwargs):
+
+ if spec_id is None:
+ # Unlike model fields, form fields are never told their field name.
+ # (Model fields are done so via `contribute_to_class()`.) Therefore
+ # we can't really generate a good spec id automatically.
+ raise TypeError('You must provide a spec_id')
+
+ SpecHost.__init__(self, processors=processors, format=format,
+ options=options, autoconvert=autoconvert, spec=spec,
+ spec_id=spec_id)
+ super().__init__(*args, **kwargs)
+
+ def clean(self, data, initial=None):
+ data = super().clean(data, initial)
+
+ if data and data != initial:
+ spec = self.get_spec(source=data)
+ f = generate(spec)
+ # Name is required in Django 1.4. When we drop support for it
+ # then we can directly return the result from `generate(spec)`
+ f.name = data.name
+ return f
+
+ return data
diff --git a/imagekit/generatorlibrary.py b/imagekit/generatorlibrary.py
new file mode 100644
index 00000000..dd5466fa
--- /dev/null
+++ b/imagekit/generatorlibrary.py
@@ -0,0 +1,16 @@
+from .processors import Thumbnail as ThumbnailProcessor
+from .registry import register
+from .specs import ImageSpec
+from django.conf import settings
+
+default_thumbnail_format = getattr(settings, 'IMAGEKIT_DEFAULT_THUMBNAIL_FORMAT', None)
+
+class Thumbnail(ImageSpec):
+ def __init__(self, width=None, height=None, anchor=None, crop=None, upscale=None, format=default_thumbnail_format, **kwargs):
+ self.processors = [ThumbnailProcessor(width, height, anchor=anchor,
+ crop=crop, upscale=upscale)]
+ super().__init__(**kwargs)
+ self.format = format
+
+
+register.generator('imagekit:thumbnail', Thumbnail)
diff --git a/imagekit/generators.py b/imagekit/generators.py
deleted file mode 100644
index 4cdceaa3..00000000
--- a/imagekit/generators.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import os
-from .lib import StringIO
-from .processors import ProcessorPipeline
-from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format,
- UnknownExtensionError, get_default_file_storage)
-
-
-class SpecFileGenerator(object):
- def __init__(self, processors=None, format=None, options=None,
- autoconvert=True, storage=None):
- self.processors = processors
- self.format = format
- self.options = options or {}
- self.autoconvert = autoconvert
- self.storage = storage
-
- def process_content(self, content, filename=None, source_file=None):
- img = open_image(content)
- original_format = img.format
-
- # Run the processors
- processors = self.processors
- if callable(processors):
- processors = processors(source_file)
- img = ProcessorPipeline(processors or []).process(img)
-
- options = dict(self.options or {})
-
- # Determine the format.
- format = self.format
- if filename and not format:
- # Try to guess the format from the extension.
- extension = os.path.splitext(filename)[1].lower()
- if extension:
- try:
- format = extension_to_format(extension)
- except UnknownExtensionError:
- pass
- format = format or img.format or original_format or 'JPEG'
-
- imgfile = img_to_fobj(img, format, **options)
- content = IKContentFile(filename, imgfile.read(), format=format)
- return img, content
-
- def generate_file(self, filename, source_file, save=True):
- """
- Generates a new image file by processing the source file and returns
- the content of the result, ready for saving.
-
- """
- if source_file: # TODO: Should we error here or something if the source_file doesn't exist?
- # Process the original image file.
-
- try:
- fp = source_file.storage.open(source_file.name)
- except IOError:
- return
- fp.seek(0)
- fp = StringIO(fp.read())
-
- img, content = self.process_content(fp, filename, source_file)
-
- if save:
- storage = self.storage or get_default_file_storage() or source_file.storage
- storage.save(filename, content)
-
- return content
diff --git a/imagekit/hashers.py b/imagekit/hashers.py
new file mode 100644
index 00000000..0c1c69ed
--- /dev/null
+++ b/imagekit/hashers.py
@@ -0,0 +1,39 @@
+import sys
+from copy import copy
+from hashlib import md5
+from io import BytesIO
+from pickle import DICT, MARK, _Pickler
+
+
+class CanonicalizingPickler(_Pickler):
+ dispatch = copy(_Pickler.dispatch)
+
+ def save_set(self, obj):
+ rv = obj.__reduce_ex__(0)
+ rv = (rv[0], (sorted(rv[1][0]),), rv[2])
+ self.save_reduce(obj=obj, *rv)
+
+ dispatch[set] = save_set
+
+ if sys.version_info[:2] >= (3, 14):
+ def save_dict(self, obj):
+ write = self.write
+ write(MARK + DICT)
+
+ self.memoize(obj)
+ self._batch_setitems(sorted(obj.items()), obj)
+ else:
+ def save_dict(self, obj):
+ write = self.write
+ write(MARK + DICT)
+
+ self.memoize(obj)
+ self._batch_setitems(sorted(obj.items()))
+
+ dispatch[dict] = save_dict
+
+
+def pickle(obj):
+ file = BytesIO()
+ CanonicalizingPickler(file, 0).dump(obj)
+ return md5(file.getvalue()).hexdigest()
diff --git a/imagekit/imagecache/__init__.py b/imagekit/imagecache/__init__.py
deleted file mode 100644
index cf98a9d8..00000000
--- a/imagekit/imagecache/__init__.py
+++ /dev/null
@@ -1,34 +0,0 @@
-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/base.py b/imagekit/imagecache/base.py
deleted file mode 100644
index f06c9b5a..00000000
--- a/imagekit/imagecache/base.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from django.core.exceptions import ImproperlyConfigured
-
-
-class InvalidImageCacheBackendError(ImproperlyConfigured):
- pass
-
-
-class PessimisticImageCacheBackend(object):
- """
- A very safe image cache backend. Guarantees that files will always be
- available, but at the cost of hitting the storage backend.
-
- """
-
- def is_invalid(self, file):
- if not getattr(file, '_file', None):
- # No file on object. Have to check storage.
- return not file.storage.exists(file.name)
- return False
-
- def validate(self, file):
- """
- Generates a new image by running the processors on the source file.
-
- """
- if self.is_invalid(file):
- file.generate(save=True)
-
- def invalidate(self, file):
- file.delete(save=False)
-
- def clear(self, file):
- file.delete(save=False)
-
-
-class NonValidatingImageCacheBackend(object):
- """
- 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.
-
- """
-
- def validate(self, file):
- """
- NonValidatingImageCacheBackend has faith, so validate's a no-op.
-
- """
- pass
-
- def invalidate(self, file):
- """
- Immediately generate a new spec file upon invalidation.
-
- """
- file.generate(save=True)
-
- def clear(self, file):
- file.delete(save=False)
diff --git a/imagekit/imagecache/celery.py b/imagekit/imagecache/celery.py
deleted file mode 100644
index 9dee5cad..00000000
--- a/imagekit/imagecache/celery.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# -*- 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
deleted file mode 100644
index 574e5874..00000000
--- a/imagekit/lib.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Required PIL classes may or may not be available from the root namespace
-# depending on the installation method used.
-try:
- from PIL import Image, ImageColor, ImageChops, ImageEnhance, ImageFile, \
- ImageFilter, ImageDraw, ImageStat
-except ImportError:
- try:
- import Image
- import ImageColor
- import ImageChops
- import ImageEnhance
- import ImageFile
- import ImageFilter
- import ImageDraw
- import ImageStat
- except ImportError:
- raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.')
-
-try:
- from cStringIO import StringIO
-except ImportError:
- from StringIO import StringIO
diff --git a/imagekit/management/commands/generateimages.py b/imagekit/management/commands/generateimages.py
new file mode 100644
index 00000000..e6e63982
--- /dev/null
+++ b/imagekit/management/commands/generateimages.py
@@ -0,0 +1,55 @@
+import re
+
+from django.core.management.base import BaseCommand
+
+from ...exceptions import MissingSource
+from ...registry import cachefile_registry, generator_registry
+
+
+class Command(BaseCommand):
+ help = ("""Generate files for the specified image generators (or all of them if
+none was provided). Simple, glob-like wildcards are allowed, with *
+matching all characters within a segment, and ** matching across
+segments. (Segments are separated with colons.) So, for example,
+"a:*:c" will match "a:b:c", but not "a:b:x:c", whereas "a:**:c" will
+match both. Subsegments are always matched, so "a" will match "a" as
+well as "a:b" and "a:b:c".""")
+ args = '[generator_ids]'
+
+ def add_arguments(self, parser):
+ parser.add_argument('generator_id', nargs='*', help=':: for model specs')
+
+ def handle(self, *args, **options):
+ generators = generator_registry.get_ids()
+
+ generator_ids = options['generator_id'] if 'generator_id' in options else args
+ if generator_ids:
+ patterns = self.compile_patterns(generator_ids)
+ generators = (id for id in generators if any(p.match(id) for p in patterns))
+
+ for generator_id in generators:
+ self.stdout.write('Validating generator: %s\n' % generator_id)
+ for image_file in cachefile_registry.get(generator_id):
+ if image_file.name:
+ self.stdout.write(' %s\n' % image_file.name)
+ try:
+ image_file.generate()
+ except MissingSource as err:
+ self.stdout.write('\t No source associated with\n')
+ except Exception as err:
+ self.stdout.write('\tFailed %s\n' % (err))
+
+ def compile_patterns(self, generator_ids):
+ return [self.compile_pattern(id) for id in generator_ids]
+
+ def compile_pattern(self, generator_id):
+ parts = re.split(r'(\*{1,2})', generator_id)
+ pattern = ''
+ for part in parts:
+ if part == '*':
+ pattern += '[^:]*'
+ elif part == '**':
+ pattern += '.*'
+ else:
+ pattern += re.escape(part)
+ return re.compile('^%s(:.*)?$' % pattern)
diff --git a/imagekit/management/commands/ikcacheinvalidate.py b/imagekit/management/commands/ikcacheinvalidate.py
deleted file mode 100644
index 2b6e915f..00000000
--- a/imagekit/management/commands/ikcacheinvalidate.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db.models.loading import cache
-from ...utils import invalidate_app_cache
-
-
-class Command(BaseCommand):
- help = ('Invalidates the image cache for a list of apps.')
- args = '[apps]'
- requires_model_validation = True
- can_import_settings = True
-
- def handle(self, *args, **options):
- apps = args or cache.app_models.keys()
- invalidate_app_cache(apps)
diff --git a/imagekit/management/commands/ikcachevalidate.py b/imagekit/management/commands/ikcachevalidate.py
deleted file mode 100644
index 8e9fc6c4..00000000
--- a/imagekit/management/commands/ikcachevalidate.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from optparse import make_option
-from django.core.management.base import BaseCommand
-from django.db.models.loading import cache
-from ...utils import validate_app_cache
-
-
-class Command(BaseCommand):
- help = ('Validates the image cache for a list of apps.')
- args = '[apps]'
- requires_model_validation = True
- can_import_settings = True
-
- option_list = BaseCommand.option_list + (
- make_option('--force-revalidation',
- dest='force_revalidation',
- action='http://github.com/store_true',
- default=False,
- help='Invalidate each image file before validating it, thereby'
- ' ensuring its revalidation. This is very similar to'
- ' running ikcacheinvalidate and then running'
- ' ikcachevalidate; the difference being that this option'
- ' causes files to be invalidated and validated'
- ' one-at-a-time, whereas running the two commands in series'
- ' would invalidate all images before validating any.'
- ),
- )
-
- def handle(self, *args, **options):
- apps = args or cache.app_models.keys()
- validate_app_cache(apps, options['force_revalidation'])
diff --git a/imagekit/models/__init__.py b/imagekit/models/__init__.py
index 42079877..b13b38f9 100644
--- a/imagekit/models/__init__.py
+++ b/imagekit/models/__init__.py
@@ -1,11 +1,4 @@
+# flake8: noqa
+
from .. import conf
from .fields import ImageSpecField, ProcessedImageField
-import warnings
-
-
-class ImageSpec(ImageSpecField):
- def __init__(self, *args, **kwargs):
- warnings.warn('ImageSpec has been moved to'
- ' 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 3e11e691..0da1427e 100644
--- a/imagekit/models/fields/__init__.py
+++ b/imagekit/models/fields/__init__.py
@@ -1,107 +1,85 @@
-import os
-
from django.db import models
+from django.db.models.signals import class_prepared
+
+from ...registry import register
+from ...specs import SpecHost
+from ...specs.sourcegroups import ImageFieldSourceGroup
+from .files import ProcessedImageFieldFile
+from .utils import ImageSpecFileDescriptor
-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
+class SpecHostField(SpecHost):
+ def _set_spec_id(self, cls, name):
+ spec_id = getattr(self, 'spec_id', None)
-configure_receivers()
+ # Generate a spec_id to register the spec with. The default spec id is
+ # ":_"
+ if not spec_id:
+ spec_id = ('%s:%s:%s' % (cls._meta.app_label,
+ cls._meta.object_name, name)).lower()
+ # Register the spec with the id. This allows specs to be overridden
+ # later, from outside of the model definition.
+ super().set_spec_id(spec_id)
-class ImageSpecField(object):
+
+class ImageSpecField(SpecHostField):
"""
The heart and soul of the ImageKit library, ImageSpecField allows you to add
variants of uploaded images to your models.
"""
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):
- """
- :param processors: A list of processors to run on the original image.
- :param format: The format of the output file. If not provided,
- ImageSpecField will try to guess the appropriate format based on the
- extension of the filename and the format of the input image.
- :param options: A dictionary that will be passed to PIL's
- ``Image.save()`` method as keyword arguments. Valid options vary
- between formats, but some examples include ``quality``,
- ``optimize``, and ``progressive`` for JPEGs. See the PIL
- documentation for others.
- :param image_field: The name of the model property that contains the
- original image.
- :param storage: A Django storage system to use to save the generated
- image.
- :param cache_to: Specifies the filename to use when saving the image
- cache file. This is modeled after ImageField's ``upload_to`` and
- can be either a string (that specifies a directory) or a
- callable (that returns a filepath). Callable values should
- accept the following arguments:
-
- - instance -- The model instance this spec belongs to
- - path -- The path of the original image
- - specname -- the property name that the spec is bound to on
- the model instance
- - extension -- A recommended extension. If the format of the
- spec is set explicitly, this suggestion will be
- 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 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
+ source=None, cachefile_storage=None, autoconvert=None,
+ cachefile_backend=None, cachefile_strategy=None, spec=None,
+ id=None):
- """
+ SpecHost.__init__(self, processors=processors, format=format,
+ options=options, cachefile_storage=cachefile_storage,
+ autoconvert=autoconvert,
+ cachefile_backend=cachefile_backend,
+ cachefile_strategy=cachefile_strategy, spec=spec,
+ spec_id=id)
- if pre_cache is not None:
- raise Exception('The pre_cache argument has been removed in favor'
- ' of cache state backends.')
-
- # The generator accepts a callable value for processors, but it
- # takes different arguments than the callable that ImageSpecField
- # expects, so we create a partial application and pass that instead.
- # TODO: Should we change the signatures to match? Even if `instance` is not part of the signature, it's accessible through the source file object's instance property.
- p = lambda file: processors(instance=file.instance, file=file) if \
- callable(processors) else processors
-
- self.generator = SpecFileGenerator(p, format=format, options=options,
- autoconvert=autoconvert, storage=storage)
- self.image_field = image_field
- self.storage = storage
- self.cache_to = cache_to
- self.image_cache_backend = image_cache_backend or \
- get_default_image_cache_backend()
+ # TODO: Allow callable for source. See https://github.com/matthewwithanm/django-imagekit/issues/158#issuecomment-10921664
+ self.source = source
def contribute_to_class(self, cls, name):
- setattr(cls, name, ImageSpecFileDescriptor(self, name))
- try:
- # 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)
-
- # Register the field with the image_cache_backend
- try:
- self.image_cache_backend.register_field(cls, self, name)
- except AttributeError:
- pass
-
-
-class ProcessedImageField(models.ImageField):
+ # If the source field name isn't defined, figure it out.
+
+ def register_source_group(source):
+ setattr(cls, name, ImageSpecFileDescriptor(self, name, source))
+ self._set_spec_id(cls, name)
+
+ # Add the model and field as a source for this spec id
+ register.source_group(self.spec_id, ImageFieldSourceGroup(cls, source))
+
+ if self.source:
+ register_source_group(self.source)
+ else:
+ # The source argument is not defined
+ # Then we need to see if there is only one ImageField in that model
+ # But we need to do that after full model initialization
+ def handle_model_preparation(sender, **kwargs):
+
+ image_fields = [f.attname for f in cls._meta.fields if
+ isinstance(f, models.ImageField)]
+ if len(image_fields) == 0:
+ raise Exception(
+ '%s does not define any ImageFields, so your %s'
+ ' ImageSpecField has no image to act on.' %
+ (cls.__name__, name))
+ elif len(image_fields) > 1:
+ raise Exception(
+ '%s defines multiple ImageFields, but you have not'
+ ' specified a source for your %s ImageSpecField.' %
+ (cls.__name__, name))
+ register_source_group(image_fields[0])
+
+ class_prepared.connect(handle_model_preparation, sender=cls, weak=False)
+
+
+class ProcessedImageField(models.ImageField, SpecHostField):
"""
ProcessedImageField is an ImageField that runs processors on the uploaded
image *before* saving it to storage. This is in contrast to specs, which
@@ -112,8 +90,8 @@ class ProcessedImageField(models.ImageField):
attr_class = ProcessedImageFieldFile
def __init__(self, processors=None, format=None, options=None,
- verbose_name=None, name=None, width_field=None, height_field=None,
- autoconvert=True, **kwargs):
+ verbose_name=None, name=None, width_field=None, height_field=None,
+ autoconvert=None, spec=None, spec_id=None, **kwargs):
"""
The ProcessedImageField constructor accepts all of the arguments that
the :class:`django.db.models.ImageField` constructor accepts, as well
@@ -121,26 +99,16 @@ def __init__(self, processors=None, format=None, options=None,
:class:`imagekit.models.ImageSpecField`.
"""
- if 'quality' in kwargs:
- raise Exception('The "quality" keyword argument has been'
- """ deprecated. Use `options={'quality': %s}` instead.""" \
- % kwargs['quality'])
+ # if spec is not provided then autoconvert will be True by default
+ if spec is None and autoconvert is None:
+ autoconvert = True
+
+ SpecHost.__init__(self, processors=processors, format=format,
+ options=options, autoconvert=autoconvert, spec=spec,
+ spec_id=spec_id)
models.ImageField.__init__(self, verbose_name, name, width_field,
height_field, **kwargs)
- self.generator = SpecFileGenerator(processors, format=format,
- options=options, autoconvert=autoconvert)
-
- def get_filename(self, filename):
- filename = os.path.normpath(self.storage.get_valid_name(
- os.path.basename(filename)))
- name, ext = os.path.splitext(filename)
- ext = suggest_extension(filename, self.generator.format)
- return u'%s%s' % (name, ext)
-
-
-try:
- from south.modelsinspector import add_introspection_rules
-except ImportError:
- pass
-else:
- add_introspection_rules([], [r'^imagekit\.models\.fields\.ProcessedImageField$'])
+
+ def contribute_to_class(self, cls, name):
+ self._set_spec_id(cls, name)
+ return super().contribute_to_class(cls, name)
diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py
index 9ae2734f..d208c5f8 100644
--- a/imagekit/models/fields/files.py
+++ b/imagekit/models/fields/files.py
@@ -1,174 +1,15 @@
import os
-import datetime
-from django.db.models.fields.files import ImageField, ImageFieldFile
-from django.utils.encoding import force_unicode, smart_str
+from django.db.models.fields.files import ImageFieldFile
-from ...utils import suggest_extension, get_default_file_storage
-
-
-class ImageSpecFieldFile(ImageFieldFile):
- def __init__(self, instance, field, attname):
- super(ImageSpecFieldFile, self).__init__(instance, field, None)
- self.attname = attname
-
- @property
- def source_file(self):
- field_name = getattr(self.field, 'image_field', None)
- if field_name:
- field_file = getattr(self.instance, field_name)
- else:
- image_fields = [getattr(self.instance, f.attname) for f in \
- self.instance.__class__._meta.fields if \
- 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.' % \
- (self.instance.__class__.__name__, self.attname))
- elif len(image_fields) > 1:
- raise Exception('%s defines multiple ImageFields, but you' \
- ' have not specified an image_field for your %s' \
- ' ImageSpecField.' % (self.instance.__class__.__name__,
- self.attname))
- else:
- field_file = image_fields[0]
- return field_file
-
- 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)
- else:
- self.validate()
-
- def clear(self):
- return self.field.image_cache_backend.clear(self)
-
- def invalidate(self):
- return self.field.image_cache_backend.invalidate(self)
-
- def validate(self):
- return self.field.image_cache_backend.validate(self)
-
- def generate(self, save=True):
- """
- Generates a new image file by processing the source file and returns
- the content of the result, ready for saving.
-
- """
- return self.field.generator.generate_file(self.name, self.source_file,
- save)
-
- def delete(self, save=False):
- """
- Pulled almost verbatim from ``ImageFieldFile.delete()`` and
- ``FieldFile.delete()`` but with the attempts to reset the instance
- property removed.
-
- """
- # Clear the image dimensions cache
- if hasattr(self, '_dimensions_cache'):
- del self._dimensions_cache
-
- # Only close the file if it's already open, which we know by the
- # presence of self._file.
- if hasattr(self, '_file'):
- self.close()
- del self.file
-
- if self.name and self.storage.exists(self.name):
- try:
- self.storage.delete(self.name)
- except NotImplementedError:
- pass
-
- # Delete the filesize cache.
- if hasattr(self, '_size'):
- del self._size
- self._committed = False
-
- if save:
- self.instance.save()
-
- def _default_cache_to(self, instance, path, specname, extension):
- """
- Determines the filename to use for the transformed image. Can be
- overridden on a per-spec basis by setting the cache_to property on
- the spec.
-
- """
- filepath, basename = os.path.split(path)
- filename = os.path.splitext(basename)[0]
- new_name = '%s_%s%s' % (filename, specname, extension)
- return os.path.join('cache', filepath, new_name)
-
- @property
- def name(self):
- """
- Specifies the filename that the cached image will use. The user can
- control this by providing a `cache_to` method to the ImageSpecField.
-
- """
- name = getattr(self, '_name', None)
- if not name:
- filename = self.source_file.name
- new_filename = None
- if filename:
- cache_to = self.field.cache_to or self._default_cache_to
-
- if not cache_to:
- raise Exception('No cache_to or default_cache_to value'
- ' specified')
- if callable(cache_to):
- 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,
- self.source_file.name, self.attname,
- suggested_extension))))
- else:
- dir_name = os.path.normpath(
- force_unicode(datetime.datetime.now().strftime(
- smart_str(cache_to))))
- filename = os.path.normpath(os.path.basename(filename))
- new_filename = os.path.join(dir_name, filename)
-
- self._name = new_filename
- return self._name
-
- @name.setter
- def name(self, value):
- # TODO: Figure out a better way to handle this. We really don't want
- # to allow anybody to set the name, but ``File.__init__`` (which is
- # called by ``ImageSpecFieldFile.__init__``) does, so we have to allow
- # it at least that one time.
- pass
-
- @property
- def storage(self):
- if not getattr(self, '_storage', None):
- self._storage = self.field.storage or get_default_file_storage() or self.source_file.storage
- return self._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)
+from ...utils import generate, suggest_extension
class ProcessedImageFieldFile(ImageFieldFile):
def save(self, name, content, save=True):
- new_filename = self.field.generate_filename(self.instance, name)
- img, content = self.field.generator.process_content(content,
- new_filename, self)
- return super(ProcessedImageFieldFile, self).save(name, content, save)
+ filename, ext = os.path.splitext(name)
+ spec = self.field.get_spec(source=content)
+ ext = suggest_extension(name, spec.format)
+ new_name = '%s%s' % (filename, ext)
+ content = generate(spec)
+ return super().save(new_name, content, save)
diff --git a/imagekit/models/fields/utils.py b/imagekit/models/fields/utils.py
index 1b3ccaa1..90e00321 100644
--- a/imagekit/models/fields/utils.py
+++ b/imagekit/models/fields/utils.py
@@ -1,42 +1,21 @@
-from .files import ImageSpecFieldFile
+from ...cachefiles import ImageCacheFile
-class BoundImageKitMeta(object):
- def __init__(self, instance, spec_fields):
- self.instance = instance
- self.spec_fields = spec_fields
-
- @property
- def spec_files(self):
- return [getattr(self.instance, n) for n in self.spec_fields]
-
-
-class ImageKitMeta(object):
- def __init__(self, spec_fields=None):
- self.spec_fields = list(spec_fields) if spec_fields else []
-
- def __get__(self, instance, owner):
- if instance is None:
- return self
- else:
- ik = BoundImageKitMeta(instance, self.spec_fields)
- setattr(instance, '_ik', ik)
- return ik
-
-
-class ImageSpecFileDescriptor(object):
- def __init__(self, field, attname):
+class ImageSpecFileDescriptor:
+ def __init__(self, field, attname, source_field_name):
self.attname = attname
self.field = field
+ self.source_field_name = source_field_name
def __get__(self, instance, owner):
if instance is None:
return self.field
else:
- img_spec_file = ImageSpecFieldFile(instance, self.field,
- self.attname)
- instance.__dict__[self.attname] = img_spec_file
- return img_spec_file
+ source = getattr(instance, self.source_field_name)
+ spec = self.field.get_spec(source=source)
+ file = ImageCacheFile(spec)
+ instance.__dict__[self.attname] = file
+ return file
def __set__(self, instance, value):
instance.__dict__[self.attname] = value
diff --git a/imagekit/models/receivers.py b/imagekit/models/receivers.py
deleted file mode 100644
index da93a69f..00000000
--- a/imagekit/models/receivers.py
+++ /dev/null
@@ -1,48 +0,0 @@
-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/pkgmeta.py b/imagekit/pkgmeta.py
new file mode 100644
index 00000000..2617735f
--- /dev/null
+++ b/imagekit/pkgmeta.py
@@ -0,0 +1,5 @@
+__title__ = 'django-imagekit'
+__author__ = 'Matthew Tretter, Venelin Stoykov, Eric Eldredge, Bryan Veloso, Greg Newman, Chris Drackett, Justin Driscoll'
+__version__ = '6.0.0'
+__license__ = 'BSD'
+__all__ = ['__title__', '__author__', '__version__', '__license__']
diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py
index c2c93200..b0195d9e 100644
--- a/imagekit/processors/__init__.py
+++ b/imagekit/processors/__init__.py
@@ -1,13 +1,12 @@
-"""
-Imagekit image processors.
+from pilkit.processors import *
-A processor accepts an image, does some stuff, and returns the result.
-Processors can do anything with the image you want, but their responsibilities
-should be limited to image manipulations--they should be completely decoupled
-from both the filesystem and the ORM.
-
-"""
-
-from .base import *
-from .crop import *
-from .resize import *
+__all__ = [
+ # Base
+ 'ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose',
+ 'Anchor', 'MakeOpaque',
+ # Crop
+ 'TrimBorderColor', 'Crop', 'SmartCrop',
+ # Resize
+ 'Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize',
+ 'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail'
+]
diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py
index 61c0e30a..c94a966b 100644
--- a/imagekit/processors/base.py
+++ b/imagekit/processors/base.py
@@ -1,209 +1,7 @@
-from imagekit.lib import Image, ImageColor, ImageEnhance
+import warnings
+from pilkit.processors.base import *
-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::
+warnings.warn('imagekit.processors.base is deprecated use imagekit.processors instead', DeprecationWarning)
- 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
+__all__ = ['ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose', 'Anchor', 'MakeOpaque']
diff --git a/imagekit/processors/crop.py b/imagekit/processors/crop.py
index da5c0fb0..b519d1a4 100644
--- a/imagekit/processors/crop.py
+++ b/imagekit/processors/crop.py
@@ -1,170 +1,7 @@
-from .base import Anchor
-from .utils import histogram_entropy
-from ..lib import Image, ImageChops, ImageDraw, ImageStat
+import warnings
+from pilkit.processors.crop import *
-class Side(object):
- TOP = 't'
- RIGHT = 'r'
- BOTTOM = 'b'
- LEFT = 'l'
- ALL = (TOP, RIGHT, BOTTOM, LEFT)
+warnings.warn('imagekit.processors.crop is deprecated use imagekit.processors instead', DeprecationWarning)
-
-def _crop(img, bbox, sides=Side.ALL):
- bbox = (
- bbox[0] if Side.LEFT in sides else 0,
- bbox[1] if Side.TOP in sides else 0,
- bbox[2] if Side.RIGHT in sides else img.size[0],
- bbox[3] if Side.BOTTOM in sides else img.size[1],
- )
- return img.crop(bbox)
-
-
-def detect_border_color(img):
- mask = Image.new('1', img.size, 1)
- w, h = img.size[0] - 2, img.size[1] - 2
- if w > 0 and h > 0:
- draw = ImageDraw.Draw(mask)
- draw.rectangle([1, 1, w, h], 0)
- return ImageStat.Stat(img.convert('RGBA').histogram(mask)).median
-
-
-class TrimBorderColor(object):
- """Trims a color from the sides of an image.
-
- """
- def __init__(self, color=None, tolerance=0.3, sides=Side.ALL):
- """
- :param color: The color to trim from the image, in a 4-tuple RGBA value,
- where each component is an integer between 0 and 255, inclusive. If
- no color is provided, the processor will attempt to detect the
- border color automatically.
- :param tolerance: A number between 0 and 1 where 0. Zero is the least
- tolerant and one is the most.
- :param sides: A list of sides that should be trimmed. Possible values
- are provided by the :class:`Side` enum class.
-
- """
- self.color = color
- self.sides = sides
- self.tolerance = tolerance
-
- def process(self, img):
- source = img.convert('RGBA')
- border_color = self.color or tuple(detect_border_color(source))
- bg = Image.new('RGBA', img.size, border_color)
- diff = ImageChops.difference(source, bg)
- if self.tolerance not in (0, 1):
- # If tolerance is zero, we've already done the job. A tolerance of
- # one would mean to trim EVERY color, and since that would result
- # in a zero-sized image, we just ignore it.
- if not 0 <= self.tolerance <= 1:
- raise ValueError('%s is an invalid tolerance. Acceptable values'
- ' are between 0 and 1 (inclusive).' % self.tolerance)
- tmp = ImageChops.constant(diff, int(self.tolerance * 255)) \
- .convert('RGBA')
- diff = ImageChops.subtract(diff, tmp)
-
- bbox = diff.getbbox()
- if bbox:
- img = _crop(img, bbox, self.sides)
- return img
-
-
-class Crop(object):
- """
- 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.
-
- """
-
- 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)
-
- return ResizeCanvas(new_width, new_height, anchor=self.anchor,
- x=self.x, y=self.y).process(img)
-
-
-class SmartCrop(object):
- """
- Crop an image to the specified dimensions, whittling away the parts of the
- image with the least entropy.
-
- Based on smart crop implementation from easy-thumbnails:
- https://github.com/SmileyChris/easy-thumbnails/blob/master/easy_thumbnails/processors.py#L193
-
- """
-
- def __init__(self, width=None, height=None):
- """
- :param width: The target width, in pixels.
- :param height: The target height, in pixels.
-
- """
- self.width = width
- self.height = height
-
- def compare_entropy(self, start_slice, end_slice, slice, difference):
- """
- Calculate the entropy of two slices (from the start and end of an axis),
- returning a tuple containing the amount that should be added to the start
- and removed from the end of the axis.
-
- """
- start_entropy = histogram_entropy(start_slice)
- end_entropy = histogram_entropy(end_slice)
-
- if end_entropy and abs(start_entropy / end_entropy - 1) < 0.01:
- # Less than 1% difference, remove from both sides.
- if difference >= slice * 2:
- return slice, slice
- half_slice = slice // 2
- return half_slice, slice - half_slice
-
- if start_entropy > end_entropy:
- return 0, slice
- else:
- return slice, 0
-
- def process(self, img):
- source_x, source_y = img.size
- diff_x = int(source_x - min(source_x, self.width))
- diff_y = int(source_y - min(source_y, self.height))
- left = top = 0
- right, bottom = source_x, source_y
-
- while diff_x:
- slice = min(diff_x, max(diff_x // 5, 10))
- start = img.crop((left, 0, left + slice, source_y))
- end = img.crop((right - slice, 0, right, source_y))
- add, remove = self.compare_entropy(start, end, slice, diff_x)
- left += add
- right -= remove
- diff_x = diff_x - add - remove
-
- while diff_y:
- slice = min(diff_y, max(diff_y // 5, 10))
- start = img.crop((0, top, source_x, top + slice))
- end = img.crop((0, bottom - slice, source_x, bottom))
- add, remove = self.compare_entropy(start, end, slice, diff_y)
- top += add
- bottom -= remove
- diff_y = diff_y - add - remove
-
- box = (left, top, right, bottom)
- img = img.crop(box)
- return img
+__all__ = ['TrimBorderColor', 'Crop', 'SmartCrop']
diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py
index e4b747a9..9c1cb30d 100644
--- a/imagekit/processors/resize.py
+++ b/imagekit/processors/resize.py
@@ -1,220 +1,7 @@
-from imagekit.lib import Image
import warnings
-from .base import Anchor
+from pilkit.processors.resize import *
-class Resize(object):
- """
- Resizes an image to the specified width and height.
+warnings.warn('imagekit.processors.resize is deprecated use imagekit.processors instead', DeprecationWarning)
- """
- def __init__(self, width, height):
- """
- :param width: The target width, in pixels.
- :param height: The target height, in pixels.
-
- """
- self.width = width
- self.height = height
-
- def process(self, img):
- return img.resize((self.width, self.height), Image.ANTIALIAS)
-
-
-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 ``ResizeToFill`` and ``SmartResize``.
-
- """
- def __init__(self, width, height):
- """
- :param width: The target width, in pixels.
- :param height: The target height, in pixels.
-
- """
- self.width, self.height = width, height
-
- def process(self, img):
- original_width, original_height = img.size
- ratio = max(float(self.width) / original_width,
- float(self.height) / original_height)
- new_width, new_height = (int(original_width * ratio),
- int(original_height * ratio))
- return Resize(new_width, new_height).process(img)
-
-
-class ResizeToFill(object):
- """
- Resizes an image, cropping it to the exact specified width and height.
-
- """
-
- 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.
- """
- self.width = width
- self.height = height
- self.anchor = anchor
-
- def process(self, img):
- from .crop import Crop
- img = ResizeToCover(self.width, self.height).process(img)
- return Crop(self.width, self.height,
- anchor=self.anchor).process(img)
-
-
-class SmartResize(object):
- """
- 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):
- """
- :param width: The target width, in pixels.
- :param height: The target height, in pixels.
-
- """
- self.width, self.height = width, height
-
- def process(self, img):
- from .crop import SmartCrop
- img = ResizeToCover(self.width, self.height).process(img)
- return SmartCrop(self.width, self.height).process(img)
-
-
-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 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, anchor=Anchor.CENTER):
- """
- :param width: The maximum width of the desired image.
- :param height: The maximum height of the desired image.
- :param upscale: A boolean value specifying whether the image should
- be enlarged if its dimensions are smaller than the target
- dimensions.
- :param mat_color: If set, the target image size will be enforced and the
- specified color will be used as a background color to pad the image.
-
- """
- self.width = width
- self.height = height
- self.upscale = upscale
- self.mat_color = mat_color
- self.anchor = anchor
-
- def process(self, img):
- cur_width, cur_height = img.size
- if not self.width is None and not self.height is None:
- ratio = min(float(self.width) / cur_width,
- float(self.height) / cur_height)
- else:
- if self.width is None:
- ratio = float(self.height) / cur_height
- else:
- ratio = float(self.width) / cur_width
- new_dimensions = (int(round(cur_width * ratio)),
- int(round(cur_height * ratio)))
- if (cur_width > new_dimensions[0] or cur_height > new_dimensions[1]) or \
- self.upscale:
- img = Resize(new_dimensions[0],
- new_dimensions[1]).process(img)
- if self.mat_color:
- img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img)
- return img
+__all__ = ['Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize', 'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail']
diff --git a/imagekit/processors/utils.py b/imagekit/processors/utils.py
index db244dbd..4b829cd1 100644
--- a/imagekit/processors/utils.py
+++ b/imagekit/processors/utils.py
@@ -1,18 +1,5 @@
-import math
-from imagekit.lib import Image
+import warnings
+from pilkit.processors.utils import *
-def histogram_entropy(im):
- """
- Calculate the entropy of an images' histogram. Used for "smart cropping" in easy-thumbnails;
- see: https://raw.github.com/SmileyChris/easy-thumbnails/master/easy_thumbnails/utils.py
-
- """
- if not isinstance(im, Image.Image):
- return 0 # Fall back to a constant entropy.
-
- histogram = im.histogram()
- hist_ceil = float(sum(histogram))
- histonorm = [histocol / hist_ceil for histocol in histogram]
-
- return -sum([p * math.log(p, 2) for p in histonorm if p != 0])
+warnings.warn('imagekit.processors.utils is deprecated use pilkit.processors.utils instead', DeprecationWarning)
diff --git a/imagekit/registry.py b/imagekit/registry.py
new file mode 100644
index 00000000..f7a5bca6
--- /dev/null
+++ b/imagekit/registry.py
@@ -0,0 +1,200 @@
+from .exceptions import AlreadyRegistered, NotRegistered
+from .signals import content_required, existence_required, source_saved
+from .utils import autodiscover, call_strategy_method
+
+
+class GeneratorRegistry:
+ """
+ An object for registering generators. This registry provides
+ a convenient way for a distributable app to define default generators
+ without locking the users of the app into it.
+
+ """
+ def __init__(self):
+ self._generators = {}
+ content_required.connect(self.content_required_receiver)
+ existence_required.connect(self.existence_required_receiver)
+
+ def register(self, id, generator):
+ registered_generator = self._generators.get(id)
+ if registered_generator and generator != self._generators[id]:
+ raise AlreadyRegistered('The generator with id %s is'
+ ' already registered' % id)
+ self._generators[id] = generator
+
+ def unregister(self, id):
+ try:
+ del self._generators[id]
+ except KeyError:
+ raise NotRegistered('The generator with id %s is not'
+ ' registered' % id)
+
+ def get(self, id, **kwargs):
+ autodiscover()
+
+ try:
+ generator = self._generators[id]
+ except KeyError:
+ raise NotRegistered('The generator with id %s is not'
+ ' registered' % id)
+ if callable(generator):
+ return generator(**kwargs)
+ else:
+ return generator
+
+ def get_ids(self):
+ autodiscover()
+ return self._generators.keys()
+
+ def content_required_receiver(self, sender, file, **kwargs):
+ self._receive(file, 'on_content_required')
+
+ def existence_required_receiver(self, sender, file, **kwargs):
+ self._receive(file, 'on_existence_required')
+
+ def _receive(self, file, callback):
+ generator = file.generator
+
+ # FIXME: I guess this means you can't register functions?
+ if generator.__class__ in self._generators.values():
+ # Only invoke the strategy method for registered generators.
+ call_strategy_method(file, callback)
+
+
+class SourceGroupRegistry:
+ """
+ The source group registry is responsible for listening to source_* signals
+ on source groups, and relaying them to the image generated file strategies
+ of the appropriate generators.
+
+ In addition, registering a new source group also registers its generated
+ files with that registry.
+
+ """
+ _signals = {
+ source_saved: 'on_source_saved',
+ }
+
+ def __init__(self):
+ self._source_groups = {}
+ for signal in self._signals.keys():
+ signal.connect(self.source_group_receiver)
+
+ def register(self, generator_id, source_group):
+ from .specs.sourcegroups import SourceGroupFilesGenerator
+ generator_ids = self._source_groups.setdefault(source_group, set())
+ generator_ids.add(generator_id)
+ cachefile_registry.register(generator_id,
+ SourceGroupFilesGenerator(source_group, generator_id))
+
+ def unregister(self, generator_id, source_group):
+ from .specs.sourcegroups import SourceGroupFilesGenerator
+ generator_ids = self._source_groups.setdefault(source_group, set())
+ if generator_id in generator_ids:
+ generator_ids.remove(generator_id)
+ cachefile_registry.unregister(generator_id,
+ SourceGroupFilesGenerator(source_group, generator_id))
+
+ def source_group_receiver(self, sender, source, signal, **kwargs):
+ """
+ Relay source group signals to the appropriate spec strategy.
+
+ """
+ from .cachefiles import ImageCacheFile
+ source_group = sender
+
+ # Ignore signals from unregistered groups.
+ if source_group not in self._source_groups:
+ return
+
+ specs = [generator_registry.get(id, source=source) for id in
+ self._source_groups[source_group]]
+ callback_name = self._signals[signal]
+
+ for spec in specs:
+ file = ImageCacheFile(spec)
+ call_strategy_method(file, callback_name)
+
+
+class CacheFileRegistry:
+ """
+ An object for registering generated files with image generators. The two are
+ associated with each other via a string id. We do this (as opposed to
+ associating them directly by, for example, putting a ``cachefiles``
+ attribute on image generators) so that image generators can be overridden
+ without losing the associated files. That way, a distributable app can
+ define its own generators without locking the users of the app into it.
+
+ """
+
+ def __init__(self):
+ self._cachefiles = {}
+
+ def register(self, generator_id, cachefiles):
+ """
+ Associates generated files with a generator id
+
+ """
+ if cachefiles not in self._cachefiles:
+ self._cachefiles[cachefiles] = set()
+ self._cachefiles[cachefiles].add(generator_id)
+
+ def unregister(self, generator_id, cachefiles):
+ """
+ Disassociates generated files with a generator id
+
+ """
+ try:
+ self._cachefiles[cachefiles].remove(generator_id)
+ except KeyError:
+ pass
+
+ def get(self, generator_id):
+ for k, v in self._cachefiles.items():
+ if generator_id in v:
+ yield from k()
+
+
+class Register:
+ """
+ Register generators and generated files.
+
+ """
+ def generator(self, id, generator=None):
+ if generator is None:
+ # Return a decorator
+ def decorator(cls):
+ self.generator(id, cls)
+ return cls
+ return decorator
+
+ generator_registry.register(id, generator)
+
+ # iterable that returns kwargs or callable that returns iterable of kwargs
+ def cachefiles(self, generator_id, cachefiles):
+ cachefile_registry.register(generator_id, cachefiles)
+
+ def source_group(self, generator_id, source_group):
+ source_group_registry.register(generator_id, source_group)
+
+
+class Unregister:
+ """
+ Unregister generators and generated files.
+
+ """
+ def generator(self, id):
+ generator_registry.unregister(id)
+
+ def cachefiles(self, generator_id, cachefiles):
+ cachefile_registry.unregister(generator_id, cachefiles)
+
+ def source_group(self, generator_id, source_group):
+ source_group_registry.unregister(generator_id, source_group)
+
+
+generator_registry = GeneratorRegistry()
+cachefile_registry = CacheFileRegistry()
+source_group_registry = SourceGroupRegistry()
+register = Register()
+unregister = Unregister()
diff --git a/imagekit/signals.py b/imagekit/signals.py
new file mode 100644
index 00000000..5430748c
--- /dev/null
+++ b/imagekit/signals.py
@@ -0,0 +1,8 @@
+from django.dispatch import Signal
+
+# Generated file signals
+content_required = Signal()
+existence_required = Signal()
+
+# Source group signals
+source_saved = Signal()
diff --git a/imagekit/specs/__init__.py b/imagekit/specs/__init__.py
new file mode 100644
index 00000000..d06cf888
--- /dev/null
+++ b/imagekit/specs/__init__.py
@@ -0,0 +1,253 @@
+from copy import copy
+
+from django.conf import settings
+from django.db.models.fields.files import ImageFieldFile
+
+from .. import hashers
+from ..cachefiles.backends import get_default_cachefile_backend
+from ..cachefiles.strategies import load_strategy
+from ..exceptions import AlreadyRegistered, MissingSource
+from ..registry import generator_registry, register
+from ..utils import get_by_qname, open_image, process_image
+
+
+class BaseImageSpec:
+ """
+ An object that defines how an new image should be generated from a source
+ image.
+
+ """
+
+ cachefile_storage = None
+ """A Django storage system to use to save a cache file."""
+
+ cachefile_backend = None
+ """
+ An object responsible for managing the state of cache files. Defaults to
+ an instance of ``IMAGEKIT_DEFAULT_CACHEFILE_BACKEND``
+
+ """
+
+ cachefile_strategy = settings.IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY
+ """
+ A dictionary containing callbacks that allow you to customize how and when
+ the image file is created. Defaults to
+ ``IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY``.
+
+ """
+
+ def __init__(self):
+ self.cachefile_backend = self.cachefile_backend or get_default_cachefile_backend()
+ self.cachefile_strategy = load_strategy(self.cachefile_strategy)
+
+ def generate(self):
+ raise NotImplementedError
+
+ MissingSource = MissingSource
+ """
+ Raised when an operation requiring a source is attempted on a spec that has
+ no source.
+
+ """
+
+
+class ImageSpec(BaseImageSpec):
+ """
+ An object that defines how to generate a new image from a source file using
+ PIL-based processors. (See :mod:`imagekit.processors`)
+
+ """
+
+ processors = []
+ """A list of processors to run on the original image."""
+
+ format = None
+ """
+ The format of the output file. If not provided, ImageSpecField will try to
+ guess the appropriate format based on the extension of the filename and the
+ format of the input image.
+
+ """
+
+ options = None
+ """
+ A dictionary that will be passed to PIL's ``Image.save()`` method as keyword
+ arguments. Valid options vary between formats, but some examples include
+ ``quality``, ``optimize``, and ``progressive`` for JPEGs. See the PIL
+ documentation for others.
+
+ """
+
+ autoconvert = True
+ """
+ Specifies whether automatic conversion using ``prepare_image()`` should be
+ performed prior to saving.
+
+ """
+
+ def __init__(self, source):
+ self.source = source
+ super().__init__()
+
+ @property
+ def cachefile_name(self):
+ if not self.source:
+ return None
+ fn = get_by_qname(settings.IMAGEKIT_SPEC_CACHEFILE_NAMER, 'namer')
+ return fn(self)
+
+ @property
+ def source(self):
+ src = getattr(self, '_source', None)
+ if not src:
+ field_data = getattr(self, '_field_data', None)
+ if field_data:
+ src = self._source = getattr(field_data['instance'], field_data['attname'])
+ del self._field_data
+ return src
+
+ @source.setter
+ def source(self, value):
+ self._source = value
+
+ def __getstate__(self):
+ state = copy(self.__dict__)
+
+ # Unpickled ImageFieldFiles won't work (they're missing a storage
+ # object). Since they're such a common use case, we special case them.
+ # Unfortunately, this also requires us to add the source getter to
+ # lazily retrieve the source on the reconstructed object; simply trying
+ # to look up the source in ``__setstate__`` would require us to get the
+ # model instance but, if ``__setstate__`` was called as part of
+ # deserializing that model, the model wouldn't be fully reconstructed
+ # yet, preventing us from accessing the source field.
+ # (This is issue #234.)
+ if isinstance(self.source, ImageFieldFile):
+ field = self.source.field
+ state['_field_data'] = {
+ 'instance': getattr(self.source, 'instance', None),
+ 'attname': getattr(field, 'name', None),
+ }
+ state.pop('_source', None)
+ return state
+
+ def get_hash(self):
+ return hashers.pickle([
+ self.source.name,
+ self.processors,
+ self.format,
+ self.options,
+ self.autoconvert,
+ ])
+
+ def generate(self):
+ if not self.source:
+ raise MissingSource("The spec '%s' has no source file associated"
+ " with it." % self)
+
+ # TODO: Move into a generator base class
+ # TODO: Factor out a generate_image function so you can create a generator and only override the PIL.Image creating part.
+ # (The tricky part is how to deal with original_format since generator base class won't have one.)
+
+ closed = self.source.closed
+ if closed:
+ # Django file object should know how to reopen itself if it was closed
+ # https://code.djangoproject.com/ticket/13750
+ self.source.open()
+
+ try:
+ img = open_image(self.source)
+ new_image = process_image(img,
+ processors=self.processors,
+ format=self.format,
+ autoconvert=self.autoconvert,
+ options=self.options)
+ finally:
+ if closed:
+ # We need to close the file if it was opened by us
+ self.source.close()
+ return new_image
+
+
+def create_spec_class(class_attrs):
+
+ class DynamicSpecBase(ImageSpec):
+ def __reduce__(self):
+ try:
+ getstate = self.__getstate__
+ except AttributeError:
+ state = self.__dict__
+ else:
+ state = getstate()
+ return (create_spec, (class_attrs, state))
+
+ return type('DynamicSpec', (DynamicSpecBase,), class_attrs)
+
+
+def create_spec(class_attrs, state):
+ cls = create_spec_class(class_attrs)
+ instance = cls.__new__(cls) # Create an instance without calling the __init__ (which may have required args).
+ try:
+ setstate = instance.__setstate__
+ except AttributeError:
+ instance.__dict__ = state
+ else:
+ setstate(state)
+ return instance
+
+
+class SpecHost:
+ """
+ An object that ostensibly has a spec attribute but really delegates to the
+ spec registry.
+
+ """
+ def __init__(self, spec=None, spec_id=None, **kwargs):
+
+ spec_attrs = {k: v for k, v in kwargs.items() if v is not None}
+
+ if spec_attrs:
+ if spec:
+ raise TypeError('You can provide either an image spec or'
+ ' arguments for the ImageSpec constructor, but not both.')
+ else:
+ spec = create_spec_class(spec_attrs)
+
+ self._original_spec = spec
+
+ if spec_id:
+ self.set_spec_id(spec_id)
+
+ def set_spec_id(self, id):
+ """
+ Sets the spec id for this object. Useful for when the id isn't
+ known when the instance is constructed (e.g. for ImageSpecFields whose
+ generated `spec_id`s are only known when they are contributed to a
+ class). If the object was initialized with a spec, it will be registered
+ under the provided id.
+
+ """
+ self.spec_id = id
+
+ if self._original_spec:
+ try:
+ register.generator(id, self._original_spec)
+ except AlreadyRegistered:
+ # Fields should not cause AlreadyRegistered exceptions. If a
+ # spec is already registered, that should be used. It is
+ # especially important that an error is not thrown here because
+ # of South, which will create duplicate models as part of its
+ # "fake orm," therefore re-registering specs.
+ pass
+
+ def get_spec(self, source):
+ """
+ Look up the spec by the spec id. We do this (instead of storing the
+ spec as an attribute) so that users can override apps' specs--without
+ having to edit model definitions--simply by registering another spec
+ with the same id.
+
+ """
+ if not getattr(self, 'spec_id', None):
+ raise Exception('Object %s has no spec id.' % self)
+ return generator_registry.get(self.spec_id, source=source)
diff --git a/imagekit/specs/sourcegroups.py b/imagekit/specs/sourcegroups.py
new file mode 100644
index 00000000..2e3e7c4a
--- /dev/null
+++ b/imagekit/specs/sourcegroups.py
@@ -0,0 +1,179 @@
+"""
+Source groups are the means by which image spec sources are identified. They
+have two responsibilities:
+
+1. To dispatch ``source_saved`` signals. (These will be relayed to the
+ corresponding specs' cache file strategies.)
+2. To provide the source files that they represent, via a generator method named
+ ``files()``. (This is used by the generateimages management command for
+ "pre-caching" image files.)
+
+"""
+
+import inspect
+
+from django.db.models.signals import post_init, post_save
+from django.utils.functional import wraps
+
+from ..cachefiles import LazyImageCacheFile
+from ..signals import source_saved
+from ..utils import get_nonabstract_descendants
+
+
+def ik_model_receiver(fn):
+ """
+ A method decorator that filters out signals coming from models that don't
+ have fields that function as ImageFieldSourceGroup sources.
+
+ """
+ @wraps(fn)
+ def receiver(self, sender, **kwargs):
+ if not inspect.isclass(sender):
+ return
+ for src in self._source_groups:
+ if issubclass(sender, src.model_class):
+ fn(self, sender=sender, **kwargs)
+
+ # If we find a match, return. We don't want to handle the signal
+ # more than once.
+ return
+ return receiver
+
+
+class ModelSignalRouter:
+ """
+ Normally, ``ImageFieldSourceGroup`` would be directly responsible for
+ watching for changes on the model field it represents. However, Django does
+ not dispatch events for abstract base classes. Therefore, we must listen for
+ the signals on all models and filter out those that aren't represented by
+ ``ImageFieldSourceGroup``s. This class encapsulates that functionality.
+
+ Related:
+ https://github.com/matthewwithanm/django-imagekit/issues/126
+ https://code.djangoproject.com/ticket/9318
+
+ """
+
+ def __init__(self):
+ self._source_groups = []
+ uid = 'ik_spec_field_receivers'
+ post_init.connect(self.post_init_receiver, dispatch_uid=uid)
+ post_save.connect(self.post_save_receiver, dispatch_uid=uid)
+
+ def add(self, source_group):
+ self._source_groups.append(source_group)
+
+ def init_instance(self, instance):
+ instance._ik = getattr(instance, '_ik', {})
+
+ def update_source_hashes(self, 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).
+
+ """
+ self.init_instance(instance)
+ instance._ik['source_hashes'] = {
+ attname: hash(getattr(instance, attname))
+ for attname in self.get_source_fields(instance)}
+ return instance._ik['source_hashes']
+
+ def get_source_fields(self, instance):
+ """
+ Returns a list of the source fields for the given instance.
+
+ """
+ return {
+ src.image_field
+ for src in self._source_groups
+ if isinstance(instance, src.model_class)}
+
+ @ik_model_receiver
+ def post_save_receiver(self, sender, instance=None, created=False, update_fields=None, raw=False, **kwargs):
+ if not raw:
+ self.init_instance(instance)
+ old_hashes = instance._ik.get('source_hashes', {}).copy()
+ new_hashes = self.update_source_hashes(instance)
+ for attname in self.get_source_fields(instance):
+ if update_fields and attname not in update_fields:
+ continue
+
+ file = getattr(instance, attname)
+ if file and old_hashes.get(attname) != new_hashes[attname]:
+ self.dispatch_signal(source_saved, file, sender, instance,
+ attname)
+
+ @ik_model_receiver
+ def post_init_receiver(self, sender, instance=None, **kwargs):
+ self.init_instance(instance)
+ source_fields = self.get_source_fields(instance)
+ local_fields = {
+ field.name: field
+ for field in instance._meta.local_fields
+ if field.name in source_fields}
+ instance._ik['source_hashes'] = {
+ attname: hash(file_field)
+ for attname, file_field in local_fields.items()}
+
+ def dispatch_signal(self, signal, file, model_class, instance, attname):
+ """
+ Dispatch the signal for each of the matching source groups. Note that
+ more than one source can have the same model and image_field; it's
+ important that we dispatch the signal for each.
+
+ """
+ for source_group in self._source_groups:
+ if issubclass(model_class, source_group.model_class) and source_group.image_field == attname:
+ signal.send(sender=source_group, source=file)
+
+
+class ImageFieldSourceGroup:
+ """
+ A source group that represents a particular field across all instances of a
+ model and its subclasses.
+
+ """
+ def __init__(self, model_class, image_field):
+ self.model_class = model_class
+ self.image_field = image_field
+ signal_router.add(self)
+
+ def files(self):
+ """
+ A generator that returns the source files that this source group
+ represents; in this case, a particular field of every instance of a
+ particular model and its subclasses.
+
+ """
+ for model in get_nonabstract_descendants(self.model_class):
+ for instance in model.objects.all().iterator():
+ yield getattr(instance, self.image_field)
+
+
+class SourceGroupFilesGenerator:
+ """
+ A Python generator that yields cache file objects for source groups.
+
+ """
+ def __init__(self, source_group, generator_id):
+ self.source_group = source_group
+ self.generator_id = generator_id
+
+ def __eq__(self, other):
+ return (isinstance(other, self.__class__)
+ and self.__dict__ == other.__dict__)
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def __hash__(self):
+ return hash((self.source_group, self.generator_id))
+
+ def __call__(self):
+ for source_file in self.source_group.files():
+ yield LazyImageCacheFile(self.generator_id,
+ source=source_file)
+
+
+signal_router = ModelSignalRouter()
diff --git a/tests/core/__init__.py b/imagekit/templatetags/__init__.py
similarity index 100%
rename from tests/core/__init__.py
rename to imagekit/templatetags/__init__.py
diff --git a/imagekit/templatetags/imagekit.py b/imagekit/templatetags/imagekit.py
new file mode 100644
index 00000000..88f69533
--- /dev/null
+++ b/imagekit/templatetags/imagekit.py
@@ -0,0 +1,313 @@
+from django import template
+from django.template.library import parse_bits
+from django.utils.encoding import force_str
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.conf import settings
+
+from ..cachefiles import ImageCacheFile
+from ..registry import generator_registry
+
+register = template.Library()
+
+
+ASSIGNMENT_DELIMETER = 'as'
+HTML_ATTRS_DELIMITER = '--'
+DEFAULT_THUMBNAIL_GENERATOR = 'imagekit:thumbnail'
+default_thumbnail_srcset_scales = getattr(settings, 'IMAGEKIT_DEFAULT_THUMBNAIL_SRCSET_SCALES', None)
+
+
+def get_cachefile(context, generator_id, generator_kwargs, source=None):
+ generator_id = generator_id.resolve(context)
+ kwargs = {k: v.resolve(context) for k, v in generator_kwargs.items()}
+ generator = generator_registry.get(generator_id, **kwargs)
+ return ImageCacheFile(generator)
+
+
+def parse_dimensions(dimensions):
+ """
+ Parse the width and height values from a dimension string. Valid values are
+ '1x1', '1x', and 'x1'. If one of the dimensions is omitted, the parse result
+ will be None for that value.
+
+ """
+ width, height = [d.strip() and int(d) or None for d in dimensions.split('x')]
+ return {'width': width, 'height': height}
+
+
+class GenerateImageAssignmentNode(template.Node):
+
+ def __init__(self, variable_name, generator_id, generator_kwargs):
+ self._generator_id = generator_id
+ self._generator_kwargs = generator_kwargs
+ self._variable_name = variable_name
+
+ def get_variable_name(self, context):
+ return force_str(self._variable_name)
+
+ def render(self, context):
+ variable_name = self.get_variable_name(context)
+ context[variable_name] = get_cachefile(context, self._generator_id,
+ self._generator_kwargs)
+ return ''
+
+
+class GenerateImageTagNode(template.Node):
+
+ def __init__(self, generator_id, generator_kwargs, html_attrs):
+ self._generator_id = generator_id
+ self._generator_kwargs = generator_kwargs
+ self._html_attrs = html_attrs
+
+ def render(self, context):
+ file = get_cachefile(context, self._generator_id,
+ self._generator_kwargs)
+ attrs = {k: v.resolve(context) for k, v in self._html_attrs.items()}
+
+ # Only add width and height if neither is specified (to allow for
+ # proportional in-browser scaling).
+ if 'width' not in attrs and 'height' not in attrs:
+ attrs.update(width=file.width, height=file.height)
+
+ attrs['src'] = file.url
+ attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in
+ attrs.items())
+ return mark_safe('
' % attr_str)
+
+
+class ThumbnailAssignmentNode(template.Node):
+
+ def __init__(self, variable_name, generator_id, dimensions, source, generator_kwargs):
+ self._variable_name = variable_name
+ self._generator_id = generator_id
+ self._dimensions = dimensions
+ self._source = source
+ self._generator_kwargs = generator_kwargs
+
+ def get_variable_name(self, context):
+ return force_str(self._variable_name)
+
+ def render(self, context):
+ variable_name = self.get_variable_name(context)
+
+ generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR
+ kwargs = {k: v.resolve(context) for k, v in self._generator_kwargs.items()}
+ kwargs['source'] = self._source.resolve(context)
+ kwargs.update(parse_dimensions(self._dimensions.resolve(context)))
+ if kwargs.get('anchor'):
+ # ImageKit uses pickle at protocol 0, which throws infinite
+ # recursion errors when anchor is set to a SafeString instance.
+ # This converts the SafeString into a str instance.
+ kwargs['anchor'] = kwargs['anchor'][:]
+ if kwargs.get('format'):
+ kwargs['format'] = kwargs['format'][:]
+ generator = generator_registry.get(generator_id, **kwargs)
+
+ context[variable_name] = ImageCacheFile(generator)
+
+ return ''
+
+
+class ThumbnailImageTagNode(template.Node):
+
+ def __init__(self, generator_id, dimensions, source, generator_kwargs, html_attrs):
+ self._generator_id = generator_id
+ self._dimensions = dimensions
+ self._source = source
+ self._generator_kwargs = generator_kwargs
+ self._html_attrs = html_attrs
+
+ def render(self, context):
+ generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR
+ dimensions = parse_dimensions(self._dimensions.resolve(context))
+ kwargs = {k: v.resolve(context) for k, v in self._generator_kwargs.items()}
+ kwargs['source'] = self._source.resolve(context)
+ kwargs.update(dimensions)
+ if kwargs.get('anchor'):
+ # ImageKit uses pickle at protocol 0, which throws infinite
+ # recursion errors when anchor is set to a SafeString instance.
+ # This converts the SafeString into a str instance.
+ kwargs['anchor'] = kwargs['anchor'][:]
+ srcset_scales = default_thumbnail_srcset_scales
+ if "srcset" in kwargs:
+ if kwargs['srcset'] is not None:
+ srcset_scales = list(map(float, kwargs['srcset'].split()))
+ else:
+ srcset_scales = None
+ kwargs.pop("srcset")
+ if kwargs.get('format'):
+ kwargs['format'] = kwargs['format'][:]
+ generator = generator_registry.get(generator_id, **kwargs)
+
+ file = ImageCacheFile(generator)
+ srcset = []
+ if srcset_scales:
+ for scale in srcset_scales:
+ scaled_kwargs = dict(kwargs)
+ if scaled_kwargs.get("height"):
+ scaled_kwargs["height"] = int(scaled_kwargs["height"] * scale)
+ if scaled_kwargs.get("width"):
+ scaled_kwargs["width"] = int(scaled_kwargs["width"] * scale)
+ srcset.append(ImageCacheFile(generator_registry.get(generator_id, **scaled_kwargs)))
+
+ attrs = {k: v.resolve(context) for k, v in self._html_attrs.items()}
+
+ # Only add width and height if neither is specified (to allow for
+ # proportional in-browser scaling).
+ if 'width' not in attrs and 'height' not in attrs:
+ attrs.update(width=file.width, height=file.height)
+
+ attrs['src'] = file.url
+ if len(srcset) > 0:
+ attrs['srcset'] = f'{file.url} 1x , ' + ' , '.join(
+ f'{entry[0].url} {entry[1]}x' for entry in zip(srcset, srcset_scales))
+ attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in
+ attrs.items())
+ return mark_safe('
' % attr_str)
+
+
+def parse_ik_tag_bits(parser, bits):
+ """
+ Parses the tag name, html attributes and variable name (for assignment tags)
+ from the provided bits. The preceding bits may vary and are left to be
+ parsed by specific tags.
+
+ """
+ varname = None
+ html_attrs = {}
+ tag_name = bits.pop(0)
+
+ if len(bits) >= 2 and bits[-2] == ASSIGNMENT_DELIMETER:
+ varname = bits[-1]
+ bits = bits[:-2]
+
+ if HTML_ATTRS_DELIMITER in bits:
+
+ if varname:
+ raise template.TemplateSyntaxError('Do not specify html attributes'
+ ' (using "%s") when using the "%s" tag as an assignment'
+ ' tag.' % (HTML_ATTRS_DELIMITER, tag_name))
+
+ index = bits.index(HTML_ATTRS_DELIMITER)
+ html_bits = bits[index + 1:]
+ bits = bits[:index]
+
+ if not html_bits:
+ raise template.TemplateSyntaxError('Don\'t use "%s" unless you\'re'
+ ' setting html attributes.' % HTML_ATTRS_DELIMITER)
+
+ args, html_attrs = parse_bits(parser, html_bits, [], 'args',
+ 'kwargs', None, [], None, False, tag_name)
+ if len(args):
+ raise template.TemplateSyntaxError('All "%s" tag arguments after'
+ ' the "%s" token must be named.' % (tag_name,
+ HTML_ATTRS_DELIMITER))
+
+ return (tag_name, bits, html_attrs, varname)
+
+
+@register.tag
+def generateimage(parser, token):
+ """
+ Creates an image based on the provided arguments.
+
+ By default::
+
+ {% generateimage 'myapp:thumbnail' source=mymodel.profile_image %}
+
+ generates an ``
`` tag::
+
+
+
+ You can add additional attributes to the tag using "--". For example,
+ this::
+
+ {% generateimage 'myapp:thumbnail' source=mymodel.profile_image -- alt="Hello!" %}
+
+ will result in the following markup::
+
+
+
+ For more flexibility, ``generateimage`` also works as an assignment tag::
+
+ {% generateimage 'myapp:thumbnail' source=mymodel.profile_image as th %}
+
+
+ """
+ bits = token.split_contents()
+
+ tag_name, bits, html_attrs, varname = parse_ik_tag_bits(parser, bits)
+
+ args, kwargs = parse_bits(parser, bits, ['generator_id'], 'args', 'kwargs',
+ None, [], None, False, tag_name)
+
+ if len(args) != 1:
+ raise template.TemplateSyntaxError('The "%s" tag requires exactly one'
+ ' unnamed argument (the generator id).' % tag_name)
+
+ generator_id = args[0]
+
+ if varname:
+ return GenerateImageAssignmentNode(varname, generator_id, kwargs)
+ else:
+ return GenerateImageTagNode(generator_id, kwargs, html_attrs)
+
+
+@register.tag
+def thumbnail(parser, token):
+ """
+ A convenient shortcut syntax for generating a thumbnail. The following::
+
+ {% thumbnail '100x100' mymodel.profile_image %}
+
+ is equivalent to::
+
+ {% generateimage 'imagekit:thumbnail' source=mymodel.profile_image width=100 height=100 %}
+
+ The thumbnail tag supports the "--" and "as" bits for adding html
+ attributes and assigning to a variable, respectively. It also accepts the
+ kwargs "format", "srcset", "anchor", and "crop".
+
+ To use "srcset" (generating multiple thumbnails for different pixel densities) list the scale factors::
+
+ {% thumbnail '100x100' mymodel.profile_image srcset="http://github.com/service/http://github.com/2%203 " %}
+
+ To use "smart cropping" (the ``SmartResize`` processor)::
+
+ {% thumbnail '100x100' mymodel.profile_image %}
+
+ To crop, anchoring the image to the top right (the ``ResizeToFill``
+ processor)::
+
+ {% thumbnail '100x100' mymodel.profile_image anchor='tr' %}
+
+ To resize without cropping (using the ``ResizeToFit`` processor)::
+
+ {% thumbnail '100x100' mymodel.profile_image crop=0 %}
+
+ """
+ bits = token.split_contents()
+
+ tag_name, bits, html_attrs, varname = parse_ik_tag_bits(parser, bits)
+
+ args, kwargs = parse_bits(parser, bits, [], 'args', 'kwargs',
+ None, [], None, False, tag_name)
+
+ if len(args) < 2:
+ raise template.TemplateSyntaxError('The "%s" tag requires at least two'
+ ' unnamed arguments: the dimensions and the source image.'
+ % tag_name)
+ elif len(args) > 3:
+ raise template.TemplateSyntaxError('The "%s" tag accepts at most three'
+ ' unnamed arguments: a generator id, the dimensions, and the'
+ ' source image.' % tag_name)
+
+ dimensions, source = args[-2:]
+ generator_id = args[0] if len(args) > 2 else None
+
+ if varname:
+ return ThumbnailAssignmentNode(varname, generator_id, dimensions,
+ source, kwargs)
+ else:
+ return ThumbnailImageTagNode(generator_id, dimensions, source, kwargs,
+ html_attrs)
diff --git a/imagekit/utils.py b/imagekit/utils.py
index ede309b0..08f4aac2 100644
--- a/imagekit/utils.py
+++ b/imagekit/utils.py
@@ -1,419 +1,155 @@
-import os
-import mimetypes
-import sys
-import types
+import logging
+import re
+from hashlib import md5
+from importlib import import_module
+from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
-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 django.utils.importlib import import_module
+from django.core.files import File
+from pilkit.utils import *
-from .lib import Image, ImageFile, StringIO
+bad_memcached_key_chars = re.compile('[\u0000-\u001f\\s]+')
+_autodiscovered = False
-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_nonabstract_descendants(model):
+ """ Returns all non-abstract descendants of the model. """
+ if not model._meta.abstract:
+ yield model
+ for s in model.__subclasses__():
+ yield from get_nonabstract_descendants(s)
-def get_spec_files(instance):
+def get_by_qname(path, desc):
try:
- return instance._ik.spec_files
+ dot = path.rindex('.')
+ except ValueError:
+ raise ImproperlyConfigured("%s isn't a %s module." % (path, desc))
+ module, objname = path[:dot], path[dot + 1:]
+ try:
+ mod = import_module(module)
+ except ImportError as e:
+ raise ImproperlyConfigured('Error importing %s module %s: "%s"' %
+ (desc, module, e))
+ try:
+ obj = getattr(mod, objname)
+ return obj
except AttributeError:
- return []
-
-
-def open_image(target):
- target.seek(0)
- img = Image.open(target)
- img.copy = types.MethodType(_wrap_copy(img.copy), img, img.__class__)
- return img
-
-
-def _wrap_copy(f):
- @wraps(f)
- def copy(self):
- img = f()
- try:
- img.app = self.app
- except AttributeError:
- pass
- try:
- img._getexif = self._getexif
- except AttributeError:
- pass
- return img
- return copy
+ raise ImproperlyConfigured('%s module "%s" does not define "%s"'
+ % (desc[0].upper() + desc[1:], module, objname))
-class UnknownExtensionError(Exception):
- pass
+_singletons = {}
-class UnknownFormatError(Exception):
- pass
+def get_singleton(class_path, desc):
+ global _singletons
+ cls = get_by_qname(class_path, desc)
+ instance = _singletons.get(cls)
+ if not instance:
+ instance = _singletons[cls] = cls()
+ return instance
-_pil_init = 0
-
-
-def _preinit_pil():
- """Loads the standard PIL file format drivers. Returns True if ``preinit()``
- was called (and there's a potential that more drivers were loaded) or False
- if there is no possibility that new drivers were loaded.
-
+def autodiscover():
"""
- global _pil_init
- if _pil_init < 1:
- Image.preinit()
- _pil_init = 1
- return True
- return False
-
-
-def _init_pil():
- """Loads all PIL file format drivers. Returns True if ``init()`` was called
- (and there's a potential that more drivers were loaded) or False if there is
- no possibility that new drivers were loaded.
+ Auto-discover INSTALLED_APPS imagegenerators.py modules and fail silently
+ when not present. This forces an import on them to register any admin bits
+ they may want.
+ Copied from django.contrib.admin
"""
- global _pil_init
- _preinit_pil()
- if _pil_init < 2:
- Image.init()
- _pil_init = 2
- return True
- return False
-
-
-def _extension_to_format(extension):
- return Image.EXTENSION.get(extension.lower())
-
+ global _autodiscovered
-def _format_to_extension(format):
- if format:
- for k, v in Image.EXTENSION.iteritems():
- if v == format.upper():
- return k
- return None
+ if _autodiscovered:
+ return
-
-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
+ from django.utils.module_loading import autodiscover_modules
+ autodiscover_modules('imagegenerators')
+ _autodiscovered = True
-def format_to_mimetype(format):
- return extension_to_mimetype(format_to_extension(format))
+def get_logger(logger_name='imagekit', add_null_handler=True):
+ logger = logging.getLogger(logger_name)
+ if add_null_handler:
+ logger.addHandler(logging.NullHandler())
+ return logger
-def extension_to_format(extension):
- """Returns the format that corresponds to the provided extension.
-
+def get_field_info(field_file):
"""
- format = _extension_to_format(extension)
- if not format and _preinit_pil():
- format = _extension_to_format(extension)
- if not format and _init_pil():
- format = _extension_to_format(extension)
- if not format:
- raise UnknownExtensionError(extension)
- return format
-
+ A utility for easily extracting information about the host model from a
+ Django FileField (or subclass). This is especially useful for when you want
+ to alter processors based on a property of the source model. For example::
-def format_to_extension(format):
- """Returns the first extension that matches the provided format.
+ class MySpec(ImageSpec):
+ def __init__(self, source):
+ instance, attname = get_field_info(source)
+ self.processors = [SmartResize(instance.thumbnail_width,
+ instance.thumbnail_height)]
"""
- extension = None
- if format:
- extension = _format_to_extension(format)
- if not extension and _preinit_pil():
- extension = _format_to_extension(format)
- if not extension and _init_pil():
- extension = _format_to_extension(format)
- if not extension:
- raise UnknownFormatError(format)
- return extension
-
-
-def _get_models(apps):
- models = []
- for app_label in apps or []:
- app = cache.get_app(app_label)
- models += [m for m in cache.get_models(app)]
- return models
-
-
-def invalidate_app_cache(apps):
- for model in _get_models(apps):
- print 'Invalidating cache for "%s.%s"' % (model._meta.app_label, model.__name__)
- for obj in model._default_manager.order_by('-pk'):
- for f in get_spec_files(obj):
- f.invalidate()
-
-
-def validate_app_cache(apps, force_revalidation=False):
- for model in _get_models(apps):
- for obj in model._default_manager.order_by('-pk'):
- model_name = '%s.%s' % (model._meta.app_label, model.__name__)
- if force_revalidation:
- print 'Invalidating & validating cache for "%s"' % model_name
- else:
- print 'Validating cache for "%s"' % model_name
- for f in get_spec_files(obj):
- if force_revalidation:
- f.invalidate()
- 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:
+ return (
+ getattr(field_file, 'instance', None),
+ getattr(getattr(field_file, 'field', None), 'attname', None),
+ )
- 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.
+def generate(generator):
"""
- 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
+ Calls the ``generate()`` method of a generator instance, and then wraps the
+ result in a Django File object so Django knows how to save it.
"""
- 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)
+ content = generator.generate()
+ f = File(content)
+ # The size of the File must be known or Django will try to open a file
+ # without a name and raise an Exception.
+ f.size = len(content.read())
+ # After getting the size reset the file pointer for future reads.
+ content.seek(0)
+ return f
- def __exit__(self, *args, **kwargs):
- os.dup2(self.old, self.stderr_fd)
- os.close(self.null_fd)
- os.close(self.old)
+def call_strategy_method(file, method_name):
+ strategy = getattr(file, 'cachefile_strategy', None)
+ fn = getattr(strategy, method_name, None)
+ if fn is not None:
+ fn(file)
-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.
+def get_cache():
+ from django.core.cache import caches
- """
- 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')
+ return caches[settings.IMAGEKIT_CACHE_BACKEND]
- # 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
-
-
-_default_file_storage = None
+def get_storage():
+ try:
+ from django.core.files.storage import storages, InvalidStorageError
+ except ImportError: # Django < 4.2
+ return get_singleton(
+ settings.IMAGEKIT_DEFAULT_FILE_STORAGE, 'file storage backend'
+ )
+ else:
+ try:
+ return storages[settings.IMAGEKIT_DEFAULT_FILE_STORAGE]
+ except InvalidStorageError:
+ return get_singleton(
+ settings.IMAGEKIT_DEFAULT_FILE_STORAGE, 'file storage backend'
+ )
-# Nasty duplication of get_default_image_cache_backend. Cleaned up in ik3
-def get_default_file_storage():
- """
- Get the default storage. Uses the same method as
- django.core.file.storage.get_storage_class
- """
- global _default_file_storage
- if not _default_file_storage:
- from django.conf import settings
- import_path = settings.IMAGEKIT_DEFAULT_FILE_STORAGE
+def sanitize_cache_key(key):
+ if settings.IMAGEKIT_USE_MEMCACHED_SAFE_CACHE_KEY:
+ # Memcached keys can't contain whitespace or control characters.
+ new_key = bad_memcached_key_chars.sub('', key)
- if not import_path:
- return None
+ # The also can't be > 250 chars long. Since we don't know what the
+ # user's cache ``KEY_FUNCTION`` setting is like, we'll limit it to 200.
+ if len(new_key) >= 200:
+ new_key = '%s:%s' % (new_key[:200 - 33], md5(key.encode('utf-8')).hexdigest())
- try:
- dot = import_path.rindex('.')
- except ValueError:
- raise ImproperlyConfigured("%s isn't an storage module." % \
- import_path)
- module, classname = import_path[:dot], import_path[dot + 1:]
- try:
- mod = import_module(module)
- except ImportError, e:
- raise ImproperlyConfigured('Error importing storage module %s: "%s"' % (module, e))
- try:
- cls = getattr(mod, classname)
- _default_file_storage = cls()
- except AttributeError:
- raise ImproperlyConfigured('Storage module "%s" does not define a "%s" class.' % (module, classname))
- return _default_file_storage
+ key = new_key
+ return key
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..a1c7384f
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+django_find_project = false
+DJANGO_SETTINGS_MODULE = tests.settings
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 5161716e..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-Django>=1.3.1
-django-appconf>=0.5
-PIL>=1.1.7
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 00000000..af57bc0b
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1 @@
+[bdist_wheel]
diff --git a/setup.py b/setup.py
index 6f662f93..d1a499a9 100644
--- a/setup.py
+++ b/setup.py
@@ -1,36 +1,58 @@
-#/usr/bin/env python
+#!/usr/bin/env python
import codecs
import os
import sys
-from setuptools import setup, find_packages
+from setuptools import find_packages, setup
+
if 'publish' in sys.argv:
- os.system('python setup.py sdist upload')
+ os.system('python3 -m build')
+ os.system('python3 -m twine upload --repository django_imagekit dist/*')
sys.exit()
-read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read()
-# Dynamically calculate the version based on imagekit.VERSION.
-version = __import__('imagekit').get_version()
+def read(filepath):
+ with codecs.open(filepath, 'r', 'utf-8') as f:
+ return f.read()
+
+
+def exec_file(filepath, globalz=None, localz=None):
+ exec(read(filepath), globalz, localz)
+
+
+# Load package meta from the pkgmeta module without loading imagekit.
+pkgmeta = {}
+exec_file(
+ os.path.join(os.path.dirname(__file__), 'imagekit', 'pkgmeta.py'),
+ pkgmeta
+)
+
setup(
name='django-imagekit',
- version=version,
+ version=pkgmeta['__version__'],
description='Automated image processing for Django models.',
long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')),
- author='Justin Driscoll',
- author_email='justin@driscolldev.com',
- maintainer='Bryan Veloso',
- maintainer_email='bryan@revyver.com',
+ long_description_content_type='text/x-rst',
+ author='Matthew Tretter',
+ author_email='m@tthewwithanm.com',
+ maintainer='Venelin Stoykov',
+ maintainer_email='venelin.stoykov@industria.tech',
license='BSD',
- url='http://github.com/jdriscoll/django-imagekit/',
- packages=find_packages(),
+ url='http://github.com/matthewwithanm/django-imagekit/',
+ packages=find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']),
zip_safe=False,
include_package_data=True,
install_requires=[
- 'django-appconf>=0.5',
+ 'django-appconf',
+ 'pilkit',
],
+ extras_require={
+ 'async': ['django-celery>=3.0'],
+ 'async_rq': ['django-rq>=0.6.0'],
+ 'async_dramatiq': ['django-dramatiq>=0.4.0'],
+ },
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
@@ -38,9 +60,15 @@
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
- 'Programming Language :: Python :: 2.5',
- 'Programming Language :: Python :: 2.6',
- 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3 :: Only',
+ 'Programming Language :: Python :: 3.9',
+ 'Programming Language :: Python :: 3.10',
+ 'Programming Language :: Python :: 3.11',
+ 'Programming Language :: Python :: 3.12',
+ 'Programming Language :: Python :: 3.13',
+ 'Programming Language :: Python :: 3.14',
'Topic :: Utilities'
],
)
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 00000000..a8c7120a
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1,7 @@
+# Test requirements
+mock; python_version<'3'
+beautifulsoup4
+Pillow
+pytest
+pytest-cov
+pytest-django
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..89b4f7e2
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,8 @@
+import pytest
+
+from .utils import clear_imagekit_test_files
+
+
+@pytest.fixture(scope='session', autouse=True)
+def imagekit_test_files_teardown(request):
+ request.addfinalizer(clear_imagekit_test_files)
diff --git a/tests/core/assets/Lenna.png b/tests/core/assets/Lenna.png
deleted file mode 100644
index 59ef68aa..00000000
Binary files a/tests/core/assets/Lenna.png and /dev/null differ
diff --git a/tests/core/assets/lenna-800x600-white-border.jpg b/tests/core/assets/lenna-800x600-white-border.jpg
deleted file mode 100644
index d0b1183b..00000000
Binary files a/tests/core/assets/lenna-800x600-white-border.jpg and /dev/null differ
diff --git a/tests/core/assets/lenna-800x600.jpg b/tests/core/assets/lenna-800x600.jpg
deleted file mode 100644
index 7c2ccd83..00000000
Binary files a/tests/core/assets/lenna-800x600.jpg and /dev/null differ
diff --git a/tests/core/models.py b/tests/core/models.py
deleted file mode 100644
index 2c3d8e47..00000000
--- a/tests/core/models.py
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index a73915e7..00000000
--- a/tests/core/tests.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import with_statement
-
-import os
-
-from django.test import TestCase
-
-from imagekit import utils
-from .models import (Photo, AbstractImageModel, ConcreteImageModel1,
- ConcreteImageModel2)
-from .testutils import create_photo, pickleback
-
-
-class IKTest(TestCase):
-
- def setUp(self):
- 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
- self.photo.save()
- self.assertTrue(self.photo.thumbnail.storage.exists(filename))
-
- def test_save_image(self):
- photo = Photo.objects.get(id=self.photo.id)
- self.assertTrue(os.path.isfile(photo.original_image.path))
-
- def test_setup(self):
- self.assertEqual(self.photo.original_image.width, 800)
- self.assertEqual(self.photo.original_image.height, 600)
-
- def test_thumbnail_creation(self):
- photo = Photo.objects.get(id=self.photo.id)
- self.assertTrue(os.path.isfile(photo.thumbnail.file.name))
-
- def test_thumbnail_size(self):
- """ Explicit and smart-cropped thumbnail size """
- self.assertEqual(self.photo.thumbnail.width, 50)
- self.assertEqual(self.photo.thumbnail.height, 50)
- self.assertEqual(self.photo.smartcropped_thumbnail.width, 50)
- self.assertEqual(self.photo.smartcropped_thumbnail.height, 50)
-
- def test_thumbnail_source_file(self):
- self.assertEqual(
- self.photo.thumbnail.source_file, self.photo.original_image)
-
-
-class IKUtilsTest(TestCase):
- def test_extension_to_format(self):
- self.assertEqual(utils.extension_to_format('.jpeg'), 'JPEG')
- self.assertEqual(utils.extension_to_format('.rgba'), 'SGI')
-
- 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')
-
- self.assertRaises(utils.UnknownFormatError,
- lambda: 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
deleted file mode 100644
index 4acc13cb..00000000
--- a/tests/core/testutils.py
+++ /dev/null
@@ -1,46 +0,0 @@
-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/tests/imagegenerators.py b/tests/imagegenerators.py
new file mode 100644
index 00000000..d6edb356
--- /dev/null
+++ b/tests/imagegenerators.py
@@ -0,0 +1,16 @@
+from imagekit import ImageSpec, register
+from imagekit.processors import ResizeToFill
+
+
+class TestSpec(ImageSpec):
+ __test__ = False
+
+
+class ResizeTo1PixelSquare(ImageSpec):
+ def __init__(self, width=None, height=None, anchor=None, crop=None, **kwargs):
+ self.processors = [ResizeToFill(1, 1)]
+ super().__init__(**kwargs)
+
+
+register.generator('testspec', TestSpec)
+register.generator('1pxsq', ResizeTo1PixelSquare)
diff --git a/tests/media/reference.png b/tests/media/reference.png
new file mode 100644
index 00000000..385716ca
Binary files /dev/null and b/tests/media/reference.png differ
diff --git a/tests/models.py b/tests/models.py
new file mode 100644
index 00000000..f4892cfd
--- /dev/null
+++ b/tests/models.py
@@ -0,0 +1,71 @@
+from django.db import models
+
+from imagekit import ImageSpec
+from imagekit.models import ImageSpecField, ProcessedImageField
+from imagekit.processors import Adjust, ResizeToFill, SmartCrop
+
+
+class Thumbnail(ImageSpec):
+ processors = [ResizeToFill(100, 60)]
+ format = 'JPEG'
+ options = {'quality': 60}
+
+
+class ImageModel(models.Model):
+ image = models.ImageField(upload_to='b')
+
+
+class Photo(models.Model):
+ original_image = models.ImageField(upload_to='photos')
+
+ # Implicit source field
+ thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
+ ResizeToFill(50, 50)], format='JPEG',
+ options={'quality': 90})
+
+ smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2,
+ sharpness=1.1), SmartCrop(50, 50)], source='original_image',
+ format='JPEG', options={'quality': 90})
+
+
+class ProcessedImageFieldModel(models.Model):
+ processed = ProcessedImageField([SmartCrop(50, 50)], format='JPEG',
+ options={'quality': 90}, upload_to='p')
+
+
+class ProcessedImageFieldWithSpecModel(models.Model):
+ processed = ProcessedImageField(spec=Thumbnail, upload_to='p')
+
+
+class CountingCacheFileStrategy:
+ def __init__(self):
+ self.on_existence_required_count = 0
+ self.on_content_required_count = 0
+ self.on_source_saved_count = 0
+
+ def on_existence_required(self, file):
+ self.on_existence_required_count += 1
+
+ def on_content_required(self, file):
+ self.on_content_required_count += 1
+
+ def on_source_saved(self, file):
+ self.on_source_saved_count += 1
+
+
+class AbstractImageModel(models.Model):
+ original_image = models.ImageField(upload_to='photos')
+ abstract_class_spec = ImageSpecField(source='original_image',
+ format='JPEG',
+ cachefile_strategy=CountingCacheFileStrategy())
+
+ class Meta:
+ abstract = True
+
+
+class ConcreteImageModel(AbstractImageModel):
+ pass
+
+
+class ConcreteImageModelSubclass(ConcreteImageModel):
+ pass
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
deleted file mode 100755
index 6d3f37ba..00000000
--- a/tests/run_tests.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-PYTHONPATH=$PWD:$PWD/..${PYTHONPATH:+:$PYTHONPATH}
-export PYTHONPATH
-
-echo "Running django-imagekit tests..."
-django-admin.py test core --settings=settings
diff --git a/tests/settings.py b/tests/settings.py
index f034e9c0..ed59d4bc 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -8,12 +8,6 @@
MEDIA_ROOT = os.path.normpath(os.path.join(BASE_PATH, 'media'))
-# Django <= 1.2
-DATABASE_ENGINE = 'sqlite3'
-DATABASE_NAME = 'imagekit.db'
-TEST_DATABASE_NAME = 'imagekit-test.db'
-
-# Django >= 1.3
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
@@ -21,13 +15,31 @@
},
}
+SECRET_KEY = '_uobce43e5osp8xgzle*yag2_16%y$sf*5(12vfg25hpnxik_*'
+
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'imagekit',
- 'core',
+ 'tests',
]
-DEBUG = True
-TEMPLATE_DEBUG = DEBUG
CACHE_BACKEND = 'locmem://'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.contrib.auth.context_processors.auth',
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.i18n',
+ 'django.template.context_processors.media',
+ 'django.template.context_processors.static',
+ 'django.template.context_processors.tz',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
diff --git a/tests/test_abstract_models.py b/tests/test_abstract_models.py
new file mode 100644
index 00000000..e0f88ca3
--- /dev/null
+++ b/tests/test_abstract_models.py
@@ -0,0 +1,9 @@
+from imagekit.utils import get_nonabstract_descendants
+
+from .models import (AbstractImageModel, ConcreteImageModel,
+ ConcreteImageModelSubclass)
+
+
+def test_nonabstract_descendants_generator():
+ descendants = list(get_nonabstract_descendants(AbstractImageModel))
+ assert descendants == [ConcreteImageModel, ConcreteImageModelSubclass]
diff --git a/tests/test_cachefiles.py b/tests/test_cachefiles.py
new file mode 100644
index 00000000..a05bf7a7
--- /dev/null
+++ b/tests/test_cachefiles.py
@@ -0,0 +1,138 @@
+from hashlib import md5
+from unittest import mock
+import os
+
+import pytest
+from django.conf import settings
+
+from imagekit.cachefiles import ImageCacheFile, LazyImageCacheFile
+from imagekit.cachefiles.backends import Simple
+
+from .imagegenerators import TestSpec
+from .utils import (DummyAsyncCacheFileBackend, assert_file_is_falsy,
+ assert_file_is_truthy, get_image_file,
+ get_unique_image_file)
+
+
+def test_no_source_falsiness():
+ """
+ Ensure cache files generated from sourceless specs are falsy.
+
+ """
+ spec = TestSpec(source=None)
+ file = ImageCacheFile(spec)
+ assert_file_is_falsy(file)
+
+
+def test_sync_backend_truthiness():
+ """
+ Ensure that a cachefile with a synchronous cache file backend (the default)
+ is truthy.
+
+ """
+ with get_unique_image_file() as source_file:
+ spec = TestSpec(source=source_file)
+ file = ImageCacheFile(spec)
+ assert_file_is_truthy(file)
+
+
+def test_async_backend_falsiness():
+ """
+ Ensure that a cachefile with an asynchronous cache file backend is falsy.
+
+ """
+ with get_unique_image_file() as source_file:
+ spec = TestSpec(source=source_file)
+ file = ImageCacheFile(spec, cachefile_backend=DummyAsyncCacheFileBackend())
+ assert_file_is_falsy(file)
+
+
+def test_no_source_error():
+ spec = TestSpec(source=None)
+ file = ImageCacheFile(spec)
+ with pytest.raises(TestSpec.MissingSource):
+ file.generate()
+
+
+def test_repr_does_not_send_existence_required():
+ """
+ Ensure that `__repr__` method does not send `existance_required` signal
+
+ Cachefile strategy may be configured to generate file on
+ `existance_required`.
+ To generate images, backend passes `ImageCacheFile` instance to worker.
+ Both celery and RQ calls `__repr__` method for each argument to enque call.
+ And if `__repr__` of object will send this signal, we will get endless
+ recursion
+
+ """
+ with mock.patch('imagekit.cachefiles.existence_required') as signal:
+ # import here to apply mock
+ from imagekit.cachefiles import ImageCacheFile
+
+ with get_unique_image_file() as source_file:
+ spec = TestSpec(source=source_file)
+ file = ImageCacheFile(
+ spec,
+ cachefile_backend=DummyAsyncCacheFileBackend()
+ )
+ file.__repr__()
+ assert signal.send.called is False
+
+
+def test_memcached_cache_key():
+ """
+ Ensure the default cachefile backend is sanitizing its cache key for
+ memcached by default.
+
+ """
+
+ class MockFile:
+ def __init__(self, name):
+ self.name = name
+
+ backend = Simple()
+ extra_char_count = len('state-') + len(settings.IMAGEKIT_CACHE_PREFIX)
+
+ length = 199 - extra_char_count
+ filename = '1' * length
+ file = MockFile(filename)
+ assert backend.get_key(file) == '%s%s-state' % (settings.IMAGEKIT_CACHE_PREFIX, file.name)
+
+ length = 200 - extra_char_count
+ filename = '1' * length
+ file = MockFile(filename)
+ assert backend.get_key(file) == '%s%s:%s' % (
+ settings.IMAGEKIT_CACHE_PREFIX,
+ '1' * (200 - len(':') - 32 - len(settings.IMAGEKIT_CACHE_PREFIX)),
+ md5(('%s%s-state' % (settings.IMAGEKIT_CACHE_PREFIX, filename)).encode('utf-8')).hexdigest())
+
+
+def test_lazyfile_stringification():
+ file = LazyImageCacheFile('testspec', source=None)
+ assert str(file) == ''
+ assert repr(file) == ''
+
+ with get_image_file() as source_file:
+ file = LazyImageCacheFile('testspec', source=source_file)
+ file.name = 'a.jpg'
+ assert str(file) == 'a.jpg'
+ assert repr(file) == ''
+
+
+def test_generate_file_already_exists(caplog):
+ with get_unique_image_file() as source_file:
+ spec = TestSpec(source=source_file)
+ file_1 = ImageCacheFile(spec)
+ file_1._generate()
+ # generate another cache image with the same name
+ file_2 = ImageCacheFile(spec, name=file_1.name)
+ file_2._generate()
+
+ assert len(caplog.records) == 1
+ storage, name, actual_name, cachefile_backend = caplog.records[0].args
+ assert storage == file_2.storage
+ assert name == file_2.name
+ assert actual_name != name
+ assert os.path.basename(actual_name) in storage.listdir(os.path.dirname(actual_name))[1]
+ assert cachefile_backend == file_2.cachefile_backend
diff --git a/tests/test_closing_fieldfiles.py b/tests/test_closing_fieldfiles.py
new file mode 100644
index 00000000..cb8d4e2b
--- /dev/null
+++ b/tests/test_closing_fieldfiles.py
@@ -0,0 +1,27 @@
+import pytest
+
+from .models import Thumbnail
+from .utils import create_photo
+
+
+@pytest.mark.django_db(transaction=True)
+def test_do_not_leak_open_files():
+ instance = create_photo('leak-test.jpg')
+ source_file = instance.original_image
+ # Ensure the FieldFile is closed before generation
+ source_file.close()
+ image_generator = Thumbnail(source=source_file)
+ image_generator.generate()
+ assert source_file.closed
+
+
+@pytest.mark.django_db(transaction=True)
+def test_do_not_close_open_files_after_generate():
+ instance = create_photo('do-not-close-test.jpg')
+ source_file = instance.original_image
+ # Ensure the FieldFile is opened before generation
+ source_file.open()
+ image_generator = Thumbnail(source=source_file)
+ image_generator.generate()
+ assert not source_file.closed
+ source_file.close()
diff --git a/tests/test_fields.py b/tests/test_fields.py
new file mode 100644
index 00000000..47ba0d93
--- /dev/null
+++ b/tests/test_fields.py
@@ -0,0 +1,57 @@
+import pytest
+from django import forms
+from django.core.files.base import File
+from django.core.files.uploadedfile import SimpleUploadedFile
+
+from imagekit import forms as ikforms
+from imagekit.processors import SmartCrop
+
+from . import imagegenerators # noqa
+from .models import (ImageModel, ProcessedImageFieldModel,
+ ProcessedImageFieldWithSpecModel)
+from .utils import get_image_file
+
+
+@pytest.mark.django_db(transaction=True)
+def test_model_processedimagefield():
+ instance = ProcessedImageFieldModel()
+ with File(get_image_file()) as file:
+ instance.processed.save('whatever.jpeg', file)
+ instance.save()
+
+ assert instance.processed.width == 50
+ assert instance.processed.height == 50
+
+
+@pytest.mark.django_db(transaction=True)
+def test_model_processedimagefield_with_spec():
+ instance = ProcessedImageFieldWithSpecModel()
+ with File(get_image_file()) as file:
+ instance.processed.save('whatever.jpeg', file)
+ instance.save()
+
+ assert instance.processed.width == 100
+ assert instance.processed.height == 60
+
+
+@pytest.mark.django_db(transaction=True)
+def test_form_processedimagefield():
+ class TestForm(forms.ModelForm):
+ image = ikforms.ProcessedImageField(spec_id='tests:testform_image',
+ processors=[SmartCrop(50, 50)],
+ format='JPEG')
+
+ class Meta:
+ model = ImageModel
+ fields = 'image',
+
+ with get_image_file() as upload_file:
+ files = {
+ 'image': SimpleUploadedFile('abc.jpg', upload_file.read())
+ }
+
+ form = TestForm({}, files)
+ instance = form.save()
+
+ assert instance.image.width == 50
+ assert instance.image.height == 50
diff --git a/tests/test_generateimage_tag.py b/tests/test_generateimage_tag.py
new file mode 100644
index 00000000..eb2b7ad4
--- /dev/null
+++ b/tests/test_generateimage_tag.py
@@ -0,0 +1,57 @@
+import pytest
+from django.template import TemplateSyntaxError
+
+from . import imagegenerators # noqa
+from .utils import clear_imagekit_cache, get_html_attrs, render_tag
+
+
+def test_img_tag():
+ ttag = r"""{% generateimage 'testspec' source=img %}"""
+ clear_imagekit_cache()
+ attrs = get_html_attrs(ttag)
+ expected_attrs = {'src', 'width', 'height'}
+ assert set(attrs.keys()) == expected_attrs
+ for k in expected_attrs:
+ assert attrs[k].strip() != ''
+
+
+def test_img_tag_attrs():
+ ttag = r"""{% generateimage 'testspec' source=img -- alt="Hello" %}"""
+ clear_imagekit_cache()
+ attrs = get_html_attrs(ttag)
+ assert attrs.get('alt') == 'Hello'
+
+
+def test_dangling_html_attrs_delimiter():
+ ttag = r"""{% generateimage 'testspec' source=img -- %}"""
+ with pytest.raises(TemplateSyntaxError):
+ render_tag(ttag)
+
+
+def test_html_attrs_assignment():
+ """
+ You can either use generateimage as an assignment tag or specify html attrs,
+ but not both.
+
+ """
+ ttag = r"""{% generateimage 'testspec' source=img -- alt="Hello" as th %}"""
+ with pytest.raises(TemplateSyntaxError):
+ render_tag(ttag)
+
+
+def test_single_dimension_attr():
+ """
+ If you only provide one of width or height, the other should not be added.
+
+ """
+ ttag = r"""{% generateimage 'testspec' source=img -- width="50" %}"""
+ clear_imagekit_cache()
+ attrs = get_html_attrs(ttag)
+ assert 'height' not in attrs
+
+
+def test_assignment_tag():
+ ttag = r"""{% generateimage 'testspec' source=img as th %}{{ th.url }}{{ th.height }}{{ th.width }}"""
+ clear_imagekit_cache()
+ html = render_tag(ttag)
+ assert html.strip() != ''
diff --git a/tests/test_no_extra_queries.py b/tests/test_no_extra_queries.py
new file mode 100644
index 00000000..612d027e
--- /dev/null
+++ b/tests/test_no_extra_queries.py
@@ -0,0 +1,16 @@
+from unittest.mock import Mock, PropertyMock, patch
+
+from .models import Photo
+
+
+def test_dont_access_source():
+ """
+ Touching the source may trigger an unneeded query.
+ See
+
+ """
+ pmock = PropertyMock()
+ pmock.__get__ = Mock()
+ with patch.object(Photo, 'original_image', pmock):
+ photo = Photo() # noqa
+ assert not pmock.__get__.called
diff --git a/tests/test_optimistic_strategy.py b/tests/test_optimistic_strategy.py
new file mode 100644
index 00000000..72e5a81c
--- /dev/null
+++ b/tests/test_optimistic_strategy.py
@@ -0,0 +1,51 @@
+from unittest.mock import Mock
+
+from django.core.files.storage import FileSystemStorage
+
+from imagekit.cachefiles import ImageCacheFile
+from imagekit.cachefiles.backends import Simple as SimpleCFBackend
+from imagekit.cachefiles.strategies import Optimistic as OptimisticStrategy
+
+from .utils import create_image
+
+
+class ImageGenerator:
+ def generate(self):
+ return create_image()
+
+ def get_hash(self):
+ return 'abc123'
+
+
+def get_image_cache_file():
+ storage = Mock(FileSystemStorage)
+ backend = SimpleCFBackend()
+ strategy = OptimisticStrategy()
+ generator = ImageGenerator()
+ return ImageCacheFile(generator, storage=storage,
+ cachefile_backend=backend,
+ cachefile_strategy=strategy)
+
+
+def test_no_io_on_bool():
+ """
+ When checking the truthiness of an ImageCacheFile, the storage shouldn't
+ perform IO operations.
+
+ """
+ with get_image_cache_file() as file:
+ bool(file)
+ assert not file.storage.exists.called
+ assert not file.storage.open.called
+
+
+def test_no_io_on_url():
+ """
+ When getting the URL of an ImageCacheFile, the storage shouldn't be
+ checked.
+
+ """
+ with get_image_cache_file() as file:
+ file.url
+ assert not file.storage.exists.called
+ assert not file.storage.open.called
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
new file mode 100644
index 00000000..a7755b20
--- /dev/null
+++ b/tests/test_serialization.py
@@ -0,0 +1,49 @@
+"""
+Make sure that the various IK classes can be successfully serialized and
+deserialized. This is important when using IK with Celery.
+
+"""
+import pytest
+
+from imagekit.cachefiles import ImageCacheFile
+
+from .imagegenerators import TestSpec
+from .utils import (clear_imagekit_cache, create_photo, get_unique_image_file,
+ pickleback)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_imagespecfield():
+ clear_imagekit_cache()
+ instance = create_photo('pickletest2.jpg')
+ thumbnail = pickleback(instance.thumbnail)
+ thumbnail.generate()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_circular_ref():
+ """
+ A model instance with a spec field in its dict shouldn't raise a KeyError.
+
+ This corresponds to #234
+
+ """
+ clear_imagekit_cache()
+ instance = create_photo('pickletest3.jpg')
+ instance.thumbnail # Cause thumbnail to be added to instance's __dict__
+ pickleback(instance)
+
+
+def test_cachefiles():
+ clear_imagekit_cache()
+ with get_unique_image_file() as source_file:
+ spec = TestSpec(source=source_file)
+ file = ImageCacheFile(spec)
+ file.url
+ # remove link to file from spec source generator
+ # test __getstate__ of ImageCacheFile
+ file.generator.source = None
+ restored_file = pickleback(file)
+ assert file is not restored_file
+ # Assertion for #437 and #451
+ assert file.storage is restored_file.storage
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 00000000..2cf49301
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,74 @@
+import django
+from django.test import override_settings
+import pytest
+from imagekit.conf import ImageKitConf, settings
+from imagekit.utils import get_storage
+
+
+@pytest.mark.skipif(
+ django.VERSION < (4, 2),
+ reason="STORAGES was introduced in Django 4.2",
+)
+def test_custom_storages():
+ with override_settings(
+ STORAGES={
+ "default": {
+ "BACKEND": "tests.utils.CustomStorage",
+ }
+ },
+ ):
+ conf = ImageKitConf()
+ assert conf.configure_default_file_storage(None) == "default"
+
+
+@pytest.mark.skipif(
+ django.VERSION >= (5, 1),
+ reason="DEFAULT_FILE_STORAGE is removed in Django 5.1.",
+)
+def test_custom_default_file_storage():
+ with override_settings(DEFAULT_FILE_STORAGE="tests.utils.CustomStorage"):
+ # If we don’t remove this, Django 4.2 will keep the old value.
+ del settings.STORAGES
+ conf = ImageKitConf()
+
+ if django.VERSION >= (4, 2):
+ assert conf.configure_default_file_storage(None) == "default"
+ else:
+ assert (
+ conf.configure_default_file_storage(None) == "tests.utils.CustomStorage"
+ )
+
+
+def test_get_storage_default():
+ from django.core.files.storage import FileSystemStorage
+
+ assert isinstance(get_storage(), FileSystemStorage)
+
+
+@pytest.mark.skipif(
+ django.VERSION >= (5, 1),
+ reason="DEFAULT_FILE_STORAGE is removed in Django 5.1.",
+)
+def test_get_storage_custom_path():
+ from tests.utils import CustomStorage
+
+ with override_settings(IMAGEKIT_DEFAULT_FILE_STORAGE="tests.utils.CustomStorage"):
+ assert isinstance(get_storage(), CustomStorage)
+
+
+@pytest.mark.skipif(
+ django.VERSION < (4, 2),
+ reason="STORAGES was introduced in Django 4.2",
+)
+def test_get_storage_custom_key():
+ from tests.utils import CustomStorage
+
+ with override_settings(
+ STORAGES={
+ "custom": {
+ "BACKEND": "tests.utils.CustomStorage",
+ }
+ },
+ IMAGEKIT_DEFAULT_FILE_STORAGE="custom",
+ ):
+ assert isinstance(get_storage(), CustomStorage)
diff --git a/tests/test_sourcegroups.py b/tests/test_sourcegroups.py
new file mode 100644
index 00000000..56174179
--- /dev/null
+++ b/tests/test_sourcegroups.py
@@ -0,0 +1,62 @@
+import pytest
+from django.core.files import File
+
+from imagekit.signals import source_saved
+from imagekit.specs.sourcegroups import ImageFieldSourceGroup
+
+from .models import AbstractImageModel, ConcreteImageModel, ImageModel
+from .utils import get_image_file
+
+
+def make_counting_receiver(source_group):
+ def receiver(sender, *args, **kwargs):
+ if sender is source_group:
+ receiver.count += 1
+ receiver.count = 0
+ return receiver
+
+
+@pytest.mark.django_db(transaction=True)
+def test_source_saved_signal():
+ """
+ Creating a new instance with an image causes the source_saved signal to be
+ dispatched.
+
+ """
+ source_group = ImageFieldSourceGroup(ImageModel, 'image')
+ receiver = make_counting_receiver(source_group)
+ source_saved.connect(receiver)
+ with File(get_image_file(), name='reference.png') as image:
+ ImageModel.objects.create(image=image)
+ assert receiver.count == 1
+
+
+@pytest.mark.django_db(transaction=True)
+def test_no_source_saved_signal():
+ """
+ Creating a new instance without an image shouldn't cause the source_saved
+ signal to be dispatched.
+
+ https://github.com/matthewwithanm/django-imagekit/issues/214
+
+ """
+ source_group = ImageFieldSourceGroup(ImageModel, 'image')
+ receiver = make_counting_receiver(source_group)
+ source_saved.connect(receiver)
+ ImageModel.objects.create()
+ assert receiver.count == 0
+
+
+@pytest.mark.django_db(transaction=True)
+def test_abstract_model_signals():
+ """
+ Source groups created for abstract models must cause signals to be
+ dispatched on their concrete subclasses.
+
+ """
+ source_group = ImageFieldSourceGroup(AbstractImageModel, 'original_image')
+ receiver = make_counting_receiver(source_group)
+ source_saved.connect(receiver)
+ with File(get_image_file(), name='reference.png') as image:
+ ConcreteImageModel.objects.create(original_image=image)
+ assert receiver.count == 1
diff --git a/tests/test_thumbnail_tag.py b/tests/test_thumbnail_tag.py
new file mode 100644
index 00000000..ded3ed1a
--- /dev/null
+++ b/tests/test_thumbnail_tag.py
@@ -0,0 +1,105 @@
+import pytest
+import re
+from django.template import TemplateSyntaxError
+
+from . import imagegenerators # noqa
+from .utils import clear_imagekit_cache, get_html_attrs, render_tag
+
+
+def test_img_tag():
+ ttag = r"""{% thumbnail '100x100' img %}"""
+ clear_imagekit_cache()
+ attrs = get_html_attrs(ttag)
+ expected_attrs = {'src', 'width', 'height'}
+ assert set(attrs.keys()) == expected_attrs
+ for k in expected_attrs:
+ assert attrs[k].strip() != ''
+
+
+def test_img_tag_anchor():
+ ttag = r"""{% thumbnail '100x100' img anchor='c' %}"""
+ clear_imagekit_cache()
+ attrs = get_html_attrs(ttag)
+ expected_attrs = {'src', 'width', 'height'}
+ assert set(attrs.keys()) == expected_attrs
+ for k in expected_attrs:
+ assert attrs[k].strip() != ''
+
+
+def test_img_tag_attrs():
+ ttag = r"""{% thumbnail '100x100' img -- alt="Hello" %}"""
+ clear_imagekit_cache()
+ attrs = get_html_attrs(ttag)
+ assert attrs.get('alt') == 'Hello'
+
+
+def test_dangling_html_attrs_delimiter():
+ ttag = r"""{% thumbnail '100x100' img -- %}"""
+ with pytest.raises(TemplateSyntaxError):
+ render_tag(ttag)
+
+
+def test_not_enough_args():
+ ttag = r"""{% thumbnail '100x100' %}"""
+ with pytest.raises(TemplateSyntaxError):
+ render_tag(ttag)
+
+
+def test_too_many_args():
+ ttag = r"""{% thumbnail 'generator_id' '100x100' img 'extra' %}"""
+ with pytest.raises(TemplateSyntaxError):
+ render_tag(ttag)
+
+
+def test_html_attrs_assignment():
+ """
+ You can either use thumbnail as an assignment tag or specify html attrs,
+ but not both.
+
+ """
+ ttag = r"""{% thumbnail '100x100' img -- alt="Hello" as th %}"""
+ with pytest.raises(TemplateSyntaxError):
+ render_tag(ttag)
+
+
+def test_assignment_tag():
+ ttag = r"""{% thumbnail '100x100' img as th %}{{ th.url }}"""
+ clear_imagekit_cache()
+ html = render_tag(ttag)
+ assert html != ''
+
+
+def test_assignment_tag_anchor():
+ ttag = r"""{% thumbnail '100x100' img anchor='c' as th %}{{ th.url }}"""
+ clear_imagekit_cache()
+ html = render_tag(ttag)
+ assert html != ''
+
+
+def test_single_dimension():
+ ttag = r"""{% thumbnail '100x' img as th %}{{ th.width }}"""
+ clear_imagekit_cache()
+ html = render_tag(ttag)
+ assert html == '100'
+
+
+def test_alternate_generator():
+ ttag = r"""{% thumbnail '1pxsq' '100x' img as th %}{{ th.width }}"""
+ clear_imagekit_cache()
+ html = render_tag(ttag)
+ assert html == '1'
+
+
+def test_srcset_arg():
+ ttag = r"""{% thumbnail '100x' img srcset="http://github.com/service/http://github.com/1.5%202 " %}"""
+ clear_imagekit_cache()
+ html = render_tag(ttag)
+ srcset_regex = re.compile('srcset="http://github.com/service/http://github.com/.*%201x%20 , http://github.com/.*%201//.5x%20 , http://github.com/.*%202.0x "')
+ assert srcset_regex.search(html) is not None
+
+
+def test_alternate_format():
+ ttag = r"""{% thumbnail '100x' img format='webp' as th %}{{ th.url }}"""
+ clear_imagekit_cache()
+ html = render_tag(ttag)
+ assert html.endswith('webp')
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..0bac5dc8
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,42 @@
+import django
+from django.test import override_settings
+import pytest
+from imagekit.utils import get_storage
+
+
+def test_get_storage_default():
+ from django.core.files.storage import default_storage
+
+ if django.VERSION >= (4, 2):
+ assert get_storage() == default_storage
+ else:
+ assert isinstance(get_storage(), type(default_storage._wrapped))
+
+
+@pytest.mark.skipif(
+ django.VERSION >= (5, 1),
+ reason="DEFAULT_FILE_STORAGE is removed in Django 5.1.",
+)
+def test_get_storage_custom_import_path():
+ from tests.utils import CustomStorage
+
+ with override_settings(IMAGEKIT_DEFAULT_FILE_STORAGE="tests.utils.CustomStorage"):
+ assert isinstance(get_storage(), CustomStorage)
+
+
+@pytest.mark.skipif(
+ django.VERSION < (4, 2),
+ reason="STORAGES was introduced in Django 4.2",
+)
+def test_get_storage_custom_key():
+ from tests.utils import CustomStorage
+
+ with override_settings(
+ STORAGES={
+ "custom": {
+ "BACKEND": "tests.utils.CustomStorage",
+ }
+ },
+ IMAGEKIT_DEFAULT_FILE_STORAGE="custom",
+ ):
+ assert isinstance(get_storage(), CustomStorage)
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 00000000..1909772e
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,115 @@
+import os
+import pickle
+import shutil
+from io import BytesIO
+from tempfile import NamedTemporaryFile
+
+from bs4 import BeautifulSoup
+from django.core.files import File
+from django.core.files.storage import FileSystemStorage
+from django.template import Context, Template
+from PIL import Image
+
+from imagekit.cachefiles.backends import Simple
+from imagekit.conf import settings
+from imagekit.utils import get_cache
+
+from .models import Photo
+
+
+def get_image_file():
+ """
+ See also:
+
+ http://en.wikipedia.org/wiki/Lenna
+ http://sipi.usc.edu/database/database.php?volume=misc&image=12
+ https://lintian.debian.org/tags/license-problem-non-free-img-lenna.html
+ https://github.com/libav/libav/commit/8895bf7b78650c0c21c88cec0484e138ec511a4b
+ """
+ path = os.path.join(settings.MEDIA_ROOT, 'reference.png')
+ return open(path, 'r+b')
+
+
+def get_unique_image_file():
+ file = NamedTemporaryFile()
+ with get_image_file() as image:
+ file.write(image.read())
+ return file
+
+
+def create_image():
+ return Image.open(get_image_file())
+
+
+def create_instance(model_class, image_name):
+ instance = model_class()
+ img = File(get_image_file())
+ instance.original_image.save(image_name, img)
+ instance.save()
+ img.close()
+ return instance
+
+
+def create_photo(name):
+ return create_instance(Photo, name)
+
+
+def pickleback(obj):
+ pickled = BytesIO()
+ pickle.dump(obj, pickled)
+ pickled.seek(0)
+ return pickle.load(pickled)
+
+
+def render_tag(ttag):
+ with get_image_file() as img:
+ template = Template('{%% load imagekit %%}%s' % ttag)
+ context = Context({'img': img})
+ return template.render(context)
+
+
+def get_html_attrs(ttag):
+ return BeautifulSoup(render_tag(ttag), features="html.parser").img.attrs
+
+
+def assert_file_is_falsy(file):
+ assert not bool(file), 'File is not falsy'
+
+
+def assert_file_is_truthy(file):
+ assert bool(file), 'File is not truthy'
+
+
+class CustomStorage(FileSystemStorage):
+ pass
+
+
+class DummyAsyncCacheFileBackend(Simple):
+ """
+ A cache file backend meant to simulate async generation.
+
+ """
+ is_async = True
+
+ def generate(self, file, force=False):
+ pass
+
+
+def clear_imagekit_cache():
+ cache = get_cache()
+ cache.clear()
+ # Clear IMAGEKIT_CACHEFILE_DIR
+ cache_dir = os.path.join(settings.MEDIA_ROOT, settings.IMAGEKIT_CACHEFILE_DIR)
+ if os.path.exists(cache_dir):
+ shutil.rmtree(cache_dir)
+
+
+def clear_imagekit_test_files():
+ clear_imagekit_cache()
+ for fname in os.listdir(settings.MEDIA_ROOT):
+ if fname != 'reference.png':
+ path = os.path.join(settings.MEDIA_ROOT, fname)
+ if os.path.isdir(path):
+ shutil.rmtree(path)
+ else:
+ os.remove(path)
diff --git a/tox.ini b/tox.ini
index 1ad1957d..647cf6a0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,45 +1,39 @@
[tox]
envlist =
- py27-django14, py27-django13, py27-django12,
- py26-django14, py26-django13, py26-django12
+ django32-py3{9,10}
+ django42-py3{9,10,11,12}
+ django52-py3{10,11,12,13,14}
+ djangomain-py3{13,14}
+ coverage-report
-[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,<1.5
- Pillow
-
-[testenv:py27-django13]
-basepython = python2.7
-deps =
- Django>=1.3,<1.4
- Pillow
+[gh-actions]
+python =
+ 3.9: py39
+ 3.10: py310
+ 3.11: py311, coverage-report
+ 3.12: py312
+ 3.13: py313
+ 3.14: py314
-[testenv:py27-django12]
-basepython = python2.7
+[testenv]
deps =
- Django>=1.2,<1.3
- Pillow
+ -r test-requirements.txt
+ django32: django~=3.2.0
+ django42: django~=4.2.0
+ django51: django~=5.1.0
+ django52: django~=5.2.0
+ djangomain: https://github.com/django/django/archive/refs/heads/main.zip
-[testenv:py26-django14]
-basepython = python2.6
-deps =
- Django>=1.4,<1.5
- Pillow
+setenv = COVERAGE_FILE=.coverage.{envname}
+commands = python -Wdefault -m pytest --cov --cov-report term-missing:skip-covered
-[testenv:py26-django13]
-basepython = python2.6
-deps =
- Django>=1.3,<1.4
- Pillow
+ignore_outcome =
+ djangomain: true
-[testenv:py26-django12]
-basepython = python2.6
-deps =
- Django>=1.2,<1.3
- Pillow
+[testenv:coverage-report]
+deps = coverage
+skip_install = true
+setenv = COVERAGE_FILE=.coverage
+commands =
+ coverage combine
+ coverage report