Compare commits

..

No commits in common. "develop" and "3.0a4" have entirely different histories.

65 changed files with 727 additions and 1660 deletions

6
.gitignore vendored
View file

@ -4,14 +4,10 @@
*.pyc *.pyc
.DS_Store .DS_Store
.tox .tox
.idea
.vscode
MANIFEST MANIFEST
build build
dist dist
/tests/media/* /tests/media/*
!/tests/media/reference.png !/tests/media/lenna.png
/venv /venv
/venv3
/.env /.env
/tags

View file

@ -1,35 +1,7 @@
sudo: false
language: python language: python
python: python:
- "3.8" - 2.7
- "3.7" install: pip install tox --use-mirrors
- "3.6" script: tox
- "3.5"
env:
- DJANGO="master"
- DJANGO="30"
- DJANGO="22"
- DJANGO="21"
- DJANGO="21"
- DJANGO="20"
- DJANGO="111"
install:
- pip install tox
script:
- tox -e py$(python -c 'import sys;print("".join(map(str, sys.version_info[:2])))')-django${DJANGO}
jobs:
fast_finish: true
allow_failures:
- env: DJANGO="master"
exclude:
- python: "3.5"
env: DJANGO="30"
- python: "3.5"
env: DJANGO="master"
notifications: notifications:
irc: "irc.freenode.org#imagekit" irc: "irc.freenode.org#imagekit"

View file

@ -6,8 +6,8 @@ HZDG_.
Maintainers Maintainers
----------- -----------
* `Matthew Tretter`_
* `Bryan Veloso`_ * `Bryan Veloso`_
* `Matthew Tretter`_
* `Chris Drackett`_ * `Chris Drackett`_
* `Greg Newman`_ * `Greg Newman`_
@ -27,8 +27,6 @@ Contributors
* `Clay McClure`_ * `Clay McClure`_
* `Jannis Leidel`_ * `Jannis Leidel`_
* `Sean Bell`_ * `Sean Bell`_
* `Saul Shanabrook`_
* `Venelin Stoykov`_
.. _Justin Driscoll: http://github.com/jdriscoll .. _Justin Driscoll: http://github.com/jdriscoll
.. _HZDG: http://hzdg.com .. _HZDG: http://hzdg.com
@ -49,5 +47,3 @@ Contributors
.. _Clay McClure: https://github.com/claymation .. _Clay McClure: https://github.com/claymation
.. _Jannis Leidel: https://github.com/jezdez .. _Jannis Leidel: https://github.com/jezdez
.. _Sean Bell: https://github.com/seanbell .. _Sean Bell: https://github.com/seanbell
.. _Saul Shanabrook: https://github.com/saulshanabrook
.. _Venelin Stoykov: https://github.com/vstoykov

View file

@ -8,7 +8,7 @@ contributions merged as quickly as possible:
2. If you want to add a new feature, talk to us on the `mailing list`__ or 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. `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 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 tests, use ``make test``. This will let you know about any errors or style
issues. issues.
4. While we're talking about tests, creating new ones for your code makes it 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 nose_, so much easier for us to merge your code quickly. ImageKit uses nose_, so
@ -21,4 +21,4 @@ __ http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
__ https://groups.google.com/forum/#!forum/django-imagekit __ https://groups.google.com/forum/#!forum/django-imagekit
__ irc://irc.freenode.net/imagekit __ irc://irc.freenode.net/imagekit
.. _nose: https://nose.readthedocs.org/en/latest/ .. _nose: https://nose.readthedocs.org/en/latest/
__ https://github.com/matthewwithanm/django-imagekit/tree/develop/tests __ https://github.com/jdriscoll/django-imagekit/tree/develop/tests

View file

@ -1,18 +1,5 @@
include AUTHORS include AUTHORS
include LICENSE include LICENSE
include README.rst include README.rst
include testrunner.py recursive-include docs *
include setup.cfg recursive-include imagekit/templates *
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

View file

@ -1,22 +1,13 @@
|Build Status|_
.. |Build Status| image:: https://travis-ci.org/matthewwithanm/django-imagekit.svg?branch=develop
.. _Build Status: https://travis-ci.org/matthewwithanm/django-imagekit
ImageKit is a Django app for processing images. Need a thumbnail? A 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 black-and-white version of a user-uploaded image? ImageKit will make them for
you. If you need to programatically generate one image from another, you need you. If you need to programatically generate one image from another, you need
ImageKit. 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** **For the complete documentation on the latest stable version of ImageKit, see**
`ImageKit on RTD`_. `ImageKit on RTD`_. Our `changelog is also available`_.
.. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org .. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org
__ https://github.com/fish2000/instakit .. _`changelog is also available`: http://django-imagekit.readthedocs.org/en/latest/changelog.html
Installation Installation
@ -25,6 +16,7 @@ Installation
1. Install `PIL`_ or `Pillow`_. (If you're using an ``ImageField`` in Django, 1. Install `PIL`_ or `Pillow`_. (If you're using an ``ImageField`` in Django,
you should have already done this.) you should have already done this.)
2. ``pip install django-imagekit`` 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 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 .. note:: If you've never seen Pillow before, it considers itself a
@ -39,7 +31,6 @@ Installation
Usage Overview Usage Overview
============== ==============
.. _specs:
Specs Specs
----- -----
@ -71,8 +62,8 @@ your model class:
options={'quality': 60}) options={'quality': 60})
profile = Profile.objects.all()[0] profile = Profile.objects.all()[0]
print(profile.avatar_thumbnail.url) # > /media/CACHE/images/982d5af84cddddfd0fbf70892b4431e4.jpg print profile.avatar_thumbnail.url # > /media/CACHE/images/982d5af84cddddfd0fbf70892b4431e4.jpg
print(profile.avatar_thumbnail.width) # > 100 print profile.avatar_thumbnail.width # > 100
As you can probably tell, ImageSpecFields work a lot like Django's As you can probably tell, ImageSpecFields work a lot like Django's
ImageFields. The difference is that they're automatically generated by ImageFields. The difference is that they're automatically generated by
@ -89,7 +80,6 @@ class:
from django.db import models from django.db import models
from imagekit.models import ProcessedImageField from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFill
class Profile(models.Model): class Profile(models.Model):
avatar_thumbnail = ProcessedImageField(upload_to='avatars', avatar_thumbnail = ProcessedImageField(upload_to='avatars',
@ -98,8 +88,8 @@ class:
options={'quality': 60}) options={'quality': 60})
profile = Profile.objects.all()[0] profile = Profile.objects.all()[0]
print(profile.avatar_thumbnail.url) # > /media/avatars/MY-avatar.jpg print profile.avatar_thumbnail.url # > /media/avatars/MY-avatar.jpg
print(profile.avatar_thumbnail.width) # > 100 print profile.avatar_thumbnail.width # > 100
This is pretty similar to our previous example. We don't need to specify a 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 "source" any more since we're not processing another image field, but we do need
@ -138,29 +128,24 @@ particularly when the processing being done depends on user input.
format = 'JPEG' format = 'JPEG'
options = {'quality': 60} options = {'quality': 60}
It's probably not surprising that this class is capable of processing an image It's probaby not surprising that this class is capable of processing an image
in the exact same way as our ImageSpecField above. However, unlike with the 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 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: on, or what should be done with the result; that's up to you:
.. code-block:: python .. code-block:: python
source_file = open('/path/to/myimage.jpg', 'rb') source_file = open('/path/to/myimage.jpg')
image_generator = Thumbnail(source=source_file) image_generator = Thumbnail(source=source_file)
result = image_generator.generate() result = image_generator.generate()
.. note::
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 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 containing our resized image, with which you can do whatever you want. For
example, if you wanted to save it to disk: example, if you wanted to save it to disk:
.. code-block:: python .. code-block:: python
dest = open('/path/to/dest.jpg', 'wb') dest = open('/path/to/dest.jpg', 'w')
dest.write(result.read()) dest.write(result.read())
dest.close() dest.close()
@ -187,7 +172,7 @@ to register it.
.. code-block:: python .. code-block:: python
from imagekit import ImageSpec, register from imagekit import ImageSpec
from imagekit.processors import ResizeToFill from imagekit.processors import ResizeToFill
class Thumbnail(ImageSpec): class Thumbnail(ImageSpec):
@ -230,7 +215,7 @@ that's what we need to pass to use our thumbnail spec:
{% load imagekit %} {% load imagekit %}
{% generateimage 'myapp:thumbnail' source=source_file %} {% generateimage 'myapp:thumbnail' source=source_image %}
This will output the following HTML: This will output the following HTML:
@ -245,7 +230,7 @@ keyword args using two dashes:
{% load imagekit %} {% load imagekit %}
{% generateimage 'myapp:thumbnail' source=source_file -- alt="A picture of Me" id="mypicture" %} {% generateimage 'myapp:thumbnail' source=source_image -- alt="A picture of Me" id="mypicture" %}
Not generating HTML image tags? No problem. The tag also functions as an Not generating HTML image tags? No problem. The tag also functions as an
assignment tag, providing access to the underlying file object: assignment tag, providing access to the underlying file object:
@ -254,7 +239,7 @@ assignment tag, providing access to the underlying file object:
{% load imagekit %} {% load imagekit %}
{% generateimage 'myapp:thumbnail' source=source_file as th %} {% generateimage 'myapp:thumbnail' source=source_image as th %}
<a href="{{ th.url }}">Click to download a cool {{ th.width }} x {{ th.height }} image!</a> <a href="{{ th.url }}">Click to download a cool {{ th.width }} x {{ th.height }} image!</a>
@ -268,7 +253,7 @@ template tag:
{% load imagekit %} {% load imagekit %}
{% thumbnail '100x50' source_file %} {% thumbnail '100x50' source_image %}
Like the generateimage tag, the thumbnail tag outputs an <img> tag: Like the generateimage tag, the thumbnail tag outputs an <img> tag:
@ -289,15 +274,15 @@ with the id "imagekit:thumbnail" which, by default, is
Second, we're passing two positional arguments (the dimensions and the source 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. 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 Like with the generatethumbnail tag, you can also specify additional HTML
for the thumbnail tag, or use it as an assignment tag: attributes for the thumbnail tag, or use it as an assignment tag:
.. code-block:: html .. code-block:: html
{% load imagekit %} {% load imagekit %}
{% thumbnail '100x50' source_file -- alt="A picture of Me" id="mypicture" %} {% thumbnail '100x50' source_image -- alt="A picture of Me" id="mypicture" %}
{% thumbnail '100x50' source_file as th %} {% thumbnail '100x50' source_image as th %}
Using Specs in Forms Using Specs in Forms
@ -380,12 +365,6 @@ it in your spec's ``processors`` list:
format = 'JPEG' format = 'JPEG'
options = {'quality': 60} 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 Admin
----- -----
@ -407,37 +386,6 @@ Django admin classes:
admin.site.register(Photo, PhotoAdmin) admin.site.register(Photo, PhotoAdmin)
To use specs defined outside of models:
.. 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
class AdminThumbnailSpec(ImageSpec):
processors = [ResizeToFill(100, 30)]
format = 'JPEG'
options = {'quality': 60 }
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
class PhotoAdmin(admin.ModelAdmin):
list_display = ('__str__', 'admin_thumbnail')
admin_thumbnail = AdminThumbnail(image_field=cached_admin_thumb)
admin.site.register(Photo, PhotoAdmin)
AdminThumbnail can even use a custom template. For more information, see AdminThumbnail can even use a custom template. For more information, see
``imagekit.admin.AdminThumbnail``. ``imagekit.admin.AdminThumbnail``.
@ -455,7 +403,7 @@ of generator ids in order to generate images selectively.
Community Community
========= =========
Please use `the GitHub issue tracker <https://github.com/matthewwithanm/django-imagekit/issues>`_ Please use `the GitHub issue tracker <https://github.com/jdriscoll/django-imagekit/issues>`_
to report bugs with django-imagekit. `A mailing list <https://groups.google.com/forum/#!forum/django-imagekit>`_ to report bugs with django-imagekit. `A mailing list <https://groups.google.com/forum/#!forum/django-imagekit>`_
also exists to discuss the project and ask questions, as well as the official also exists to discuss the project and ask questions, as well as the official
`#imagekit <irc://irc.freenode.net/imagekit>`_ channel on Freenode. `#imagekit <irc://irc.freenode.net/imagekit>`_ channel on Freenode.
@ -477,5 +425,5 @@ Check out our `contributing guidelines`__ for more information about pitching in
with ImageKit. with ImageKit.
__ https://github.com/matthewwithanm/django-imagekit/issues?labels=contributor-friendly&state=open __ https://github.com/jdriscoll/django-imagekit/issues?labels=contributor-friendly&state=open
__ https://github.com/matthewwithanm/django-imagekit/blob/develop/CONTRIBUTING.rst __ https://github.com/jdriscoll/django-imagekit/blob/master/CONTRIBUTING.rst

View file

@ -47,13 +47,14 @@ for creating an ``ImageSpec``, registering it, and associating it with an
class Profile(models.Model): class Profile(models.Model):
avatar = models.ImageField(upload_to='avatars') avatar = models.ImageField(upload_to='avatars')
avatar_thumbnail = ImageSpecField(source='avatar', avatar_thumbnail = ImageSpecField(source='avatar',
id='myapp:profile:avatar_thumbnail') spec_id='myapp:profile:avatar_thumbnail')
Obviously, the shorthand version is a lot, well…shorter. So why would you ever 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 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 long form—creating an image spec class and registering it—gives you a lot more
power over the generated image. power over the generated image.
.. _dynamic-specs: .. _dynamic-specs:
Specs That Change Specs That Change
@ -97,7 +98,7 @@ for getting this information.
class Profile(models.Model): class Profile(models.Model):
avatar = models.ImageField(upload_to='avatars') avatar = models.ImageField(upload_to='avatars')
avatar_thumbnail = ImageSpecField(source='avatar', avatar_thumbnail = ImageSpecField(source='avatar',
id='myapp:profile:avatar_thumbnail') spec_id='myapp:profile:avatar_thumbnail')
thumbnail_width = models.PositiveIntegerField() thumbnail_width = models.PositiveIntegerField()
thumbnail_height = models.PositiveIntegerField() thumbnail_height = models.PositiveIntegerField()
@ -108,6 +109,107 @@ 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. the source image; spec behavior can change in any way you want.
Optimizing
==========
Unlike Django's ImageFields, ImageKit's ImageSpecFields and template tags don't
persist any data in the database. Therefore, in order to know whether an image
file needs to be generated, ImageKit needs to check if the file already exists
(using the appropriate file storage object`__). The object responsible for
performing these checks is called a *cache file backend*.
Cache!
------
By default, ImageKit checks for the existence of a cache file every time you
attempt to use the file and, if it doesn't exist, creates it synchronously. This
is a very safe behavior because it ensures that your ImageKit-generated images
are always available. However, that's a lot of checking with storage and those
kinds of operations can be slow—especially if you're using a remote storage—so
you'll want to try to avoid them as much as possible.
Luckily, the default cache file backend makes use of Django's caching
abilities to mitigate the number of checks it actually has to do; it will use
the cache specified by the ``IMAGEKIT_CACHE_BACKEND`` to save the state of the
generated file. If your Django project is running in debug mode
(``settings.DEBUG`` is true), this will be a dummy cache by default. Otherwise,
it will use your project's default cache.
In normal operation, your cache files will never be deleted; once they're
created, they'll stay created. So the simplest optimization you can make is to
set your ``IMAGEKIT_CACHE_BACKEND`` to a cache with a very long, or infinite,
timeout.
Even More Advanced
------------------
For many applications—particularly those using local storage for generated image
files—a cache with a long timeout is all the optimization you'll need. However,
there may be times when that simply doesn't cut it. In these cases, you'll want
to change when the generation is actually done.
The objects responsible for specifying when cache files are created are
called *cache file strategies*. The default strategy can be set using the
``IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY`` setting, and its default value is
`'imagekit.cachefiles.strategies.JustInTime'`. As we've already seen above,
the "just in time" strategy determines whether a file needs to be generated each
time it's accessed and, if it does, generates it synchronously (that is, as part
of the request-response cycle).
Another strategy is to simply assume the file exists. This requires the fewest
number of checks (zero!), so we don't have to worry about expensive IO. The
strategy that takes this approach is
``imagekit.cachefiles.strategies.Optimistic``. In order to use this
strategy, either set the ``IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY`` setting or,
to use it on a per-generator basis, set the ``cachefile_strategy`` attribute
of your spec or generator. Avoiding checking for file existence can be a real
boon to performance, but it also means that ImageKit has no way to know when a
file needs to be generated—well, at least not all the time.
With image specs, we can know at least some of the times that a new file needs
to be generated: whenever the source image is created or changed. For this
reason, the optimistic strategy defines callbacks for these events. Every
source registered with ImageKit will automatically cause its specs' files to be
generated when it is created or changed.
.. note::
In order to understand source registration, read :ref:`source-groups`
If you have specs that :ref:`change based on attributes of the source
<dynamic-specs>`, 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.
.. _source-groups: .. _source-groups:
Source Groups Source Groups
@ -122,7 +224,7 @@ The answer is that, when you define an ImageSpecField, ImageKit automatically
creates and registers an object called a *source group*. Source groups are creates and registers an object called a *source group*. Source groups are
responsible for two things: responsible for two things:
1. They dispatch signals when a source is saved, and 1. They dispatch signals when a source is created, changed, or deleted, and
2. They expose a generator method that enumerates source files. 2. They expose a generator method that enumerates source files.
When these objects are registered (using ``imagekit.register.source_group()``), When these objects are registered (using ``imagekit.register.source_group()``),
@ -163,7 +265,7 @@ A simple example of a custom source group class is as follows:
def files(self): def files(self):
os.chdir(self.dir) os.chdir(self.dir)
for name in glob.glob('*.jpg'): for name in glob.glob('*.jpg'):
yield open(name, 'rb') yield open(name)
Instances of this class could then be registered with one or more spec id: Instances of this class could then be registered with one or more spec id:
@ -177,6 +279,6 @@ Running the "generateimages" management command would now cause thumbnails to be
generated (using the "myapp:profile:avatar_thumbnail" spec) for each of the generated (using the "myapp:profile:avatar_thumbnail" spec) for each of the
JPEGs in `/path/to/some/pics`. JPEGs in `/path/to/some/pics`.
Note that, since this source group doesnt send the `source_saved` signal, the Note that, since this source group doesnt send the `source_created` or
corresponding cache file strategy callbacks would not be called for them. `source_changed` signals, the corresponding cache file strategy callbacks
would not be called for them.

View file

@ -1,256 +0,0 @@
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
seprate 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 %}
<img src="/path/to/placeholder.jpg" />
{% else %}
<img src="{{ profile.avatar_thumbnail.url }}" />
{% 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
thruthiness 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-responce 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
<dynamic-specs>`, 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.

142
docs/changelog.rst Normal file
View file

@ -0,0 +1,142 @@
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.

View file

@ -54,7 +54,7 @@ execfile(os.path.join(os.path.dirname(__file__), '..', 'imagekit',
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = re.match(r'\d+\.\d+', pkgmeta['__version__']).group() version = re.match('\d+\.\d+', pkgmeta['__version__']).group()
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = pkgmeta['__version__'] release = pkgmeta['__version__']

View file

@ -23,7 +23,7 @@ Settings
The qualified class name of a Django storage backend to use to save the 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``, cached images. If no value is provided for ``IMAGEKIT_DEFAULT_FILE_STORAGE``,
and none is specified by the spec definition, `your default file storage`__ and none is specified by the spec definition, the storage of the source file
will be used. will be used.
@ -44,24 +44,11 @@ Settings
.. attribute:: IMAGEKIT_CACHE_BACKEND .. attribute:: IMAGEKIT_CACHE_BACKEND
:default: ``'default'`` :default: If ``DEBUG`` is ``True``, ``'django.core.cache.backends.dummy.DummyCache'``.
Otherwise, ``'default'``.
The Django cache backend alias to retrieve the shared cache instance defined The Django cache backend to be used to store information like the state of
in your settings, as described in the `Django cache section`_. cached images (i.e. validated or not).
The cache is then used to store information like the state of cached
images (i.e. validated or not).
.. _`Django cache section`: https://docs.djangoproject.com/en/1.8/topics/cache/#accessing-the-cache
.. 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 .. attribute:: IMAGEKIT_CACHE_PREFIX
@ -85,6 +72,3 @@ Settings
A function responsible for generating file names for cache files that 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 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. 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

View file

@ -20,5 +20,5 @@ Indices and tables
configuration configuration
advanced_usage advanced_usage
caching changelog
upgrading upgrading

View file

@ -79,9 +79,12 @@ IK3 provides analogous settings for cache file backends and strategies:
IMAGEKIT_DEFAULT_CACHEFILE_BACKEND = 'path.to.MyCacheFileBackend' IMAGEKIT_DEFAULT_CACHEFILE_BACKEND = 'path.to.MyCacheFileBackend'
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = 'path.to.MyCacheFileStrategy' IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = 'path.to.MyCacheFileStrategy'
See the documentation on :ref:`cache file backends <cache-file-backend>` and :ref:`cache file strategies <cache-file-strategy>` See the documentation on `cache file backends`_ and `cache file strategies`_
for more details. for more details.
.. _`cache file backends`:
.. _`cache file strategies`:
Conditional model ``processors`` Conditional model ``processors``
-------------------------------- --------------------------------
@ -90,7 +93,9 @@ In IK2, an ``ImageSpecField`` could take a ``processors`` callable instead of
an iterable, which allowed processing decisions to made based on other 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 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 (if one kwarg could be callable, why not all?), but provides a much more robust
solution: the custom ``spec``. See the :doc:`advanced usage <advanced_usage>` documentation for more. solution: the custom ``spec``. See the `advanced usage`_ documentation for more.
.. _`advanced usage`:
Conditonal ``cache_to`` file names Conditonal ``cache_to`` file names
@ -104,14 +109,6 @@ 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 ``cachefile_name``, but it is not recommended, as the spec's default
behavior is to hash the combination of ``source``, ``processors``, ``format``, behavior is to hash the combination of ``source``, ``processors``, ``format``,
and other spec options to ensure that changes to the spec always result in 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. unique file names. See the documentation on `specs`_ for more.
.. _`specs`:
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

View file

@ -1,4 +1,5 @@
# flake8: noqa # flake8: noqa
from . import importers
from . import conf from . import conf
from . import generatorlibrary from . import generatorlibrary
from .specs import ImageSpec from .specs import ImageSpec

View file

@ -1,12 +1,9 @@
from copy import copy
from django.conf import settings from django.conf import settings
from django.core.files import File
from django.core.files.images import ImageFile from django.core.files.images import ImageFile
from django.utils.functional import SimpleLazyObject from django.utils.functional import LazyObject
from django.utils.encoding import smart_str
from ..files import BaseIKFile from ..files import BaseIKFile
from ..registry import generator_registry from ..registry import generator_registry
from ..signals import content_required, existence_required from ..signals import before_access
from ..utils import get_logger, get_singleton, generate, get_by_qname from ..utils import get_logger, get_singleton, generate, get_by_qname
@ -18,7 +15,7 @@ class ImageCacheFile(BaseIKFile, ImageFile):
to be deferred until the time that the cache file strategy requires it. 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): def __init__(self, generator, name=None, storage=None, cachefile_backend=None):
""" """
:param generator: The object responsible for generating a new image. :param generator: The object responsible for generating a new image.
:param name: The filename :param name: The filename
@ -26,8 +23,6 @@ class ImageCacheFile(BaseIKFile, ImageFile):
file. file.
:param cachefile_backend: The object responsible for managing the :param cachefile_backend: The object responsible for managing the
state of the file. state of the file.
:param cachefile_strategy: The object responsible for handling events
for this file.
""" """
self.generator = generator self.generator = generator
@ -43,55 +38,20 @@ class ImageCacheFile(BaseIKFile, ImageFile):
storage = storage or getattr(generator, 'cachefile_storage', storage = storage or getattr(generator, 'cachefile_storage',
None) or get_singleton(settings.IMAGEKIT_DEFAULT_FILE_STORAGE, None) or get_singleton(settings.IMAGEKIT_DEFAULT_FILE_STORAGE,
'file storage backend') 'file storage backend')
self.cachefile_backend = ( self.cachefile_backend = cachefile_backend or getattr(generator,
cachefile_backend 'cachefile_backend', None)
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(ImageCacheFile, self).__init__(storage=storage) super(ImageCacheFile, self).__init__(storage=storage)
def _require_file(self): def _require_file(self):
if getattr(self, '_file', None) is None: before_access.send(sender=self, file=self)
content_required.send(sender=self, file=self) return super(ImageCacheFile, self)._require_file()
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(self):
return self._storage_attr('url')
def generate(self, force=False): def generate(self, force=False):
""" if force:
Generate the file. If ``force`` is ``True``, the file will be generated self._generate()
whether the file already exists or not. else:
self.cachefile_backend.ensure_exists(self)
"""
if force or getattr(self, '_file', None) is None:
self.cachefile_backend.generate(self, force)
def _generate(self): def _generate(self):
# Generate the file # Generate the file
@ -99,85 +59,39 @@ class ImageCacheFile(BaseIKFile, ImageFile):
actual_name = self.storage.save(self.name, content) actual_name = self.storage.save(self.name, content)
# We're going to reuse the generated file, so we need to reset the pointer.
content.seek(0)
# 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)
if actual_name != self.name: if actual_name != self.name:
get_logger().warning( get_logger().warning('The storage backend %s did not save the file'
'The storage backend %s did not save the file with the' ' with the requested name ("%s") and instead used'
' requested name ("%s") and instead used "%s". This may be' ' "%s". This may be because a file already existed with'
' because a file already existed with the requested name. If' ' the requested name. If so, you may have meant to call'
' so, you may have meant to call generate() instead of' ' ensure_exists() instead of generate(), or there may be a'
' generate(force=True), or there may be a race condition in the' ' race condition in the file backend %s. The saved file'
' file backend %s. The saved file will not be used.' % ( ' will not be used.' % (self.storage,
self.storage,
self.name, actual_name, self.name, actual_name,
self.cachefile_backend 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_singleton(
settings.IMAGEKIT_DEFAULT_FILE_STORAGE,
'file storage backend'
)
if state['storage'] == settings_storage:
state.pop('storage')
return state
def __setstate__(self, state):
if 'storage' not in state:
state['storage'] = get_singleton(
settings.IMAGEKIT_DEFAULT_FILE_STORAGE,
'file storage backend'
)
self.__dict__.update(state)
def __nonzero__(self):
# Python 2 compatibility
return self.__bool__()
def __repr__(self):
return smart_str("<%s: %s>" % (
self.__class__.__name__, self if self.name else "None")
)
class LazyImageCacheFile(SimpleLazyObject): class LazyImageCacheFile(LazyObject):
def __init__(self, generator_id, *args, **kwargs): def __init__(self, generator_id, *args, **kwargs):
super(LazyImageCacheFile, self).__init__()
def setup(): def setup():
generator = generator_registry.get(generator_id, *args, **kwargs) generator = generator_registry.get(generator_id, *args, **kwargs)
return ImageCacheFile(generator) self._wrapped = ImageCacheFile(generator)
super(LazyImageCacheFile, self).__init__(setup)
self.__dict__['_setup'] = setup
def __repr__(self): def __repr__(self):
return '<%s: %s>' % (self.__class__.__name__, str(self) or 'None') if self._wrapped is None:
self._setup()
return '<%s: %s>' % (self.__class__.__name__, self or 'None')
def __str__(self):
if self._wrapped is None:
self._setup()
return str(self._wrapped)
def __unicode__(self):
if self._wrapped is None:
self._setup()
return unicode(self._wrapped)

View file

@ -0,0 +1,22 @@
def generate(file):
file.generate()
try:
from celery.task import task
except ImportError:
pass
else:
generate_task = task(generate)
def generate_deferred(file):
try:
import celery # NOQA
except:
raise ImportError("Deferred validation requires the the 'celery' library")
generate_task.delay(file)
def clear_now(file):
file.clear()

View file

@ -1,14 +1,6 @@
from ..utils import get_singleton, get_cache, sanitize_cache_key from ..utils import get_singleton
import warnings from django.core.cache import get_cache
from copy import copy
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.conf import settings
class CacheFileState(object):
EXISTS = 'exists'
GENERATING = 'generating'
DOES_NOT_EXIST = 'does_not_exist'
def get_default_cachefile_backend(): def get_default_cachefile_backend():
@ -18,178 +10,55 @@ def get_default_cachefile_backend():
""" """
from django.conf import settings from django.conf import settings
return get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_BACKEND, return get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_BACKEND,
'file backend') 'file backend')
class InvalidFileBackendError(ImproperlyConfigured): class InvalidFileBackendError(ImproperlyConfigured):
pass pass
class AbstractCacheFileBackend(object):
"""
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(object): class CachedFileBackend(object):
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 @property
def cache(self): def cache(self):
if not getattr(self, '_cache', None): if not getattr(self, '_cache', None):
self._cache = get_cache() from django.conf import settings
self._cache = get_cache(settings.IMAGEKIT_CACHE_BACKEND)
return self._cache return self._cache
def get_key(self, file): def get_key(self, file):
from django.conf import settings from django.conf import settings
return sanitize_cache_key('%s%s-state' % return '%s%s-exists' % (settings.IMAGEKIT_CACHE_PREFIX, file.name)
(settings.IMAGEKIT_CACHE_PREFIX, file.name))
def get_state(self, file, check_if_unknown=True): def file_exists(self, file):
key = self.get_key(file) key = self.get_key(file)
state = self.cache.get(key) exists = self.cache.get(key)
if state is None and check_if_unknown: if exists is None:
exists = self._exists(file) exists = self._file_exists(file)
state = CacheFileState.EXISTS if exists else CacheFileState.DOES_NOT_EXIST self.cache.set(key, exists)
self.set_state(file, state) return exists
return state
def set_state(self, file, state): def ensure_exists(self, file):
key = self.get_key(file) if self.file_exists(file):
if state == CacheFileState.DOES_NOT_EXIST: self.create(file)
self.cache.set(key, state, self.existence_check_timeout) self.cache.set(self.get_key(file), True)
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): class Simple(CachedFileBackend):
""" """
The most basic file backend. The storage is consulted to see if the file The most basic file backend. The storage is consulted to see if the file
exists. Files are generated synchronously. exists.
""" """
def generate(self, file, force=False): def _file_exists(self, file):
self.generate_now(file, force=force) if not getattr(file, '_file', None):
# No file on object. Have to check storage.
return not file.storage.exists(file.name)
return False
def _exists(self, file): def create(self, file):
return bool(getattr(file, '_file', None) """
or (file.name and file.storage.exists(file.name))) Generates a new image by running the processors on the source file.
"""
def _generate_file(backend, file, force=False): file.generate(force=True)
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 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(Celery, self).__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(Async, self).__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(RQ, self).__init__(*args, **kwargs)
def schedule_generation(self, file, force=False):
_rq_job.delay(self, file, force=force)

View file

@ -1,7 +1,4 @@
import six
from django.utils.functional import LazyObject from django.utils.functional import LazyObject
from ..lib import force_text
from ..utils import get_singleton from ..utils import get_singleton
@ -11,10 +8,7 @@ class JustInTime(object):
""" """
def on_existence_required(self, file): def before_access(self, file):
file.generate()
def on_content_required(self, file):
file.generate() file.generate()
@ -26,11 +20,11 @@ class Optimistic(object):
""" """
def on_source_saved(self, file): def on_source_created(self, file):
file.generate() file.generate()
def should_verify_existence(self, file): def on_source_changed(self, file):
return False file.generate()
class DictStrategy(object): class DictStrategy(object):
@ -39,11 +33,24 @@ class DictStrategy(object):
setattr(self, k, v) setattr(self, k, v)
def load_strategy(strategy): class StrategyWrapper(LazyObject):
if isinstance(strategy, six.string_types): def __init__(self, strategy):
strategy = get_singleton(strategy, 'cache file strategy') if isinstance(strategy, basestring):
elif isinstance(strategy, dict): strategy = get_singleton(strategy, 'cache file strategy')
strategy = DictStrategy(strategy) elif isinstance(strategy, dict):
elif callable(strategy): strategy = DictStrategy(strategy)
strategy = strategy() elif callable(strategy):
return strategy strategy = strategy()
self._wrapped = strategy
def __getstate__(self):
return {'_wrapped': self._wrapped}
def __setstate__(self, state):
self._wrapped = state['_wrapped']
def __unicode__(self):
return unicode(self._wrapped)
def __str__(self):
return str(self._wrapped)

View file

@ -1,6 +1,5 @@
from appconf import AppConf from appconf import AppConf
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class ImageKitConf(AppConf): class ImageKitConf(AppConf):
@ -10,31 +9,15 @@ class ImageKitConf(AppConf):
DEFAULT_CACHEFILE_BACKEND = 'imagekit.cachefiles.backends.Simple' DEFAULT_CACHEFILE_BACKEND = 'imagekit.cachefiles.backends.Simple'
DEFAULT_CACHEFILE_STRATEGY = 'imagekit.cachefiles.strategies.JustInTime' DEFAULT_CACHEFILE_STRATEGY = 'imagekit.cachefiles.strategies.JustInTime'
DEFAULT_FILE_STORAGE = None DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
CACHE_BACKEND = None CACHE_BACKEND = None
CACHE_PREFIX = 'imagekit:' CACHE_PREFIX = 'imagekit:'
CACHE_TIMEOUT = None
USE_MEMCACHED_SAFE_CACHE_KEY = True
def configure_cache_backend(self, value): def configure_cache_backend(self, value):
if value is None: if value is None:
from django.core.cache import DEFAULT_CACHE_ALIAS if getattr(settings, 'CACHES', None):
return DEFAULT_CACHE_ALIAS value = 'django.core.cache.backends.dummy.DummyCache' if settings.DEBUG else 'default'
else:
if value not in settings.CACHES: value = 'dummy://' if settings.DEBUG else settings.CACHE_BACKEND
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:
value = settings.DEFAULT_FILE_STORAGE
return value return value

View file

@ -13,10 +13,6 @@ class MissingGeneratorId(Exception):
pass pass
class MissingSource(ValueError):
pass
# Aliases for backwards compatibility # Aliases for backwards compatibility
UnknownExtensionError = UnknownExtension UnknownExtensionError = UnknownExtension
UnknownFormatError = UnknownFormat UnknownFormatError = UnknownFormat

View file

@ -1,9 +1,6 @@
from __future__ import unicode_literals
import os
from django.core.files.base import File, ContentFile from django.core.files.base import File, ContentFile
from django.utils.encoding import smart_str from django.utils.encoding import smart_str, smart_unicode
from .lib import smart_text import os
from .utils import format_to_mimetype, extension_to_mimetype from .utils import format_to_mimetype, extension_to_mimetype
@ -49,25 +46,14 @@ class BaseIKFile(File):
def _get_size(self): def _get_size(self):
self._require_file() self._require_file()
if not getattr(self, '_committed', False): if not self._committed:
return self.file.size return self.file.size
return self.storage.size(self.name) return self.storage.size(self.name)
size = property(_get_size) size = property(_get_size)
def open(self, mode='rb'): def open(self, mode='rb'):
self._require_file() self._require_file()
try: self.file.open(mode)
self.file.open(mode)
except ValueError:
# if the underlaying 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): def _get_closed(self):
file = getattr(self, '_file', None) file = getattr(self, '_file', None)
@ -106,5 +92,4 @@ class IKContentFile(ContentFile):
return smart_str(self.file.name or '') return smart_str(self.file.name or '')
def __unicode__(self): def __unicode__(self):
# Python 2 return smart_unicode(self.file.name or u'')
return smart_text(self.file.name or '')

View file

@ -22,12 +22,8 @@ class ProcessedImageField(ImageField, SpecHost):
def clean(self, data, initial=None): def clean(self, data, initial=None):
data = super(ProcessedImageField, self).clean(data, initial) data = super(ProcessedImageField, self).clean(data, initial)
if data and data != initial: if data:
spec = self.get_spec(source=data) spec = self.get_spec(source=data)
f = generate(spec) data = generate(spec)
# Name is required in Django 1.4. When we drop support for it
# then we can dirrectly return the result from `generate(spec)`
f.name = data.name
return f
return data return data

View file

@ -4,9 +4,9 @@ from .specs import ImageSpec
class Thumbnail(ImageSpec): class Thumbnail(ImageSpec):
def __init__(self, width=None, height=None, anchor=None, crop=None, upscale=None, **kwargs): def __init__(self, width=None, height=None, anchor=None, crop=None, **kwargs):
self.processors = [ThumbnailProcessor(width, height, anchor=anchor, self.processors = [ThumbnailProcessor(width, height, anchor=anchor,
crop=crop, upscale=upscale)] crop=crop)]
super(Thumbnail, self).__init__(**kwargs) super(Thumbnail, self).__init__(**kwargs)

View file

@ -1,35 +0,0 @@
from copy import copy
from hashlib import md5
from pickle import MARK, DICT
try:
from pickle import _Pickler
except ImportError:
# Python 2 compatible
from pickle import Pickler as _Pickler
from .lib import StringIO
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
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 = StringIO()
CanonicalizingPickler(file, 0).dump(obj)
return md5(file.getvalue()).hexdigest()

29
imagekit/importers.py Normal file
View file

@ -0,0 +1,29 @@
from django.utils.importlib import import_module
import re
import sys
class ProcessorImporter(object):
"""
The processors were moved to the PILKit project so they could be used
separtely from ImageKit (which has a bunch of Django dependencies). However,
there's no real need to expose this fact (and we want to maintain backwards
compatibility), so we proxy all "imagekit.processors" imports to
"pilkit.processors" using this object.
"""
pattern = re.compile(r'^imagekit\.processors((\..*)?)$')
def find_module(self, name, path=None):
if self.pattern.match(name):
return self
def load_module(self, name):
if name in sys.modules:
return sys.modules[name]
new_name = self.pattern.sub(r'pilkit.processors\1', name)
return import_module(new_name)
sys.meta_path.append(ProcessorImporter())

View file

@ -19,34 +19,6 @@ 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.') raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.')
try: try:
from io import BytesIO as StringIO from cStringIO import StringIO
except:
try:
from cStringIO import StringIO
except ImportError:
from StringIO import StringIO
try:
from logging import NullHandler
except ImportError: except ImportError:
from logging import Handler from StringIO import StringIO
class NullHandler(Handler):
def emit(self, record):
pass
# Try to import `force_text` available from Django 1.5
# This function will replace `unicode` used in the code
# If Django version is under 1.5 then use `force_unicde`
# It is used for compatibility between Python 2 and Python 3
try:
from django.utils.encoding import force_text, force_bytes, smart_text
except ImportError:
# Django < 1.5
from django.utils.encoding import (force_unicode as force_text,
smart_str as force_bytes,
smart_unicode as smart_text)
__all__ = ['Image', 'ImageColor', 'ImageChops', 'ImageEnhance', 'ImageFile',
'ImageFilter', 'ImageDraw', 'ImageStat', 'StringIO', 'NullHandler',
'force_text', 'force_bytes', 'smart_text']

View file

@ -1,7 +1,6 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
import re import re
from ...registry import generator_registry, cachefile_registry from ...registry import generator_registry, cachefile_registry
from ...exceptions import MissingSource
class Command(BaseCommand): class Command(BaseCommand):
@ -14,28 +13,23 @@ match both. Subsegments are always matched, so "a" will match "a" as
well as "a:b" and "a:b:c".""") well as "a:b" and "a:b:c".""")
args = '[generator_ids]' args = '[generator_ids]'
def add_arguments(self, parser):
parser.add_argument('generator_id', nargs='*', help='<app_name>:<model>:<field> for model specs')
def handle(self, *args, **options): def handle(self, *args, **options):
generators = generator_registry.get_ids() generators = generator_registry.get_ids()
generator_ids = options['generator_id'] if 'generator_id' in options else args if args:
if generator_ids: patterns = self.compile_patterns(args)
patterns = self.compile_patterns(generator_ids)
generators = (id for id in generators if any(p.match(id) for p in patterns)) generators = (id for id in generators if any(p.match(id) for p in patterns))
for generator_id in generators: for generator_id in generators:
self.stdout.write('Validating generator: %s\n' % generator_id) self.stdout.write('Validating generator: %s\n' % generator_id)
for image_file in cachefile_registry.get(generator_id): for file in cachefile_registry.get(generator_id):
if image_file.name: self.stdout.write(' %s\n' % file)
self.stdout.write(' %s\n' % image_file.name) try:
try: # TODO: Allow other validation actions through command option
image_file.generate() file.generate()
except MissingSource as err: except Exception, err:
self.stdout.write('\t No source associated with\n') # TODO: How should we handle failures? Don't want to error, but should call it out more than this.
except Exception as err: self.stdout.write(' FAILED: %s\n' % err)
self.stdout.write('\tFailed %s\n' % (err))
def compile_patterns(self, generator_ids): def compile_patterns(self, generator_ids):
return [self.compile_pattern(id) for id in generator_ids] return [self.compile_pattern(id) for id in generator_ids]

View file

@ -1,8 +1,4 @@
from __future__ import unicode_literals
from django.conf import settings
from django.db import models from django.db import models
from django.db.models.signals import class_prepared
from .files import ProcessedImageFieldFile from .files import ProcessedImageFieldFile
from .utils import ImageSpecFileDescriptor from .utils import ImageSpecFileDescriptor
from ...specs import SpecHost from ...specs import SpecHost
@ -11,18 +7,16 @@ from ...registry import register
class SpecHostField(SpecHost): class SpecHostField(SpecHost):
def _set_spec_id(self, cls, name): def set_spec_id(self, cls, name):
spec_id = getattr(self, 'spec_id', None)
# Generate a spec_id to register the spec with. The default spec id is # Generate a spec_id to register the spec with. The default spec id is
# "<app>:<model>_<field>" # "<app>:<model>_<field>"
if not spec_id: if not getattr(self, 'spec_id', None):
spec_id = ('%s:%s:%s' % (cls._meta.app_label, spec_id = (u'%s:%s:%s' % (cls._meta.app_label,
cls._meta.object_name, name)).lower() cls._meta.object_name, name)).lower()
# Register the spec with the id. This allows specs to be overridden # Register the spec with the id. This allows specs to be overridden
# later, from outside of the model definition. # later, from outside of the model definition.
super(SpecHostField, self).set_spec_id(spec_id) super(SpecHostField, self).set_spec_id(spec_id)
class ImageSpecField(SpecHostField): class ImageSpecField(SpecHostField):
@ -43,42 +37,16 @@ class ImageSpecField(SpecHostField):
cachefile_strategy=cachefile_strategy, spec=spec, cachefile_strategy=cachefile_strategy, spec=spec,
spec_id=id) spec_id=id)
# TODO: Allow callable for source. See https://github.com/matthewwithanm/django-imagekit/issues/158#issuecomment-10921664 # TODO: Allow callable for source. See https://github.com/jdriscoll/django-imagekit/issues/158#issuecomment-10921664
self.source = source self.source = source
def contribute_to_class(self, cls, name): def contribute_to_class(self, cls, name):
# If the source field name isn't defined, figure it out. setattr(cls, name, ImageSpecFileDescriptor(self, name))
self.set_spec_id(cls, name)
def register_source_group(source): # Add the model and field as a source for this spec id
setattr(cls, name, ImageSpecFileDescriptor(self, name, source)) register.source_group(self.spec_id,
self._set_spec_id(cls, name) ImageFieldSourceGroup(cls, self.source))
# 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): class ProcessedImageField(models.ImageField, SpecHostField):
@ -93,7 +61,7 @@ class ProcessedImageField(models.ImageField, SpecHostField):
def __init__(self, processors=None, format=None, options=None, def __init__(self, processors=None, format=None, options=None,
verbose_name=None, name=None, width_field=None, height_field=None, verbose_name=None, name=None, width_field=None, height_field=None,
autoconvert=None, spec=None, spec_id=None, **kwargs): autoconvert=True, spec=None, spec_id=None, **kwargs):
""" """
The ProcessedImageField constructor accepts all of the arguments that The ProcessedImageField constructor accepts all of the arguments that
the :class:`django.db.models.ImageField` constructor accepts, as well the :class:`django.db.models.ImageField` constructor accepts, as well
@ -101,10 +69,6 @@ class ProcessedImageField(models.ImageField, SpecHostField):
:class:`imagekit.models.ImageSpecField`. :class:`imagekit.models.ImageSpecField`.
""" """
# 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, SpecHost.__init__(self, processors=processors, format=format,
options=options, autoconvert=autoconvert, spec=spec, options=options, autoconvert=autoconvert, spec=spec,
spec_id=spec_id) spec_id=spec_id)
@ -112,15 +76,13 @@ class ProcessedImageField(models.ImageField, SpecHostField):
height_field, **kwargs) height_field, **kwargs)
def contribute_to_class(self, cls, name): def contribute_to_class(self, cls, name):
self._set_spec_id(cls, name) self.set_spec_id(cls, name)
return super(ProcessedImageField, self).contribute_to_class(cls, name) return super(ProcessedImageField, self).contribute_to_class(cls, name)
# If the project does not use south, then we will not try to add introspection try:
if 'south' in settings.INSTALLED_APPS: from south.modelsinspector import add_introspection_rules
try: except ImportError:
from south.modelsinspector import add_introspection_rules pass
except ImportError: else:
pass add_introspection_rules([], [r'^imagekit\.models\.fields\.ProcessedImageField$'])
else:
add_introspection_rules([], [r'^imagekit\.models\.fields\.ProcessedImageField$'])

View file

@ -1,17 +1,34 @@
from ...cachefiles import ImageCacheFile from ...cachefiles import ImageCacheFile
from django.db.models.fields.files import ImageField
class ImageSpecFileDescriptor(object): class ImageSpecFileDescriptor(object):
def __init__(self, field, attname, source_field_name): def __init__(self, field, attname):
self.attname = attname self.attname = attname
self.field = field self.field = field
self.source_field_name = source_field_name
def __get__(self, instance, owner): def __get__(self, instance, owner):
if instance is None: if instance is None:
return self.field return self.field
else: else:
source = getattr(instance, self.source_field_name) field_name = getattr(self.field, 'source', None)
if field_name:
source = getattr(instance, field_name)
else:
image_fields = [getattr(instance, f.attname) for f in
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.' %
(instance.__class__.__name__, self.attname))
elif len(image_fields) > 1:
raise Exception('%s defines multiple ImageFields, but you'
' have not specified a source for your %s'
' ImageSpecField.' % (instance.__class__.__name__,
self.attname))
else:
source = image_fields[0]
spec = self.field.get_spec(source=source) spec = self.field.get_spec(source=source)
file = ImageCacheFile(spec) file = ImageCacheFile(spec)
instance.__dict__[self.attname] = file instance.__dict__[self.attname] = file

View file

@ -1,5 +1,5 @@
__title__ = 'django-imagekit' __title__ = 'django-imagekit'
__author__ = 'Matthew Tretter, Venelin Stoykov, Eric Eldredge, Bryan Veloso, Greg Newman, Chris Drackett, Justin Driscoll' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge'
__version__ = '4.0.2' __version__ = '3.0a4'
__license__ = 'BSD' __license__ = 'BSD'
__all__ = ['__title__', '__author__', '__version__', '__license__'] __all__ = ['__title__', '__author__', '__version__', '__license__']

View file

@ -1,12 +0,0 @@
from pilkit.processors import *
__all__ = [
# Base
'ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose',
'Anchor', 'MakeOpaque',
# Crop
'TrimBorderColor', 'Crop', 'SmartCrop',
# Resize
'Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize',
'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail'
]

View file

@ -1,7 +0,0 @@
import warnings
from pilkit.processors.base import *
warnings.warn('imagekit.processors.base is deprecated use imagekit.processors instead', DeprecationWarning)
__all__ = ['ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose', 'Anchor', 'MakeOpaque']

View file

@ -1,7 +0,0 @@
import warnings
from pilkit.processors.crop import *
warnings.warn('imagekit.processors.crop is deprecated use imagekit.processors instead', DeprecationWarning)
__all__ = ['TrimBorderColor', 'Crop', 'SmartCrop']

View file

@ -1,7 +0,0 @@
import warnings
from pilkit.processors.resize import *
warnings.warn('imagekit.processors.resize is deprecated use imagekit.processors instead', DeprecationWarning)
__all__ = ['Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize', 'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail']

View file

@ -1,5 +0,0 @@
import warnings
from pilkit.processors.utils import *
warnings.warn('imagekit.processors.utils is deprecated use pilkit.processors.utils instead', DeprecationWarning)

View file

@ -1,6 +1,6 @@
from .exceptions import AlreadyRegistered, NotRegistered from .exceptions import AlreadyRegistered, NotRegistered
from .signals import content_required, existence_required, source_saved from .signals import before_access, source_created, source_changed, source_deleted
from .utils import autodiscover, call_strategy_method from .utils import call_strategy_method
class GeneratorRegistry(object): class GeneratorRegistry(object):
@ -12,17 +12,16 @@ class GeneratorRegistry(object):
""" """
def __init__(self): def __init__(self):
self._generators = {} self._generators = {}
content_required.connect(self.content_required_receiver) before_access.connect(self.before_access_receiver)
existence_required.connect(self.existence_required_receiver)
def register(self, id, generator): def register(self, id, generator):
registered_generator = self._generators.get(id) if id in self._generators:
if registered_generator and generator != self._generators[id]:
raise AlreadyRegistered('The generator with id %s is' raise AlreadyRegistered('The generator with id %s is'
' already registered' % id) ' already registered' % id)
self._generators[id] = generator self._generators[id] = generator
def unregister(self, id): def unregister(self, id, generator):
# TODO: Either don't require the generator, or--if we do--assert that it's registered with the provided id
try: try:
del self._generators[id] del self._generators[id]
except KeyError: except KeyError:
@ -30,8 +29,6 @@ class GeneratorRegistry(object):
' registered' % id) ' registered' % id)
def get(self, id, **kwargs): def get(self, id, **kwargs):
autodiscover()
try: try:
generator = self._generators[id] generator = self._generators[id]
except KeyError: except KeyError:
@ -43,22 +40,15 @@ class GeneratorRegistry(object):
return generator return generator
def get_ids(self): def get_ids(self):
autodiscover()
return self._generators.keys() return self._generators.keys()
def content_required_receiver(self, sender, file, **kwargs): def before_access_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 generator = file.generator
# FIXME: I guess this means you can't register functions? # FIXME: I guess this means you can't register functions?
if generator.__class__ in self._generators.values(): if generator.__class__ in self._generators.values():
# Only invoke the strategy method for registered generators. # Only invoke the strategy method for registered generators.
call_strategy_method(file, callback) call_strategy_method(generator, 'before_access', file=file)
class SourceGroupRegistry(object): class SourceGroupRegistry(object):
@ -72,7 +62,9 @@ class SourceGroupRegistry(object):
""" """
_signals = { _signals = {
source_saved: 'on_source_saved', source_created: 'on_source_created',
source_changed: 'on_source_changed',
source_deleted: 'on_source_deleted',
} }
def __init__(self): def __init__(self):
@ -113,7 +105,7 @@ class SourceGroupRegistry(object):
for spec in specs: for spec in specs:
file = ImageCacheFile(spec) file = ImageCacheFile(spec)
call_strategy_method(file, callback_name) call_strategy_method(spec, callback_name, file=file)
class CacheFileRegistry(object): class CacheFileRegistry(object):
@ -184,8 +176,8 @@ class Unregister(object):
Unregister generators and generated files. Unregister generators and generated files.
""" """
def generator(self, id): def generator(self, id, generator):
generator_registry.unregister(id) generator_registry.unregister(id, generator)
def cachefiles(self, generator_id, cachefiles): def cachefiles(self, generator_id, cachefiles):
cachefile_registry.unregister(generator_id, cachefiles) cachefile_registry.unregister(generator_id, cachefiles)

View file

@ -2,8 +2,9 @@ from django.dispatch import Signal
# Generated file signals # Generated file signals
content_required = Signal() before_access = Signal()
existence_required = Signal()
# Source group signals # Source group signals
source_saved = Signal() source_created = Signal()
source_changed = Signal()
source_deleted = Signal()

View file

@ -1,11 +1,11 @@
from copy import copy
from django.conf import settings from django.conf import settings
from django.db.models.fields.files import ImageFieldFile from django.db.models.fields.files import ImageFieldFile
from hashlib import md5
import pickle
from ..cachefiles.backends import get_default_cachefile_backend from ..cachefiles.backends import get_default_cachefile_backend
from ..cachefiles.strategies import load_strategy from ..cachefiles.strategies import StrategyWrapper
from .. import hashers from ..processors import ProcessorPipeline
from ..exceptions import AlreadyRegistered, MissingSource from ..utils import open_image, img_to_fobj, get_by_qname
from ..utils import open_image, get_by_qname, process_image
from ..registry import generator_registry, register from ..registry import generator_registry, register
@ -36,18 +36,11 @@ class BaseImageSpec(object):
def __init__(self): def __init__(self):
self.cachefile_backend = self.cachefile_backend or get_default_cachefile_backend() self.cachefile_backend = self.cachefile_backend or get_default_cachefile_backend()
self.cachefile_strategy = load_strategy(self.cachefile_strategy) self.cachefile_strategy = StrategyWrapper(self.cachefile_strategy)
def generate(self): def generate(self):
raise NotImplementedError raise NotImplementedError
MissingSource = MissingSource
"""
Raised when an operation requiring a source is attempted on a spec that has
no source.
"""
class ImageSpec(BaseImageSpec): class ImageSpec(BaseImageSpec):
""" """
@ -94,76 +87,48 @@ class ImageSpec(BaseImageSpec):
fn = get_by_qname(settings.IMAGEKIT_SPEC_CACHEFILE_NAMER, 'namer') fn = get_by_qname(settings.IMAGEKIT_SPEC_CACHEFILE_NAMER, 'namer')
return fn(self) 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): def __getstate__(self):
state = copy(self.__dict__) state = self.__dict__
# Unpickled ImageFieldFiles won't work (they're missing a storage # Unpickled ImageFieldFiles won't work (they're missing a storage
# object). Since they're such a common use case, we special case them. # 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): if isinstance(self.source, ImageFieldFile):
field = getattr(self.source, 'field') field = getattr(self.source, 'field')
state['_field_data'] = { state['_field_data'] = {
'instance': getattr(self.source, 'instance', None), 'instance': getattr(self.source, 'instance', None),
'attname': getattr(field, 'name', None), 'attname': getattr(field, 'name', None),
} }
state.pop('_source', None)
return state return state
def __setstate__(self, state):
field_data = state.pop('_field_data', None)
self.__dict__ = state
if field_data:
self.source = getattr(field_data['instance'], field_data['attname'])
def get_hash(self): def get_hash(self):
return hashers.pickle([ return md5(pickle.dumps([
self.source.name, self.source.name,
self.processors, self.processors,
self.format, self.format,
self.options, self.options,
self.autoconvert, self.autoconvert,
]) ])).hexdigest()
def generate(self): 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: Move into a generator base class
# TODO: Factor out a generate_image function so you can create a generator and only override the PIL.Image creating part. (The tricky part is how to deal with original_format since generator base class won't have one.) # TODO: Factor out a generate_image function so you can create a generator and only override the PIL.Image creating part. (The tricky part is how to deal with original_format since generator base class won't have one.)
img = open_image(self.source)
original_format = img.format
closed = self.source.closed # Run the processors
if closed: processors = self.processors
# Django file object should know how to reopen itself if it was closed img = ProcessorPipeline(processors or []).process(img)
# https://code.djangoproject.com/ticket/13750
self.source.open()
try: options = dict(self.options or {})
img = open_image(self.source) format = self.format or img.format or original_format or 'JPEG'
new_image = process_image(img, content = img_to_fobj(img, format, **options)
processors=self.processors, return content
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): def create_spec_class(class_attrs):
@ -225,17 +190,7 @@ class SpecHost(object):
""" """
self.spec_id = id self.spec_id = id
register.generator(id, self._original_spec)
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): def get_spec(self, source):
""" """

View file

@ -2,19 +2,20 @@
Source groups are the means by which image spec sources are identified. They Source groups are the means by which image spec sources are identified. They
have two responsibilities: have two responsibilities:
1. To dispatch ``source_saved`` signals. (These will be relayed to the 1. To dispatch ``source_created``, ``source_changed``, and ``source_deleted``
corresponding specs' cache file strategies.) 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 2. To provide the source files that they represent, via a generator method named
``files()``. (This is used by the generateimages management command for ``files()``. (This is used by the generateimages management command for
"pre-caching" image files.) "pre-caching" image files.)
""" """
from django.db.models.signals import post_init, post_save from django.db.models.signals import post_init, post_save, post_delete
from django.utils.functional import wraps from django.utils.functional import wraps
import inspect import inspect
from ..cachefiles import LazyImageCacheFile from ..cachefiles import LazyImageCacheFile
from ..signals import source_saved from ..signals import source_created, source_changed, source_deleted
from ..utils import get_nonabstract_descendants from ..utils import get_nonabstract_descendants
@ -47,7 +48,7 @@ class ModelSignalRouter(object):
``ImageFieldSourceGroup``s. This class encapsulates that functionality. ``ImageFieldSourceGroup``s. This class encapsulates that functionality.
Related: Related:
https://github.com/matthewwithanm/django-imagekit/issues/126 https://github.com/jdriscoll/django-imagekit/issues/126
https://code.djangoproject.com/ticket/9318 https://code.djangoproject.com/ticket/9318
""" """
@ -57,6 +58,7 @@ class ModelSignalRouter(object):
uid = 'ik_spec_field_receivers' uid = 'ik_spec_field_receivers'
post_init.connect(self.post_init_receiver, dispatch_uid=uid) post_init.connect(self.post_init_receiver, dispatch_uid=uid)
post_save.connect(self.post_save_receiver, dispatch_uid=uid) post_save.connect(self.post_save_receiver, dispatch_uid=uid)
post_delete.connect(self.post_delete_receiver, dispatch_uid=uid)
def add(self, source_group): def add(self, source_group):
self._source_groups.append(source_group) self._source_groups.append(source_group)
@ -72,45 +74,41 @@ class ModelSignalRouter(object):
""" """
self.init_instance(instance) self.init_instance(instance)
instance._ik['source_hashes'] = dict( instance._ik['source_hashes'] = dict((attname, hash(file_field))
(attname, hash(getattr(instance, attname))) for attname, file_field in self.get_field_dict(instance).items())
for attname in self.get_source_fields(instance))
return instance._ik['source_hashes'] return instance._ik['source_hashes']
def get_source_fields(self, instance): def get_field_dict(self, instance):
""" """
Returns a list of the source fields for the given instance. Returns the source fields for the given instance, in a dictionary whose
keys are the field names and values are the fields themselves.
""" """
return set(src.image_field return dict((src.image_field, getattr(instance, src.image_field)) for
for src in self._source_groups src in self._source_groups if isinstance(instance, src.model_class))
if isinstance(instance, src.model_class))
@ik_model_receiver @ik_model_receiver
def post_save_receiver(self, sender, instance=None, created=False, update_fields=None, raw=False, **kwargs): def post_save_receiver(self, sender, instance=None, created=False, raw=False, **kwargs):
if not raw: if not raw:
self.init_instance(instance) self.init_instance(instance)
old_hashes = instance._ik.get('source_hashes', {}).copy() old_hashes = instance._ik.get('source_hashes', {}).copy()
new_hashes = self.update_source_hashes(instance) new_hashes = self.update_source_hashes(instance)
for attname in self.get_source_fields(instance): for attname, file in self.get_field_dict(instance).items():
if update_fields and attname not in update_fields: if created:
continue self.dispatch_signal(source_created, file, sender, instance,
attname)
file = getattr(instance, attname) elif old_hashes[attname] != new_hashes[attname]:
if file and old_hashes.get(attname) != new_hashes[attname]: self.dispatch_signal(source_changed, file, sender, instance,
self.dispatch_signal(source_saved, file, sender, instance,
attname) attname)
@ik_model_receiver
def post_delete_receiver(self, sender, instance=None, **kwargs):
for attname, file in self.get_field_dict(instance).items():
self.dispatch_signal(source_deleted, file, sender, instance, attname)
@ik_model_receiver @ik_model_receiver
def post_init_receiver(self, sender, instance=None, **kwargs): def post_init_receiver(self, sender, instance=None, **kwargs):
self.init_instance(instance) self.update_source_hashes(instance)
source_fields = self.get_source_fields(instance)
local_fields = dict((field.name, field)
for field in instance._meta.local_fields
if field.name in source_fields)
instance._ik['source_hashes'] = dict(
(attname, hash(file_field))
for attname, file_field in local_fields.items())
def dispatch_signal(self, signal, file, model_class, instance, attname): def dispatch_signal(self, signal, file, model_class, instance, attname):
""" """

View file

@ -1,13 +1,9 @@
from __future__ import unicode_literals
from django import template from django import template
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from .compat import parse_bits
from ..compat import parse_bits
from ..cachefiles import ImageCacheFile from ..cachefiles import ImageCacheFile
from ..registry import generator_registry from ..registry import generator_registry
from ..lib import force_text
register = template.Library() register = template.Library()
@ -32,7 +28,7 @@ def parse_dimensions(dimensions):
will be None for that value. will be None for that value.
""" """
width, height = [d.strip() and int(d) or None for d in dimensions.split('x')] width, height = [d.strip() or None for d in dimensions.split('x')]
return dict(width=width, height=height) return dict(width=width, height=height)
@ -44,9 +40,12 @@ class GenerateImageAssignmentNode(template.Node):
self._variable_name = variable_name self._variable_name = variable_name
def get_variable_name(self, context): def get_variable_name(self, context):
return force_text(self._variable_name) return unicode(self._variable_name)
def render(self, context): def render(self, context):
from ..utils import autodiscover
autodiscover()
variable_name = self.get_variable_name(context) variable_name = self.get_variable_name(context)
context[variable_name] = get_cachefile(context, self._generator_id, context[variable_name] = get_cachefile(context, self._generator_id,
self._generator_kwargs) self._generator_kwargs)
@ -61,6 +60,9 @@ class GenerateImageTagNode(template.Node):
self._html_attrs = html_attrs self._html_attrs = html_attrs
def render(self, context): def render(self, context):
from ..utils import autodiscover
autodiscover()
file = get_cachefile(context, self._generator_id, file = get_cachefile(context, self._generator_id,
self._generator_kwargs) self._generator_kwargs)
attrs = dict((k, v.resolve(context)) for k, v in attrs = dict((k, v.resolve(context)) for k, v in
@ -74,7 +76,7 @@ class GenerateImageTagNode(template.Node):
attrs['src'] = file.url attrs['src'] = file.url
attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in
attrs.items()) attrs.items())
return mark_safe('<img %s />' % attr_str) return mark_safe(u'<img %s />' % attr_str)
class ThumbnailAssignmentNode(template.Node): class ThumbnailAssignmentNode(template.Node):
@ -87,9 +89,12 @@ class ThumbnailAssignmentNode(template.Node):
self._generator_kwargs = generator_kwargs self._generator_kwargs = generator_kwargs
def get_variable_name(self, context): def get_variable_name(self, context):
return force_text(self._variable_name) return unicode(self._variable_name)
def render(self, context): def render(self, context):
from ..utils import autodiscover
autodiscover()
variable_name = self.get_variable_name(context) variable_name = self.get_variable_name(context)
generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR
@ -114,6 +119,9 @@ class ThumbnailImageTagNode(template.Node):
self._html_attrs = html_attrs self._html_attrs = html_attrs
def render(self, context): def render(self, context):
from ..utils import autodiscover
autodiscover()
generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR
dimensions = parse_dimensions(self._dimensions.resolve(context)) dimensions = parse_dimensions(self._dimensions.resolve(context))
kwargs = dict((k, v.resolve(context)) for k, v in kwargs = dict((k, v.resolve(context)) for k, v in
@ -135,7 +143,7 @@ class ThumbnailImageTagNode(template.Node):
attrs['src'] = file.url attrs['src'] = file.url
attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in
attrs.items()) attrs.items())
return mark_safe('<img %s />' % attr_str) return mark_safe(u'<img %s />' % attr_str)
def parse_ik_tag_bits(parser, bits): def parse_ik_tag_bits(parser, bits):

View file

@ -1,24 +1,12 @@
from __future__ import unicode_literals
import logging import logging
import re
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from hashlib import md5
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.core.files import File from django.core.files import File
try: from django.utils.importlib import import_module
from importlib import import_module
except ImportError:
from django.utils.importlib import import_module
from pilkit.utils import * from pilkit.utils import *
from .lib import NullHandler, force_bytes
bad_memcached_key_chars = re.compile('[\u0000-\u001f\\s]+')
_autodiscovered = False
def get_nonabstract_descendants(model): def get_nonabstract_descendants(model):
""" Returns all non-abstract descendants of the model. """ """ Returns all non-abstract descendants of the model. """
if not model._meta.abstract: if not model._meta.abstract:
@ -36,7 +24,7 @@ def get_by_qname(path, desc):
module, objname = path[:dot], path[dot + 1:] module, objname = path[:dot], path[dot + 1:]
try: try:
mod = import_module(module) mod = import_module(module)
except ImportError as e: except ImportError, e:
raise ImproperlyConfigured('Error importing %s module %s: "%s"' % raise ImproperlyConfigured('Error importing %s module %s: "%s"' %
(desc, module, e)) (desc, module, e))
try: try:
@ -67,60 +55,28 @@ def autodiscover():
Copied from django.contrib.admin Copied from django.contrib.admin
""" """
global _autodiscovered
if _autodiscovered:
return
try:
from django.utils.module_loading import autodiscover_modules
except ImportError:
# Django<1.7
_autodiscover_modules_fallback()
else:
autodiscover_modules('imagegenerators')
_autodiscovered = True
def _autodiscover_modules_fallback():
"""
Auto-discover INSTALLED_APPS imagegenerators.py modules and fail silently
when not present. This forces an import on them to register any admin bits
they may want.
Copied from django.contrib.admin
Used for Django versions < 1.7
"""
from django.conf import settings from django.conf import settings
try: from django.utils.importlib import import_module
from importlib import import_module
except ImportError:
from django.utils.importlib import import_module
from django.utils.module_loading import module_has_submodule from django.utils.module_loading import module_has_submodule
for app in settings.INSTALLED_APPS: for app in settings.INSTALLED_APPS:
# As of Django 1.7, settings.INSTALLED_APPS may contain classes instead of modules, hence the try/except mod = import_module(app)
# See here: https://docs.djangoproject.com/en/dev/releases/1.7/#introspecting-applications # Attempt to import the app's admin module.
try: try:
mod = import_module(app) import_module('%s.imagegenerators' % app)
# Attempt to import the app's admin module. except:
try: # Decide whether to bubble up this error. If the app just
import_module('%s.imagegenerators' % app) # doesn't have an imagegenerators module, we can ignore the error
except: # attempting to import it, otherwise we want it to bubble up.
# Decide whether to bubble up this error. If the app just if module_has_submodule(mod, 'imagegenerators'):
# doesn't have an imagegenerators module, we can ignore the error raise
# attempting to import it, otherwise we want it to bubble up.
if module_has_submodule(mod, 'imagegenerators'):
raise
except ImportError:
pass
def get_logger(logger_name='imagekit', add_null_handler=True): def get_logger(logger_name='imagekit', add_null_handler=True):
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
if add_null_handler: if add_null_handler:
logger.addHandler(NullHandler()) logger.addHandler(logging.NullHandler())
return logger return logger
@ -150,42 +106,20 @@ def generate(generator):
""" """
content = generator.generate() content = generator.generate()
f = File(content)
# The size of the File must be known or Django will try to open a file # If the file doesn't have a name, Django will raise an Exception while
# without a name and raise an Exception. # trying to save it, so we create a named temporary file.
f.size = len(content.read()) if not getattr(content, 'name', None):
# After getting the size reset the file pointer for future reads. f = NamedTemporaryFile()
content.seek(0) f.write(content.read())
return f f.seek(0)
content = f
return File(content)
def call_strategy_method(file, method_name): def call_strategy_method(generator, method_name, *args, **kwargs):
strategy = getattr(file, 'cachefile_strategy', None) strategy = getattr(generator, 'cachefile_strategy', None)
fn = getattr(strategy, method_name, None) fn = getattr(strategy, method_name, None)
if fn is not None: if fn is not None:
fn(file) fn(*args, **kwargs)
def get_cache():
try:
from django.core.cache import caches
except ImportError:
# Django < 1.7
from django.core.cache import get_cache
return get_cache(settings.IMAGEKIT_CACHE_BACKEND)
return caches[settings.IMAGEKIT_CACHE_BACKEND]
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)
# The also can't be > 250 chars long. Since we don't know what the
# user's cache ``KEY_FUNCTION`` setting is like, we'll limit it to 200.
if len(new_key) >= 200:
new_key = '%s:%s' % (new_key[:200-33], md5(force_bytes(key)).hexdigest())
key = new_key
return key

View file

@ -1,2 +0,0 @@
[bdist_wheel]
universal = 1

View file

@ -1,4 +1,4 @@
#!/usr/bin/env python #/usr/bin/env python
import codecs import codecs
import os import os
from setuptools import setup, find_packages from setuptools import setup, find_packages
@ -7,27 +7,23 @@ import sys
# Workaround for multiprocessing/nose issue. See http://bugs.python.org/msg170215 # Workaround for multiprocessing/nose issue. See http://bugs.python.org/msg170215
try: try:
import multiprocessing # NOQA import multiprocessing
except ImportError: except ImportError:
pass pass
if 'publish' in sys.argv: if 'publish' in sys.argv:
os.system('python setup.py sdist bdist_wheel upload') os.system('python setup.py sdist upload')
sys.exit() sys.exit()
read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read() read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read()
def exec_file(filepath, globalz=None, localz=None):
exec(read(filepath), globalz, localz)
# Load package meta from the pkgmeta module without loading imagekit. # Load package meta from the pkgmeta module without loading imagekit.
pkgmeta = {} pkgmeta = {}
exec_file(os.path.join(os.path.dirname(__file__), execfile(os.path.join(os.path.dirname(__file__),
'imagekit', 'pkgmeta.py'), pkgmeta) 'imagekit', 'pkgmeta.py'), pkgmeta)
setup( setup(
@ -35,33 +31,27 @@ setup(
version=pkgmeta['__version__'], version=pkgmeta['__version__'],
description='Automated image processing for Django models.', description='Automated image processing for Django models.',
long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')), long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')),
author='Matthew Tretter', author='Justin Driscoll',
author_email='m@tthewwithanm.com', author_email='justin@driscolldev.com',
maintainer='Bryan Veloso', maintainer='Bryan Veloso',
maintainer_email='bryan@revyver.com', maintainer_email='bryan@revyver.com',
license='BSD', license='BSD',
url='http://github.com/matthewwithanm/django-imagekit/', url='http://github.com/jdriscoll/django-imagekit/',
packages=find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']), packages=find_packages(),
zip_safe=False, zip_safe=False,
include_package_data=True, include_package_data=True,
tests_require=[ tests_require=[
'beautifulsoup4>=4.4.0', 'beautifulsoup4==4.1.3',
'nose>=1.3.6', 'nose==1.2.1',
'nose-progressive>=1.5.1', 'nose-progressive==1.3',
'django-nose>=1.4', 'django-nose==1.1',
'Pillow', 'Pillow==1.7.8',
'mock>=1.0.1',
], ],
test_suite='testrunner.run_tests', test_suite='testrunner.run_tests',
install_requires=[ install_requires=[
'django-appconf>=0.5', 'django-appconf>=0.5',
'pilkit>=0.2.0', 'pilkit',
'six',
], ],
extras_require={
'async': ['django-celery>=3.0'],
'async_rq': ['django-rq>=0.6.0'],
},
classifiers=[ classifiers=[
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment', 'Environment :: Web Environment',
@ -69,12 +59,9 @@ setup(
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License', 'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.5',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Topic :: Utilities' 'Topic :: Utilities'
], ],
) )

View file

@ -16,7 +16,4 @@ def run_tests():
cls = get_runner(settings) cls = get_runner(settings)
runner = cls() runner = cls()
failures = runner.run_tests(['tests']) failures = runner.run_tests(['tests'])
# Clean autogenerated junk before exit
from tests.utils import clear_imagekit_test_files
clear_imagekit_test_files()
sys.exit(failures) sys.exit(failures)

BIN
tests/assets/Lenna.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
tests/media/lenna.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

View file

@ -1,17 +1,10 @@
from django.db import models from django.db import models
from imagekit import ImageSpec
from imagekit.models import ProcessedImageField from imagekit.models import ProcessedImageField
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import Adjust, ResizeToFill, SmartCrop from imagekit.processors import Adjust, ResizeToFill, SmartCrop
class Thumbnail(ImageSpec):
processors = [ResizeToFill(100, 60)]
format = 'JPEG'
options = {'quality': 60}
class ImageModel(models.Model): class ImageModel(models.Model):
image = models.ImageField(upload_to='b') image = models.ImageField(upload_to='b')
@ -19,10 +12,9 @@ class ImageModel(models.Model):
class Photo(models.Model): class Photo(models.Model):
original_image = models.ImageField(upload_to='photos') original_image = models.ImageField(upload_to='photos')
# Implicit source field
thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
ResizeToFill(50, 50)], format='JPEG', ResizeToFill(50, 50)], source='original_image', format='JPEG',
options={'quality': 90}) options={'quality': 90})
smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2, smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2,
sharpness=1.1), SmartCrop(50, 50)], source='original_image', sharpness=1.1), SmartCrop(50, 50)], source='original_image',
@ -34,24 +26,20 @@ class ProcessedImageFieldModel(models.Model):
options={'quality': 90}, upload_to='p') options={'quality': 90}, upload_to='p')
class ProcessedImageFieldWithSpecModel(models.Model):
processed = ProcessedImageField(spec=Thumbnail, upload_to='p')
class CountingCacheFileStrategy(object): class CountingCacheFileStrategy(object):
def __init__(self): def __init__(self):
self.on_existence_required_count = 0 self.before_access_count = 0
self.on_content_required_count = 0 self.on_source_changed_count = 0
self.on_source_saved_count = 0 self.on_source_created_count = 0
def on_existence_required(self, file): def before_access(self, file):
self.on_existence_required_count += 1 self.before_access_count += 1
def on_content_required(self, file): def on_source_changed(self, file):
self.on_content_required_count += 1 self.on_source_changed_count += 1
def on_source_saved(self, file): def on_source_created(self, file):
self.on_source_saved_count += 1 self.on_source_created_count += 1
class AbstractImageModel(models.Model): class AbstractImageModel(models.Model):

View file

@ -21,8 +21,6 @@ DATABASES = {
}, },
} }
SECRET_KEY = '_uobce43e5osp8xgzle*yag2_16%y$sf*5(12vfg25hpnxik_*'
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -34,6 +32,7 @@ INSTALLED_APPS = [
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
NOSE_ARGS = [ NOSE_ARGS = [
'-s', '-s',
'--with-progressive',
# When the tests are run --with-coverage, these args configure coverage # When the tests are run --with-coverage, these args configure coverage
# reporting (requires coverage to be installed). # reporting (requires coverage to be installed).
@ -44,26 +43,6 @@ NOSE_ARGS = [
'--cover-html-dir=%s' % os.path.join(BASE_PATH, 'cover') '--cover-html-dir=%s' % os.path.join(BASE_PATH, 'cover')
] ]
if os.getenv('TERM'): DEBUG = True
NOSE_ARGS.append('--with-progressive') TEMPLATE_DEBUG = DEBUG
CACHE_BACKEND = 'locmem://' CACHE_BACKEND = 'locmem://'
# Django >= 1.8
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',
],
},
},
]

View file

@ -1,7 +1,27 @@
from django.core.files import File
from imagekit.signals import source_created
from imagekit.specs.sourcegroups import ImageFieldSourceGroup
from imagekit.utils import get_nonabstract_descendants from imagekit.utils import get_nonabstract_descendants
from nose.tools import eq_ from nose.tools import eq_
from . models import (AbstractImageModel, ConcreteImageModel, from . models import (AbstractImageModel, ConcreteImageModel,
ConcreteImageModelSubclass) ConcreteImageModelSubclass)
from .utils import get_image_file
def test_source_created_signal():
source_group = ImageFieldSourceGroup(AbstractImageModel, 'original_image')
count = [0]
def receiver(sender, *args, **kwargs):
if sender is source_group:
count[0] += 1
source_created.connect(receiver, dispatch_uid='test_source_created')
instance = ConcreteImageModel()
img = File(get_image_file())
instance.original_image.save('test_source_created_signal.jpg', img)
eq_(count[0], 1)
def test_nonabstract_descendants_generator(): def test_nonabstract_descendants_generator():

View file

@ -1,115 +0,0 @@
from unittest import mock
from django.conf import settings
from hashlib import md5
from imagekit.cachefiles import ImageCacheFile, LazyImageCacheFile
from imagekit.cachefiles.backends import Simple
from imagekit.lib import force_bytes
from nose.tools import raises, eq_
from .imagegenerators import TestSpec
from .utils import (assert_file_is_truthy, assert_file_is_falsy,
DummyAsyncCacheFileBackend, get_unique_image_file,
get_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.
"""
spec = TestSpec(source=get_unique_image_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.
"""
spec = TestSpec(source=get_unique_image_file())
file = ImageCacheFile(spec, cachefile_backend=DummyAsyncCacheFileBackend())
assert_file_is_falsy(file)
@raises(TestSpec.MissingSource)
def test_no_source_error():
spec = TestSpec(source=None)
file = ImageCacheFile(spec)
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
spec = TestSpec(source=get_unique_image_file())
file = ImageCacheFile(
spec,
cachefile_backend=DummyAsyncCacheFileBackend()
)
file.__repr__()
eq_(signal.send.called, False)
def test_memcached_cache_key():
"""
Ensure the default cachefile backend is sanitizing its cache key for
memcached by default.
"""
class MockFile(object):
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)
eq_(backend.get_key(file), '%s%s-state' %
(settings.IMAGEKIT_CACHE_PREFIX, file.name))
length = 200 - extra_char_count
filename = '1' * length
file = MockFile(filename)
eq_(backend.get_key(file), '%s%s:%s' % (
settings.IMAGEKIT_CACHE_PREFIX,
'1' * (200 - len(':') - 32 - len(settings.IMAGEKIT_CACHE_PREFIX)),
md5(force_bytes('%s%s-state' % (settings.IMAGEKIT_CACHE_PREFIX, filename))).hexdigest()))
def test_lazyfile_stringification():
file = LazyImageCacheFile('testspec', source=None)
eq_(str(file), '')
eq_(repr(file), '<ImageCacheFile: None>')
source_file = get_image_file()
file = LazyImageCacheFile('testspec', source=source_file)
file.name = 'a.jpg'
eq_(str(file), 'a.jpg')
eq_(repr(file), '<ImageCacheFile: a.jpg>')

View file

@ -1,25 +0,0 @@
from nose.tools import assert_false, assert_true
from .models import Thumbnail
from .utils import create_photo
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_true(source_file.closed)
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_false(source_file.closed)
source_file.close()

View file

@ -5,9 +5,7 @@ from imagekit import forms as ikforms
from imagekit.processors import SmartCrop from imagekit.processors import SmartCrop
from nose.tools import eq_ from nose.tools import eq_
from . import imagegenerators # noqa from . import imagegenerators # noqa
from .models import (ProcessedImageFieldModel, from .models import ProcessedImageFieldModel, ImageModel
ProcessedImageFieldWithSpecModel,
ImageModel)
from .utils import get_image_file from .utils import get_image_file
@ -21,16 +19,6 @@ def test_model_processedimagefield():
eq_(instance.processed.height, 50) eq_(instance.processed.height, 50)
def test_model_processedimagefield_with_spec():
instance = ProcessedImageFieldWithSpecModel()
file = File(get_image_file())
instance.processed.save('whatever.jpeg', file)
instance.save()
eq_(instance.processed.width, 100)
eq_(instance.processed.height, 60)
def test_form_processedimagefield(): def test_form_processedimagefield():
class TestForm(forms.ModelForm): class TestForm(forms.ModelForm):
image = ikforms.ProcessedImageField(spec_id='tests:testform_image', image = ikforms.ProcessedImageField(spec_id='tests:testform_image',
@ -38,7 +26,6 @@ def test_form_processedimagefield():
class Meta: class Meta:
model = ImageModel model = ImageModel
fields = 'image',
upload_file = get_image_file() upload_file = get_image_file()
file_dict = {'image': SimpleUploadedFile('abc.jpg', upload_file.read())} file_dict = {'image': SimpleUploadedFile('abc.jpg', upload_file.read())}

View file

@ -1,12 +1,11 @@
from django.template import TemplateSyntaxError from django.template import TemplateSyntaxError
from nose.tools import eq_, assert_false, raises, assert_not_equal from nose.tools import eq_, assert_false, raises, assert_not_equal
from . import imagegenerators # noqa from . import imagegenerators # noqa
from .utils import render_tag, get_html_attrs, clear_imagekit_cache from .utils import render_tag, get_html_attrs
def test_img_tag(): def test_img_tag():
ttag = r"""{% generateimage 'testspec' source=img %}""" ttag = r"""{% generateimage 'testspec' source=img %}"""
clear_imagekit_cache()
attrs = get_html_attrs(ttag) attrs = get_html_attrs(ttag)
expected_attrs = set(['src', 'width', 'height']) expected_attrs = set(['src', 'width', 'height'])
eq_(set(attrs.keys()), expected_attrs) eq_(set(attrs.keys()), expected_attrs)
@ -16,7 +15,6 @@ def test_img_tag():
def test_img_tag_attrs(): def test_img_tag_attrs():
ttag = r"""{% generateimage 'testspec' source=img -- alt="Hello" %}""" ttag = r"""{% generateimage 'testspec' source=img -- alt="Hello" %}"""
clear_imagekit_cache()
attrs = get_html_attrs(ttag) attrs = get_html_attrs(ttag)
eq_(attrs.get('alt'), 'Hello') eq_(attrs.get('alt'), 'Hello')
@ -30,7 +28,7 @@ def test_dangling_html_attrs_delimiter():
@raises(TemplateSyntaxError) @raises(TemplateSyntaxError)
def test_html_attrs_assignment(): def test_html_attrs_assignment():
""" """
You can either use generateimage as an assignment tag or specify html attrs, You can either use generateimage as an assigment tag or specify html attrs,
but not both. but not both.
""" """
@ -44,13 +42,11 @@ def test_single_dimension_attr():
""" """
ttag = r"""{% generateimage 'testspec' source=img -- width="50" %}""" ttag = r"""{% generateimage 'testspec' source=img -- width="50" %}"""
clear_imagekit_cache()
attrs = get_html_attrs(ttag) attrs = get_html_attrs(ttag)
assert_false('height' in attrs) assert_false('height' in attrs)
def test_assignment_tag(): def test_assignment_tag():
ttag = r"""{% generateimage 'testspec' source=img as th %}{{ th.url }}{{ th.height }}{{ th.width }}""" ttag = r"""{% generateimage 'testspec' source=img as th %}{{ th.url }}"""
clear_imagekit_cache()
html = render_tag(ttag) html = render_tag(ttag)
assert_not_equal(html.strip(), '') assert_not_equal(html.strip(), '')

View file

@ -1,16 +0,0 @@
from nose.tools import assert_false
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 <https://github.com/matthewwithanm/django-imagekit/issues/295>
"""
pmock = PropertyMock()
pmock.__get__ = Mock()
with patch.object(Photo, 'original_image', pmock):
photo = Photo() # noqa
assert_false(pmock.__get__.called)

View file

@ -1,49 +0,0 @@
from nose.tools import assert_true, assert_false
from imagekit.cachefiles import ImageCacheFile
from unittest.mock import Mock
from .utils import create_image
from django.core.files.storage import FileSystemStorage
from imagekit.cachefiles.backends import Simple as SimpleCFBackend
from imagekit.cachefiles.strategies import Optimistic as OptimisticStrategy
class ImageGenerator(object):
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
peform IO operations.
"""
file = get_image_cache_file()
bool(file)
assert_false(file.storage.exists.called)
assert_false(file.storage.open.called)
def test_no_io_on_url():
"""
When getting the URL of an ImageCacheFile, the storage shouldn't be
checked.
"""
file = get_image_cache_file()
file.url
assert_false(file.storage.exists.called)
assert_false(file.storage.open.called)

View file

@ -4,40 +4,10 @@ deserialized. This is important when using IK with Celery.
""" """
from imagekit.cachefiles import ImageCacheFile from .utils import create_photo, pickleback
from .imagegenerators import TestSpec
from .utils import create_photo, pickleback, get_unique_image_file, clear_imagekit_cache
def test_imagespecfield(): def test_imagespecfield():
clear_imagekit_cache()
instance = create_photo('pickletest2.jpg') instance = create_photo('pickletest2.jpg')
thumbnail = pickleback(instance.thumbnail) thumbnail = pickleback(instance.thumbnail)
thumbnail.generate() thumbnail.generate()
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()
spec = TestSpec(source=get_unique_image_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

View file

@ -1,55 +0,0 @@
from django.core.files import File
from imagekit.signals import source_saved
from imagekit.specs.sourcegroups import ImageFieldSourceGroup
from nose.tools import eq_
from . models import AbstractImageModel, ImageModel, ConcreteImageModel
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
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)
ImageModel.objects.create(image=File(get_image_file()))
eq_(receiver.count, 1)
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()
eq_(receiver.count, 0)
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)
ConcreteImageModel.objects.create(original_image=File(get_image_file()))
eq_(receiver.count, 1)

12
tests/test_specs.py Normal file
View file

@ -0,0 +1,12 @@
from imagekit.cachefiles import ImageCacheFile
from nose.tools import assert_false
from .imagegenerators import TestSpec
def test_no_source():
"""
Ensure sourceless specs are falsy.
"""
spec = TestSpec(source=None)
file = ImageCacheFile(spec)
assert_false(bool(file))

View file

@ -1,12 +1,11 @@
from django.template import TemplateSyntaxError from django.template import TemplateSyntaxError
from nose.tools import eq_, raises, assert_not_equal from nose.tools import eq_, raises, assert_not_equal
from . import imagegenerators # noqa from . import imagegenerators # noqa
from .utils import render_tag, get_html_attrs, clear_imagekit_cache from .utils import render_tag, get_html_attrs
def test_img_tag(): def test_img_tag():
ttag = r"""{% thumbnail '100x100' img %}""" ttag = r"""{% thumbnail '100x100' img %}"""
clear_imagekit_cache()
attrs = get_html_attrs(ttag) attrs = get_html_attrs(ttag)
expected_attrs = set(['src', 'width', 'height']) expected_attrs = set(['src', 'width', 'height'])
eq_(set(attrs.keys()), expected_attrs) eq_(set(attrs.keys()), expected_attrs)
@ -16,7 +15,6 @@ def test_img_tag():
def test_img_tag_attrs(): def test_img_tag_attrs():
ttag = r"""{% thumbnail '100x100' img -- alt="Hello" %}""" ttag = r"""{% thumbnail '100x100' img -- alt="Hello" %}"""
clear_imagekit_cache()
attrs = get_html_attrs(ttag) attrs = get_html_attrs(ttag)
eq_(attrs.get('alt'), 'Hello') eq_(attrs.get('alt'), 'Hello')
@ -42,7 +40,7 @@ def test_too_many_args():
@raises(TemplateSyntaxError) @raises(TemplateSyntaxError)
def test_html_attrs_assignment(): def test_html_attrs_assignment():
""" """
You can either use thumbnail as an assignment tag or specify html attrs, You can either use thumbnail as an assigment tag or specify html attrs,
but not both. but not both.
""" """
@ -52,20 +50,17 @@ def test_html_attrs_assignment():
def test_assignment_tag(): def test_assignment_tag():
ttag = r"""{% thumbnail '100x100' img as th %}{{ th.url }}""" ttag = r"""{% thumbnail '100x100' img as th %}{{ th.url }}"""
clear_imagekit_cache()
html = render_tag(ttag) html = render_tag(ttag)
assert_not_equal(html, '') assert_not_equal(html, '')
def test_single_dimension(): def test_single_dimension():
ttag = r"""{% thumbnail '100x' img as th %}{{ th.width }}""" ttag = r"""{% thumbnail '100x' img as th %}{{ th.width }}"""
clear_imagekit_cache()
html = render_tag(ttag) html = render_tag(ttag)
eq_(html, '100') eq_(html, '100')
def test_alternate_generator(): def test_alternate_generator():
ttag = r"""{% thumbnail '1pxsq' '100x' img as th %}{{ th.width }}""" ttag = r"""{% thumbnail '1pxsq' '100x' img as th %}{{ th.width }}"""
clear_imagekit_cache()
html = render_tag(ttag) html = render_tag(ttag)
eq_(html, '1') eq_(html, '1')

View file

@ -1,15 +1,10 @@
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import os import os
import shutil from django.conf import settings
from django.core.files import File from django.core.files import File
from django.template import Context, Template from django.template import Context, Template
from imagekit.cachefiles.backends import Simple, CacheFileState
from imagekit.conf import settings
from imagekit.lib import Image, StringIO from imagekit.lib import Image, StringIO
from imagekit.utils import get_cache
from nose.tools import assert_true, assert_false
import pickle import pickle
from tempfile import NamedTemporaryFile
from .models import Photo from .models import Photo
@ -19,19 +14,12 @@ def get_image_file():
http://en.wikipedia.org/wiki/Lenna http://en.wikipedia.org/wiki/Lenna
http://sipi.usc.edu/database/database.php?volume=misc&image=12 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') path = os.path.join(settings.MEDIA_ROOT, 'lenna.png')
return open(path, 'r+b') return open(path, 'r+b')
def get_unique_image_file():
file = NamedTemporaryFile()
file.write(get_image_file().read())
return file
def create_image(): def create_image():
return Image.open(get_image_file()) return Image.open(get_image_file())
@ -64,43 +52,4 @@ def render_tag(ttag):
def get_html_attrs(ttag): def get_html_attrs(ttag):
return BeautifulSoup(render_tag(ttag), features="html.parser").img.attrs return BeautifulSoup(render_tag(ttag)).img.attrs
def assert_file_is_falsy(file):
assert_false(bool(file), 'File is not falsy')
def assert_file_is_truthy(file):
assert_true(bool(file), 'File is not truthy')
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)

41
tox.ini
View file

@ -1,18 +1,37 @@
[tox] [tox]
envlist = envlist =
py38-django{master,30,22,21,20,111}, py27-django14, py27-django13, py27-django12,
py37-django{master,30,22,21,20,111}, py26-django14, py26-django13, py26-django12
py36-django{master,30,22,21,20,111},
py35-django{21,20,111},
[testenv] [testenv]
commands = python setup.py test commands = python setup.py test
[testenv:py27-django14]
basepython = python2.7
deps = deps =
djangomaster: git+https://github.com/django/django.git@master#egg=Django Django>=1.4,<1.5
django30: Django>=3.0,<3.1
django22: Django>=2.2,<3.0 [testenv:py27-django13]
django21: Django>=2.1,<2.2 basepython = python2.7
django20: Django>=2.0,<2.1 deps =
django111: Django>=1.11,<2.0 Django>=1.3,<1.4
django{21,20,111}: django-nose==1.4.5
[testenv:py27-django12]
basepython = python2.7
deps =
Django>=1.2,<1.3
[testenv:py26-django14]
basepython = python2.6
deps =
Django>=1.4,<1.5
[testenv:py26-django13]
basepython = python2.6
deps =
Django>=1.3,<1.4
[testenv:py26-django12]
basepython = python2.6
deps =
Django>=1.2,<1.3