diff --git a/.frigg.yml b/.frigg.yml
index cedb431..390b922 100644
--- a/.frigg.yml
+++ b/.frigg.yml
@@ -1,18 +1,15 @@
tasks:
- - tox -e py34-pillow
- - tox -e py27-pillow
- - tox -e py34-pgmagick
- - tox -e py27-pgmagick
- - tox -e py34-django17
- - tox -e py27-django17
- - tox -e py34-django18
- - tox -e py27-django18
- - tox -e py34-redis
- - tox -e py27-redis
- - flake8
- - isort -rc -c thumbnails tests
- - coverage combine && coverage report && coverage xml
- - tox -e py34-docs
+ - tox -e flake8
+ - tox -e isort
+ - tox -e pillow
+ - tox -e django18
+ - tox -e django19
+ - tox -e redis
+ - tox -e coverage-report
+ - tox -e docs
+
+services:
+ - redis-server
coverage:
path: coverage.xml
diff --git a/.gitignore b/.gitignore
index 020241d..da61634 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,6 @@ target/
thumbnails-cache
+tests/test_image.jpg
+tests/test_image.png
+tests/test_image.gif
diff --git a/.isort.cfg b/.isort.cfg
deleted file mode 100644
index 2cd9ef6..0000000
--- a/.isort.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-[settings]
-line_length=100
-skip=.tox,venv
-default_section=THIRDPARTY
-known_first_party=thumbnails,tests
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..a3f47e1
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,42 @@
+language: python
+
+python:
+ - "3.4"
+
+cache:
+ apt:
+ directories:
+ - .tox
+ - .cache
+
+services:
+ - redis-server
+
+before_install:
+ - sudo apt-get update -qq
+ - sudo apt-get install -qq libjpeg62 libjpeg62-dev zlib1g-dev
+
+install:
+ - pip install -U pip wheel
+ - pip install -q tox hide-env codecov
+
+script:
+ - set -o pipefail
+ - tox -e flake8 | hide-env GH_TOKEN PYPI_PASSWORD
+ - tox -e isort | hide-env GH_TOKEN PYPI_PASSWORD
+ - tox -e pillow | hide-env GH_TOKEN PYPI_PASSWORD
+ - tox -e django111 | hide-env GH_TOKEN PYPI_PASSWORD
+ - tox -e django18 | hide-env GH_TOKEN PYPI_PASSWORD
+ - tox -e redis | hide-env GH_TOKEN PYPI_PASSWORD
+ - tox -e coverage-report | hide-env GH_TOKEN PYPI_PASSWORD
+ - tox -e docs | hide-env GH_TOKEN PYPI_PASSWORD
+
+after_success:
+ - codecov
+ - git config --global user.name "semantic-release"
+ - git config --global user.email "semantic-release@travis"
+ - pip install python-semantic-release
+ - semantic-release publish | hide-env GH_TOKEN PYPI_PASSWORD
+
+
+
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 5cc151d..49de0c6 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,35 @@
+Changes since last release
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+Nothing yet.
+
+0.5.0
+~~~~~
+ - Drop support for Python 2
+ - Add django filters for markdown and html
+ - Tested against release version of Django 1.8
+ - Add ``THUMBNAIL_FORCE_FORMAT``
+ - Add ``THUMBNAIL_FALLBACK_FORMAT``
+ - Change ``THUMBNAIL_DUMMY_URL`` to use keyword arguments in string format
+
+0.4.1
+~~~~~
+ - Add missing call of colormode in engine.create
+
+0.4.0
+~~~~~
+ - Add support for base64 encoded images as source
+ - Add support for colormode through setting the option ``colormode`` or the setting
+ ``THUMBNAIL_COLORMODE``.
+
+0.3.0
+~~~~~
+ - Add Django templatetag ``get_thumbnail``
+ - Catch IOError and OSError in PillowEngine.engine_load_image and throw ThumbnailError, which
+ will be caught in get_thumbnail if ``THUMBNAIL_DEBUG = False``.
+
+0.2.1
+~~~~~
+
0.2.0
~~~~~
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index bbd5d08..d624bff 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -4,6 +4,9 @@ process of including the contributions easier.
It sums up to write tests, follow pep8 and the import-sorting guidelines. New features
needs to be documented.
+Please remember to add your change to the CHANGELOG.rst under the section
+"Changes since last release".
+
Adding features
~~~~~~~~~~~~~~~
@@ -23,7 +26,7 @@ engine.
.. code-block:: python
- @unittest.skipIf(not has_pillow(), 'Pillow not installed')
+ @unittest.skipIf(not has_installed('pillow'), 'Pillow not installed')
class PillowEngineTestCase(EngineTestMixin, unittest.TestCase):
ENGINE = PillowEngine
diff --git a/README.md b/README.md
index fb37455..03ded0f 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,9 @@
Thumbnails for Django, Flask and other Python projects.
+[](https://readthedocs.org/projects/python-thumbnails/?badge=latest)
[](https://gitter.im/relekang/python-thumbnails?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
-
-## Work in progress
-This project is currently work in progress. It is not production ready.
-[This gist](https://gist.github.com/relekang/1544815ce1370a0be2b4) outlines the planned features
-and the status of them.
-
## Install
```bash
pip install pillow # default image engine, not necessary if another engine is used
diff --git a/docs/conf.py b/docs/conf.py
index 4d51d95..d0e86f1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -195,8 +195,8 @@
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
- ('index', 'python-thumbnails.tex', u'python-thumbnails Documentation',
- u'Rolf Erik Lekang', 'manual'),
+ ('index', 'python-thumbnails.tex', u'python-thumbnails Documentation',
+ u'Rolf Erik Lekang', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@@ -239,9 +239,9 @@
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
- ('index', 'python-thumbnails', u'python-thumbnails Documentation',
- 'Rolf Erik Lekang', 'python-thumbnails', 'One line description of project.',
- 'Miscellaneous'),
+ ('index', 'python-thumbnails', u'python-thumbnails Documentation',
+ 'Rolf Erik Lekang', 'python-thumbnails', 'One line description of project.',
+ 'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
diff --git a/docs/creating-thumbnails.rst b/docs/creating-thumbnails.rst
index be44b6d..a061e4d 100644
--- a/docs/creating-thumbnails.rst
+++ b/docs/creating-thumbnails.rst
@@ -7,3 +7,67 @@ called. Other options can be passed as keyword arguments. The available options
.. autofunction:: thumbnails.get_thumbnail
+
+
+Django specific features
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+Templatetags
+^^^^^^^^^^^^
+
+**get_thumbnail**
+
+This templatetag is a shortcut for ``thumbnails.get_thumbnail``, thus all arguments and keyword
+arguments are the same as described in the section above. It is necessary to define the variable
+name for the thumbnail with an ``as`` keyword as shown in the example below.
+
+.. code-block:: html+django
+
+ {% load thumbnails %}
+
+ {% get_thumbnail "image.jpg" "400x400" crop="center" as thumbnail %}
+
=1.9.0
+requests>=2.7.0
+
+# Dev requirements
+pillow>=2.8.2
+
+# Test requirements
+pytest
+coverage
+mock
diff --git a/setup.cfg b/setup.cfg
index 5e40900..f4548d5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,18 @@
[wheel]
universal = 1
+
+[flake8]
+max_line_length = 100
+exclude = .tox,venv
+
+[tool:pytest]
+python_files = tests/test_*.py
+
+[isort]
+line_length = 100
+skip = .tox,venv
+default_section = THIRDPARTY
+known_first_party = thumbnails,tests
+
+[semantic_release]
+version_variable = thumbnails/__init__.py:__version__
diff --git a/setup.py b/setup.py
index 7e17692..ed33092 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,6 @@
-from setuptools import setup, find_packages
+import re
+import sys
+from setuptools import find_packages, setup
def _read_long_description():
@@ -9,9 +11,22 @@ def _read_long_description():
return None
+with open('thumbnails/__init__.py', 'r') as fd:
+ version = re.search(
+ r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]',
+ fd.read(),
+ re.MULTILINE
+ ).group(1)
+
+try:
+ from semantic_release import setup_hook
+ setup_hook(sys.argv)
+except ImportError:
+ pass
+
setup(
name='python-thumbnails',
- version='0.2.0',
+ version=version,
url='http://github.com/relekang/python-thumbnails',
author='Rolf Erik Lekang',
author_email='me@rolflekang.com',
@@ -20,14 +35,13 @@ def _read_long_description():
packages=find_packages(exclude='tests'),
license='MIT',
install_requires=[
- 'six',
- 'requests'
+ 'six>=1.9.0',
+ 'requests>=2.7.0'
],
include_package_data=True,
classifiers=[
'Development Status :: 4 - Beta',
'Programming Language :: Python',
- 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.4',
]
)
diff --git a/t.py b/t.py
index 94be128..5d47d95 100644
--- a/t.py
+++ b/t.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import thumbnails
+from tests import data
from thumbnails.conf import settings
URL = 'https://unsplash.imgix.net/photo-1422405153578-4bd676b19036?q=75&fm=jpg&' \
@@ -7,9 +8,10 @@
print(settings.THUMBNAIL_PATH)
-print(thumbnails.get_thumbnail(URL, '800x800'))
-print(thumbnails.get_thumbnail(URL, '800'))
-print(thumbnails.get_thumbnail(URL, 'x800'))
-print(thumbnails.get_thumbnail(URL, '400x400', 'center'))
-print(thumbnails.get_thumbnail(URL, '400x400', 'top'))
-print(thumbnails.get_thumbnail(URL, '400x400', 'left'))
+print(thumbnails.get_thumbnail(URL, '800x800').url)
+print(thumbnails.get_thumbnail(URL, '800').url)
+print(thumbnails.get_thumbnail(URL, 'x800').url)
+print(thumbnails.get_thumbnail(URL, '400x400', crop='center').url)
+print(thumbnails.get_thumbnail(URL, '400x400', crop='top').url)
+print(thumbnails.get_thumbnail(URL, '400x400', crop='left').url)
+print(thumbnails.get_thumbnail(data.BASE64_STRING_OF_IMAGE, '200x200', crop='center').url)
diff --git a/tests/compat.py b/tests/compat.py
deleted file mode 100644
index ae4ad71..0000000
--- a/tests/compat.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# -*- coding: utf-8 -*-
-import six
-
-if six.PY3:
- from unittest import mock # noqa
-
-elif six.PY2:
- import mock # noqa
diff --git a/tests/data.py b/tests/data.py
new file mode 100644
index 0000000..b771b67
--- /dev/null
+++ b/tests/data.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+"""
+This file contains test data that takes so much space that it pollutes the other test files
+"""
+
+
+BASE64_STRING_OF_IMAGE = 'data:image/jpeg;base64,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' # noqa
diff --git a/tests/django_settings.py b/tests/django_settings.py
index 4871ca9..35b9dd0 100644
--- a/tests/django_settings.py
+++ b/tests/django_settings.py
@@ -47,3 +47,14 @@
'LOCATION': 'thumbnails'
}
}
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ # ... some options here ...
+ },
+ },
+]
diff --git a/tests/test_cache_backends.py b/tests/test_cache_backends.py
index b8edfd5..7c8345a 100644
--- a/tests/test_cache_backends.py
+++ b/tests/test_cache_backends.py
@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
import unittest
+from unittest import mock
from thumbnails.cache_backends import (BaseCacheBackend, DjangoCacheBackend, RedisCacheBackend,
SimpleCacheBackend)
from thumbnails.images import Thumbnail
-from .compat import mock
-from .utils import has_django, has_redis
+from .utils import has_installed
class CacheBackendTestMixin(object):
@@ -17,7 +17,7 @@ def test_get_empty(self):
self.assertIsNone(self.backend.get('not-in-cache'))
def test_set_and_get(self):
- thumbnail = Thumbnail(['n', 'ame'])
+ thumbnail = Thumbnail(['n', 'ame'], 'jpg')
self.backend.set(thumbnail)
cached_thumbnail = self.backend.get(thumbnail.name)
self.assertIsInstance(cached_thumbnail, Thumbnail)
@@ -39,7 +39,7 @@ def test_get_calls_backend_specific_get(self, mock__get):
@mock.patch('thumbnails.cache_backends.BaseCacheBackend._set')
def test_set_calls_backend_specific_set(self, mock__set):
- thumbnail = Thumbnail(['hi', 'there'])
+ thumbnail = Thumbnail(['hi', 'there'], 'jpg')
self.backend.set(thumbnail)
mock__set.assert_has_calls([mock.call('hi/there', thumbnail)])
@@ -48,12 +48,12 @@ class SimpleCacheBackendTestCase(CacheBackendTestMixin, unittest.TestCase):
BACKEND = SimpleCacheBackend
-@unittest.skipIf(not has_django(), 'Django is not installed')
+@unittest.skipIf(not has_installed('django'), 'Django is not installed')
class DjangoCacheBackendTestCase(CacheBackendTestMixin, unittest.TestCase):
BACKEND = DjangoCacheBackend
-@unittest.skipIf(not has_redis(), 'Redis not installed')
+@unittest.skipIf(not has_installed('redis'), 'Redis not installed')
class RedisCacheBackendTestCase(CacheBackendTestMixin, unittest.TestCase):
BACKEND = RedisCacheBackend
diff --git a/tests/test_compat.py b/tests/test_compat.py
deleted file mode 100644
index 032a8bd..0000000
--- a/tests/test_compat.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# -*- coding: utf-8 -*-
-import os
-import shutil
-import unittest
-
-from thumbnails.compat import makedirs
-
-
-class CompatTestCase(unittest.TestCase):
-
- def test_makedirs(self):
- path = os.path.join(os.getcwd(), 'makedirs-test/folders')
- makedirs(path, exist_ok=True)
- makedirs(path, exist_ok=True)
-
- with self.assertRaises(OSError):
- makedirs(path)
-
- shutil.rmtree(os.path.dirname(path))
diff --git a/tests/test_conf.py b/tests/test_conf.py
index 17cfbdd..6a3ab2c 100644
--- a/tests/test_conf.py
+++ b/tests/test_conf.py
@@ -2,11 +2,11 @@
import os
import unittest
from copy import deepcopy
+from unittest import mock
from thumbnails.conf.wrapper import SettingsWrapper
-from .compat import mock
-from .utils import has_django
+from .utils import has_installed
class SettingsWrapperTestCase(unittest.TestCase):
@@ -26,7 +26,7 @@ def test_override_by_thumbnail_settings_module(self):
settings = SettingsWrapper()
self.assertEqual(settings.THUMBNAIL_ENGINE, 'thumbnails.engines.PillowEngine')
- @unittest.skipIf(not has_django(), 'Django not installed')
+ @unittest.skipIf(not has_installed('django'), 'Django not installed')
def test_django_defaults(self):
settings = SettingsWrapper()
self.assertEqual(
diff --git a/tests/test_engines.py b/tests/test_engines.py
index 99ccd68..ef30c4c 100644
--- a/tests/test_engines.py
+++ b/tests/test_engines.py
@@ -2,26 +2,25 @@
import hashlib
import os
import unittest
+from unittest import mock
from PIL import Image
-from thumbnails.engines import (BaseThumbnailEngine, DummyEngine, PgmagickEngine, PillowEngine,
- WandEngine)
+from thumbnails.engines import DummyEngine, PgmagickEngine, PillowEngine, WandEngine
+from thumbnails.engines.base import BaseThumbnailEngine
from thumbnails.errors import ThumbnailError
-from thumbnails.images import SourceFile, Thumbnail
+from thumbnails.images import SourceFile
-from .compat import mock
-from .utils import has_installed
+from .utils import is_tox_env
class EngineTestMixin(object):
def setUp(self):
- if self.ENGINE:
- self.engine = self.ENGINE()
+ self.engine = self.ENGINE()
self.filename = os.path.join(os.path.dirname(__file__), 'test_image.jpg')
self.file = SourceFile(self.filename)
- self.url = SourceFile('http://puppies.lkng.me/400x600/')
+ self.url = SourceFile('http://dummyimage.com/400x600')
image = Image.new('L', (400, 600))
image.save(self.filename)
@@ -36,15 +35,16 @@ def assertSize(self, thumbnail, width, height):
def assertRawData(self, raw_data):
self.assertEqual(hashlib.sha1(raw_data).hexdigest(), self.RAW_DATA_HASH)
- @mock.patch('thumbnails.engines.BaseThumbnailEngine.create')
- @mock.patch('thumbnails.engines.BaseThumbnailEngine.cleanup')
+ @mock.patch('thumbnails.engines.base.BaseThumbnailEngine.create')
+ @mock.patch('thumbnails.engines.base.BaseThumbnailEngine.cleanup')
def test_get_thumbnail(self, mock_create, mock_cleanup):
self.engine.get_thumbnail(self.file, '200', None, None)
self.assertTrue(mock_create.called)
self.assertTrue(mock_cleanup.called)
- @mock.patch('thumbnails.engines.BaseThumbnailEngine.create', side_effect=ThumbnailError())
- @mock.patch('thumbnails.engines.BaseThumbnailEngine.cleanup')
+ @mock.patch('thumbnails.engines.base.BaseThumbnailEngine.create',
+ side_effect=ThumbnailError(''))
+ @mock.patch('thumbnails.engines.base.BaseThumbnailEngine.cleanup')
def test_get_thumbnail_fail(self, mock_create, mock_cleanup):
self.engine.get_thumbnail(self.file, '200', None, None)
self.assertTrue(mock_create.called)
@@ -76,6 +76,26 @@ def test_raw_data(self):
def test_cleanup(self):
self.assertIsNone(self.engine.cleanup(self.file))
+ def test_engine_colormode(self):
+ image = self.engine.engine_load_image(self.file)
+ self.engine.engine_colormode(image, 'RGB')
+
+ def test_engine_get_format(self):
+ image = self.engine.engine_load_image(self.file)
+ self.assertEqual(self.engine.engine_get_format(image), 'JPEG')
+
+ png_path = os.path.join(os.path.dirname(__file__), 'test_image.png')
+ Image.new('L', (400, 600)).save(png_path)
+ image = self.engine.engine_load_image(SourceFile(png_path))
+ self.assertEqual(self.engine.engine_get_format(image), 'PNG')
+ os.remove(png_path)
+
+ png_path = os.path.join(os.path.dirname(__file__), 'test_image.gif')
+ Image.new('L', (400, 600)).save(png_path)
+ image = self.engine.engine_load_image(SourceFile(png_path))
+ self.assertEqual(self.engine.engine_get_format(image), 'GIF')
+ os.remove(png_path)
+
class BaseEngineTestCase(unittest.TestCase):
@@ -92,10 +112,6 @@ def test__calculate_scaling_factor_without_crop(self):
self.assertEqual(calculate_scaling_factor(original_size, (200, None), False), 0.5)
self.assertEqual(calculate_scaling_factor(original_size, (None, 300), False), 0.5)
- def test_create_thumbnail_object(self):
- name = ['851', '521c21fe9709802e9d4eb20a5fe84c18cd3ad']
- self.assertIsInstance(self.engine.create_thumbnail_object(name), Thumbnail)
-
def test_parse_size(self):
self.assertEqual(self.engine.parse_size('100'), (100, None))
self.assertEqual(self.engine.parse_size('100x200'), (100, 200))
@@ -141,6 +157,16 @@ def test_calculate_alternative_resolution_size(self):
(150, 150)
)
+ @mock.patch('thumbnails.engines.base.BaseThumbnailEngine.engine_colormode')
+ def test_colormode(self, mock_engine_colormode):
+ self.engine.colormode(None, {'colormode': 'RGB'})
+ mock_engine_colormode.assert_called_once_with(None, 'RGB')
+
+ @mock.patch('thumbnails.engines.base.BaseThumbnailEngine.engine_get_format', lambda *x: None)
+ def test_get_format(self):
+ self.assertEqual(self.engine.get_format(None, {}), 'JPEG')
+ self.assertEqual(self.engine.get_format(None, {'format': 'WEBP'}), 'WEBP')
+
class DummyEngineTestCase(unittest.TestCase):
@@ -163,19 +189,31 @@ def test_create_from_url(self):
self.assertEqual(thumbnail.url, 'http://puppies.lkng.me/200x300')
-@unittest.skipIf(not has_installed('PIL'), 'Pillow not installed')
+@unittest.skipIf(not is_tox_env('pillow'), 'not pillow environment')
class PillowEngineTestCase(EngineTestMixin, unittest.TestCase):
ENGINE = PillowEngine
RAW_DATA_HASH = 'cd63a4ccd85070c76db822ca5ccb11ba59966256'
+ @mock.patch('PIL.Image.Image.load', side_effect=IOError)
+ def test_load_with_io_error(self, mock_image_load):
+ with self.assertRaises(ThumbnailError):
+ self.engine.engine_load_image(self.file)
+ self.assertTrue(mock_image_load.called)
+
+ @mock.patch('PIL.Image.Image.load', side_effect=OSError)
+ def test_load_with_os_error(self, mock_image_load):
+ with self.assertRaises(ThumbnailError):
+ self.engine.engine_load_image(self.file)
+ self.assertTrue(mock_image_load.called)
+
-@unittest.skipIf(not has_installed('wand'), 'Wand not installed')
+@unittest.skipIf(not is_tox_env('wand'), 'not wand environment')
class WandEngineTestCase(EngineTestMixin, unittest.TestCase):
ENGINE = WandEngine
RAW_DATA_HASH = '8eb021308a7fb04cb0e87ce9026828f42e8a4a81'
-@unittest.skipIf(not has_installed('pgmagick'), 'pgmagick not installed')
+@unittest.skipIf(not is_tox_env('pgmagick'), 'not pgmagick environment')
class PgmagickEngineTestCase(EngineTestMixin, unittest.TestCase):
ENGINE = PgmagickEngine
RAW_DATA_HASH = '47be661ff0e19f6e78eaa38b68db74d10f3f4c96'
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 84774aa..45d131b 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -14,11 +14,11 @@ class HelpersTestCase(unittest.TestCase):
def test_generate_filename(self):
self.assertEqual(
- generate_filename(SourceFile('url'), '100x200', 'center', None),
+ generate_filename(SourceFile('url'), '100x200', 'center'),
['0af', 'a360db703bd5c2fe7c83843ce7738a0a6d37b']
)
self.assertEqual(
- generate_filename(SourceFile('url'), '200x200', 'center', None),
+ generate_filename(SourceFile('url'), '200x200', 'center'),
['851', '521c21fe9709802e9d4eb20a5fe84c18cd3ad']
)
diff --git a/tests/test_images.py b/tests/test_images.py
index eb37a61..3e6f5b1 100644
--- a/tests/test_images.py
+++ b/tests/test_images.py
@@ -1,18 +1,23 @@
# -*- coding: utf-8 -*-
+import hashlib
import os
import unittest
+from io import BytesIO
+from unittest import mock
+
+from PIL import Image
from thumbnails.conf import settings
from thumbnails.images import SourceFile, Thumbnail
-from .compat import mock
+from . import data
from .utils import has_installed
class ThumbnailTestCase(unittest.TestCase):
def setUp(self):
- self.instance = Thumbnail(['n', 'ame'])
+ self.instance = Thumbnail(['n', 'ame'], 'jpg')
self.instance.size = 200, 400
def test_name(self):
@@ -45,7 +50,7 @@ def test_is_landscape(self):
@mock.patch('{}.exists'.format(settings.THUMBNAIL_STORAGE_BACKEND))
def test_exists(self, mock_exists):
- self.instance = Thumbnail(['name'])
+ self.instance = Thumbnail(['name'], 'jpg')
self.assertTrue(self.instance.exists)
mock_exists.assert_called_with(self.instance.path)
@@ -64,3 +69,12 @@ def test_django_image_files(self):
self.assertEqual(f.file, self.FILE_PATH)
f = SourceFile(files.ImageFieldFile(field=field, instance=None, name=self.FILE_PATH))
self.assertEqual(f.file, self.FILE_PATH)
+
+ def test_base64_encoded_string(self):
+ file = SourceFile(data.BASE64_STRING_OF_IMAGE)
+ self.assertEqual(
+ hashlib.sha1(file.open().getvalue()).hexdigest(),
+ '6666212f5302426c845ecb2a2901fae021735f24'
+ )
+ image = Image.open(BytesIO(file.open().read()))
+ self.assertIsNotNone(image.load())
diff --git a/tests/test_init.py b/tests/test_init.py
index 01efc2a..2a09d21 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
import unittest
+from unittest import mock
from thumbnails import get_thumbnail
from thumbnails.conf import settings
from thumbnails.images import Thumbnail
-from .compat import mock
from .utils import override_settings
diff --git a/tests/test_storage_backends.py b/tests/test_storage_backends.py
index fe58aa2..15f77db 100644
--- a/tests/test_storage_backends.py
+++ b/tests/test_storage_backends.py
@@ -3,7 +3,7 @@
import shutil
import unittest
-from tests.utils import has_django
+from tests.utils import has_installed
from thumbnails.storage_backends import (BaseStorageBackend, DjangoStorageBackend,
FilesystemStorageBackend)
@@ -43,6 +43,6 @@ def test_create_location_in_init(self):
os.path.exists(instance.location)
-@unittest.skipIf(not has_django(), 'Django not installed')
+@unittest.skipIf(not has_installed('django'), 'Django not installed')
class DjangoStorageBackendTestCase(StorageBackendTestMixin, unittest.TestCase):
BACKEND = DjangoStorageBackend
diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py
new file mode 100644
index 0000000..ed746b7
--- /dev/null
+++ b/tests/test_templatetags.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+import unittest
+from unittest import mock
+
+from thumbnails import settings
+
+from .utils import has_installed
+
+try:
+ from django.template import Context, Template
+ from django.db.models.fields import files
+except ImportError:
+ pass
+
+
+@unittest.skipIf(not has_installed('django'), 'Django not installed')
+class TemplateTagTestCase(unittest.TestCase):
+
+ @mock.patch('thumbnails.get_thumbnail')
+ def test_get_thumbnail_templatetag(self, mock_get_thumbnail):
+ Template(
+ '{% load thumbnails %}{% get_thumbnail "static/image.jpg" "200" as im %}{{im.url}}',
+ ).render(Context())
+
+ mock_get_thumbnail.assert_called_once_with('static/image.jpg', '200')
+
+ @mock.patch('thumbnails.get_thumbnail')
+ def test_get_thumbnail_templatetag_with_kwarg(self, mock_get_thumbnail):
+ Template(
+ '{% load thumbnails %}'
+ '{% get_thumbnail "static/image.jpg" "100x100" crop="center" as im %}{{im.url}}',
+ ).render(Context())
+
+ mock_get_thumbnail.assert_called_once_with('static/image.jpg', '100x100', crop='center')
+
+ @mock.patch('thumbnails.get_thumbnail')
+ def test_get_thumbnail_templatetag_with_multiple_kwargs(self, mock_get_thumbnail):
+ Template(
+ '{% load thumbnails %}'
+ '{% get_thumbnail "static/image.jpg" "200x200" crop="center" scale_up=True as im %}'
+ '{{im.url}}',
+ ).render(Context())
+
+ mock_get_thumbnail.assert_called_once_with('static/image.jpg', '200x200', crop='center',
+ scale_up=True)
+
+ @mock.patch('thumbnails.get_thumbnail')
+ def test_get_thumbnail_templatetag_with_FileField_as_source(self, mock_get_thumbnail):
+ field = files.FileField()
+ _file = files.FieldFile(field=field, instance=None, name='image.jpg')
+ Template(
+ '{% load thumbnails %}'
+ '{% get_thumbnail image "200x200" as im %}'
+ '{{im.url}}',
+ ).render(Context({
+ 'image': _file
+ }))
+
+ mock_get_thumbnail.assert_called_once_with(_file, '200x200')
+
+
+@mock.patch('{}.get_thumbnail'.format(settings.THUMBNAIL_ENGINE), lambda *args: None)
+@mock.patch('thumbnails.images.Thumbnail.save', lambda *args: None)
+@mock.patch('thumbnails.images.Thumbnail.save_alternative_resolution', lambda *args: None)
+@unittest.skipIf(not has_installed('django'), 'Django not installed')
+class FilterTestCase(unittest.TestCase):
+
+ def setUp(self):
+ self.MARKDOWN_TEMPLATE = Template('{% load thumbnails %}{{ text|markdown_thumbnails }}')
+ self.HTML_TEMPLATE = Template('{% load thumbnails %}{{ text|html_thumbnails }}')
+ self.SAFE_HTML_TEMPLATE = Template('{% load thumbnails %}{{ text|safe_html_thumbnails }}')
+
+ def test_markdown_filter(self):
+ self.assertEqual(self.MARKDOWN_TEMPLATE.render(Context({
+ 'text': 'image: '
+ })), 'image: ')
+
+ self.assertEqual(self.MARKDOWN_TEMPLATE.render(Context({
+ 'text': 'image: '
+ })), 'image: ')
+
+ def test_html_filter(self):
+ self.assertEqual(self.HTML_TEMPLATE.render(Context({
+ 'text': 'image:
'
+ })), 'image:
')
+
+ self.assertEqual(self.HTML_TEMPLATE.render(Context({
+ 'text': 'image:
'
+ })), 'image:
')
+
+ self.assertEqual(self.HTML_TEMPLATE.render(Context({
+ 'text': 'image:
'
+ })), 'image:
')
+
+ def test_safe_html_filter(self):
+ self.assertEqual(self.SAFE_HTML_TEMPLATE.render(Context({
+ 'text': 'image:
'
+ })), 'image: <img src="thumbnails-cache/09a/e5965fd6bcafba14e3e696059acfa3db6ca8e'
+ '.jpg" alt="Title" />')
+
+ self.assertEqual(self.SAFE_HTML_TEMPLATE.render(Context({
+ 'text': 'image:
'
+ })), 'image: <img src="thumbnails-cache/09a/e5965fd6bcafba14e3e696059acfa3db6ca8e'
+ '.jpg" alt="" />')
+
+ self.assertEqual(self.SAFE_HTML_TEMPLATE.render(Context({
+ 'text': 'image:
'
+ })), 'image: <img src="thumbnails-cache/09a/e5965fd6bcafba14e3e696059acfa3db6ca8e'
+ '.jpg" />')
diff --git a/tests/utils.py b/tests/utils.py
index ed9267c..9a53812 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -12,6 +12,11 @@ def has_installed(dependency):
return False
+def is_tox_env(env):
+ if 'VIRTUAL_ENV' in os.environ:
+ return env in os.environ['VIRTUAL_ENV']
+
+
def has_django():
return has_installed('django')
diff --git a/thumbnails/__init__.py b/thumbnails/__init__.py
index 5804dfa..412425d 100644
--- a/thumbnails/__init__.py
+++ b/thumbnails/__init__.py
@@ -1,16 +1,20 @@
# -*- coding: utf-8 -*-
-from thumbnails import helpers
+
from thumbnails.conf import settings
from thumbnails.engines import DummyEngine
+from thumbnails.helpers import get_engine, generate_filename, get_cache_backend
from thumbnails.images import SourceFile, Thumbnail
+__version__ = '0.5.1'
+
def get_thumbnail(original, size, **options):
"""
Creates or gets an already created thumbnail for the given image with the given size and
options.
- :param original: File-path or url to the image that you want an thumbnail of.
+ :param original: File-path, url or base64-encoded string of the image that you want an
+ thumbnail.
:param size: String with the wanted thumbnail size. On the form: ``200x200``, ``200`` or
``x200``.
@@ -20,13 +24,21 @@ def get_thumbnail(original, size, **options):
saving the thumbnail.
:param scale_up: Overrides ``THUMBNAIL_SCALE_UP``, if set to ``True`` the image will be scaled
up if necessary.
+ :param colormode: Overrides ``THUMBNAIL_COLORMODE``, The default colormode for thumbnails.
+ Supports all values supported by pillow. In other engines there is a best
+ effort translation from pillow modes to the modes supported by the current
+ engine.
+ :param format: Overrides the format the thumbnail will be saved in. This will override both the
+ detected file type as well as the one specified in ``THUMBNAIL_FALLBACK_FORMAT``.
:return: A Thumbnail object
"""
- engine = helpers.get_engine()
- cache = helpers.get_cache_backend()
+
+ engine = get_engine()
+ cache = get_cache_backend()
original = SourceFile(original)
crop = options.get('crop', None)
- thumbnail_name = helpers.generate_filename(original, size, crop, options)
+ options = engine.evaluate_options(options)
+ thumbnail_name = generate_filename(original, size, crop)
if settings.THUMBNAIL_DUMMY:
engine = DummyEngine()
@@ -38,9 +50,8 @@ def get_thumbnail(original, size, **options):
if not force and cached:
return cached
- thumbnail = Thumbnail(thumbnail_name)
+ thumbnail = Thumbnail(thumbnail_name, engine.get_format(original, options))
if force or not thumbnail.exists:
- options = engine.evaluate_options(options)
size = engine.parse_size(size)
thumbnail.image = engine.get_thumbnail(original, size, crop, options)
thumbnail.save(options)
diff --git a/thumbnails/compat.py b/thumbnails/compat.py
deleted file mode 100644
index 2518a83..0000000
--- a/thumbnails/compat.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# -*- coding: utf-8 -*-
-import errno
-
-import six
-
-BytesIO = six.BytesIO
-StringIO = six.StringIO
-
-if six.PY3:
- from os import makedirs
-else:
- from os import makedirs as os_makedirs
-
- def makedirs(name, mode=0o777, exist_ok=False):
- try:
- os_makedirs(name, mode)
- except OSError as e:
- if e.errno != errno.EEXIST or not exist_ok:
- raise
diff --git a/thumbnails/conf/__init__.py b/thumbnails/conf/__init__.py
index a18694c..a7c1200 100644
--- a/thumbnails/conf/__init__.py
+++ b/thumbnails/conf/__init__.py
@@ -1,4 +1,2 @@
# -*- coding: utf-8 -*-
-from . import wrapper
-
-settings = wrapper.SettingsWrapper()
+from thumbnails.conf.wrapper import settings # noqa
diff --git a/thumbnails/conf/defaults.py b/thumbnails/conf/defaults.py
index f230b1a..f8cc41b 100644
--- a/thumbnails/conf/defaults.py
+++ b/thumbnails/conf/defaults.py
@@ -11,7 +11,13 @@
THUMBNAIL_SCALE_UP = False
THUMBNAIL_QUALITY = 90
+THUMBNAIL_COLORMODE = 'RGB'
+THUMBNAIL_FALLBACK_FORMAT = 'JPEG'
+THUMBNAIL_FORCE_FORMAT = None
THUMBNAIL_ALTERNATIVE_RESOLUTIONS = [2]
+THUMBNAIL_FILTER_SIZE = '500'
+THUMBNAIL_FILTER_OPTIONS = {'size': '500'}
+
THUMBNAIL_DUMMY = False
-THUMBNAIL_DUMMY_URL = 'http://puppies.lkng.me/{}x{}'
+THUMBNAIL_DUMMY_URL = 'http://puppies.lkng.me/{width}x{height}'
diff --git a/thumbnails/conf/wrapper.py b/thumbnails/conf/wrapper.py
index a1738ef..6b59dfc 100644
--- a/thumbnails/conf/wrapper.py
+++ b/thumbnails/conf/wrapper.py
@@ -3,8 +3,6 @@
import json
import os
-from thumbnails.compat import makedirs
-
from . import defaults
@@ -29,22 +27,22 @@ def __init__(self):
pass
if not os.path.exists(self.THUMBNAIL_PATH):
- makedirs(os.path.dirname(self.THUMBNAIL_PATH), exist_ok=True)
- makedirs(self.THUMBNAIL_PATH, exist_ok=True)
+ os.makedirs(os.path.dirname(self.THUMBNAIL_PATH), exist_ok=True)
+ os.makedirs(self.THUMBNAIL_PATH, exist_ok=True)
def __getattr__(self, key):
- value = self.defaults.get(key, None)
+ value = self.defaults.get(key, 'unknown setting')
for settings_module in self.settings_modules:
- if getattr(settings_module, key, None):
- value = getattr(settings_module, key, None)
+ if hasattr(settings_module, key):
+ value = getattr(settings_module, key)
if 'overridden_settings' in os.environ:
settings = json.loads(os.environ['overridden_settings'])
if key in settings:
value = settings[key]
- if value is None:
+ if value == 'unknown setting':
raise AttributeError('No setting for "{}".'.format(key))
return value
@@ -53,3 +51,6 @@ def _load_from_module(self, _module, target):
for setting in dir(_module):
if not setting.startswith('_'):
target[setting] = getattr(_module, setting)
+
+
+settings = SettingsWrapper()
diff --git a/thumbnails/engines/__init__.py b/thumbnails/engines/__init__.py
index d2bf228..504ccf7 100644
--- a/thumbnails/engines/__init__.py
+++ b/thumbnails/engines/__init__.py
@@ -1,15 +1,5 @@
# -*- coding: utf-8 -*-
-from thumbnails.engines.base import BaseThumbnailEngine # noqa
-from thumbnails.engines.pillow import PillowEngine # noqa
from thumbnails.engines.dummy import DummyEngine # noqa
-
-try:
- from thumbnails.engines.wand import WandEngine # noqa
-except ImportError:
- WandEngine = None
-
-
-try:
- from thumbnails.engines.pgmagick import PgmagickEngine # noqa
-except ImportError:
- PgmagickEngine = None
+from thumbnails.engines.pillow_engine import PillowEngine # noqa
+from thumbnails.engines.wand_engine import WandEngine # noqa
+from thumbnails.engines.pgmagick_engine import PgmagickEngine # noqa
diff --git a/thumbnails/engines/base.py b/thumbnails/engines/base.py
index 2a62167..7e77328 100644
--- a/thumbnails/engines/base.py
+++ b/thumbnails/engines/base.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
from thumbnails.conf import settings
from thumbnails.errors import ThumbnailError
-from thumbnails.images import Thumbnail
CROP_ALIASES = {
'x': {
@@ -57,6 +56,7 @@ def create(self, original, size, crop, options=None):
image = self.scale(image, size, crop, options)
crop = self.parse_crop(crop, self.get_image_size(image), size)
image = self.crop(image, size, crop, options)
+ image = self.colormode(image, options)
return image
def scale(self, image, size, crop, options):
@@ -121,6 +121,17 @@ def raw_data(self, image, options):
"""
return self.engine_raw_data(image, options)
+ def colormode(self, image, options):
+ """
+ Wrapper for ``engine_colormode``.
+
+ :param image:
+ :param options:
+ :return:
+ """
+ mode = options['colormode']
+ return self.engine_colormode(image, mode)
+
def _calculate_scaling_factor(self, original_size, size, has_crop):
factors = []
if size[0] is not None:
@@ -142,9 +153,23 @@ def evaluate_options(self, options=None):
def default_options(self):
return {
'scale_up': settings.THUMBNAIL_SCALE_UP,
- 'quality': settings.THUMBNAIL_QUALITY
+ 'quality': settings.THUMBNAIL_QUALITY,
+ 'colormode': settings.THUMBNAIL_COLORMODE,
}
+ def get_format(self, image, options):
+ if 'format' in options:
+ return options['format']
+ if settings.THUMBNAIL_FORCE_FORMAT is not None:
+ return settings.THUMBNAIL_FORCE_FORMAT
+ try:
+ image_format = self.engine_get_format(image)
+ if image_format:
+ return image_format
+ except AttributeError:
+ pass
+ return settings.THUMBNAIL_FALLBACK_FORMAT
+
@staticmethod
def parse_size(size):
"""
@@ -199,7 +224,8 @@ def calculate_offset(percent, original_length, length):
:rtype: int
"""
return int(
- max(0,
+ max(
+ 0,
min(percent * original_length / 100.0, original_length - length / 2) - length / 2)
)
@@ -215,10 +241,6 @@ def calculate_alternative_resolution_size(resolution, size):
resolution_size += None,
return resolution_size
- @staticmethod
- def create_thumbnail_object(name):
- return Thumbnail(name)
-
def engine_load_image(self, original):
"""
Engine specific loading of image, should be implemented by all subclasses.
@@ -279,3 +301,22 @@ def engine_cleanup(self, original):
:return:
"""
raise NotImplementedError
+
+ def engine_colormode(self, image, colormode):
+ """
+ Sets the correct colormode on the image.
+
+ :param image:
+ :param colormode:
+ :return:
+ """
+ raise NotImplementedError
+
+ def engine_get_format(self, image):
+ """
+ Reads the format of the image object passed into the arguments.
+
+ :param image: An image object from the engine.
+ :return: A string with the current format of the image.
+ """
+ raise NotImplementedError
diff --git a/thumbnails/engines/dummy.py b/thumbnails/engines/dummy.py
index e5e310c..bee8887 100644
--- a/thumbnails/engines/dummy.py
+++ b/thumbnails/engines/dummy.py
@@ -13,13 +13,13 @@ class DummyEngine(BaseThumbnailEngine):
"""
def create(self, original, size, crop, options=None):
- thumbnail = Thumbnail('dummy_{}x{}'.format(size[0], size[1]))
+ thumbnail = Thumbnail('dummy_{}x{}'.format(size[0], size[1]), 'jpg')
thumbnail.size = size
thumbnail._url = self._get_url(size)
return thumbnail
def _get_url(self, size):
- return settings.THUMBNAIL_DUMMY_URL.format(size[0], size[1])
+ return settings.THUMBNAIL_DUMMY_URL.format(width=size[0], height=size[1])
def cleanup(self, original):
pass
diff --git a/thumbnails/engines/pgmagick.py b/thumbnails/engines/pgmagick.py
deleted file mode 100644
index af7eeb0..0000000
--- a/thumbnails/engines/pgmagick.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import
-
-from base64 import b64decode
-
-from pgmagick import Blob, Geometry, Image
-
-from .base import BaseThumbnailEngine
-
-
-class PgmagickEngine(BaseThumbnailEngine):
- """
- Image backend for pgmagick, requires the pgpmagick package.
- """
-
- def engine_load_image(self, original):
- blob = Blob()
- blob.update(original.open().read())
- return Image(blob)
-
- def engine_raw_data(self, image, options):
- image.magick('JPEG')
- image.quality(options['quality'])
- blob = Blob()
- image.write(blob)
- return b64decode(blob.base64())
-
- def engine_image_size(self, image):
- geometry = image.size()
- return geometry.width(), geometry.height()
-
- def engine_scale(self, image, width, height):
- geometry = Geometry(width, height)
- image.scale(geometry)
- return image
-
- def engine_crop(self, image, size, crop, options):
- x, y = crop
- width, height = size
- geometry = Geometry(width, height, x, y)
- image.crop(geometry)
- return image
-
- def engine_cleanup(self, original):
- pass
diff --git a/thumbnails/engines/pgmagick_engine.py b/thumbnails/engines/pgmagick_engine.py
new file mode 100644
index 0000000..6dca7bc
--- /dev/null
+++ b/thumbnails/engines/pgmagick_engine.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+from base64 import b64decode
+
+from .base import BaseThumbnailEngine
+
+
+class PgmagickEngine(BaseThumbnailEngine):
+ """
+ Image backend for pgmagick, requires the pgmagick package.
+ """
+
+ def __init__(self):
+ super(PgmagickEngine, self).__init__()
+ from pgmagick import Blob, Geometry, Image, ImageType
+ self._Blob = Blob
+ self._Geometry = Geometry
+ self._Image = Image
+ self._ImageType = ImageType
+
+ def engine_load_image(self, original):
+ blob = self._Blob()
+ blob.update(original.open().read())
+ return self._Image(blob)
+
+ def engine_raw_data(self, image, options):
+ image.magick(self.get_format(image, options))
+ image.quality(options['quality'])
+ blob = self._Blob()
+ image.write(blob)
+ return b64decode(blob.base64())
+
+ def engine_image_size(self, image):
+ geometry = image.size()
+ return geometry.width(), geometry.height()
+
+ def engine_scale(self, image, width, height):
+ geometry = self._Geometry(width, height)
+ image.scale(geometry)
+ return image
+
+ def engine_crop(self, image, size, crop, options):
+ x, y = crop
+ width, height = size
+ geometry = self._Geometry(width, height, x, y)
+ image.crop(geometry)
+ return image
+
+ def engine_cleanup(self, original):
+ pass
+
+ def engine_colormode(self, image, colormode):
+ if colormode == 'RGB':
+ image.type(self._ImageType.TrueColorMatteType)
+ elif colormode == 'GRAY':
+ image.type(self._ImageType.GrayscaleMatteType)
+ return image
+
+ def engine_get_format(self, image):
+ _format = image.format()
+ if _format == 'Joint Photographic Experts Group JFIF format':
+ return 'JPEG'
+ if _format == 'Portable Network Graphics':
+ return 'PNG'
+ if _format == 'CompuServe graphics interchange format':
+ return 'GIF'
+ return _format
diff --git a/thumbnails/engines/pillow.py b/thumbnails/engines/pillow.py
deleted file mode 100644
index fe1e485..0000000
--- a/thumbnails/engines/pillow.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# -*- coding: utf-8 -*-
-from PIL import Image, ImageFile
-
-from thumbnails.compat import BytesIO
-
-from .base import BaseThumbnailEngine
-
-
-class PillowEngine(BaseThumbnailEngine):
- """
- Thumbnail engine for Pillow
- """
-
- def engine_load_image(self, original):
- return Image.open(BytesIO(original.open().read()))
-
- def engine_raw_data(self, image, options):
- ImageFile.MAXBLOCK = max(ImageFile.MAXBLOCK, int(image.size[0] * image.size[1]))
- pillow_options = {
- 'format': 'JPEG',
- 'quality': options['quality'],
- }
- _file = BytesIO()
- image.save(_file, **pillow_options)
- return _file.getvalue()
-
- def engine_image_size(self, image):
- return image.size
-
- def engine_scale(self, image, width, height):
- return image.resize((width, height), resample=Image.ANTIALIAS)
-
- def engine_crop(self, image, size, crop, options):
- x, y = crop
- width, height = size
- return image.crop((x, y, x + width, y + height))
-
- def engine_cleanup(self, original):
- pass
diff --git a/thumbnails/engines/pillow_engine.py b/thumbnails/engines/pillow_engine.py
new file mode 100644
index 0000000..d0ddce1
--- /dev/null
+++ b/thumbnails/engines/pillow_engine.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+from io import BytesIO
+
+from thumbnails.errors import ThumbnailError
+
+from .base import BaseThumbnailEngine
+
+
+class PillowEngine(BaseThumbnailEngine):
+ """
+ Thumbnail engine for Pillow
+ """
+
+ def __init__(self):
+ super(PillowEngine, self).__init__()
+ from PIL import Image, ImageFile
+ self._Image = Image
+ self._ImageFile = ImageFile
+
+ def engine_load_image(self, original):
+ image = self._Image.open(BytesIO(original.open().read()))
+ try:
+ image.load()
+ except (IOError, OSError) as e:
+ raise ThumbnailError('Could not load image', exception=e)
+ return image
+
+ def engine_raw_data(self, image, options):
+ self._ImageFile.MAXBLOCK = max(self._ImageFile.MAXBLOCK, int(image.size[0] * image.size[1]))
+ pillow_options = {
+ 'format': self.get_format(image, options),
+ 'quality': options['quality'],
+ }
+ _file = BytesIO()
+ image.save(_file, **pillow_options)
+ return _file.getvalue()
+
+ def engine_image_size(self, image):
+ return image.size
+
+ def engine_scale(self, image, width, height):
+ return image.resize((width, height), resample=self._Image.ANTIALIAS)
+
+ def engine_crop(self, image, size, crop, options):
+ x, y = crop
+ width, height = size
+ return image.crop((x, y, x + width, y + height))
+
+ def engine_cleanup(self, original):
+ pass
+
+ def engine_colormode(self, image, colormode):
+ if colormode == 'RGB' or colormode == 'RGBA':
+ if image.mode == 'RGBA':
+ return image
+ if image.mode == 'LA':
+ return image.convert('RGBA')
+ return image.convert(colormode)
+
+ if colormode == 'GRAY':
+ return image.convert('L')
+ return image.convert(colormode)
+
+ def engine_get_format(self, image):
+ return image.format
diff --git a/thumbnails/engines/wand.py b/thumbnails/engines/wand_engine.py
similarity index 58%
rename from thumbnails/engines/wand.py
rename to thumbnails/engines/wand_engine.py
index e44b4d8..b8c2d1b 100644
--- a/thumbnails/engines/wand.py
+++ b/thumbnails/engines/wand_engine.py
@@ -1,7 +1,4 @@
# -*- coding: utf-8 -*-
-from __future__ import absolute_import
-
-from wand.image import Image
from .base import BaseThumbnailEngine
@@ -10,12 +7,16 @@ class WandEngine(BaseThumbnailEngine):
"""
Image engine for wand.
"""
+ def __init__(self):
+ super(WandEngine, self).__init__()
+ from wand.image import Image
+ self._Image = Image
def engine_image_size(self, image):
return image.size
def engine_load_image(self, original):
- return Image(blob=original.open().read())
+ return self._Image(blob=original.open().read())
def engine_scale(self, image, width, height):
image.resize(width, height)
@@ -32,4 +33,15 @@ def engine_crop(self, image, size, crop, options):
def engine_raw_data(self, image, options):
image.compression_quality = options['quality']
+ image.format = self.get_format(image, options)
return image.make_blob()
+
+ def engine_colormode(self, image, colormode):
+ if colormode == 'RGB':
+ image.type = 'truecolor'
+ elif colormode == 'GRAY':
+ image.type = 'grayscale'
+ return image
+
+ def engine_get_format(self, image):
+ return image.format
diff --git a/thumbnails/errors.py b/thumbnails/errors.py
index 6f76963..cda7c6c 100644
--- a/thumbnails/errors.py
+++ b/thumbnails/errors.py
@@ -2,4 +2,6 @@
class ThumbnailError(Exception):
- pass
+ def __init__(self, message, exception=None):
+ super(ThumbnailError, self).__init__(message)
+ self.exception = exception
diff --git a/thumbnails/helpers.py b/thumbnails/helpers.py
index 2405401..00107a2 100644
--- a/thumbnails/helpers.py
+++ b/thumbnails/helpers.py
@@ -5,7 +5,7 @@
from thumbnails.conf import settings
-def generate_filename(original, size, crop, options):
+def generate_filename(original, size, crop):
h = hashlib.sha1(':'.join([original.file, str(size), str(crop)]).encode()).hexdigest()
return [h[:3], h[3:]]
diff --git a/thumbnails/images.py b/thumbnails/images.py
index a8df9eb..7b5d359 100644
--- a/thumbnails/images.py
+++ b/thumbnails/images.py
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
+import base64
import os
+from io import BytesIO
import requests
@@ -7,14 +9,21 @@
from thumbnails.helpers import get_engine, get_storage_backend
-class Thumbnail(object):
+def extension_of_format(_format):
+ if _format == "JPEG" or _format == "JPG":
+ return "jpg"
+ return _format.lower()
+
+class Thumbnail(object):
size = None, None
image = None
_url = None
+ extension = None
- def __init__(self, name):
+ def __init__(self, name, _format):
self.name = '/'.join(name)
+ self.extension = extension_of_format(_format)
@property
def path(self):
@@ -24,12 +33,8 @@ def path(self):
def url(self):
if self._url:
return self._url
- return '/'.join([settings.THUMBNAIL_URL, '{}.{}'.format(self.name, self.extension)])\
- .replace('//', '/')
-
- @property
- def extension(self):
- return 'jpg'
+ return '/'.join([settings.THUMBNAIL_URL, '{}.{}'.format(self.name, self.extension)]) \
+ .replace('//', '/')
@property
def width(self):
@@ -70,7 +75,6 @@ def save_alternative_resolution(self, resolution, image, options):
class SourceFile(object):
-
def __init__(self, source_file):
if hasattr(source_file, 'name'):
self.file = source_file.name
@@ -80,4 +84,6 @@ def __init__(self, source_file):
def open(self):
if self.file.startswith('http'):
return requests.get(self.file, stream=True).raw
+ elif self.file.startswith(r'data:image/'):
+ return BytesIO(base64.b64decode(self.file.replace('data:image/jpeg;base64,', '')))
return get_storage_backend().open(self.file)
diff --git a/thumbnails/storage_backends.py b/thumbnails/storage_backends.py
index 886fa52..c0311b1 100644
--- a/thumbnails/storage_backends.py
+++ b/thumbnails/storage_backends.py
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
import codecs
import os
+from io import BytesIO
from thumbnails import helpers
-from .compat import BytesIO, makedirs
from .conf import settings
@@ -51,7 +51,7 @@ class FilesystemStorageBackend(BaseStorageBackend):
def __init__(self):
super(FilesystemStorageBackend, self).__init__()
if not os.path.exists(self.location):
- makedirs(self.location, exist_ok=True)
+ os.makedirs(self.location, exist_ok=True)
def _open(self, name, mode='rb', encoding=None, errors='strict'):
return codecs.open(name, mode=mode, encoding=encoding, errors=errors)
@@ -61,7 +61,7 @@ def _exists(self, name):
def _save(self, name, data):
if not os.path.exists(os.path.dirname(name)):
- makedirs(os.path.dirname(name), exist_ok=True)
+ os.makedirs(os.path.dirname(name), exist_ok=True)
with open(name, 'wb') as f:
f.write(data)
diff --git a/thumbnails/templatetags/__init__.py b/thumbnails/templatetags/__init__.py
new file mode 100644
index 0000000..40a96af
--- /dev/null
+++ b/thumbnails/templatetags/__init__.py
@@ -0,0 +1 @@
+# -*- coding: utf-8 -*-
diff --git a/thumbnails/templatetags/thumbnails.py b/thumbnails/templatetags/thumbnails.py
new file mode 100644
index 0000000..2783e1f
--- /dev/null
+++ b/thumbnails/templatetags/thumbnails.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import re
+
+from django import template
+from django.template import Library
+from django.template.base import TemplateSyntaxError
+from django.utils.safestring import mark_safe
+
+from thumbnails import settings
+
+register = Library()
+
+
+@register.tag(name='get_thumbnail')
+class ThumbnailNode(template.Node):
+
+ variable_name = None
+
+ def __init__(self, parser, token):
+ tokens = token.split_contents()
+ self.original = parser.compile_filter(tokens[1])
+ self.size = parser.compile_filter(tokens[2])
+ self.options = {}
+
+ if tokens[-2] == 'as':
+ self.variable_name = tokens[-1]
+ else:
+ raise TemplateSyntaxError('get_thumbnail tag needs an variable assignment with "as"')
+
+ for option in tokens[3:-2]:
+ parsed_option = re.match(r'^(?P[\w]+)=(?P.+)$', option.strip())
+ if parsed_option:
+ key = parsed_option.group('key')
+ value = parser.compile_filter(parsed_option.group('value'))
+ self.options[key] = value
+ else:
+ raise TemplateSyntaxError('{} is invalid option syntax'.format(option))
+
+ def render(self, context):
+ from thumbnails import get_thumbnail # imported inline in order for mocking to work
+ if self.original and self.size:
+ original = self.original.resolve(context)
+ size = self.size.resolve(context)
+ options = {}
+ for key in self.options:
+ options[key] = self.options[key].resolve(context)
+
+ context[self.variable_name] = get_thumbnail(original, size, **options)
+ else:
+ raise TemplateSyntaxError()
+
+ return ''
+
+
+def text_filter(regex_base, value):
+ """
+ A text-filter helper, used in ``markdown_thumbnails``-filter and ``html_thumbnails``-filter.
+ It can be used to build custom thumbnail text-filters.
+
+ :param regex_base: A string with a regex that contains ``%(captions)s`` and ``%(image)s`` where
+ the caption and image should be.
+ :param value: String of text in which the source URLs can be found.
+ :return: A string ready to be put in a template.
+ """
+ from thumbnails import get_thumbnail
+ regex = regex_base % {
+ 'caption': '[a-zA-Z0-9\.\,:;/_ \(\)\-\!\?\"]+',
+ 'image': '[a-zA-Z0-9\.:/_\-\% ]+'
+ }
+ images = re.findall(regex, value)
+
+ for i in images:
+ image_url = i[1]
+ image = get_thumbnail(
+ image_url,
+ **settings.THUMBNAIL_FILTER_OPTIONS
+ )
+ value = value.replace(i[1], image.url)
+
+ return value
+
+
+@register.filter
+def markdown_thumbnails(value):
+ """
+ Markdown filter that replaces all images with thumbnails.
+ """
+ return text_filter('!\[(%(caption)s)?\][ ]?\((%(image)s)\)', value)
+
+
+@register.filter
+def safe_html_thumbnails(value):
+ """
+ HTML filter that replaces all images with thumbnails, the returned string is not marked as safe.
+ """
+ return text_filter('
=1.6,<1.7
- django17: Django>=1.7,<1.8
- django18: Django==1.8c1
+ -r{toxinidir}/requirements.txt
+ django18: Django>=1.8,<1.9
+ django18: pytest-django
+ django111: Django>=1.11,<1.12
+ django111: pytest-django
redis: redis
wand: wand
pgmagick: pgmagick
-[testenv:py34-docs]
+[testenv:docs]
+setenv =
+ PYTHONPATH = {toxinidir}:{toxinidir}/docs
+changedir = docs
commands = sphinx-build -T -b html -d {envtmpdir}/_build/doctrees . {envtmpdir}/_build/html
deps = -r{toxinidir}/docs/requirements.txt
+
+[testenv:flake8]
+deps = flake8
+commands = flake8
+
+[testenv:isort]
+deps = isort
+commands = isort -c -rc thumbnails tests
+
+[testenv:coverage-report]
+deps = coverage
+commands =
+ coverage combine
+ coverage report
+ coverage xml