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. +[![Documentation Status](https://readthedocs.org/projects/python-thumbnails/badge/?version=latest)](https://readthedocs.org/projects/python-thumbnails/?badge=latest) [![Join the chat at https://gitter.im/relekang/python-thumbnails](https://badges.gitter.im/Join%20Chat.svg)](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 %} + The 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,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAEKAZADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDwRI81OibTmnrF6Cp0i9q9lnkXGCDIyKekR9KsRxkGrItsjcBxSAqJBmp0hPpxVmKDNWUtTxVXAqxwZ4xU6W2e1XIrX2qzHbc0rlIpR23tU0dr7VpJaDHSpVtOcgUrlWM1LbDDipntfmHFaP2TDA4qw9pnHFFyktGY7W3zDipTbZQcVqPaYK8VKLPLIMd6VyrGRcW+0jjoKqtb9TiugubTMhGKqvaY7VUWRK5jNbHHSo2t62Wtvao3tsDpVpmRjNb89KY0FazWvGcVE1vxVEXMloKYYeelajQ1E0HtTIMxocnFMaLHStJoMdqiaHHaqFczmiqJosmtEw5qNovSmK5ntHjpUZirQMOO1RtFTFcoNFjtUbR1eaKmmAnoKdguUDFTRAW9q0DAsfXk+lQSAt7D0oHe25UZQvTk+tQFDjnJq40fPSo2josLmKbJUbJVwx5JqNo6dh3KbJUbJVxo+ajMdKw7lQpTCmatGOmMlTYdyqUqMx1aKUxkpWHcqlKYU9qtMlMKVLRSZVK00rVhk9qYUqR3K5WmFSKsFaYVpWKTICKaVqYrTStTYq56GkNTpB0qdIcirEcGMcUiCGODJ6VbhhK47j0qWK3yauQwcjIqWNEC2Y4delWorbd2q5b25jI4ypq6lltAZBlTU3LSKEdoc9KtxWeD7VehtckcVdSy24yKm4zNWz/KrC2hxjFakdpgdKnSz+YcUnItIyPspwOKsfZCUHFawsucYqyljmLp0qXNGkYsw3sydpxU0VlmaMYrbaw+VGxxVi3sMz5I4C5qXNWNOV3OWmtj5rcd6rvacnIropLTMhJFRGzyelaKSMWmznWs+aje09q6B7M7ulQTWoPGK0UjFw0uc/JakjgcVVlg7AV0M1vgYxiqklr2A5rVO5i1Yw2t/aomt8dq2ntdo4GTUEltjjFVclmO8OARjJqF7fHatd7fuRUDwZPTirTJZktD7VG0GOorVaHHaoXg54qiDMaGomhycCtcWhbk8D1NRuixghRk+ppgZf2UKMtwP1qKTjhRgVfeMtyahaKnbuK5mtFk1G0VaLw1E0NWTczmiqNos1oNFUTQ0DKDRVE0daDRVE0VFguUGjNRNH+daDRnrUTRUh3KLR1G0dXmjqNo/akO5SaP2qJk9qvNHmomjxSsO5TZKYUq20dRsmKmxVyo0fFRslW2SmMvNTYq5TZOaYVxVtkzUbJSZVyqVphSrLJzTClRYaZ6rHF7Vbhg9qfHBj8KuQw1kyhkVtk9MVcit89RU1vDV+O3z2rNstIht4MdRWlaRbGAI3IeoqS3tQcZFaENntwQMg1DZpZrUY2m7AJEGUP6Vbhsyy4xWjZQmMYI3IeorQGmbMMnMZ/SsHOxSjfUyI7H1FWksCccdK2YbHcM4q5DYDGNvNZOoaqBirp/3WxViKw6jHFb0enbo+nNTR6bhhx1rL2h0KJgmw+VRip4rHAmbbwF61vf2bwBjvU7abstZOOpqHULUTiJNP74qBrHBzjiusk0/HBFVZLDAPGa6FUOeUDlWtDzxk/Sq01meeOa6mSzEakkdelUJbY88da3jM5pRsczNZkLjGTUDWQQdOa6OS02545qnLa+1bqRDRzs1uB0HNVXtuc4rfktQCeKqzWwXoM1spGLiYT257ioHgrba1aQ4Ck1HJZpEMucn0q+Yixh/YzJ0H41G0CQ/wC01aswZhgDaPQVTaDnGK0RDMyVWc89PSoGgznitVoMdqiaCrRmzJaDHaoXh5rWaD2qB4MHOKq5JlvF1qJofatN4ahaGqEZrQ+1RND7VpNDUTw+1MRmtD7VE0NabQ1C0PtQBmtFUbRVovDUTxe1A7mc0XNRNH7VoPFUTRUh3M9o6jaOrzR1E0VIdyiyVE0dXWjqNo6LDuUmSo2TFXWjqJo6kdymyVGUq2yYqJkqWi0yqyVGyVbKVGyVDKTPZYoauwQZpYYKuwwVytnRYWGDpxWjBb47ZpLeLmtK3gHHGKzZpEW3tumOK04LM8UW9meDWvaQHABFc8pWNIxuJaWhGOOa3dPgCHDDKnqKZaWmSMDJrasrXawytck5XN1GxGumCI7lGYz+lWYdN4zjrWxY24zgrlD2rUj0sIOBlD0rlc2tDZK+xhQab7VZXTMMOK6CLTsdRVkaflQcdKzuzVKxzn9mcDipJ9N/0ZFx1Oa6UWHHSi5seFGOgouy9DiJ9NyTxVKXTsZ4xXay6eDniqF3YALgCtYztoZSicRc2WT04FU5LMRgnHJrr5tPCjkc1mXNpnIxXRGZjKFzkri2zniqEtqSDxgV1VxaY7ZqnLpxxukwi11Rmc7gcpJaFiQBmoXsFTJkOPYV0NxsQFYVx/tGsqe3LHLcmumLbMZJIx5zxtjXaKoSQZz61uS22M8VVkt/QVvF2MGmzEe25qB4Patt7f2qB7fJ6VqmZNGLJb+1QPb+tbMlvzUDwdeKtMhox2g9qiaCtV4MHpULw9apMhoyGg68VC0HtWu0PtULQe1XcgyGg9qieD2rVeD2qF4OKq4jKeH2qJ4a1Gh9qgeEUyTMaKoXirTeH2qF4fagDMeKoWirTaH2qFoqBmY0WKheKtJ4qheKgdzOaOoWjrRaL2qFouelIZQaOomjz2q+0VRPHSY7lB46iaOr7R1C8dIpMolOelRsmauPHzUbJUMtM9yhh9qvwQZxxRDFzWjDCOK89s7kgt7cVpW8GTTYLfNalra9KylI0iiW0g9q2bW3BxxUNrbYxxWxa2+SK5ZSOmKsWrG0ywxW9aW/I3LVKygIINdBaQ5xkZrjkzRRLFpZjgjpW3Z24A2kZX0qvaQAYxWzbRDjisbXNVoMWxAxxxUyWfBGKvwRcYI4qytvjtWqgO9jMSz6cU2e0BPSthYOaZJBntmq5A5jn5rMBTxWfNZ9SRXTS2wPOKoz22c9h71DVijk7uzBzxWVPp5OSflHqa626iVc4XJ9TWJewl8559qSbE0jmLpEiyI13H+8axryBpDljn2rp7q35IxgVlz2+c8V1wdjnkrnMzW3oKpSQcHNdFPa9aoyWw54rrjI5JIwZLaqr2/tW7JCM9KqPDjIrdSMGjEkg9qrvBzW1LBntVZ4etaqRk0Y7wZqu9tmth4KgaHPatEzNox3t6ge368VsyQcdKryW+KtMhox3g9qhaD2rXeCoHgq7kGQ8NQPD61rPb89KgeCqTIsZTw+1QPBg1rPBULwe1O4jIeHrULw81rPB7VC9v7VVxGS8NQPDWs8HtmoHgp3EZTw1C0GTWo8NQvDgHtRcDKaH2qFocCtR4OelQyQ0XGZbQ1C0Vabw9ageH2pXGZzRVC8VaLRVC8dSykZrxVE0VaDxZqFoqRaPfobdeK0ILcHFLBae9aNvZZxzXkuR6aXkFvbdMVq21twKZb2YyORWlb2YyPmrCUjeMbE1rB0rZs4CcVVtrRcjL1sWlsox89c0pHQkXbSHkZFblpEOOKz7S3UY+cmtq0iUY+Y1zN3NUjRtY+ma17aPpWfaooxzWrbgCnEZdhSrKjAqGHFTjpXXHYyYtIVzS0VZJXlQCqF0mc1py9KpTj5TWM0aRZh3UWc1kXUHXiuguFODWVcoeawNDn7q368Vl3EGO1b9zGTmsu5iJz1rSLMpGJPbk9Kz5rU81tzxcYyaoyw11RZzyRiy23NVJLcVsywcnvVWS3rdM5pIx5LcCq7QD0rYeCq0lv7VqmZNGO8A9KgeAY6VsPb+1V3t60UjJoyHhOKieHNazQYFQvb5q+YhoyHg68VXeH2rYeCoHt+atSIaMd4O9Qvb1sNbVA9t7VSkS0Yz23J4xUMluQK2Xt/aoXt+elVzENGK8HtUDQVtPB7VXe2FNSJaMd4Kge3HpWw9t7VXkt+tVzCsZDwDHSoHg68VrPb4NQSQGncVjJeCq7we1a7w+1V3g9qLhYyZIagkh61rSQmq0kJouBlvD7VA8WO1ajw+1QPDz0ouUjLeL2qF4s1qPbn0qB4PalcpH0FDCa0beE0yGKr0MdeI2eykSwQ8itKCIACq8EZJFaEMfSsmzVFu2iAArVtlAxVC3TArUtk6cVhI2TNK0IFa9selZVsAuOa1LdumBisGao17U9O1alu2MVjQPyK0IZPeqQzYjfpVhHzWbDLVlJMV0RkQ0XM0ZqDzuKaZa05kTYkkkwKpTuMelSyScdapTPzWMnctIrTnis64PWrkz+9UZmz14rIplCcZNZ06ZzWlMM1SlXk1UTORmTJ7VTliHPFacqVVdK3TMWZUkQHaq0kArVkizUDw1qmZNXMmSAGq729azw9eKheGtFIxaMh7eoGtz6VsGCoXt6tSM3Ex3t/aoXt89q2Gt+OlQvb1akQ0Yz2+Khe39q2Xt6he3I7VXMQ0Yz29Qvb81sPBUL2/tVcxFjHe3OelQPB7VsvBULwZ7VXMLlMV4OvFV3tua23tx6VA9sPSnzE8piSW1QPb9eK23tRzioHtarnJ5TDe39qryW/tW5Jan61Xktj6VXOLlMOS3wOlQSQVtyW/tVd7fB6U+YXKYklvVd7f2rbkt/aqz2/tRzCsYslv7VXeAelbTwDniq7we1HMOxjPDVd4a2ZIOOlV3go5irHvkUWKuQxcilii9qtxRY7V4jkezYfDFxV+CIAiooojV6GLpnis3ItInhUcYFX4FPFV4YwKvRDFZNmiLdvgYrRhPNZ8PBq9EcVBojRhbpV2KTGKzI2q5E2OTQWjTikwOtTpNx1rNSSp0k4qkxmh59N87qKqebSebg1VxWLDS+vSoJX7Ux35qNm3DFK4yKU1UlNWHOeD1qvIKRBVcYqtKMnpVxxVd1zVIhlMwF+gqKSyYc4rTjQEUrJxVpmTMGS3I6iq7wVtywgrk1XeEEdKpSFYxXhxULRe1a0tvwSPyqu0XtVqRDRmNDmomgrTaGomgquYhxMxoPaoXgrVMBqJoPaq5zNxMlreomt61mg9qiaCnzkuJkNb9eKge25rZe3qJreq5yOUxXtqhe2rZe3OTxUL29PnFyGM9v14qB7b2rae368VA1vT5yeUxXt8E1BJb+1bT2/XioHt+vFVzi5TEe3qCS35rbe2z2qvJa89KfOTymJJb5qu9vW1Jbe1V5LXHrT5xcpiSQVWkg9q3JLaq0ltz0p84uUw5IOvFV3g9q25Laq0lt7Uc4cpiSQ+1V3g9q2pLc1Wkt6fOOx7rFB6CrkUPSnRw1ajhrxHM9jlEijxVuOOiOH2q1FCeOKnnKSFiSrcSUkcJqzHEaXMXYfEtW4xio448VZjjpXKSJYhVlT0qJFxxUyjFO5ZMhqYHkVAlPWquBLuoLU0GlouA4nIFRt1p2etMNFwGuNwz3qFhmpulMcUXE0VWSomSrbDNRMtO5LRWGUPFK0gI54qRo6jMdPmIcbkTgN9KiZMCrO2mFM0cwcpTeLg1WaKtB4/Somi9qfMS0Z7Q1G0NaBi9qYYc0+cjlM5oqY0FaRgApjQ0+cnlMtofaomhrUeCoTBRzi5DMaCoWg9q1GgqNoDRzk8hlNBULQVrNBUTQUe0FyGS8HFQPBWu8B9KheD2p+0FyGQ8FQPb9eK2Gg9hULwe1HtBchjPbe1QSW3NbMlv7VA8Ge2KftBchivb+1V5Latp7f2qB7ej2guQxJLY1Wktq3JLeq8lv7Ue1F7MwpLbFVpLf2rdkt+KrSW/tR7UPZmFJb+1VZLf2rdkt8dqrS2/tT9qHsz1FPGfhxLS2upNbsIre4UvC0lwqGRR1KgkE15r4F+MXiHWfiRLpOs2+laVo8ivLCLgmGby14VgWfDFuGK84G70r4vluGmYuxyzdTSrN8ymQCQDjDc/Sub2LtuerofqPbxJLGsiMHjIBDqcgjGc5+nNWvKWIKXYIGIUFjjJPQDPf2r8ydO8Z6/pqyJZ6zqFrFICHjhuXVSCNuMZx90AfTitGX4jeIrt7A3usXl/HYFTbwTzMyLtwV46cYHXPSsnSkhqx+lyQYBzxgZOewplhqNhqDFbW+tbplOCsE6OR+ANfnB4l+LPifxbqt3e3uq3CfaQqPbwyssexfurjPOPf39av+E/ixr3hWV7iwnjt70RiOK6WBTIozzyQeo4/wqHCaLXKz9I0iqdYsV8K3f7WfxAuPKA1G2tQrAt9ntUUv7HINdPbftdeIgkbSYYqQCERApA+q5yahylHoWoJ9T7IVMVKq18ly/td6vJa2xeGOCRuZPsqKdo6dWzz3xj8a2rL9qtrmLznvjbKjBCj2andj+Lj16fn0qHX5d4spUr9UfT6rxT1UgV82H9phY76GD+2PMjuDkzLbAJD6DBXODW7qP7Tdlptp5gvba8kOdsUEDAkgHrnAFQsXH+V/cV7F90e8BDS+X7V86Q/tYxS6g9tIiQW0al1vFTPmYXOCh6HPGBW94c/aCstatItRuNZttOBYI1jdFVIOcDIHOD6/yqni4x3T+4FRb6o9t8ukMXNeZ3Hx/wDDtg0v2jWdJIADBVuMkA8fw5zyDVa8/aC0GyhjeXXNNx97dErSBh6cZFP63T8/uF7CR6oYTTfJzXhdz+1x4ftoPMiWe8Zyf3a2uwoMe7AHJ9+9cne/thqHkjg0m7kj6LI9wkbEZznAU4P49Kr6wntFi9l3aPpuZFhQs7qijqzEAD8aaYcjPXNfKmp/tTN4htZILvRWntlO4Qm5GGPbd8nOPSrmlfthNYW9tFL4fxDCCpjW5ByvbkgEYpKu27OLsDpJLc+nDD7U0wH049cV8+xftfabc6bIJLOWzuOizqqSA5/2d3BqhYfE+0/tGXWIvFC213ODv8/chwe21hjHoB0rOpi4wtoxxoOXU+jmgxTDBXgevfHiG5tV/wCKvsbLYMk2hIZ8c9gT+HeuXuf2rzFayGLVpLpiCm1bHY65/iGQBx2/lTjiubaL+4ToW3aPqIwcVGbc9gT9BXx1D+1XqdvEzebq0rumzmRCV+hx+uM1jXX7Rdzrkm3UG1gRA5DCXdz9ARjoKv2s/wCR/gT7OP8AMfZ2o6np+kqjXt7b2odxGvmyhcsegrjtX+MvhTSLW7ma+a4a3laExQplncdQueo9+nIr5cj8ZeHL6MlbnbMeSLqN0+vJH9atx3+hzKWkvbCJMZDm5X8utZPETW8SvYK257tq37SXhPT7820SXl4gjLG5hjHl7tuQo5yeeCcce9cPqP7UV3DrFw1vY2j6UYT5HmK4kMmzIy2egY4PHbrXjfiHXfDdtGxGoxTHsttlz+nFcHqPiyyIP2SGVm6AynA+uBXRTc6nRnPOMY9T33Vf2rNem1u0ktbOytNPic+dAwMnmg8ct1GOvA5rP1H9qrxLKkscD6da7W2rKLVndhkfNycdM5479K+crrxFdSMxVliyP4FFMh19jgTxBx/fTg/lXX7NmSaPeT+1P4zh1W4lFzp91aeYu2A2gRdgbnbzkFhxyTjORXfW37XumGyt5bnQpRKzussUFwCUAxhhkc5z0z2NfLsdq19Hvtczoe6Dp7U46JeEf6hwfcYqfd6svl8j9A/CfjXSPGuhW+radcD7NMrviUhWTa21g3OAQffuK03vLNUlf7VAUjQyuRKpCIOrHB4A9a/OCUahZQSRbpooT8zoHIQ8YyRnBqvaXuoIGFpLPGHBVvJYruB6g4PIo9nfVMhryPtfW/2kfAmj3TQ/2jNfAIriaygMkZyCQM5HPQHjjNeKa/8AtZeIb2W4GnWVpp8DA+Vhd8idMZLcHv2HX2rw1rS7yd0Mn4qaia1m4Hlvn6VrGnFE2PcPDP7VOu6e7Lq0Q1SNwBvYKrRnI5G0DIxnIPU+nSvpHwl460Hx1p6Xmj38VwjZBiYhZUI6hkPIr8+3tZ0ODGwP0p1rLc2VxHMhlRkYMCjFSCPQ9jRKkpbMD9HXh9qgeGvmPwD+0xqmjaeLXWpV1RFkQrNdq5uI48/MuVA8w46E4xjnOePprwzrul+NNJTUtFu0v7J2KCSMEYYdQQQCCPpXBUcqfxGigpbETw+1QPDx0rbey9qgex9qy9sP2TMN4OvFV5Ifat17I+lV5LE+lHtg9kzBkg9qrSQ+1b0lifQVWksj6Ue2D2TMCSD2qtJB7VvyWJ9KqyWR9KPbD9kfCYgGOhNKIT/d/WvR0+HNyHC/ZZCOT+FWbf4azXDgfZLhffYSP0rpeLp9zp9hLseaLEUAOP0p3l9yCPrXr8PwcvJ8DyGRSQAXUr/OuhsvgLcjar2cznGMMvBrB4+iupaw030PAkjGfU1chtm6k8V9EWv7OMNwQWt5YX7jORWxD+zGlwgVsKOxViD+tYvMaPQ0WEqI+aBE0jAnoPSr/GECLwBjkV9Jw/srhcYlP/AhUv8Awy9KhyJFIrN46k+5X1aaPm4I8wwzNgVatoMsFySo5NfScH7MMm042/8AAiD/ACrW0/8AZXMy83G09wsZqfrcZaRT+4fsGt2fM8LSfIoZwoPHarrGeQjI3qOm/mvp+y/ZQeNyXuyADwPKrqrH9liwjjHnszt324WhOpL4abYWit5I+NJLOVyDsPXp2qOS1mXOFxX2037MWmnhS4XGOcHFKP2X9NIPPX+8RVJV/wDn2xN0/wCZHw41vMvUErSqsxtyoJ+XoK+0rz9lKxkUlZwp7Zbj+VYkv7LjWruAbbyycB2fk/hiqbqpa039wkoPaSPj/bJnBXJ+lW7fQr2/+5CQOuSMV9QD9nVoJlZpLQ4PQEn+lbkHwZigO0vGcd0U4rKVea+GBapx6s+Sz4Y1S2yUjDqeCKy9S0C8iG4oQe4Ar7Nl+DdvIDtuAp/3M1Uf4H2jnEtypU/9M+KmNeqndxG6UGrXPiRrG5DbSjHHtioXt7jbnYSPpX2tN+z9pEuc3aL/ANs6rS/s6+HpI8PfhOOqpXQsTLrAydFfzHxU0FyVIDPj0zVZ1uIcgbvzzX2XP+zZ4YDEf2tIG9BHwazJ/gF4XtR815cyAdxGqj8ya1WK/umfsP7x8gyPcv13flVV/PB6uPzr62f4K+FWk4a4xjPMi0L8EvBYAMs8oznAJxWqxNvsfkR7JP7R8ivHKw5Lc+pNQiFwecivr1Pg98Po8O92HGMgNLwf0q7H8J/h10JiDHjlzWn1p/yMj2K/mR8bHIBBHNNwSDgEmvsOT4R/DshmBQ7cZxIeOM0RfDrwFApdY4MDucVf1r+6yHRX8yPjo20snRCRS/2ZcH7sTc+1fYEvgzwLG5YRwM3f58f4VZHhzwgu0fZrMHHqKX1qXSA/Yx/mPjuPTbyA5VHU9eOK7Dw/481HT4oLC6Xbar8guNm9oh2OMfMB+dfScvh3wcwx9ntWHs2M1Xk0HwUTsGnWbOCAVZ8kfrSlUdRWnAElB3jI8Mj+Gs/i+c3tlqEWqGdjuZHAYH02nBH0xXW+CPhJqXhnXUlvbJrizkUpJGV3AE9GI9vb1r0CHSfClhcedBplnFJHysqOVYH2Ocg1rp4wsrOMYeMbMYQzs30xg1zzVeS5YvQuMqUXd7mtF4D8NxIqyw2kLvgAC4Qg59AeQfarUHw08LQ8SWofIyDlc/XNc6PFunkuzW9gAcNlxknPuac/xCitwQv2VY1XICLggdsAcYrkWFq9zb6zTNy6+GnhN5Cy6fGB0CGUnPuTtrNuPhf4QFwxbSR5OONk4yD9T/hWNN8SCyFlMLg9kQt+FUJ/iGGyVEO3buGFBzWyw1XuZvEUzppPhb4G2gf2bKpI/wCfpMg/lTdA8J6b4Z1GV7K38q1mAWRY791ZgOQflABIPrXE33j6cFQjQI7cb2OB+X9KqT+ObjBR9jsDncrAY+tbLC1Ho2R9Yh2PbbjXG860MV7exLb8hBcKQ49HyDu/E1JL46cg7ZIwQM4wua+fb3xrcI2cQyYBx82T+JrMuPF9zJGJFQAg4w7nDfT2q1gmS8Sj6Fn8dXQbHnwoPXaDWZdfEG8RT/pK7ieAqrj8a8Fk8XSy/wAGT/Eu8/l2qg/iVzvLDyQSeCSa0WCI+snu8/xC1FQSLpScdlWsa4+Jmrc4ulX/AL5/wrxO48SThTJkAEYBBz+NVLnxBIh27RJkdfM6e3WtVg12IeIZ7PN8QddkyRehR2wQM/pWTcfEPWyT/wATF/oGA/pXk/8AbszkFolG4HmSUcfrUEutvtwIw57gNyB7VqsJHsR7d9z6i0DxD4deRI/tEIuGG4R+YASPXArvbCfTriMGF4Jl9VYH9a+I9LOkSRi4a+kjvoV4aVGO3J5wF659a9J8MeO4dPy03jCA28hBOLd4trD3Cjb9CTXyVbBOPwNv5M+kp4hP4vzPqiNbGf8AcvbowIzgd8VvWDxMUEaldw4wD/hXhGi/GLTI7BWGq2urTAAhIriMSSDPJCsRzjnHtXqWgeMredYpomRwwyAUzx715/LKm/fVjpupL3T0a0sTJgMSPrx/StL+ybaNQZGlz7KCK5zT/GMJKhjtB67XBx+FdVZaxpl0oUzIxPcsAa9vDqhNWur+Z59V1Yu9iAR2KAGON5T/ALfSnDUrOLgWUJPXJUn+taw0qO5wYZht7K3Ipj6BcDJ2xP8AQ/4ivQVCa1jFfccntYvdmUNZZWBjs7dMjIxEDx+Jq7a+IbjIAw3cIoC5/AVF/Zk1rISLXtgkrwanjAJJkjWFcAbd/OO5NODqRe9gkotdzatdQ3qNxbPHUZ5q6rBgCO9ZtsYVk3GaJgOBVxJIvvCQelevTk7as4JpX0LFIaajq2MNmlJHrj2re5kVLy4eEbgm5RyaxL25ZX2LEhblm8x/yArpWUMCCODWXqUMaQoREr4ONrj5cHrn0rjrQdm7nRTavaxxGoXrAqwiXaD1J5Pfmsua6vSyE46dvTsa3Na0m4N6zxfPC64SRDkZyBjsAB6+1chfSXcVxIIwLiJVKlt3Xk9B/hXiTvF2Z6CV1oPl1S8i3lpS4DZ4BwOwHQVn3OrXE5fEjYToFXgn6mm3NzNDMhkkBZuAjIM9PQf55rC1HVd1z5B8sXA4EeDgduR1HapTTE9C5canMykGUFhkD1+vFYlze3WDGJySzZUnd/Qc4xUH2i4nlnAiHlJyXwFH1GDyP1qC4LzqzmaFcN84B+bPQFc4H4V0RSOdtiXV1dQOZGubqQsMYQALx3yTx1rC1Ka7uUdZmn2nnaHAK8jGTx3qR7yK6GxZmEQBba6hQx6dO/rVS7mRLqLzRGIzt+cq7BOT6DHH4Y9a6I2MJXKELoJSskuw4IAMucnuOOhPHrVd4ImgJkLBiu4kEsQM8HrwfpWhc2JuWYlgyN/AAxYj6jHX6+lUry1aCZPPjUp90GbLMcdQuW649a3TRk7iXMEcdsYpYd/lZffhskenHU81j3d5DbySbluZC6kLEyjKgY6Z6fnWhKLVFjklljkjYnAceYQvpwePaq8zm4kcxTOsW7J8thuC9l56fX9K0RBRtXgAkuIbS5jZgB8mPxHpj3plxqn2YqVhZos4baPT/PNRXVmdSE0MsJSRCRuO5pACe5wuPzrPutLWKONpZF8w/KpWQBRjkfe3enetFbqQ27aGgb+AOHTb5fJwycqfTnrVObV1SF5sqFiz8xIwo7FexNQ3Nsk1u0Ucssk2SCCAG56HIXgYrPa2Vk2SOEkDYUKm7f64J5Y+/tWqUTF3L934jiMa7p3jjkwHcYB56ZA9PUUR+JbWyRkkkhOGHyRnJP8AtHjisqSwkSZsRu0WPnVcKR+fFQxQ28js8C4RFy4kBAOPfcBVWiybs15PF1mudu6UNllCMMeh5wfp+NVU1sXMIdiYLdMgjcpGPpgY7fWudXSZZ5TtYqZMgeVMNvXsAw/rVmLTLhEZoxckldmGb7w9s5BFVyxIbZtXer2SJ58Ue/cMssjYU47ZHH51VuNYgeJYkgjijA4CMCF9s/jWf/Z1wluMMwJyXZyctnsfkIFZN1aI95+8kkZl55lZmGO3A/WhJEao1otX8+GXNysmxc7RIQM9gevSmvqcToqN5AON5CSnJ9cc+/1rBzFH5nnSXG3OVU55/A1k3EUT3JI27S2fmUo5Hp14rRRRSuzqLvUwAVEJkbg72mbGT6gE+lVTq7STKotyARlsS/KSe47g1hpFFbDdE6oB/DtIJ/WmTymV0Krgr90mTGMf571VkB0Al/d7gp2uT8u7OPbt/jUBkicnKIzMTtUNwvTuf61zhuLk5CNI6qcnDgr+VK+sTrBtZ+gI+ZVJNOxVjdN5mNvLVjt+VnjYEVHNcXCxBQzqG6tIAce4rCTxFcIoUFcD7p8sZ/MCoH1aWQPuUOxPDFj/ACoCzN9dSOwLLNuYZGRGSD+FRSTSZGD2+bkj9M1jRatI4wyq5xgArkD+tI+pH5SseD2O7H5ZoHZmpJMLlvKGTt7+YCuO/vULMillWF2BPXuKzjdysWYZZmHO1uv1OKRLsohJRiCP4ZVHNMdjr4fDGvMSU0NppZs4mZSVx3BX+XSrml+AdZklX7TaSQwsCBbSkBi3TnOM+o55rm5PHviK8R421K5lCnnyWKhsHPzbcMP0ra034x+LbEQFdSE8KnaYrqEHcffv+tfNTp4m3u8v4nvxnSvrc7hPgO2oLHtFxA2whmWIAcn+IEnn/Gtrwh8LPEWjy3VrczvDDMApnjIYSKOhHIZWH5Vwdh+0P4jt5JBMmm3gVgcbGhYdRgbSD9c1rj9qXX4bWdY7KyecKRGXZyFPrjnP0yK4J0cdJcjszpjUwy95aHs/hTQvFWioqy6uJkjJAWfdKJRnq+TwfcdK7Ow1/VIbYi+srQ3I/ht7goDz1y3HT1rwHRP2tVW0gXV9Eke6K4kltJQsRPbAYZGfTJxR44/absrzwyyaDYXFnrVwSjy3YVlgTHLKQSGJ6DIGOtcDwOKnUtKG/XQ6frNGMbqR9G+E/ibpPiG1+0aVfPfQo/kyy2MomSOUdVJBB6HOehrt7Hxi0UgRdTnhfGdsoYA/0r88fD3xcm8B/D/UPD2jWyW+q30qztq9vOS8SkAFdpHDgDbxwM55NfRvwH8dvqXhT7L4g1axl1CzEKi8bUUZrlJIxIu7OPmXcEPU5HPNaV8JVwqc4PS+ne3fQinXhWajLf8AA+q9P8fXBCh5rW4H12k10UHiqzuVHmxsp78BhXi9vIJcbMyL/s4b+VTyatDpaxvcXQtVkcRo0zBNzHgKMnkn0FFLM68NG7+oTwdOWq0Pb4J7G8/1bKD6Z2mrQhXHy/oa8dh1iWMj96fxFXE8RSj7twVP1Ir0oZvC3vQOSWBl0ker+SM5y2frQYwOTg/UV5hH4qv4wNly5H+9mrMXjrUEI3TqfZ0zXQs2oPeLRk8DV6M9ENwudu4Z9MioLm5ijBEhznjGRk1y1l8QQ7KtyibT/Gnb8K2F8QWk4DI0cv8AukZ/KuyGMo1l7kjB4epTfvRI7ia3CFWjLccZAJIz05rN1RtPunV1kdGGFUKPukHPI71ptd28hyCQxOSj8A/nVGTTWld28sMQcglVGfr6/wD1q553lorM2jpvoctqHhLTr1JQ0828tkSKxJRD1B2kc559a5PUfh/ZLbNH5lxKFADbxuZlJ4ABzj19a9KOmQwgtI7EJn9yGwufXisPULVyGZh5UfUZYHArilC2tjdNPQ8x1jw2IENqmpyWiKoXy1QKFA7dec+tc5P4SuWuDu1G4YDLMRvyFx6E/Q165c6fBGwE0CNKpHzALkDsM1k3gtppRLs80kncA2V54OfmyfxrNNxBxTPLZNMlt1jjluV2BcBJE3nv+f61l3zNDH5YhSHYSdiKyN7en+Feo32kRXPm+ZG7hzgRfKiqOuAM9K5mbwnHcMY1isYYB96SQM5P/j/Wto1WjGVPsebXet3UczNHcEkjYymAlguPXPH5Vky6h9m8uCK1knJUAF3QIpPXjv8ATFekzeGoFdY7QW0zbsEuSPw4H9KoXOgw6WpdwZpG+Tbbwvz7cdhzyDXRGsYSpHAz+IbpJFYwJFHwm9YNjAd9vPSs651i4j855XYhXKuph4+vIPNdjq8FzbQjIaIb8Rr9mljSMZ49f5VzzadCwdpluG2r9zy2UuBk7sHB/HFdUaqe5zumzNn8RwPcE217HuA5jmgwoPfGAPxJoOs2ZDE3cWUXbgqOPp6fTBqxd6cl15aJDK6If3ebaRsDGeRj/Csu/k5iS2uIpwWYMjW5Up/PHatlOLMnBiDXIwiLNDGUyVEgULx2wMjj61FcajGgiYSwLGRu2ywkBcHsRzmsu50a5iXdHO+yMZUPGWOOuMf1/WpjrE0Mipc7PMGM7oAg9+d39K1TT2MnFoWLUJpbkyR6jEzHAW2WEqF6ckk1LNqM0F0V/ta3VWB3LIg2g/UnP5VHZanHfFvNkUkn7jOMfjgDNV5r2xjldJI7Io2QHjjKHH4//Wq7kNDJ9Sge6CR30bXIXAZC+z3yMYNLbG4mkKrcwhWGXV5cFffIwB06Yqu2uWsJKwRwbIx/rWfB5PQLzk1K95a3FykwggkmZMs27Bx+OPywaZNia7ubi3PM6xxLgqYmLMfoDkcVSZzF88V20rHkvOoXA/LIqkftbymTqinAQP8Ad/Js06SO5DRqq4YryVBfgfXP86tWJsJeX0jyMZAJeQ2Ofl9xjmki1TYyiNNgPzAsDkH3NQXYmaNnKI0nQbSUB/DGKqxi4ZCyxSKyj7v2hRgeuMdKtBYuSSyz7mmFuyE4UlAB+BzVW9tpXJUtaLz94IrCqUuozRB9yHfJjImdTn9KWO/i3K7WjDb1EMmV/wC+cUxj2DxxbYUt5QTlj8sfPtUAhmcqzwxwrnjBQhvx9asHU5IwHMYgU85YDiqYu4hK8pkgY9QBGcfgRVAOuGWCQiWaMnqqhelVvtcQOJFjnbHAG0AH61LJrCykn7Md2MeYkeDUBvmDfvVkZB/C8RGfqRii4WDyxIhVLUJKefmfAP4d6hmjVcceUMZKgcZ+tTT6pavGgCAuOgUnj86rfbhKdpgZwTngmncpXGtAwjB2bozwCzZ/KkhijIIMcY+pUGnG+WKQL9nWROoGCD+h/pT49QAc/uypJyMZP9KB6kTK9r5biEzLkhZoMNx3HY5qzeb5GQrdhoeMkgEL9aittR1HTXaVsLBk4DMCpPcAjvT4dXgvLvcVFqx67UAds9jgHd+VeU1K97HqabXKGpQNAiSfaomiY/Kq/K3ucen406GLZErKscZYZV2Y4bnqCM1tTajo6RJus0u0JwzQ4BQ+67R/kUsWky6rDPLpkbNbBSfIkVkIPqvy4P60vaWXvKwcmumpl28lt5QiaELJuYuxY44HFZGoXYu5N0cflKOoByDV2cebEY40kF4zESg8bh755/OqV1ZSWYCyK6sT8wK8e2D3rogknfqYybasVFHPH6VueH54hKYLiJW3nKs46n0zwf1qnpej32q3EaWdvLLIxypjU8Y6nI7D1r0nSPhCXi+1avLc3UsnJjgVhyfVtrN+O3FZ4ivSpq03/mXSpzk7xRIktvpWkK+m6hcWt/E3LRJKEx3xInfHHB+tU/EkH/CXmLVRqmqyiAIAt1M86I3XMbu25M9eV/GtbQ9B8P6F4kJnvfsKIMCK+3RM5xz1JR/+BAZz0rutPtNE0+1uLqDUF1AEHMVkrrkDgjERx6fNgivFlXVKV43b9O56Kp86tKyO08J/GnUZ7G3t2121nujHgtqUfzKexLKgUnjpmtDxB+0XeeDpYF1XSNP1KFsI0thcOkm4dcoykD8zXmus+JLzRvDcV7oVrKLZwY5EubcFozk8l93JPXkc+lVvC/g218f6Fcm/1+8cxSf6u7tQ5jc4J2Ow3KD0+Xb7ivOVKmv3lRe7f+tkdbnN+5Hc928PftCeGta08XL201s24hooriGRwPXaWVj9ACa2Ifjt4EeSFBrEo81igZ7SVQhHXdleBXkvhLwHaaDbNBG9vqcTcFLyGORgo6AnGSOT1zituXwF4cMJ8zR9MtIsHLoDEyZHVSDwa5JOgpNJOxulVsmz2rTvEWha1EkljrFlcLJ93bOuT+B5/StIwylQYpNynoRgivlWf4Q2dlfxX9rqT6kPMUrZSDe7kHJG7I6+p6V0NnMdDFp5tlr+kTzZJ/sqXKMcndujJIX69+1RKnD7DuUpS+0j6VstVv8AT/uykJ/dzx+RrRHi+6wMlY/X90GX9MGvGNL8WSxWyiPULh0Vcj7UMP75Gc5/CtO38YyyopLFue+KIYmrS0i2OVGE9Wj05/Fssan9zZTZ4JIZCfbnNQf8JBuAljtoIZTwwwXBB642gV59/wAJQX9OO1NbXVbHyrn1FarH1epn9Vh0O5vbu8UAR2emzQclnMzgE9sKFIH41SnuJ5QR9hiw33kjuGTH0OwVyB18qpDK+0+jGok8QxxSh0donXkN6GtFj094kPDPozT1HJkjM+kwuoGGLzuR/IZPvVW3Fk6N5dosMgPzImcfn3/OpIfGUq78yR3Jc9X6j6EVIni2Eyfv4CFIwTFjNbLF0nvoZuhNFW602AxK8awb8dJZtgHr1zmuZu/DUkk7rDqRXHzlUuwyjPTAxiu1S/0e+h3GaOI9Nkhwf14NZ66LpQn82CSENztZSoI/KuqNSMtmc8oNbo851S2bSbgNHclrpV4cTbSff5RWHEou5xsvFu5WOZE87Ic+hGR+ea9VvvCKzNG0WpT2rKcnynJD/XOePoazNQ8GzTMCksbp0I5Uk8ck10qaMHA8z1XTNUKTlbcRQuCBHblFHI+h/nXPw2ms6fcCa2tbeR0ASOS4lDInOfuKAD9MV6Xe6DLFIwls2SLBwXmUjrjqOf09aw73SGggM8drHCc4DQkE/mwArWM0ZSgzzC6tfFEDy3nmRLMZSWVdjkN7Ltxz6Dt6VmandalpVyqXUMFzg4JhR1Dj1Bxz19K9Zl0nUbgRIi7m4SNEwCWb6YJPHfNcvqGn3q5aHTbed34UtMRL7npXRGpqYygc4lytzZRyx29xaTMRiGSTKOOe/UfiKlmhlWwNzcbbSLoN0bMZfXau3OB69PeuhtI76IMk2nLbLjLrcXbsOO21SC3txVcQWM6S/arIrICQXOnjB/4E5P8AnFa85l7NHGi4s7kBk2s2QmE+VnPoMdT7CknaSHck8EkTg8pcg8Y9cnNdjbQxtMhtLW6gIOBLZIplkHtj7v4YqFfD0EMv2zU/7ZkjlYl/Pi8tCe2WL7j71ftbbk+zTOHSWGaRUiS2lZugUbcH8VIP4VHcaezxsAU+b+E7QfwINbw0eC4laOCVplLfKeA+PoSRjFZ2oWMM5G6JVWPALLOq8fTH+NbKepk4Iw2SRnAj84bRg7dx49zzx+NMe7hhYKbnLbcEMT/PrV99IWN2ayeSWNT80iKdg+pyP1FNuNDlA81MPg5cKxJHocen0rRTI5CjFFDD+9Wa3hXdgh4yM/ie1SeYZNyOtq8Z6ssjD8s55qvd6UqvI11IYiD1lzk59utZjC3LYiMjYPUAoMValcTia9z9nYqylVjAHCyc+/AH86aBCWURGMKRw2/f+YPSsyMQDkyk4OdrNzVlL6dXCxzyxljtAUB81VyeUllMMkhSRpiigZaPoKrGCCcf6MS2zkmUYOPzxUguZXlPm3EY28ETRlD+ODzS/Z4rtRI0sACnlVkwfwzj+dO4WK9xBEjK7S+WCOSQefwBqrLOuAIyxA4DfMMj8c8VM+m2jsWV1wDjLOP8aT7AF4QeYP4SrA4NNMditl2bDRhVJ4JH/wBYU1oCpG3CE9CHP8qsLZTDLJJMCT1BH+NVZUuY25nLDoSwJpjR09roIv7yXF9OkUh3SeSiSnPqwU8++K7C58LwQ2aLbWovXChkUKJN3HQeYQQfbNZum2eoreESmLUoj/HeW/kyf+gnNdPFrGnadE8NvcWdvc7Mi3ecohPoQcD8a+UrVJ3XK7n0NOMbO5zH9lWF7A0F1Y32mXakErHFJArY6ZHKn9K7LTZrfTLSJbq3eUEBDJFHvx6bgCD+lY0V3rfiH9xNptskI4FzDqasqfUI+7FcnqOiS+HNYEWp6rbQRcOB9rmVseoBV6nk9t7knr23Hzez95LT7j0TxO9np1vFeNptrLIAczXkDMFH4kEdfWuPafQtcj8u8trKaZmaXzdIjMzKB1BgY8cckg54ru9MsfD/AIq0+O0t9XjupAuAtneOtwM8k/LgH/vn8KvaF8KLbTbsXMeo3sj5OIrt43ByO4MXP41zQqwpRtK6kjWUJVHdWscd8N/C/hO6vHmu9Rhvo4HARby2mtJR6FdzYbH4163q3gfSruwEMDyW8BUCQKI3DpknaSwzjnPBFagtmjh2tLd22QFDRuDg9MgEH9Kyybi3naKXUJtQEh3IJx0GcEZwP51w1cRKrPnTf9fJI6YUlCPLYhvrGx0vTbdLa1tdWlg4MN7IU2Lj+E4k9uD+dYmi+KPDGjXMT6hocGiXaFm/cOJYkUjO7zEA2556eldrBJDNGUlaIBh0Zc5BwORTbLwppNhfG6tLPT7S4KlfMih8piD1BxgGso1IpNTT+/8ApfgaODunEyZPE2heJ7eVJ7W1e1YKi/aHMu8HkHCA556ZNZdvdw2+pzWemWBtTtDGTT7Q70OcfMj/AHvfGTW9qXh3VLC3vLrTNTma6YZWCdVmQqOqIAoZc+xrm7OHU4bqXV9ejsLCKFcW5uLwhVc9iJkDDPP8RxWkeVp2ena/6EO6audLZR6rbzH7TNDcKvQxQeW316/41btdFSUOl3dX14kucxTyYTB45AwMj1GKz4dcbVYxfrBY6ikKkPJp10zPGfQFGKn/AIEK0NP1mP7IRcXIgl6+VfNGGA7ZCnBHpzXPJSWxsuVm3YW0VhBHDGrGNOEMsgdh/wACY5/Os/xFBa3tixkv47dVb5ZS6ny37c8hT9RUOk3bOViTyrpd2JJYX8oIOx2vnP4Gs7XvBGiateyi7t9TgnlG9rqJnWNT6h/ug8d6mKSl7zG9vdLkM8aslrqcIM4XeLgEtEVzwfMOOSKZA5sbjzzqVrDZ9GDzDaDjA4PA7cZqvYaXcw2Etu99Ya9axJtj+1QYkUAcKSmc8DrjJrG8L3+pXM8+dKjuNNQiMxWrRuqkdthAbsOvNact02n/AF8yb7Jm1a+M5rmQQ21impBX2vNBcqvH95RzkfiOtdCt9G5x90nkA4Nc7ba/pkwl3s2mpB9+O5g+z7Aeh+bGBWpbzQ3sEcltcrcRMOJI2DL+BGazmvKxcX53NE3e7gEionmDcF/wJqsW2sVJU8YwGBqAXClRvzg8rhSeKysWTzydePyFVXvZYwAGYcdD2p5kVxuUHjjgEVE2CCQzAnsRVCAanMM7gpH1waik1RSQGBU+vWo2XzB0XIPfiq8tuVwwA45+WnoTqa2n+Kbiwimjtnik83GfOUsR6Y5HFWk8YzRoqz2CPjktFIy5+gOf51yMluysM7sZ6YqrJJNHna8h54B6YrqhVnFWizCUIy1aO9PirT7gFPtVxZk9TNkgfj8wqs+rWmo+YlrqcV2yfeUQrJj68A1wbXc/8Y3ezCqksdpfNtmhAlHCsvX867oYhr4kcsqK6HfyCIsSt1DGWQIQuYfqBg8Dr/KqV/bQi2WB7VLmPIZWgxIAB6gnP4j8q45Zb/TY9lpq1zFF/wA8pD5ifk2a2LbxpC1mItSsFknXINxZBI931RlI/I12QrwezOaVKSJbxoCzJHPcQCQKuNhGDnjqox6cGornw9exwM8rrcQBMlI1ZWx04J3A1nw+JNOF8nkiZrmRwqL9nAbJOMZXjv61072Os+auLUhw5V0nQq8TKSNr5fA69K6010ZzuL6o5GDSZ4Y18zTbpHGNi2pVnOeMlQoP5VDceH/LupobaZYp41WR/OhcKAQDgnIHHTFdl9oSOUQ3Esce8EttLEryR2zjpnA5/Oufk1XSLW0uCbJ9PjRvLBkcBp8ZI+U5IB7Dn3xWibvoZuKscjNp9216ouEsWhHysLSV0bA/A9akeG0aFYbbQpZpMZ8yW43YI9Ay4/pXT2V3b6pNGLe0ht7pIs4uU3Fz125U/d5xmpI0kt5PJWCztpsYJacMwOegA6dOhrW8upHKjhreG6eVUWG7sUOImljMciKSfTP8hxSajomoQ+ascl/JtJXzVVNr4PXcW6fSu31a2MUQuAl+GUYKWkOwEd8KFJPuc81g6nbXs7mWSGZrRwu3zIAnA9Qcc+9WpN6kuNjlU8Mai5f7TdRwNt8wpLA0jbfqvf261nzWGn28TJJpv225J+ZnYwoB2wobcT7n8q7eG9t7ZXFukpjOAUMkjHI74AwB7ZqKSeFpVDafJdY5G9SR/P8AnV876kcq6Hnkvhy0mmilTTZVgPDRxzKSfoSxP6Vck8K6W1nc3RWa08l9i28zRp17bgCT+IFdbc6ek83nHT4oD/tR8L+hp6RWqxvb3F19nDZZWgKxDI6ZVVBb8afPcSR51LoEFherHerHaKyhgVmBbaRkHHv9KybrT4Jp3MKR+WOcBiAB6k7a66+sbKe9c3F6kjtx+8k8wE9uSM/rUMXh1ra3L28kCMx4kMZbP0xx+lbKdiLXOOe0gjkC7oR/tJKGqOSKZ5P3R388YcGumSwmtZRceXas6vn5o2GW/lVd9KRTNNfxTmYklIg28OT69MVpzi5TnTb3nmMVglOeD8pP9KU/bYiAYG3dBkc1o21wbSRiY5INwwdqMP61IIIBuee+MfOQhVjvH5iq5mFkdDocRijjuLW/vLqDOwLGonUHryP/AK1dBH4ihjjK3Pl2s/8ABJOvlL+IYY/KvKZ7+GFgtpd3RhYfOjqsZ3dsAAj9a6rQNVaWAJJPDcqE2+ReQFmAHOAQ3P1xXiVsPpzM9WnV+yjrorqdlBSGC+Dfdl05IiPo2JAfzWmweFLDUZJPtmj3CN99ZIPMIJ75UcCqdnrsVurxLps1pPtyjRxxyKRzn5GIP5VFY+J7SK7Wf7Rp8WMFnkja2fd7bSwPFcfLUV+XT0/4B0Xi9zWNomi2sh0hrixvMhYpFsl3gjpncnT3P51W0LxH45i1PbqGvXUSk5P2m3jlU+wwwx9a7ew8RQ6qFkgnsrhwMfK6M35q2T+Va7384T/j0SePGT5c+B+TVxuu4pxlBNvva/4o2VNOzT+4ib4y+GNMhS31LWLi2vQcEIkpA/DBH6mtS01iy8WK7aT4gsb9UA8yJEzIpPIyc5zXM3vhzS9dbzJjJayc/ItzjHHOMEj0qDR/AS6GN+najc2c2D80XlMeeuGZMgfjXO4UOXRtS89V+RsnU5tbNHTw6gIZfLMsshGEJjhByR168+latpqkUixlxcIxGADGB/I15p4k0fxbbX4vLXxFf3CIMiH7FDJnjGGC7cjHXPNW/DfjO4e28vXBfC83Y3W2lTqmPcc/mD+FKWHvHmg0/S/+Q1Vs+WSt/Xqeox3izbGDuDkgHHt9fan3DWt3D5E+LhB1SWPcDXJXWqPZ2xu4pka3GC7ThomVR1OMZ464wKw4PjR4ZW68htWi88HaGdJFQe+SOa540Kk9YJv0NXVhHSTsd3b6Fp+mPK9j5Fp5v+sWODy92OhJArBn0bX9MmEtrqD+ILaRi8tnqc8W0j0UmPIFX7TxamqQJJp9xaTlgfneXKt/3yPrWl/akzBQ6Jkpu+U9qV5wev4lWUtitGF1nRIoNQtpNJ64t1lRwnJ5BwykHrjH5Vzdj41tdCml0bUNbgkhXKxtGstsUU8KnmbAhJPuBXbC7Mg+VAq9lP8ACMfWs7UrWO+iaJoZsMCpMb4yPwORRCS1Ulp/XqJxe6epU1LVRpcggur0xRSFUjTWl3oW7bZFbI6+/wBRV2bT4oyZ4NOMjzYaV9NuhFk+ucrn/Oa4/ULaTwpG9zZy3N0P4rXUdRY27A9Sd4YAjjGK47/haet32vLp+zRLSDZmJDd/KTjoZIpfl9uPwrphh5VVensv62MpVVDSXU9sXXLfUle2CytMqgSRXEfKjpzkYP4ZrHg8LWNkg/scrpQaTfKtpGoSX6g8e1U9JFzthlluWKFB5tqXkmVH9Ukbkjr1HpTbqBI28y0t5EmBOXspVjZj/tA8H8a5VHldos2vdXaNC9vtbs79Et9OtL2xbA8wTGORPUkEEH8KuR6zatObd5fJusAmGZSpOfQkYP4VjNq7zKsMFyLe8C5Md0gYn8FYe/Q1JbX00u5Lu2QFP4tysj47qOo+hpOOmq/r+uw09dGbrbGUvyfXH+NRvJgE7t2eQD0rk57HTdcuQ0UstpeQHeXti0Dn3IIAYce9WpJtSivF8poLmxP3g52yIfYjgj680vZ+Yc3kbxn55UNx1BpolSRSWAHGdtcrd+NLPTtR+x3a3Vs5AKStbkxPn0YZ79sVqCdplMkLeYG7AdaHTlFJtbgpJ7F6Vo1wAFI9VPSq021wQMj8ah+1njdnr09aiNwx6cH6Ukh3GzQjH3eenNVJYVRw2SpHQqcYq157MPmJXuciq8xEvIBPrjitFch2K0lyVxuXcP1/OqxnjlyArL7HFTy2/mDjeMiqM1p1GWPp8tbKxk0E1oWAZQvHIIIrpLPxw76Fc2WuC7v7iI77O5hlG8A43JJkgMO4JyQcjoeORaExsNrMv4GmG4YffdTj2INdUJyg7xZjKKkrMvXOpWFwglu7kAcgQ3VmxaMD1ZGxz6g0yJ9Pu2DWdtBcLgE7JGyMjk4Ln8jVGSWArln59hVC6e0kPzQmT3ZAa9CGKb3RySw66M7G0uU0tWiNrNc85WRo13L7Aswq9aeKo7K3dLh7mF2Y4ZIwh9MHbgH615kzRKT5LTRbeQI3Zcfkahk1K/QnytTv0J7eexH5GulVYy3MXSktjvJ/Emm3F4SJFkKkFfNSRQW6HPXvx0NZeoSXmtX7mCWaaTIw8LrHGi56bduW7ZJHauLuvEGqBWEkougevnISfzDCq9t40ns5w72Ee4cFo7iWNsZ9ctXXCz+E55Rktz0XX9Ev9LijgluNQnaRQUeORVUEdchlHH0rFihBSYTXzxiPO7ejMx/DYB+ZrLT4iRvE6f2e8ZIIVhc+YQD1GGAz+NInxGu1sEgWyjkKggt5a5YZ4HDYAA46VSjK1iHYbdyvuPk6tcYDDaFtCFx3HUU+HULMR7ZtWuBLnDnySrH8zWa/ia3mYzXE15augB2hGKk+i4BqKXXrKXbPb3huCD/qpMxEc989a15WZmq+vadbxNHDd30j9d20E/qTVD/hJIz5geW6LNwGJCgflWbLq84do8JP5i5U53bG/MCol1PzJSb2Ng3QAL+X+QapREy/JrltFFKPtTSBuqAZ/MnOPwqpb38E5AzuyP4Sx/pU0Vubm2ll8hZ4YSRI8N4oKAY5YY4HzDk8ZOOtZ91dQeY627y2/YjzFf8AlVJLoL1NW3iMvyw2ZnBy3+qc4/EkcfQ0klvcSz+R9nCEDgswAX6c5qC01OfT96GWeRFHKS/Ko+tQXl+Lp9yjDEYYRqp4/PNTrcZyQvLKKTMcLfQzA4/8dq3f6jDHDBPZ38iTD5WjCLuX0I9R9KbpFtC4fdEjfOvVQe5qO8gjGqRqI02+m0Yo5VzWNbuxetXvdcMZvLiaVQcK5jMpXjk/KRio73SDpt2DFPPJsO4RSWxcfiDwRWTeO0FxMI2MY8v+E49K6HwDdTPq1wjTSMgiGFLEgVhUbppyW3Y0glNqL37kv2zQbh4RcaPNBMfvz2sZjH1C5robfQY7y3+1aL4h1CGRRgIkrFvoQSD/ADrZuUXzR8o5I7V5v4llcapIgdggDfLnjgZFcUG6rtFtfj+Z1Siqau9fwOr0rUdb06RXuLu8ugGLMlxZoxP4l844rstM1CS7kuLiKT7FcPGFZpIDlgvQbQ7DivNvDl7cf8I7LJ58vmKBh95yOPWrXhHVLy71WUT3c8wDcCSRmxyPU1zVoqV/LyNqbtY9ktPEl2sCxi6gcsMEsQFz3yjAEdPwqWzvYZr0GS2RVTgSRzR4J9MB8gnntXnt1I9u8YiZogY3J2HGT61ev7qf7LAfOkyYtxO49cdfrXnukk7I6lNnqMZmjiYrN5gOOqlsdcZ5NZV7pFvflzJDaNcbSFkls42x75YH9ao+H2L6ZbyMS0jM2XPU/KO9a1si3EBWVRKoCYDjIHArj1i9DosmjkX+G2tQszafrs1rGefLgs4McDuFQZrV0bSNSsAU1XVZLufACbYfs/zDJ+bqDnp0FWtSRYZQI1CDKnCjHPNdFp0jS2MZdi5wRljmtKlabVnb7kRCnFO6/NnmGua543tiILTSLqK1fj7RZXEc8i4PPYDnGMEfStbwp4u16ezT+0bqcXG8rtv9P8l1UdOhG7jqcV216xDtyf8AIryPx7aQTTTyyQxvLz87KC3511UeXEfu+VLz/wCHMqilR9/mbPTmv5rqFlf7NIHOcKpwR6fh6GuF8b+BbrxIUe1a3iUkbrZ5GijB9mVe/pj+deZ+B55bfxDcxRSPHE1vISiMQpIHBxXqvge4ln0uFZZHkVN20OxIHHanOk8HO8HsTCaxEPeRj2XwluNLaK8tNa1OxuIsMwgkE6fQA4yPqK7D/hLJbWzD/Zb2QxA+ZLcpHAuMfMWOT6dhXA6jq99/aWow/bbjycY8vzW2/lmvOdV5vL1TyqtwOw5FdNOjLFv97K9vIynUWH+BHtqeKbvxRaNcaN9jMkUvliGVkxIxHUORnj0ABq+INZ1OWE3D3um7EUOsF2mxz3IC5x+NeT+JJHs/CWkXUDNBdFOZ4ztfqP4hzXc/CjUbvUNKiN1dTXOBgedIX7+9YVqSpQ5omtObnK0jpZLWW0eO4ZluNuAJbtd8i+u1wB696zb3VLuxu1uBeXEkQb54XgjkjbPptwwP511MZLMwJyPKzz+NcT9qmfxRdwtNI0Ky7RGWO0DC8Yrkpu97o3mrbGhqOr75IWW6Y7/mNmI4gyL/AHiHOeKoXWg2/iu7S/i1N4/LwA9v8rAdcHpgmsvTbue4v7iKWaSWLy2+R3JXv2rRtYI28OspjUrtztIGM8810Rjy2cdHt95i3zaSOsh320KxL5p8vAzKxY49yeT+NNkkAJz93171x/gy5mltmV5XdVI2hmJA69K6VWJZuTwRj865Zw5ZNG8ZcyuTbwCTlgeuCBimMzE8gEnk/LUDncwzzyOtMuxtAI4OT0+tJILkrTMvXnjH3qZJcbcYYjHUdahPU/X+lOdR5IOBkinYQjyE8kAn6VCcAZZeOtVpRiNiODx0qEMeOTWiiTclu50HyjGfTvVQzv8Awhhn2zVl+QKrSffFaxIZC8smeUx75qFwX4+X6E0/cSep6+tQlicZJPPrWqIZXmtdwyUz7gis+40xJM5jP4itQDP5VVLEjqetdEZNGLSZiy6KOdmV+lVZdNuVzsdh+NdHMP3afQ1QmJz1rqhWmYypxMBoLpD83mH3DU4OVHzhv+BKa2HPH4VXl6n6muqNdvdGDoozGa1b7wXPulMZof4dv4CrcoBXnmqUygN0FdEalzF00iL7LGN5Uhd4w2zjIz0PNPW4uIwAtw+BwOAf51Uk4NNUn1rdO5jaxcF7ICRIFkz1LJgn8QRUk9/b3BPmxyFuMNvzj8CP61SHWnL1FFguf//Z' # 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: ![Title](/image.jpg)' + })), 'image: ![Title](thumbnails-cache/09a/e5965fd6bcafba14e3e696059acfa3db6ca8e.jpg)') + + self.assertEqual(self.MARKDOWN_TEMPLATE.render(Context({ + 'text': 'image: ![](/image.jpg)' + })), 'image: ![](thumbnails-cache/09a/e5965fd6bcafba14e3e696059acfa3db6ca8e.jpg)') + + def test_html_filter(self): + self.assertEqual(self.HTML_TEMPLATE.render(Context({ + 'text': 'image: Title' + })), 'image: Title') + + 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: Title' + })), '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