├── .dockerignore ├── .editorconfig ├── .gitignore ├── .isort.cfg ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── appveyor.yml ├── conftest.py ├── docker-compose.yml ├── docs ├── Makefile ├── about.rst ├── api.rst ├── changelog.rst ├── command-line.rst ├── conf.py ├── configuration.rst ├── index.rst ├── installation.rst ├── todo.rst └── usage.rst ├── flask_resize ├── __init__.py ├── _compat.py ├── bin.py ├── cache.py ├── configuration.py ├── constants.py ├── exc.py ├── fonts │ └── DroidSans.ttf ├── metadata.py ├── resizing.py ├── storage.py └── utils.py ├── manage.py ├── requirements_docs.txt ├── setup.cfg ├── setup.py ├── test.Dockerfile ├── test.docker-entrypoint.sh ├── tests ├── __init__.py ├── _mocking.py ├── base.py ├── decorators.py ├── test_bin.py ├── test_cache.py ├── test_concurrency.py ├── test_conf.py ├── test_resize.py ├── test_storage.py └── test_utils.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !test.docker-entrypoint.sh 3 | !flask_resize/*.py 4 | !flask_resize/fonts/* 5 | !tests/*.py 6 | !setup.py 7 | !tox.ini 8 | !.isort.cfg 9 | !conftest.py 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | indent_style = space 10 | indent_size = 4 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.egg-info/ 3 | *.pyc 4 | *.sublime-project 5 | *.sublime-workspace 6 | .coverage 7 | .env 8 | .python-version 9 | .cache/ 10 | .tox/ 11 | .venv 12 | build/ 13 | dist/ 14 | docs/_build 15 | venv 16 | MANIFEST 17 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_first_party= 3 | flask_resize 4 | line_length=79 5 | multi_line_output=3 6 | not_skip=__init__.py 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: pip 4 | 5 | services: 6 | - redis-server 7 | 8 | matrix: 9 | include: 10 | - python: 2.7 11 | env: TOXENV=py-release 12 | - python: 2.7 13 | env: TOXENV=py-full 14 | - python: 2.7 15 | env: TOXENV=py-redis 16 | - python: 2.7 17 | env: TOXENV=py-s3 18 | - python: 3.4 19 | env: TOXENV=py-release 20 | - python: 3.4 21 | env: TOXENV=py-full 22 | - python: 3.4 23 | env: TOXENV=py-redis 24 | - python: 3.4 25 | env: TOXENV=py-s3 26 | - python: 3.4 27 | env: TOXENV=py-svg 28 | - python: 3.5 29 | env: TOXENV=py-release 30 | - python: 3.5 31 | env: TOXENV=py-full 32 | - python: 3.5 33 | env: TOXENV=py-redis 34 | - python: 3.5 35 | env: TOXENV=py-s3 36 | - python: 3.5 37 | env: TOXENV=py-svg 38 | - python: 3.6 39 | env: TOXENV=py-release 40 | - python: 3.6 41 | env: TOXENV=py-full 42 | - python: 3.6 43 | env: TOXENV=py-redis 44 | - python: 3.6 45 | env: TOXENV=py-s3 46 | - python: 3.6 47 | env: TOXENV=py-svg 48 | - python: pypy 49 | env: TOXENV=py-release 50 | - python: pypy 51 | env: TOXENV=py-full 52 | - python: pypy 53 | env: TOXENV=py-redis 54 | - python: pypy 55 | env: TOXENV=py-s3 56 | 57 | install: 58 | - pip install tox 59 | 60 | script: 61 | - tox 62 | 63 | after_success: 64 | - pip install coveralls 65 | - coveralls 66 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | [See the docs](https://flask-resize.readthedocs.io/changelog.html) 4 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Created by Jacob Magnusson, 2013 2 | 3 | ## Contributors 4 | 5 | * No other contributors yet 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Jacob Magnusson 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | The views and conclusions contained in the software and documentation are those 25 | of the authors and should not be interpreted as representing official policies, 26 | either expressed or implied, of the FreeBSD Project. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-Resize 2 | 3 | [![Travis CI build status (Linux)](https://travis-ci.org/jmagnusson/Flask-Resize.svg?branch=master)](https://travis-ci.org/jmagnusson/Flask-Resize) 4 | [![PyPI version](https://img.shields.io/pypi/v/Flask-Resize.svg)](https://pypi.python.org/pypi/Flask-Resize/) 5 | [![Downloads from PyPI per month](https://img.shields.io/pypi/dm/Flask-Resize.svg)](https://pypi.python.org/pypi/Flask-Resize/) 6 | [![License](https://img.shields.io/pypi/l/Flask-Resize.svg)](https://pypi.python.org/pypi/Flask-Resize/) 7 | [![Available as wheel](https://img.shields.io/pypi/wheel/Flask-Resize.svg)](https://pypi.python.org/pypi/Flask-Resize/) 8 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/Flask-Resize.svg)](https://pypi.python.org/pypi/Flask-Resize/) 9 | [![PyPI status (alpha/beta/stable)](https://img.shields.io/pypi/status/Flask-Resize.svg)](https://pypi.python.org/pypi/Flask-Resize/) 10 | [![Coverage Status](https://coveralls.io/repos/jmagnusson/Flask-Resize/badge.svg?branch=master)](https://coveralls.io/r/jmagnusson/Flask-Resize?branch=master) 11 | [![Code Health](https://landscape.io/github/jmagnusson/Flask-Resize/master/landscape.svg?style=flat)](https://landscape.io/github/jmagnusson/Flask-Resize/master) 12 | 13 | 14 | ## About 15 | 16 | Flask extension for resizing images in your code and templates. Can convert from JPEG|PNG|SVG to JPEG|PNG, resize to fit and crop. File-based and S3-based storage options are available. 17 | 18 | Created by [Jacob Magnusson](https://twitter.com/jacobsvante_). 19 | 20 | ## Installation 21 | 22 | pip install flask-resize 23 | 24 | # With S3 25 | pip install flask-resize[s3] 26 | 27 | # With Redis caching 28 | pip install flask-resize[redis] 29 | 30 | # With SVG source file support 31 | pip install flask-resize[svg] 32 | 33 | # With all features above 34 | pip install flask-resize[full] 35 | 36 | ## Documentation 37 | 38 | Found @ [flask-resize.readthedocs.io](https://flask-resize.readthedocs.io/). 39 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # Made with help from https://packaging.python.org/appveyor/ 2 | 3 | environment: 4 | TOX_TESTENV_PASSENV: DISTUTILS_USE_SDK MSSdk INCLUDE LIB 5 | 6 | matrix: 7 | - PYTHON: "C:\\Python27" 8 | TOXENV: py-release 9 | - PYTHON: "C:\\Python27" 10 | TOXENV: py-redis 11 | - PYTHON: "C:\\Python27" 12 | TOXENV: py-s3 13 | - PYTHON: "C:\\Python34" 14 | TOXENV: py-release 15 | - PYTHON: "C:\\Python34" 16 | TOXENV: py-redis 17 | - PYTHON: "C:\\Python34" 18 | TOXENV: py-s3 19 | - PYTHON: "C:\\Python35" 20 | TOXENV: py-release 21 | - PYTHON: "C:\\Python35" 22 | TOXENV: py-redis 23 | - PYTHON: "C:\\Python35" 24 | TOXENV: py-s3 25 | - PYTHON: "C:\\Python36" 26 | TOXENV: py-release 27 | - PYTHON: "C:\\Python36" 28 | TOXENV: py-redis 29 | - PYTHON: "C:\\Python36" 30 | TOXENV: py-s3 31 | 32 | install: 33 | # Install Redis 34 | - nuget install redis-64 -excludeversion 35 | - redis-64\tools\redis-server.exe --service-install 36 | - redis-64\tools\redis-server.exe --service-start 37 | # Install tox for testing 38 | - "%PYTHON%\\Scripts\\pip install tox" 39 | 40 | build: off 41 | 42 | test_script: 43 | - "%PYTHON%\\Scripts\\tox" 44 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import uuid 4 | 5 | import pytest 6 | from PIL import Image 7 | 8 | import flask_resize 9 | from flask_resize import _compat, resizing 10 | 11 | 12 | logging.basicConfig(format='%(levelname)s:%(name)s:%(thread)d:%(message)s') 13 | log_level = os.environ.get('RESIZE_LOG_LEVEL', 'error') 14 | flask_resize.logger.setLevel(getattr(logging, log_level.upper())) 15 | 16 | 17 | def pytest_addoption(parser): 18 | parser.addoption( 19 | '--skip-slow', 20 | action='store_true', 21 | help='Skip slow-running tests', 22 | ) 23 | 24 | 25 | @pytest.fixture 26 | def image1(): 27 | return Image.new("RGB", (512, 512), "red") 28 | 29 | 30 | @pytest.fixture 31 | def image1_data(image1): 32 | return resizing.image_data(image1, 'PNG') 33 | 34 | 35 | @pytest.fixture 36 | def image1_name(): 37 | return 'flask-resize-test-{}.png'.format(uuid.uuid4().hex) 38 | 39 | 40 | @pytest.fixture 41 | def image1_key(image1_name, resizetarget_opts): 42 | target = resizing.ResizeTarget(**resizetarget_opts) 43 | # Obviously only the key if no processing settings have been pas 44 | return target.unique_key 45 | 46 | 47 | @pytest.fixture 48 | def image2(): 49 | return Image.new("RGB", (512, 512), "blue") 50 | 51 | 52 | @pytest.fixture 53 | def image2_data(image2): 54 | return resizing.image_data(image2, 'PNG') 55 | 56 | 57 | @pytest.fixture 58 | def filestorage(tmpdir): 59 | return flask_resize.storage.FileStorage(base_path=str(tmpdir)) 60 | 61 | 62 | @pytest.fixture 63 | def redis_cache(): 64 | if _compat.redis: 65 | cache_store = flask_resize.cache.RedisCache( 66 | host=os.environ.get('REDIS_HOST', 'localhost'), 67 | key='flask-resize-redis-test-{}'.format(uuid.uuid4().hex) 68 | ) 69 | else: 70 | class FakeRedis: 71 | key = None 72 | _host = None 73 | # NOTE: Won't be used as long as @tests.decorators.requires_redis 74 | # is used. 75 | cache_store = FakeRedis() 76 | 77 | yield cache_store 78 | 79 | if _compat.redis: 80 | cache_store.clear() 81 | 82 | 83 | @pytest.fixture 84 | def resizetarget_opts(filestorage, image1_name): 85 | return dict( 86 | image_store=filestorage, 87 | source_image_relative_url=image1_name, 88 | dimensions='100x50', 89 | format='jpeg', 90 | quality=80, 91 | fill=False, 92 | bgcolor=None, 93 | upscale=True, 94 | progressive=True, 95 | use_placeholder=False, 96 | cache_store=flask_resize.cache.NoopCache(), 97 | ) 98 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Scripts for running the tests inside docker 2 | # NOTE: pypy not added here. Needs to be run in tox. 3 | # NOTE: The tests are run with all dependencies installed 4 | 5 | version: "3.4" 6 | 7 | services: 8 | redis: 9 | image: redis:3.2 10 | py27: 11 | image: flask-resize-test:py27 12 | build: 13 | context: . 14 | dockerfile: ./test.Dockerfile 15 | args: 16 | PYTHON_VERSION: "2.7" 17 | command: test 18 | environment: 19 | REDIS_HOST: redis 20 | volumes: 21 | - ./tests:/app/tests 22 | - ./flask_resize:/app/flask_resize 23 | py34: 24 | image: flask-resize-test:py34 25 | build: 26 | context: . 27 | dockerfile: ./test.Dockerfile 28 | args: 29 | PYTHON_VERSION: "3.4" 30 | command: test 31 | environment: 32 | REDIS_HOST: redis 33 | volumes: 34 | - ./tests:/app/tests 35 | - ./flask_resize:/app/flask_resize 36 | py35: 37 | image: flask-resize-test:py35 38 | build: 39 | context: . 40 | dockerfile: ./test.Dockerfile 41 | args: 42 | PYTHON_VERSION: "3.5" 43 | command: test 44 | environment: 45 | REDIS_HOST: redis 46 | volumes: 47 | - ./tests:/app/tests 48 | - ./flask_resize:/app/flask_resize 49 | py36: 50 | image: flask-resize-test:py36 51 | build: 52 | context: . 53 | dockerfile: ./test.Dockerfile 54 | args: 55 | PYTHON_VERSION: "3.6" 56 | command: test 57 | environment: 58 | REDIS_HOST: redis 59 | volumes: 60 | - ./tests:/app/tests 61 | - ./flask_resize:/app/flask_resize 62 | lint: 63 | image: flask-resize-test:py36 64 | command: lint 65 | build: 66 | context: . 67 | dockerfile: ./test.Dockerfile 68 | args: 69 | PYTHON_VERSION: "3.6" 70 | volumes: 71 | - ./tests:/app/tests 72 | - ./flask_resize:/app/flask_resize 73 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-Resize.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-Resize.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-Resize" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-Resize" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/about.rst: -------------------------------------------------------------------------------- 1 | About 2 | ===== 3 | 4 | Created by `Jacob Magnusson `__. 5 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | Resizing 5 | ~~~~~~~~ 6 | 7 | .. automodule:: flask_resize.resizing 8 | :members: 9 | :special-members: 10 | :exclude-members: __weakref__ 11 | 12 | Storage 13 | ~~~~~~~ 14 | 15 | .. automodule:: flask_resize.storage 16 | :members: 17 | :special-members: 18 | :exclude-members: __weakref__ 19 | 20 | Cache 21 | ~~~~~ 22 | 23 | .. automodule:: flask_resize.cache 24 | :members: 25 | :special-members: 26 | :exclude-members: __weakref__ 27 | 28 | 29 | Configuration 30 | ~~~~~~~~~~~~~ 31 | 32 | .. automodule:: flask_resize.configuration 33 | :members: 34 | :special-members: 35 | :exclude-members: __weakref__ 36 | 37 | 38 | Constants 39 | ~~~~~~~~~ 40 | 41 | .. automodule:: flask_resize.constants 42 | :members: 43 | :special-members: 44 | :exclude-members: __weakref__ 45 | 46 | Exceptions 47 | ~~~~~~~~~~ 48 | 49 | .. automodule:: flask_resize.exc 50 | :members: 51 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 2.0.4 (2017-12-19) 5 | ------------------ 6 | 7 | - **Feature** Add the ability to pass in a pre-configured redis host with option `RESIZE_REDIS_HOST`. 8 | 9 | 2.0.3 (2017-04-19) 10 | ------------------ 11 | 12 | - **Feature** `RESIZE_REDIS_PASSWORD` added 13 | 14 | 2.0.2 (2017-03-25) 15 | ------------------ 16 | 17 | - **Bugfix** `RESIZE_RAISE_ON_GENERATE_IN_PROGRESS` wasn't respected - error was always raised. 18 | 19 | 2.0.1 (2017-03-23) 20 | ------------------ 21 | 22 | - **Bugfix** `s3` was erroneously added as a dependency instead of `boto3` in the recommended installation option `flask-resize[full]` 23 | 24 | 25 | 2.0.0 (2017-03-22) 26 | ------------------ 27 | 28 | - **Feature** Added commands for generating images, listing/clearing cache and generated images (see :ref:`cli`) 29 | - **Feature** Ability to run resizing without a Flask app being involved (see :ref:`standalone-usage`) 30 | - **Enhancement** Windows is now supported, at least on paper. The test suite runs on a Windows host after each pushed commit, using the excellent CI service AppVeyor. The project still needs some real world usage though. Please report any issues you might find to the Github issues page. (see :ref:`compatibility`) 31 | - **Enhancement** `dimensions` is now an optional argument. Useful when just converting to a different format for example. (see :ref:`resize-arguments-dimensions`) 32 | - **Enhancement** Added some basic logging statements for debugging purposes 33 | - **Enhancement** Pypy and Python 3.6 are now officially supported 34 | - **Bugfix** Concurrent generation of the same image wasn't handled properly 35 | - **Bugfix** There was a logic error when installing `flask-resize[full]`. `redis` and `s3` weren't installed below python3.4. 36 | - **Breaking** `flask_resize.resize` is no longer available. It's instead accessible when creating the Flask app extension. I.e: `resize = flask_resize.Resize(app)` or `resize = flask_resize.Resize(); resize.init_app(app)` 37 | 38 | 1.0.3 (2017-03-17) 39 | ------------------ 40 | 41 | - **Improvement** Add image to cache when the path is found while checking for its existence, even though it wasn't in the local cache since before. This way a path can be cached even though the generation was done on another computer, or after the local cache has been cleared. 42 | 43 | 1.0.2 (2017-03-17) 44 | ------------------ 45 | 46 | - **Breaking** Removed option `RESIZE_USE_S3` and replaced it with `RESIZE_STORAGE_BACKEND`, more explicit and simple. 47 | - **Improvement** Automatically load `RESIZE_S3_ACCESS_KEY`, `RESIZE_S3_SECRET_KEY` and `RESIZE_S3_REGION` settings using `botocore.session`, in case they are not explicitly specified. 48 | 49 | 1.0.1 (2017-03-17) 50 | ------------------ 51 | 52 | - **Improvement** Added the option `RESIZE_S3_REGION`. Can be set if the S3 region has to be specified manually for some reason. 53 | 54 | 1.0.0 (2017-03-17) 55 | ------------------ 56 | 57 | Flask-Resize 1.0 🎊 🍻 🎈 🎉 58 | 59 | The big changes are: 60 | 61 | - **Feature** Support Amazon S3 as a storage backend 62 | - **Feature** Support Redis caching. The usefulness of this is threefold: 63 | 1. When using S3 as a storage backend to save on HTTP requests 64 | 2. No need to check if file exists on file storage backends - perhaps noticable on servers with slow disk IO 65 | 3. Much lower chance of multiple threads/processes trying to generate the 66 | same image at the same time. 67 | - **Breaking** Please note that this release forces all generated images to be recreated because of a change in the creation of the "unique key" which is used to determine if the image has already been generated. 68 | - **Breaking** Drop RESIZE_HASH_FILENAME as an option - always hash the cache object's filename. Less moving parts in the machinery. 69 | - **Breaking** Rename RESIZE_CACHE_DIR to RESIZE_TARGET_DIRECTORY to better reflect what the setting does, now that we have Redis caching. 70 | - **Breaking** Use SHA1 as default filename hashing method for less likelyhood of collision 71 | 72 | 0.8.0 (2016-10-01) 73 | ------------------ 74 | 75 | - **Improvement** Release as a universal python wheel 76 | 77 | 0.8.0 (2016-09-07) 78 | ------------------ 79 | 80 | - **Feature** Support SVG as input format by utilizing [CairoSVG](http://cairosvg.org/). 81 | 82 | 0.7.0 (2016-09-01) 83 | ------------------ 84 | 85 | - **Improvement** Keep ICC profile from source image 86 | - **Minor fix** Clarify that Python 3.5 is supported 87 | 88 | 0.6.0 (2015-10-01) 89 | ------------------ 90 | 91 | - **Bugfix** Fill doesn't cut the image any more 92 | 93 | 0.5.2 (2015-06-12) 94 | ------------------ 95 | 96 | - **Bugfix** Fix Python 2 regression 97 | 98 | 0.5.1 (2015-06-12) 99 | ------------------ 100 | 101 | - **Improvement** Tests that actually convert images with the :func:`flask_resize.resize` command. 102 | - **Improvement** Validates that ``RESIZE_ROOT`` and ``RESIZE_URL`` are strings. 103 | 104 | 105 | 0.5.0 (2015-06-10) 106 | ------------------ 107 | 108 | - **Improvement** Proper documentation, hosted on ``RTD`` 109 | - **Improvement** Properly documented all functions and classes 110 | - **Improvement** Continuous integration with ``Travis CI`` 111 | - **Improvement** Code coverage with ``coveralls`` 112 | - **Improvement** More tests 113 | - **Change** Dropped ``nose`` in favor of ``py.test`` 114 | - **Change** Removed unused method ``Resize.teardown`` 115 | 116 | 0.4.0 (2015-04-28) 117 | ------------------ 118 | 119 | - **Feature** Adds the setting ``RESIZE_NOOP`` which will just return the 120 | passed in image path, as is. This was added to ease the pain of unit 121 | testing when Flask-Resize is a part of the project. 122 | - **Change** Added more tests 123 | 124 | 0.3.0 (2015-04-23) 125 | ------------------ 126 | 127 | - **Feature** Adds the ``bgcolor`` option for specifying a background 128 | color to apply to the image. 129 | 130 | 0.2.5 (2015-03-20) 131 | ------------------ 132 | 133 | - **Bugfix** Because of a logic error no exception was raised when file 134 | to resize didn't exist 135 | 136 | 0.2.4 (2015-03-19) 137 | ------------------ 138 | 139 | - **Bugfix** Fix for pip parse\_requirements syntax change (fixes #6) 140 | 141 | 0.2.3 (2015-01-30) 142 | ------------------ 143 | 144 | - **Feature** Python 3.4 support (might work in other Pythons as well) 145 | 146 | 0.2.2 (2014-02-01) 147 | ------------------ 148 | 149 | - **Bugfix** Placeholders were being regenerated on each page load. 150 | 151 | 0.2.1 (2013-12-09) 152 | ------------------ 153 | 154 | - **Bugfix** Same placeholder reason text was used for all resizes with 155 | identical dimensions 156 | 157 | 0.2.0 (2013-12-04) 158 | ------------------ 159 | 160 | - **Feature** Support for generating image placeholders 161 | 162 | 0.1.1 (2013-11-09) 163 | ------------------ 164 | 165 | - **Bugfix** Format argument wasn't respected 166 | - **Change** Bumped default JPEG quality to 80 167 | 168 | 0.1.0 (2013-11-09) 169 | ------------------ 170 | 171 | - Initial version 172 | -------------------------------------------------------------------------------- /docs/command-line.rst: -------------------------------------------------------------------------------- 1 | .. _cli: 2 | 3 | Command line usage 4 | ================== 5 | 6 | .. _cli-general: 7 | 8 | General 9 | ------- 10 | 11 | When installing flask-resize with pip you'll get a command installed along it called ``flask-resize``. See below for the sub commands and what they're used 12 | for. 13 | 14 | .. _cli-usage: 15 | 16 | Usage 17 | ~~~~~ 18 | 19 | .. argparse:: 20 | :module: flask_resize.bin 21 | :func: parser 22 | :prog: flask-resize 23 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Flask-Resize documentation build configuration file, created by 5 | # sphinx-quickstart on Wed Jun 10 14:09:50 2015. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import os 17 | import sys 18 | import tempfile 19 | 20 | 21 | def add_mock_config_for_flask_resize_bin(): 22 | _, tempconf_path = tempfile.mkstemp(suffix='.py') 23 | with open(tempconf_path, 'w') as fp: 24 | fp.write("RESIZE_ROOT = RESIZE_URL = '/'".format(tempconf_path)) 25 | os.environ['FLASK_RESIZE_CONF'] = tempconf_path 26 | 27 | 28 | add_mock_config_for_flask_resize_bin() 29 | 30 | 31 | package_name = 'flask_resize' 32 | metadata_fn = '../{}/metadata.py'.format(package_name) 33 | 34 | # Get package metadata. To avoid loading the package __init__ we 35 | # use exec instead. 36 | with open(metadata_fn) as fh: 37 | metadata = {} 38 | exec(fh.read(), globals(), metadata) 39 | 40 | # If extensions (or modules to document with autodoc) are in another directory, 41 | # add these directories to sys.path here. If the directory is relative to the 42 | # documentation root, use os.path.abspath to make it absolute, like shown here. 43 | sys.path.insert(0, os.path.abspath('..')) 44 | 45 | # -- General configuration ------------------------------------------------ 46 | 47 | # If your documentation needs a minimal Sphinx version, state it here. 48 | needs_sphinx = '1.3' 49 | 50 | # Add any Sphinx extension module names here, as strings. They can be 51 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 52 | # ones. 53 | extensions = [ 54 | 'sphinx.ext.autodoc', 55 | 'sphinx.ext.intersphinx', 56 | 'sphinxcontrib.napoleon', 57 | 'sphinxarg.ext', 58 | ] 59 | 60 | # Add any paths that contain templates here, relative to this directory. 61 | templates_path = ['_templates'] 62 | 63 | # The suffix(es) of source filenames. 64 | # You can specify multiple suffix as a list of string: 65 | # source_suffix = ['.rst', '.md'] 66 | source_suffix = '.rst' 67 | 68 | # The encoding of source files. 69 | # source_encoding = 'utf-8-sig' 70 | 71 | # The master toctree document. 72 | master_doc = 'index' 73 | 74 | # General information about the project. 75 | project = 'Flask-Resize' 76 | copyright = '2013, Jacob Magnusson' 77 | author = 'Jacob Magnusson' 78 | 79 | # The version info for the project you're documenting, acts as replacement for 80 | # |version| and |release|, also used in various other places throughout the 81 | # built documents. 82 | # 83 | # The short X.Y version. 84 | version = metadata['__version__'] 85 | # The full version, including alpha/beta/rc tags. 86 | release = version 87 | 88 | # The language for content autogenerated by Sphinx. Refer to documentation 89 | # for a list of supported languages. 90 | # 91 | # This is also used if you do content translation via gettext catalogs. 92 | # Usually you set "language" from the command line for these cases. 93 | language = None 94 | 95 | # There are two options for replacing |today|: either, you set today to some 96 | # non-false value, then it is used: 97 | # today = '' 98 | # Else, today_fmt is used as the format for a strftime call. 99 | # today_fmt = '%B %d, %Y' 100 | 101 | # List of patterns, relative to source directory, that match files and 102 | # directories to ignore when looking for source files. 103 | exclude_patterns = ['_build'] 104 | 105 | # The reST default role (used for this markup: `text`) to use for all 106 | # documents. 107 | # default_role = None 108 | 109 | # If true, '()' will be appended to :func: etc. cross-reference text. 110 | # add_function_parentheses = True 111 | 112 | # If true, the current module name will be prepended to all description 113 | # unit titles (such as .. function::). 114 | # add_module_names = True 115 | 116 | # If true, sectionauthor and moduleauthor directives will be shown in the 117 | # output. They are ignored by default. 118 | # show_authors = False 119 | 120 | # The name of the Pygments (syntax highlighting) style to use. 121 | pygments_style = 'sphinx' 122 | 123 | # A list of ignored prefixes for module index sorting. 124 | # modindex_common_prefix = [] 125 | 126 | # If true, keep warnings as "system message" paragraphs in the built documents. 127 | # keep_warnings = False 128 | 129 | # If true, `todo` and `todoList` produce output, else they produce nothing. 130 | todo_include_todos = False 131 | 132 | 133 | # -- Options for HTML output ---------------------------------------------- 134 | 135 | # The theme to use for HTML and HTML Help pages. See the documentation for 136 | # a list of builtin themes. 137 | # NOTE: Readthedocs.io requires that this is unset 138 | # html_theme = 'alabaster' 139 | 140 | # Theme options are theme-specific and customize the look and feel of a theme 141 | # further. For a list of options available for each theme, see the 142 | # documentation. 143 | # html_theme_options = {} 144 | 145 | # Add any paths that contain custom themes here, relative to this directory. 146 | # html_theme_path = [] 147 | 148 | # The name for this set of Sphinx documents. If None, it defaults to 149 | # " v documentation". 150 | # html_title = None 151 | 152 | # A shorter title for the navigation bar. Default is the same as html_title. 153 | # html_short_title = None 154 | 155 | # The name of an image file (relative to this directory) to place at the top 156 | # of the sidebar. 157 | # html_logo = None 158 | 159 | # The name of an image file (within the static path) to use as favicon of the 160 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 161 | # pixels large. 162 | # html_favicon = None 163 | 164 | # Add any paths that contain custom static files (such as style sheets) here, 165 | # relative to this directory. They are copied after the builtin static files, 166 | # so a file named "default.css" will overwrite the builtin "default.css". 167 | html_static_path = ['_static'] 168 | 169 | # Add any extra paths that contain custom files (such as robots.txt or 170 | # .htaccess) here, relative to this directory. These files are copied 171 | # directly to the root of the documentation. 172 | # html_extra_path = [] 173 | 174 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 175 | # using the given strftime format. 176 | # html_last_updated_fmt = '%b %d, %Y' 177 | 178 | # If true, SmartyPants will be used to convert quotes and dashes to 179 | # typographically correct entities. 180 | # html_use_smartypants = True 181 | 182 | # Custom sidebar templates, maps document names to template names. 183 | # html_sidebars = {} 184 | 185 | # Additional templates that should be rendered to pages, maps page names to 186 | # template names. 187 | # html_additional_pages = {} 188 | 189 | # If false, no module index is generated. 190 | # html_domain_indices = True 191 | 192 | # If false, no index is generated. 193 | # html_use_index = True 194 | 195 | # If true, the index is split into individual pages for each letter. 196 | # html_split_index = False 197 | 198 | # If true, links to the reST sources are added to the pages. 199 | # html_show_sourcelink = True 200 | 201 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 202 | # html_show_sphinx = True 203 | 204 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 205 | # html_show_copyright = True 206 | 207 | # If true, an OpenSearch description file will be output, and all pages will 208 | # contain a tag referring to it. The value of this option must be the 209 | # base URL from which the finished HTML is served. 210 | # html_use_opensearch = '' 211 | 212 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 213 | # html_file_suffix = None 214 | 215 | # Language to be used for generating the HTML full-text search index. 216 | # Sphinx supports the following languages: 217 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 218 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 219 | # html_search_language = 'en' 220 | 221 | # A dictionary with options for the search language support, empty by default. 222 | # Now only 'ja' uses this config value 223 | # html_search_options = {'type': 'default'} 224 | 225 | # The name of a javascript file (relative to the configuration directory) that 226 | # implements a search results scorer. If empty, the default will be used. 227 | # html_search_scorer = 'scorer.js' 228 | 229 | # Output file base name for HTML help builder. 230 | htmlhelp_basename = 'Flask-Resizedoc' 231 | 232 | # -- Options for LaTeX output --------------------------------------------- 233 | 234 | latex_elements = { 235 | # The paper size ('letterpaper' or 'a4paper'). 236 | # 'papersize': 'letterpaper', 237 | 238 | # The font size ('10pt', '11pt' or '12pt'). 239 | # 'pointsize': '10pt', 240 | 241 | # Additional stuff for the LaTeX preamble. 242 | # 'preamble': '', 243 | 244 | # Latex figure (float) alignment 245 | # 'figure_align': 'htbp', 246 | } 247 | 248 | # Grouping the document tree into LaTeX files. List of tuples 249 | # (source start file, target name, title, 250 | # author, documentclass [howto, manual, or own class]). 251 | latex_documents = [ 252 | ( 253 | master_doc, 254 | 'Flask-Resize.tex', 255 | 'Flask-Resize Documentation', 256 | 'Jacob Magnusson', 257 | 'manual' 258 | ), 259 | ] 260 | 261 | # The name of an image file (relative to this directory) to place at the top of 262 | # the title page. 263 | # latex_logo = None 264 | 265 | # For "manual" documents, if this is true, then toplevel headings are parts, 266 | # not chapters. 267 | # latex_use_parts = False 268 | 269 | # If true, show page references after internal links. 270 | # latex_show_pagerefs = False 271 | 272 | # If true, show URL addresses after external links. 273 | # latex_show_urls = False 274 | 275 | # Documents to append as an appendix to all manuals. 276 | # latex_appendices = [] 277 | 278 | # If false, no module index is generated. 279 | # latex_domain_indices = True 280 | 281 | 282 | # -- Options for manual page output --------------------------------------- 283 | 284 | # One entry per manual page. List of tuples 285 | # (source start file, name, description, authors, manual section). 286 | man_pages = [ 287 | (master_doc, 'flask-resize', 'Flask-Resize Documentation', 288 | [author], 1) 289 | ] 290 | 291 | # If true, show URL addresses after external links. 292 | # man_show_urls = False 293 | 294 | 295 | # -- Options for Texinfo output ------------------------------------------- 296 | 297 | # Grouping the document tree into Texinfo files. List of tuples 298 | # (source start file, target name, title, author, 299 | # dir menu entry, description, category) 300 | texinfo_documents = [ 301 | ( 302 | master_doc, 303 | 'Flask-Resize', 304 | 'Flask-Resize Documentation', 305 | author, 306 | 'Flask-Resize', 307 | 'One line description of project.', 308 | 'Miscellaneous' 309 | ), 310 | ] 311 | 312 | # Documents to append as an appendix to all manuals. 313 | # texinfo_appendices = [] 314 | 315 | # If false, no module index is generated. 316 | # texinfo_domain_indices = True 317 | 318 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 319 | # texinfo_show_urls = 'footnote' 320 | 321 | # If true, do not generate a @detailmenu in the "Top" node's menu. 322 | # texinfo_no_detailmenu = False 323 | 324 | 325 | intersphinx_mapping = { 326 | 'python': ('https://docs.python.org/3.4', None), 327 | 'flask': ('http://flask.pocoo.org/docs/0.10/', None), 328 | 'pillow': ('https://pillow.readthedocs.io/en/4.0.x/', None), 329 | 'pilkit': ('https://pilkit.readthedocs.io/en/latest/', None), 330 | } 331 | 332 | 333 | # on_rtd is whether we are on readthedocs.org 334 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 335 | 336 | if not on_rtd: # only import and set the theme if we're building docs locally 337 | import sphinx_rtd_theme 338 | html_theme = 'sphinx_rtd_theme' 339 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 340 | 341 | napoleon_google_docstring = True 342 | napoleon_numpy_docstring = False 343 | napoleon_include_special_with_doc = True 344 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | Setting up your flask app to use Flask-Resize 5 | --------------------------------------------- 6 | 7 | Using direct initialization:: 8 | 9 | import flask 10 | import flask_resize 11 | 12 | app = flask.Flask(__name__) 13 | app.config['RESIZE_URL'] = 'https://mysite.com/' 14 | app.config['RESIZE_ROOT'] = '/home/user/myapp/images' 15 | 16 | resize = flask_resize.Resize(app) 17 | 18 | Using the app factory pattern:: 19 | 20 | import flask 21 | import flask_resize 22 | 23 | resize = flask_resize.Resize() 24 | 25 | def create_app(**config_values): 26 | app = flask.Flask() 27 | app.config.update(**config_values) 28 | resize.init_app(app) 29 | return app 30 | 31 | # And later on... 32 | app = create_app( 33 | RESIZE_URL='https://mysite.com/', 34 | RESIZE_ROOT='/home/user/myapp/images' 35 | ) 36 | 37 | .. _standalone-usage: 38 | 39 | Setting up for standalone usage 40 | ------------------------------- 41 | 42 | One doesn't actually need to involve Flask at all to utilize the resizing (perhaps one day we'll break out the actual resizing into its own package). E.g:: 43 | 44 | import flask_resize 45 | 46 | config = flask_resize.configuration.Config( 47 | url='https://mysite.com/', 48 | root='/home/user/myapp/images', 49 | ) 50 | 51 | resize = flask_resize.make_resizer(config) 52 | 53 | .. versionadded:: 2.0.0 54 | Standalone mode was introduced 55 | 56 | 57 | Available settings 58 | ------------------ 59 | 60 | Required for ``file`` storage 61 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 62 | 63 | You need to set at least two configuration options when using the default ``file`` storage: 64 | 65 | .. code:: python 66 | 67 | # Where your media resides 68 | RESIZE_ROOT = '/path/to/your/media/root/' 69 | 70 | # The URL where your media is served at. For the best performance you 71 | # should serve your media with a proper web server, under a subdomain 72 | # and with cookies turned off. 73 | RESIZE_URL = 'http://media.yoursite.com/' 74 | 75 | 76 | Required for ``s3`` storage 77 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 78 | 79 | For Amazon S3 storage you only need to do the following if you've already 80 | configured Amazon Web Services with ``aws configure`` (or similar). Default 81 | section configuration can then be extracted using the included ``botocore`` 82 | package. 83 | 84 | .. code:: python 85 | 86 | RESIZE_STORAGE_BACKEND = 's3' 87 | RESIZE_S3_BUCKET = 'mybucket' 88 | 89 | If you haven't done so then you need to manually specify the following options in addition to above: 90 | 91 | .. code:: python 92 | 93 | RESIZE_S3_ACCESS_KEY = 'dq8rJLaMtkHEze4example3C1V' 94 | RESIZE_S3_SECRET_KEY = 'MC9T4tRqXQexample3d1l7C9sG3M9qes0VEHiNJTG24q4a5' 95 | RESIZE_S3_REGION = 'eu-central-1' 96 | 97 | .. versionadded:: 1.0.0 98 | ``RESIZE_S3_ACCESS_KEY``, ``RESIZE_S3_SECRET_KEY`` and ``RESIZE_S3_BUCKET`` were added. 99 | 100 | .. versionadded:: 1.0.1 101 | ``RESIZE_S3_REGION`` was added. 102 | 103 | Optional 104 | ~~~~~~~~ 105 | 106 | There are also some optional settings. They are listed below, with their default values. 107 | 108 | .. code:: python 109 | 110 | # Where the resized images should be saved. Relative to `RESIZE_ROOT` 111 | # if using the file-based storage option. 112 | RESIZE_TARGET_DIRECTORY = 'resized-images' 113 | 114 | # Set to False if you want Flask-Resize to create sub-directories for 115 | # each resize setting instead of using a hash. 116 | RESIZE_HASH_FILENAME = True 117 | 118 | # Change if you want to use something other than sha1 for your hashes. 119 | # Supports all methods that hashlib supports. 120 | RESIZE_HASH_METHOD = 'sha1' 121 | 122 | # Useful when testing. Makes Flask-Resize skip all processing and just 123 | # return the original image URL. 124 | RESIZE_NOOP = False 125 | 126 | # Which backend to store files in. Defaults to the `file` backend. 127 | # Can be either `file` or `s3`. 128 | RESIZE_STORAGE_BACKEND = 'file' 129 | 130 | # Which cache store to use. Currently only redis is supported (`pip install 131 | # flask-resize[redis]`), and will be configured automatically if the 132 | # package is installed and `RESIZE_CACHE_STORE` hasn't been set 133 | # explicitly. Otherwise a no-op cache is used. 134 | RESIZE_CACHE_STORE = 'noop' if redis is None else 'redis' 135 | 136 | # Which host to use for redis if it is enabled with `RESIZE_CACHE_STORE` 137 | # This can also be a pre-configured `redis.StrictRedis` instance, in which 138 | # case the redis options below are ignored by Flask-Resize. 139 | RESIZE_REDIS_HOST = 'localhost' 140 | 141 | # Which port to use for redis if it is enabled with `RESIZE_CACHE_STORE` 142 | RESIZE_REDIS_PORT = 6379 143 | 144 | # Which db to use for redis if it is enabled with `RESIZE_CACHE_STORE` 145 | RESIZE_REDIS_DB = 0 146 | 147 | # Which password to use for redis if it is enabled with `RESIZE_CACHE_STORE`. Defaults to not using a password. 148 | RESIZE_REDIS_PASSWORD = None 149 | 150 | # Which key to use for redis if it is enabled with `RESIZE_CACHE_STORE` 151 | RESIZE_REDIS_KEY = 0 152 | 153 | # If True then GenerateInProgress exceptions aren't swallowed. Default is 154 | # to only raise these exceptions when Flask is configured in debug mode. 155 | RESIZE_RAISE_ON_GENERATE_IN_PROGRESS = app.debug 156 | 157 | .. versionadded:: 0.4.0 158 | ``RESIZE_NOOP`` was added. 159 | 160 | .. versionadded:: 1.0.0 161 | ``RESIZE_CACHE_STORE``, ``RESIZE_REDIS_HOST``, ``RESIZE_REDIS_PORT``, ``RESIZE_REDIS_DB`` and ``RESIZE_REDIS_KEY`` were added. 162 | 163 | .. versionadded:: 1.0.2 164 | ``RESIZE_STORAGE_BACKEND`` was added. 165 | 166 | .. versionadded:: 1.0.4 167 | ``RESIZE_RAISE_ON_GENERATE_IN_PROGRESS`` was added. 168 | 169 | 170 | .. versionadded:: 2.0.3 171 | ``RESIZE_REDIS_PASSWORD`` was added. 172 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Flask-Resize 2 | ============ 3 | 4 | Flask extension for resizing images in code and templates. Can convert from JPEG|PNG|SVG to JPEG|PNG, resize to fit and crop. File-based and S3-based storage options are available. 5 | 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | installation 11 | configuration 12 | usage 13 | command-line 14 | api 15 | changelog 16 | todo 17 | about 18 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Production version 5 | ------------------ 6 | 7 | Recommended install with all features (Redis cache + S3 storage + SVG if py3.4+): 8 | 9 | .. code:: sh 10 | 11 | pip install flask-resize[full] 12 | 13 | Other alternatives: 14 | 15 | .. code:: sh 16 | 17 | # File-based storage only 18 | pip install flask-resize 19 | 20 | # With S3 storage support 21 | pip install flask-resize[s3] 22 | 23 | # With Redis caching 24 | pip install flask-resize[redis] 25 | 26 | # With SVG source file support (only available with py3.4+) 27 | pip install flask-resize[svg] 28 | 29 | # Or any combination of above. E.g: 30 | pip install flask-resize[s3,redis] 31 | 32 | Development version 33 | ------------------- 34 | 35 | .. code:: sh 36 | 37 | pip install -e git+https://github.com/jmagnusson/flask-resize@master#egg=Flask-Resize 38 | 39 | .. _compatibility: 40 | 41 | Compatibility 42 | ------------- 43 | 44 | Tested with Python 2.7/3.4/3.5/3.6/pypy and latest version of Flask. 45 | 46 | Should work well on most Linux and MacOS versions. 47 | 48 | Windows is supported on paper with the full test suite running smoothly, but the package needs some real world usage on Windows though. Please report any issues you might find to the Github issues page. Any feedback is welcome! 😊 49 | 50 | Running the tests 51 | ----------------- 52 | 53 | .. code:: sh 54 | 55 | git clone https://github.com/jmagnusson/Flask-Resize.git 56 | cd Flask-Resize 57 | pip install tox 58 | tox 59 | 60 | You can also run the tests inside docker: 61 | 62 | .. code:: sh 63 | 64 | docker-compose --build up 65 | 66 | Or to run a single python version's tests: 67 | 68 | .. code:: sh 69 | 70 | docker-compose run py36 71 | 72 | Note that the tests in docker are run with all dependencies installed. See docker-compose.yml for more info. 73 | 74 | Generating the docs 75 | ------------------- 76 | 77 | .. code:: sh 78 | 79 | git clone https://github.com/jmagnusson/Flask-Resize.git 80 | cd Flask-Resize 81 | pip install -r requirements_docs.txt 82 | python manage.py docs clean build serve 83 | 84 | Now you should be able to view the docs @ `localhost:8000 `_. 85 | 86 | Contributing 87 | ------------ 88 | 89 | Fork the code `on the Github project page `_ then: 90 | 91 | .. code:: sh 92 | 93 | git clone git@github.com:YOUR_USERNAME/Flask-Resize.git 94 | cd Flask-Resize 95 | pip install '.[full,test,test_s3]' -r requirements_docs.txt 96 | git checkout -b my-fix 97 | # Create your fix and add any tests if deemed necessary. 98 | # Run the test suite to make sure everything works smooth. 99 | py.test 100 | git commit -am 'My fix!' 101 | git push 102 | 103 | Now you should see a box on the `project page `_ with which you can create a pull request. 104 | -------------------------------------------------------------------------------- /docs/todo.rst: -------------------------------------------------------------------------------- 1 | To-do 2 | ===== 3 | 4 | Automatic fitting of placeholder text 5 | ------------------------------------- 6 | 7 | See `issue #7 `_. 8 | 9 | Support for signals 10 | ------------------- 11 | Generate images directly instead of when web page is first hit. 12 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Usage in Jinja templates 5 | ------------------------ 6 | 7 | After having :doc:`installed ` and :doc:`configured ` Flask-Resize in your app the ``resize`` filter should be available in your code and jinja templates. 8 | 9 | To generate an image from the supplied image URL that will fit 10 | within an area of 600px width and 400px height:: 11 | 12 | resized_url = resize(original_image_url, '600x400') 13 | 14 | Resize and crop so that the image will fill the entire area:: 15 | 16 | resized_url = resize(original_image_url, '300x300', fill=1) 17 | 18 | Convert to JPG:: 19 | 20 | resized_url = resize(original_image_url, '300x300', format='jpg') 21 | 22 | 23 | The function will also be available in your jinja templates as a filter, where you'll call the resize filter like this:: 24 | 25 | My kittens 26 | 27 | .. _resize-arguments: 28 | 29 | List of arguments 30 | ----------------- 31 | 32 | .. _resize-arguments-dimensions: 33 | 34 | dimensions 35 | ~~~~~~~~~~ 36 | 37 | Default: Keep original dimensions 38 | 39 | Can be either a string or a two item list. The format when using a 40 | string is any of ``x``, ```` or ``x``. If 41 | width or height is left out they will be determined from the ratio of 42 | the original image. If both width and height are supplied then the 43 | output image will be within those boundries. 44 | 45 | placeholder 46 | ~~~~~~~~~~~ 47 | 48 | Default: False 49 | 50 | A placeholder image will be returned if the image couldn't be generated. 51 | The placeholder will contain text specifying dimensions, and reason for 52 | image not being generated (either ``empty image path`` or 53 | `` does not exist``) 54 | 55 | format 56 | ~~~~~~ 57 | 58 | Default: Keep original format 59 | 60 | If you want to change the format. A white background color is applied when a transparent image is converted to JPEG, or the color specified with ``bgcolor``. Available formats are PNG and JPEG at the moment. Defaults to using the same format as the original. 61 | 62 | bgcolor 63 | ~~~~~~~ 64 | 65 | Default: Don't add a background color 66 | 67 | Adds a background color to the image. Can be in any of the following 68 | formats: 69 | 70 | - ``#333`` 71 | - ``#333333`` 72 | - ``333`` 73 | - ``333333`` 74 | - ``(51, 51, 51)`` 75 | 76 | quality 77 | ~~~~~~~ 78 | 79 | Default: ``80`` 80 | 81 | Only matters if output format is jpeg. Quality of the output image. 82 | 0-100. 83 | 84 | upscale 85 | ~~~~~~~ 86 | 87 | Default: ``True`` 88 | 89 | Disable if you don't want the original image to be upscaled if the 90 | dimensions are smaller than those of ``dimensions``. 91 | 92 | fill 93 | ~~~~ 94 | 95 | Default: ``False`` 96 | 97 | The default is to keep the ratio of the original image. With ``fill`` it 98 | will crop the image after resizing so that it will have exactly width 99 | and height as specified. 100 | 101 | progressive 102 | ~~~~~~~~~~~ 103 | 104 | Default: True 105 | 106 | Whether to use progressive or not. Only matters if the output format is 107 | jpeg. `Article about progressive 108 | JPEGs `__. 109 | -------------------------------------------------------------------------------- /flask_resize/__init__.py: -------------------------------------------------------------------------------- 1 | from . import cache, configuration, exc, resizing, storage # noqa 2 | from .metadata import __version__, __version_info__ # noqa 3 | from .resizing import Resize, ResizeTarget, logger, make_resizer # noqa 4 | -------------------------------------------------------------------------------- /flask_resize/_compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | PY2 = sys.version_info[0] == 2 4 | PY3 = sys.version_info[0] == 3 5 | 6 | if PY3: 7 | string_types = str, 8 | 9 | def b(s): 10 | return s.encode("latin-1") 11 | 12 | else: 13 | string_types = basestring, # noqa 14 | 15 | def b(s): 16 | return s 17 | 18 | 19 | try: 20 | import cairosvg 21 | except ImportError: 22 | cairosvg = None 23 | 24 | 25 | try: 26 | import redis 27 | except ImportError: 28 | redis = None 29 | 30 | 31 | try: 32 | import boto3 33 | import botocore 34 | except ImportError: 35 | boto3 = None 36 | botocore = None 37 | 38 | 39 | try: 40 | FileExistsError = FileExistsError 41 | except NameError: 42 | class FileExistsError(IOError): 43 | pass 44 | -------------------------------------------------------------------------------- /flask_resize/bin.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import argh 4 | 5 | import flask_resize 6 | 7 | config = flask_resize.configuration.Config.from_pyfile( 8 | os.environ['FLASK_RESIZE_CONF'] 9 | ) 10 | resize = flask_resize.make_resizer(config) 11 | 12 | 13 | @argh.named('images') 14 | def clear_images(): 15 | """Delete all generated images from the storage backend""" 16 | for filepath in resize.storage_backend.delete_tree( 17 | resize.target_directory 18 | ): 19 | yield filepath 20 | 21 | 22 | @argh.named('cache') 23 | def list_cache(): 24 | """List all items found in cache""" 25 | for key in resize.cache_store.all(): 26 | yield key 27 | 28 | 29 | @argh.named('images') 30 | def list_images(): 31 | """List all generated images found in storage backend""" 32 | for key in resize.storage_backend.list_tree(resize.target_directory): 33 | yield key 34 | 35 | 36 | @argh.named('cache') 37 | def sync_cache(): 38 | """ 39 | Syncs paths stored in the cache backend with what's in the storage backend 40 | 41 | Useful when the storage backend destination is shared between multiple 42 | environments. One use case is when one has synced generated imagery 43 | from production into one's development environment (for example with 44 | `aws s3 sync --delete s3://prod-bucket s3://my-dev-bucket`). 45 | The cache can then be synced with what's been added/removed from the 46 | bucket `my-dev-bucket`. 47 | """ 48 | generated_image_paths = set(resize.storage_backend.list_tree( 49 | resize.target_directory 50 | )) 51 | cached_paths = set(resize.cache_store.all()) 52 | 53 | for path in (cached_paths - generated_image_paths): 54 | resize.cache_store.remove(path) 55 | yield 'Removed {}'.format(path) 56 | 57 | for path in (generated_image_paths - cached_paths): 58 | resize.cache_store.add(path) 59 | yield 'Added {}'.format(path) 60 | 61 | 62 | @argh.named('cache') 63 | def clear_cache(): 64 | """Clear the cache backend from generated images' paths""" 65 | resize.cache_store.clear() 66 | 67 | 68 | @argh.named('all') 69 | def clear_all(): 70 | """Clear both the cache and all generated images""" 71 | clear_cache() 72 | for filepath in clear_images(): 73 | yield filepath 74 | 75 | 76 | @argh.arg('-f', '--format') 77 | @argh.arg('-F', '--fill') 78 | def generate( 79 | filename, 80 | dimensions=None, 81 | format=None, 82 | quality=80, 83 | fill=False, 84 | bgcolor=None, 85 | upscale=True, 86 | progressive=True, 87 | placeholder=False 88 | ): 89 | """ 90 | Generate images passed in through stdin. Return URL for resulting image 91 | 92 | Useful to generate images outside of the regular request/response cycle 93 | of a web app = happier visitors who don't have to wait until image 94 | processing by Flask-Resize completes. Care has to be taken so that the 95 | exact same arguments are passed in as what is specified in 96 | code/templates - the smallest difference in passed in options will cause 97 | flask resize to generate a new image. 98 | 99 | Use GNU Parallel or similar tool to parallelize the generation 100 | """ 101 | return resize( 102 | filename, 103 | dimensions=dimensions, 104 | format=format, 105 | quality=quality, 106 | fill=fill, 107 | bgcolor=bgcolor, 108 | upscale=upscale, 109 | progressive=progressive, 110 | placeholder=placeholder, 111 | ) 112 | 113 | 114 | parser = argh.ArghParser() 115 | 116 | argh.add_commands(parser, [generate]) 117 | 118 | argh.add_commands( 119 | parser, 120 | [list_cache, list_images], 121 | namespace='list', 122 | title="Commands for listing images and cache", 123 | ) 124 | argh.add_commands( 125 | parser, 126 | [sync_cache], 127 | namespace='sync', 128 | title="Commands for syncing data", 129 | ) 130 | argh.add_commands( 131 | parser, 132 | [clear_cache, clear_images, clear_all], 133 | namespace='clear', 134 | title="Commands for clearing/deleting images and cache", 135 | ) 136 | -------------------------------------------------------------------------------- /flask_resize/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import contextmanager 3 | 4 | from . import _compat, constants, exc 5 | 6 | 7 | def make(config): 8 | """Generate cache store from supplied config 9 | 10 | Args: 11 | config (dict): 12 | The config to extract settings from 13 | 14 | Returns: 15 | Any[RedisCache, NoopCache]: 16 | A :class:`Cache` sub-class, based on the `RESIZE_CACHE_STORE` 17 | value. 18 | 19 | Raises: 20 | RuntimeError: If another `RESIZE_CACHE_STORE` value was set 21 | 22 | """ 23 | if config.cache_store == 'redis': 24 | kw = dict( 25 | host=config.redis_host, 26 | port=config.redis_port, 27 | db=config.redis_db, 28 | password=config.redis_password, 29 | key=config.redis_key, 30 | ) 31 | return RedisCache(**kw) 32 | elif config.cache_store == 'noop': 33 | return NoopCache() 34 | else: 35 | raise RuntimeError( 36 | 'Non-supported RESIZE_CACHE_STORE value: "{}"' 37 | .format(config.cache_store) 38 | ) 39 | 40 | 41 | class Cache: 42 | """Cache base class""" 43 | 44 | def exists(self, unique_key): 45 | raise NotImplementedError 46 | 47 | def add(self, unique_key): 48 | raise NotImplementedError 49 | 50 | def remove(self, unique_key): 51 | raise NotImplementedError 52 | 53 | def clear(self): 54 | raise NotImplementedError 55 | 56 | def all(self): 57 | raise NotImplementedError 58 | 59 | def transaction(self, unique_key, ttl=600): 60 | raise NotImplementedError 61 | 62 | 63 | class NoopCache(Cache): 64 | """ 65 | No-op cache, just to get the same API regardless of whether cache is 66 | used or not. 67 | """ 68 | 69 | def exists(self, unique_key): 70 | """ 71 | Check if key exists in cache 72 | 73 | Args: 74 | unique_key (str): Unique key to check for 75 | 76 | Returns: 77 | bool: Whether key exist in cache or not 78 | """ 79 | return False 80 | 81 | def add(self, unique_key): 82 | """ 83 | Add key to cache 84 | 85 | Args: 86 | unique_key (str): Add this key to the cache 87 | 88 | Returns: 89 | bool: Whether key was added or not 90 | """ 91 | return False 92 | 93 | def remove(self, unique_key): 94 | """ 95 | Remove key from cache 96 | 97 | Args: 98 | unique_key (str): Remove this key from the cache 99 | 100 | Returns: 101 | bool: Whether key was removed or not 102 | """ 103 | return False 104 | 105 | def clear(self): 106 | """ 107 | Remove all keys from cache 108 | 109 | Returns: 110 | bool: Whether any keys were removed or not 111 | """ 112 | return False 113 | 114 | def all(self): 115 | """ 116 | List all keys in cache 117 | 118 | Returns: 119 | List[str]: All the keys in the set, as a list 120 | """ 121 | return [] 122 | 123 | @contextmanager 124 | def transaction(self, unique_key, ttl=600): 125 | """ 126 | No-op context-manager for transactions. Always yields `True`. 127 | """ 128 | yield True 129 | 130 | 131 | class RedisCache(Cache): 132 | """A Redis-based cache that works with a single set-type key 133 | 134 | Basically just useful for checking whether an expected value in the set 135 | already exists (which is exactly what's needed in Flask-Resize) 136 | """ 137 | 138 | def __init__( 139 | self, 140 | host='localhost', 141 | port=6379, 142 | db=0, 143 | password=None, 144 | key=constants.DEFAULT_REDIS_KEY 145 | ): 146 | if _compat.redis is None: 147 | raise exc.RedisImportError( 148 | "Redis must be installed for Redis support. " 149 | "Package found @ https://pypi.python.org/pypi/redis." 150 | ) 151 | self.key = key 152 | self._host = host 153 | self._port = port 154 | self._db = db 155 | 156 | if isinstance(host, _compat.string_types): 157 | self.redis = _compat.redis.StrictRedis( 158 | host=host, 159 | port=port, 160 | db=db, 161 | password=password, 162 | ) 163 | else: 164 | self.redis = host 165 | 166 | def exists(self, unique_key): 167 | """ 168 | Check if key exists in cache 169 | 170 | Args: 171 | unique_key (str): Unique key to check for 172 | 173 | Returns: 174 | bool: Whether key exist in cache or not 175 | """ 176 | return self.redis.sismember(self.key, unique_key) 177 | 178 | def add(self, unique_key): 179 | """ 180 | Add key to cache 181 | 182 | Args: 183 | unique_key (str): Add this key to the cache 184 | 185 | Returns: 186 | bool: Whether key was added or not 187 | """ 188 | return bool(self.redis.sadd(self.key, unique_key)) 189 | 190 | def remove(self, unique_key): 191 | """ 192 | Remove key from cache 193 | 194 | Args: 195 | unique_key (str): Remove this key from the cache 196 | 197 | Returns: 198 | bool: Whether key was removed or not 199 | """ 200 | return bool(self.redis.srem(self.key, unique_key)) 201 | 202 | def clear(self): 203 | """ 204 | Remove all keys from cache 205 | 206 | Returns: 207 | bool: Whether any keys were removed or not 208 | """ 209 | return bool(self.redis.delete(self.key)) 210 | 211 | def all(self): 212 | """ 213 | List all keys in cache 214 | 215 | Returns: 216 | List[str]: All the keys in the set, as a list 217 | """ 218 | return [v.decode() for v in self.redis.smembers(self.key)] 219 | 220 | @contextmanager 221 | def transaction( 222 | self, 223 | unique_key, 224 | ttl=600 225 | ): 226 | """ 227 | Context-manager to use when it's important that no one else 228 | handles `unique_key` at the same time (for example when 229 | saving data to a storage backend). 230 | 231 | Args: 232 | unique_key (str): 233 | The unique key to ensure atomicity for 234 | ttl (int): 235 | Time before the transaction is deemed irrelevant and discarded 236 | from cache. Is only relevant if the host forcefully restarts. 237 | """ 238 | tkey = '-transaction-'.join([self.key, unique_key]) 239 | 240 | if self.redis.set(tkey, str(os.getpid()), nx=True): 241 | self.redis.expire(tkey, ttl) 242 | else: 243 | yield False 244 | 245 | try: 246 | yield True 247 | finally: 248 | self.redis.delete(tkey) 249 | -------------------------------------------------------------------------------- /flask_resize/configuration.py: -------------------------------------------------------------------------------- 1 | from . import constants 2 | from ._compat import redis 3 | 4 | 5 | class Config: 6 | """The main configuration entry point""" 7 | 8 | url = None 9 | noop = False 10 | root = None 11 | raise_on_generate_in_progress = False 12 | storage_backend = 'file' 13 | target_directory = constants.DEFAULT_TARGET_DIRECTORY 14 | hash_method = constants.DEFAULT_NAME_HASHING_METHOD 15 | cache_store = 'noop' if redis is None else 'redis' 16 | redis_host = 'localhost' 17 | redis_port = 6379 18 | redis_db = 0 19 | redis_password = None 20 | redis_key = constants.DEFAULT_REDIS_KEY 21 | s3_access_key = None 22 | s3_secret_key = None 23 | s3_bucket = None 24 | s3_region = None 25 | 26 | def __init__(self, **config): 27 | for key, val in config.items(): 28 | if key.startswith('_') or key not in dir(self): 29 | raise ValueError('Not a valid config val: {}'.format(key)) 30 | setattr(self, key, val) 31 | 32 | @classmethod 33 | def from_dict(cls, dct, default_overrides=None): 34 | """ 35 | Turns a dictionary with `RESIZE_` prefix config options into lower-case 36 | keys on this object. 37 | 38 | Args: 39 | dct (dict): 40 | The dictionary to get explicitly set values from 41 | 42 | default_overrides (Any[dict,None]): 43 | A dictionary with default overrides, where all declared keys' 44 | values will be used as defaults instead of the "app default", 45 | in case a setting wasn't explicitly added to config. 46 | 47 | """ 48 | prefix = 'RESIZE_' 49 | default_overrides = default_overrides or {} 50 | config = cls() 51 | 52 | def format_key(k): 53 | return k.split(prefix, 1)[1].lower() 54 | 55 | for key, val in dict(default_overrides, **dct).items(): 56 | if key.startswith(prefix): 57 | setattr(config, format_key(key), val) 58 | 59 | return config 60 | 61 | @classmethod 62 | def from_pyfile(cls, filepath): 63 | conf = {} 64 | with open(filepath) as config_file: 65 | exec(compile(config_file.read(), filepath, 'exec'), conf) 66 | return cls.from_dict(conf) 67 | -------------------------------------------------------------------------------- /flask_resize/constants.py: -------------------------------------------------------------------------------- 1 | DEFAULT_NAME_HASHING_METHOD = 'sha1' 2 | """Default filename hashing method for generated images""" 3 | 4 | DEFAULT_TARGET_DIRECTORY = 'resized-images' 5 | """Default target directory for generated images""" 6 | 7 | DEFAULT_REDIS_KEY = 'flask-resize' 8 | """Default key to store redis cache as""" 9 | 10 | JPEG = 'JPEG' 11 | """JPEG format""" 12 | 13 | PNG = 'PNG' 14 | """PNG format""" 15 | 16 | SVG = 'SVG' 17 | """SVG format""" 18 | 19 | SUPPORTED_OUTPUT_FILE_FORMATS = (JPEG, PNG) 20 | """Image formats that can be generated""" 21 | -------------------------------------------------------------------------------- /flask_resize/exc.py: -------------------------------------------------------------------------------- 1 | from ._compat import FileExistsError # noqa 2 | 3 | 4 | class InvalidResizeSettingError(ValueError): 5 | """Raised when a resize argument such as if `width` was invalid.""" 6 | 7 | 8 | class EmptyImagePathError(InvalidResizeSettingError): 9 | """Raised if an empty image path was encountered.""" 10 | 11 | 12 | class InvalidDimensionsError(InvalidResizeSettingError): 13 | """Raised when a dimension string/tuple is improperly specified.""" 14 | 15 | 16 | class MissingDimensionsError(InvalidDimensionsError): 17 | """Raised when width and/or height is missing.""" 18 | 19 | 20 | class UnsupportedImageFormatError(InvalidResizeSettingError): 21 | """Raised when an unsupported output image format is encountered.""" 22 | 23 | 24 | class ImageNotFoundError(IOError): 25 | """Raised if the image could not be fetched from storage.""" 26 | 27 | 28 | class CacheMiss(RuntimeError): 29 | """Raised when a cached image path could not be found""" 30 | 31 | 32 | class GenerateInProgress(RuntimeError): 33 | """The image is currently being generated""" 34 | 35 | 36 | class CairoSVGImportError(ImportError): 37 | """ 38 | Raised when an SVG input file is encountered but CairoSVG is not 39 | installed. 40 | """ 41 | 42 | 43 | class RedisImportError(ImportError): 44 | """ 45 | Raised when Redis cache is configured, but the `redis` library is not 46 | installed. 47 | """ 48 | 49 | 50 | class Boto3ImportError(ImportError): 51 | """Raised when S3 is configured, but the `boto3` library is not installed. 52 | """ 53 | -------------------------------------------------------------------------------- /flask_resize/fonts/DroidSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/Flask-Resize/7f90ead1ff046874b0ec2ec7b66a8615461c686e/flask_resize/fonts/DroidSans.ttf -------------------------------------------------------------------------------- /flask_resize/metadata.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | 3 | __version__ = pkg_resources.require('flask_resize')[0].version 4 | __version_info__ = tuple(int(p) for p in __version__.split('.')) 5 | -------------------------------------------------------------------------------- /flask_resize/resizing.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import io 3 | import logging 4 | import os 5 | 6 | import pilkit.processors 7 | import pilkit.utils 8 | from flask import current_app 9 | from PIL import Image, ImageColor, ImageDraw, ImageFont 10 | 11 | from . import cache, constants, exc, storage, utils 12 | from ._compat import b, cairosvg 13 | from .configuration import Config 14 | 15 | logger = logging.getLogger('flask_resize') 16 | 17 | 18 | def format_to_ext(format): 19 | """Return the file extension to use for format""" 20 | return { 21 | constants.JPEG: 'jpg', 22 | constants.PNG: 'png', 23 | constants.SVG: 'svg', 24 | }[format] 25 | 26 | 27 | def image_data(img, format, **save_options): 28 | """Save a PIL Image instance and return its byte contents""" 29 | fp = io.BytesIO() 30 | img.save(fp, format, **save_options) 31 | fp.seek(0) 32 | return fp.read() 33 | 34 | 35 | def _get_package_path(relpath): 36 | """Get the full path for a file within the package 37 | 38 | Args: 39 | relpath (str): 40 | A path contained within the flask_resize 41 | 42 | Returns: 43 | str: Full path for the file requested 44 | """ 45 | pkgdir = os.path.dirname(__file__) 46 | return os.path.join(pkgdir, 'fonts', relpath) 47 | 48 | 49 | def make_opaque(img, bgcolor): 50 | """Apply a background color to image 51 | 52 | Args: 53 | img (PIL.Image): 54 | Image to alter. 55 | bgcolor (str): 56 | A :func:`parse_rgb` parseable value to use as background color. 57 | 58 | Returns: 59 | PIL.Image: 60 | A new image with the background color applied. 61 | """ 62 | bgcolor = ImageColor.getrgb(utils.parse_rgb(bgcolor)) 63 | processor = pilkit.processors.MakeOpaque(background_color=bgcolor) 64 | return processor.process(img) 65 | 66 | 67 | def convert_svg(bdata): 68 | if cairosvg is None: 69 | raise exc.CairoSVGImportError( 70 | "CairoSVG must be installed for SVG input file support. " 71 | "Package found @ https://pypi.python.org/pypi/CairoSVG." 72 | ) 73 | svg_data = cairosvg.svg2png(bytestring=bdata) 74 | return Image.open(io.BytesIO(svg_data)) 75 | 76 | 77 | def create_placeholder_image(width=None, height=None, message=None): 78 | """ 79 | Create a placeholder image that specified its width and height, and an 80 | optional text. 81 | 82 | Args: 83 | width (Optional[:class:`str`]): 84 | Width to use for the image. Will use `height` if not provided. 85 | height (Optional[:class:`str`]): 86 | Height to use for the image. Will use `width` if not provided. 87 | message (Optional[:class:`str`]): 88 | Text to add to the center of the placeholder image. 89 | 90 | Raises: 91 | :class:`exc.MissingDimensionsError`: 92 | If neither `width` nor `height` are provided. 93 | 94 | Returns: 95 | PIL.Image: 96 | The placeholder image. 97 | """ 98 | if width is None and height is None: 99 | raise exc.MissingDimensionsError("Specify at least one of `width` " 100 | "or `height`") 101 | placeholder_width = width or height 102 | placeholder_height = height or width 103 | placeholder_text = '{}x{}'.format(placeholder_width, 104 | placeholder_height) 105 | if message is not None: 106 | placeholder_text += u' ({})'.format(message) 107 | text_fill = (255, ) * 3 108 | bg_fill = (220, ) * 3 109 | img = Image.new('RGB', (placeholder_width, placeholder_height), bg_fill) 110 | draw = ImageDraw.Draw(img) 111 | font = ImageFont.truetype(_get_package_path('DroidSans.ttf'), size=36) 112 | text_width, text_height = draw.textsize(placeholder_text, font=font) 113 | draw.text((((placeholder_width - text_width) / 2), 114 | ((placeholder_height - text_height) / 2)), 115 | text=placeholder_text, font=font, fill=text_fill) 116 | del draw 117 | return img 118 | 119 | 120 | class ResizeTarget: 121 | 122 | def __init__( 123 | self, 124 | image_store, 125 | source_image_relative_url, 126 | dimensions=None, 127 | format=None, 128 | quality=80, 129 | fill=False, 130 | bgcolor=None, 131 | upscale=True, 132 | progressive=True, 133 | name_hashing_method=constants.DEFAULT_NAME_HASHING_METHOD, 134 | cache_store=cache.NoopCache(), 135 | target_directory=constants.DEFAULT_TARGET_DIRECTORY, 136 | use_placeholder=False, 137 | ): 138 | self.source_image_relative_url = source_image_relative_url 139 | self.use_placeholder = use_placeholder 140 | self.width, self.height = ( 141 | utils.parse_dimensions(dimensions) if dimensions is not None 142 | else (None, None) 143 | ) 144 | self.format = utils.parse_format(source_image_relative_url, format) 145 | self.quality = quality 146 | self.fill = fill 147 | self.bgcolor = ( 148 | utils.parse_rgb(bgcolor, include_number_sign=False) 149 | if bgcolor is not None else None 150 | ) 151 | self.upscale = upscale 152 | self.progressive = progressive 153 | self.name_hashing_method = name_hashing_method 154 | self.target_directory = target_directory 155 | 156 | self.image_store = image_store 157 | self.cache_store = cache_store 158 | 159 | self._validate_arguments() 160 | self.unique_key = self._generate_unique_key() 161 | 162 | def _validate_arguments(self): 163 | if not self.source_image_relative_url and not self.use_placeholder: 164 | raise exc.EmptyImagePathError() 165 | if self.fill and not all([self.width, self.height]): 166 | raise exc.MissingDimensionsError( 167 | 'Fill requires both width and height to be set.' 168 | ) 169 | 170 | @property 171 | def file_extension(self): 172 | return format_to_ext(self.format) 173 | 174 | def _get_generate_unique_key_args(self): 175 | return [ 176 | self.source_image_relative_url, 177 | self.format, 178 | self.quality if self.format == constants.JPEG else '', 179 | self.width or 'auto', 180 | self.height or 'auto', 181 | 'fill' if self.fill else '', 182 | 'fill' if self.fill else 'no-fill', 183 | 'upscale' if self.upscale else 'no-upscale', 184 | self.bgcolor or '', 185 | ] 186 | 187 | def _generate_unique_key(self): 188 | cache_key_args = self._get_generate_unique_key_args() 189 | hash = hashlib.new(self.name_hashing_method) 190 | hash.update(b(''.join(str(a) for a in cache_key_args))) 191 | name = hash.hexdigest() 192 | return '.'.join([ 193 | '/'.join([self.target_directory, name]), 194 | self.file_extension 195 | ]) 196 | 197 | def get_cached_path(self): 198 | if self.cache_store.exists(self.unique_key): 199 | logger.debug('Fetched from cache: {}'.format(self.unique_key)) 200 | return self.unique_key 201 | else: 202 | msg = '`{}` is not cached.'.format(self.unique_key) 203 | logger.debug(msg) 204 | raise exc.CacheMiss(msg) 205 | 206 | def get_path(self): 207 | if self.image_store.exists(self.unique_key): 208 | # As the generated image might've been created on another instance, 209 | # we'll store the path in cache key here so we won't have to 210 | # manually check the path again. 211 | self.cache_store.add(self.unique_key) 212 | 213 | logger.debug('Found non-cached image: {}'.format(self.unique_key)) 214 | return self.unique_key 215 | else: 216 | raise exc.ImageNotFoundError(self.unique_key) 217 | 218 | def get_generated_image(self): 219 | return self.image_store.get(self.unique_key) 220 | 221 | @property 222 | def source_format(self): 223 | if not self.source_image_relative_url: 224 | # Missing path means only a placeholder could be generated 225 | return constants.PNG 226 | else: 227 | fmt = os.path.splitext(self.source_image_relative_url)[1] 228 | assert fmt.startswith('.') 229 | return fmt[1:].upper() 230 | 231 | def _generate_impl(self): 232 | try: 233 | source_data = self.image_store.get( 234 | self.source_image_relative_url 235 | ) 236 | except exc.ImageNotFoundError: 237 | if self.use_placeholder: 238 | source_data = self.generate_placeholder( 239 | 'Source image `{}` not found'.format( 240 | self.source_image_relative_url 241 | ) 242 | ) 243 | else: 244 | raise 245 | 246 | if self.source_format == constants.SVG: 247 | img = convert_svg(source_data) 248 | else: 249 | fp = io.BytesIO(source_data) 250 | img = Image.open(fp) 251 | 252 | if self.width or self.height: 253 | resize_to_fit_kw = dict( 254 | width=self.width, 255 | height=self.height, 256 | upscale=self.upscale 257 | ) 258 | if self.fill: 259 | if self.bgcolor: 260 | mat_color = ImageColor.getrgb(self.bgcolor) 261 | elif self.format == constants.JPEG: 262 | mat_color = (255, 255, 255, 255) # White 263 | else: 264 | mat_color = (0, 0, 0, 0) # Transparent 265 | resize_to_fit_kw['mat_color'] = mat_color 266 | 267 | processor = pilkit.processors.ResizeToFit(**resize_to_fit_kw) 268 | img = processor.process(img) 269 | 270 | options = { 271 | 'icc_profile': img.info.get('icc_profile'), 272 | } 273 | 274 | if self.format == constants.JPEG: 275 | options.update( 276 | quality=int(self.quality), 277 | progressive=self.progressive 278 | ) 279 | 280 | if self.bgcolor is not None: 281 | img = make_opaque(img, self.bgcolor) 282 | 283 | img, save_kwargs = pilkit.utils.prepare_image(img, self.format) 284 | save_kwargs.update(options) 285 | options = save_kwargs 286 | 287 | return image_data(img, self.format, **options) 288 | 289 | def generate(self): 290 | with self.cache_store.transaction( 291 | self.unique_key 292 | ) as transaction_successful: 293 | 294 | if transaction_successful: 295 | logger.info('Generating image: {}'.format(self.unique_key)) 296 | else: 297 | logger.error( 298 | 'GenerateInProgress error for: {}'.format(self.unique_key) 299 | ) 300 | raise exc.GenerateInProgress(self.unique_key) 301 | 302 | try: 303 | data = self._generate_impl() 304 | self.image_store.save(self.unique_key, data) 305 | except Exception as e: 306 | logger.info( 307 | 'Exception occurred - removing {} from cache and ' 308 | 'image store. Exception was: {}' 309 | .format(self.unique_key, e) 310 | ) 311 | 312 | try: 313 | self.image_store.delete(self.unique_key) 314 | except Exception as e2: 315 | logger.warning( 316 | 'Another exception occurred while doing error cleanup ' 317 | 'for: {}. The exception was: {}' 318 | .format(self.unique_key, e2) 319 | ) 320 | pass 321 | 322 | self.cache_store.remove(self.unique_key) 323 | 324 | raise e 325 | else: 326 | self.cache_store.add(self.unique_key) 327 | return data 328 | 329 | def generate_placeholder(self, message): 330 | img = create_placeholder_image(self.width, self.height, message) 331 | return image_data(img, 'PNG') 332 | 333 | 334 | class Resizer: 335 | """Factory for creating the resize function""" 336 | 337 | def __init__( 338 | self, 339 | storage_backend, 340 | cache_store, 341 | base_url, 342 | name_hashing_method=constants.DEFAULT_NAME_HASHING_METHOD, 343 | target_directory=constants.DEFAULT_TARGET_DIRECTORY, 344 | raise_on_generate_in_progress=False, 345 | noop=False 346 | ): 347 | self.storage_backend = storage_backend 348 | self.cache_store = cache_store 349 | self.base_url = base_url 350 | self.name_hashing_method = name_hashing_method 351 | self.target_directory = target_directory 352 | self.raise_on_generate_in_progress = raise_on_generate_in_progress 353 | self.noop = noop 354 | self._fix_base_url() 355 | 356 | def _fix_base_url(self): 357 | if not self.base_url.endswith('/'): 358 | self.base_url += '/' 359 | 360 | def __call__( 361 | self, 362 | image_url, 363 | dimensions=None, 364 | format=None, 365 | quality=80, 366 | fill=False, 367 | bgcolor=None, 368 | upscale=True, 369 | progressive=True, 370 | placeholder=False 371 | ): 372 | """Method for resizing, converting and caching images 373 | 374 | Args: 375 | image_url (str): 376 | URL for the image to resize. A URL relative to `base_url` 377 | dimensions (str, Sequence[:class:`int`, :class:`int`]): 378 | Width and height to use when generating the new image. 379 | Uses the format of :func:`parse_dimensions`. No resizing 380 | is done if None is passed in. 381 | format (Optional[:class:`str`]): 382 | Format to convert into. Defaults to using the same format as 383 | the original image. An exception to this default is when the 384 | source image is of type SVG/SVGZ, then PNG is used as default. 385 | quality (int): 386 | Quality of the output image, if the format is JPEG. 387 | Defaults to 80. 388 | fill (bool): 389 | Fill the entire width and height that was specified if True, 390 | otherwise keep the original image dimensions. 391 | Defaults to False. 392 | bgcolor (Optional[:class:`str`]): 393 | If specified this color will be used as background. 394 | upscale (bool): 395 | Whether or not to allow the image to become bigger than the 396 | original if the request width and/or height is bigger than its 397 | dimensions. Defaults to True. 398 | progressive (bool): 399 | Whether to use progressive encoding or not when JPEG is the 400 | output format. Defaults to True. 401 | placeholder (bool): 402 | Whether to show a placeholder if the specified ``image_url`` 403 | couldn't be found. 404 | 405 | Raises: 406 | :class:`exc.EmptyImagePathError`: 407 | If an empty image path was received. 408 | :class:`exc.ImageNotFoundError`: 409 | If the image could not be found. 410 | :class:`exc.MissingDimensionsError`: 411 | If ``fill`` argument was True, but width or height was 412 | not passed. 413 | 414 | Returns: 415 | str: 416 | URL to the generated and cached image. 417 | 418 | Usage: 419 | Generate an image from the supplied image URL that will fit 420 | within an area of 600px width and 400px height:: 421 | 422 | resize('somedir/kittens.png', '600x400') 423 | 424 | Resize and crop so that the image will fill the entire area:: 425 | 426 | resize('somedir/kittens.png', '300x300', fill=1) 427 | 428 | Convert to JPG:: 429 | 430 | resize('somedir/kittens.png', '300x300', format='jpg') 431 | """ 432 | 433 | if self.noop: 434 | return image_url 435 | 436 | if image_url and image_url.startswith(self.base_url): 437 | image_url = image_url[len(self.base_url):] 438 | 439 | target = ResizeTarget( 440 | self.storage_backend, 441 | image_url, 442 | dimensions=dimensions, 443 | format=format, 444 | quality=quality, 445 | fill=fill, 446 | bgcolor=bgcolor, 447 | upscale=upscale, 448 | progressive=progressive, 449 | use_placeholder=placeholder, 450 | cache_store=self.cache_store, 451 | name_hashing_method=self.name_hashing_method, 452 | target_directory=self.target_directory, 453 | ) 454 | 455 | try: 456 | relative_url = target.get_cached_path() 457 | except exc.CacheMiss: 458 | try: 459 | relative_url = target.get_path() 460 | except exc.ImageNotFoundError: 461 | try: 462 | target.generate() 463 | except exc.GenerateInProgress: 464 | if self.raise_on_generate_in_progress: 465 | raise 466 | else: 467 | relative_url = target.unique_key 468 | else: 469 | relative_url = target.get_path() 470 | 471 | return os.path.join(self.base_url, relative_url) 472 | 473 | 474 | def make_resizer(config): 475 | """Resizer instance factory""" 476 | return Resizer( 477 | storage_backend=storage.make(config), 478 | cache_store=cache.make(config), 479 | base_url=config.url, 480 | name_hashing_method=config.hash_method, 481 | target_directory=config.target_directory, 482 | raise_on_generate_in_progress=config.raise_on_generate_in_progress, 483 | noop=config.noop, 484 | ) 485 | 486 | 487 | class Resize(object): 488 | """ 489 | Used for initializing the configuration needed for the ``Resizer`` 490 | instance, and for the jinja filter to work in the flask app. 491 | 492 | Args: 493 | app (Any[flask.Flask, None]): 494 | A Flask app can be passed in immediately if not using the app 495 | factory pattern. 496 | """ 497 | 498 | def __init__(self, app=None): 499 | if app is not None: 500 | self.init_app(app) 501 | 502 | def __call__(self, *args, **kwargs): 503 | return current_app.resize(*args, **kwargs) 504 | 505 | def init_app(self, app): 506 | """Initialize Flask-Resize 507 | 508 | Args: 509 | app (:class:`flask.Flask`): 510 | The Flask app to configure. 511 | 512 | Raises: 513 | RuntimeError: 514 | A setting wasn't specified, or was invalid. 515 | """ 516 | 517 | config = Config.from_dict( 518 | app.config, 519 | default_overrides={ 520 | 'RESIZE_RAISE_ON_GENERATE_IN_PROGRESS': app.debug, 521 | } 522 | ) 523 | 524 | app.resize = app.jinja_env.filters['resize'] = make_resizer(config) 525 | -------------------------------------------------------------------------------- /flask_resize/storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from . import exc, utils 4 | from ._compat import PY2, boto3, botocore, string_types 5 | 6 | 7 | def make(config): 8 | """Generate storage backend from supplied config 9 | 10 | Args: 11 | config (dict): 12 | The config to extract settings from 13 | 14 | Returns: 15 | Any[FileStorage, S3Storage]: 16 | A :class:`Storage` sub-class, based on the `RESIZE_STORAGE_BACKEND` 17 | value. 18 | 19 | Raises: 20 | RuntimeError: If another `RESIZE_STORAGE_BACKEND` value was set 21 | """ 22 | 23 | if config.noop: 24 | config.url = '/' 25 | return None 26 | 27 | elif config.storage_backend == 's3': 28 | if not config.s3_bucket: 29 | raise RuntimeError( 30 | 'You must specify RESIZE_S3_BUCKET when ' 31 | 'RESIZE_STORAGE_BACKEND is set to "s3".' 32 | ) 33 | 34 | store = S3Storage( 35 | access_key=config.s3_access_key, 36 | secret_key=config.s3_secret_key, 37 | bucket=config.s3_bucket, 38 | region_name=config.s3_region, 39 | ) 40 | config.url = store.base_url 41 | 42 | elif config.storage_backend == 'file': 43 | if not isinstance(config.url, string_types): 44 | raise RuntimeError( 45 | 'You must specify a valid RESIZE_URL ' 46 | 'when RESIZE_STORAGE_BACKEND is set to "file".' 47 | ) 48 | 49 | if not isinstance(config.root, string_types): 50 | raise RuntimeError( 51 | 'You must specify a valid RESIZE_ROOT ' 52 | 'when RESIZE_STORAGE_BACKEND is set to "file".' 53 | ) 54 | if not os.path.isdir(config.root): 55 | raise RuntimeError( 56 | 'Your RESIZE_ROOT does not exist or is a regular file.' 57 | ) 58 | if not config.root.endswith(os.sep): 59 | config.root = config.root + os.sep 60 | 61 | store = FileStorage(base_path=config.root) 62 | 63 | else: 64 | raise RuntimeError( 65 | 'Non-supported RESIZE_STORAGE_BACKEND value: "{}"' 66 | .format(config.storage_backend) 67 | ) 68 | 69 | return store 70 | 71 | 72 | class Storage: 73 | """Storage backend base class""" 74 | 75 | def __init__(self, *args, **kwargs): 76 | pass 77 | 78 | def get(self, relative_path): 79 | raise NotImplementedError 80 | 81 | def save(self, relative_path, bdata): 82 | raise NotImplementedError 83 | 84 | def exists(self, relative_path): 85 | raise NotImplementedError 86 | 87 | def delete(self, relative_path): 88 | raise NotImplementedError 89 | 90 | def list_tree(self, subdir): 91 | raise NotImplementedError 92 | 93 | def delete_tree(self, subdir): 94 | raise NotImplementedError 95 | 96 | 97 | class FileStorage(Storage): 98 | """Local file based storage 99 | 100 | Note that to follow the same convention as for `S3Storage`, all 101 | methods' passed in and extracted paths must use forward slash as 102 | path separator. the path separator. The only exception is the `base_path` 103 | passed in to the constructor. 104 | 105 | Args: 106 | base_path (str): 107 | The directory where files will be read from and written to. 108 | Expected to use the local OS's path separator. 109 | 110 | """ 111 | 112 | def __init__(self, base_path): 113 | self.base_path = base_path 114 | 115 | def _get_full_path(self, key): 116 | """ 117 | Replaces key with OS specific path separator and joins with `base_path` 118 | 119 | Args: 120 | key (str): The key / relative file path to make whole 121 | 122 | Returns: 123 | str: The full path 124 | """ 125 | key = key.replace('/', os.sep) 126 | return os.path.join(self.base_path, key) 127 | 128 | def get(self, key): 129 | """Get binary file data for specified key 130 | 131 | Args: 132 | key (str): The key / relative file path to get data for 133 | 134 | Returns: 135 | bytes: The file's binary data 136 | """ 137 | if not key: 138 | raise exc.ImageNotFoundError() 139 | path = self._get_full_path(key) 140 | try: 141 | with open(path, 'rb') as fp: 142 | return fp.read() 143 | except IOError as e: 144 | if e.errno == 2: 145 | raise exc.ImageNotFoundError(*e.args) 146 | else: 147 | raise 148 | 149 | def save(self, key, bdata): 150 | """Store binary file data at specified key 151 | 152 | Args: 153 | key (str): The key / relative file path to store data at 154 | bdata (bytes): The file data 155 | """ 156 | full_path = self._get_full_path(key) 157 | utils.mkdir_p(os.path.split(full_path)[0]) 158 | 159 | # Python 2 doesn't natively support `xb` mode as used below. Have to 160 | # manually check for the file's existence. 161 | if PY2 and self.exists(key): 162 | raise exc.FileExistsError(17, 'File exists') 163 | with open(full_path, 'wb' if PY2 else 'xb') as fp: 164 | fp.write(bdata) 165 | 166 | def exists(self, key): 167 | """Check if the key exists in the backend 168 | 169 | Args: 170 | key (str): The key / relative file path to check 171 | 172 | Returns: 173 | bool: Whether the file exists or not 174 | """ 175 | if not key: 176 | return False 177 | full_path = self._get_full_path(key) 178 | return os.path.exists(full_path) 179 | 180 | def delete(self, key): 181 | """Delete file at specified key 182 | 183 | Args: 184 | key (str): The key / relative file path to delete 185 | """ 186 | os.remove(self._get_full_path(key)) 187 | 188 | def list_tree(self, subdir): 189 | """ 190 | Recursively yields all regular files' names in a sub-directory of the 191 | storage backend. 192 | 193 | Keys/filenames are returned `self.base_path`-relative, and uses 194 | forward slash as a path separator, regardless of OS. 195 | 196 | Args: 197 | subdir (str): 198 | The subdirectory to list paths for 199 | 200 | Returns: 201 | Generator[str, str, None]: 202 | Yields subdirectory's filenames 203 | """ 204 | assert subdir, 'Subdir must be specified' 205 | base_path = self.base_path 206 | if not base_path.endswith(os.sep): 207 | base_path += os.sep 208 | tree_base = os.path.join(base_path, subdir) 209 | for root, dirs, filenames in os.walk(tree_base, topdown=False): 210 | for filename in filenames: 211 | root_relative_path = root[len(base_path):].replace(os.sep, '/') 212 | relative_path = '/'.join([root_relative_path, filename]) 213 | yield relative_path 214 | 215 | def delete_tree(self, subdir): 216 | """ 217 | Recursively deletes all regular files in specified sub-directory 218 | 219 | Args: 220 | subdir (str): 221 | The subdirectory to delete files in 222 | 223 | Returns: 224 | Generator[str, str, None]: 225 | Yields deleted subdirectory's filenames 226 | """ 227 | for relative_path in self.list_tree(subdir): 228 | full_path = self._get_full_path(relative_path) 229 | os.remove(full_path) 230 | yield relative_path 231 | 232 | 233 | class S3Storage(Storage): 234 | """ 235 | Amazon Web Services S3 based storage 236 | 237 | Args: 238 | bucket (str): Bucket name 239 | access_key (Any[str, None]): 240 | The access key. 241 | Defaults to reading from the local AWS config. 242 | secret_key (Any[str, None]): 243 | The secret access key. 244 | Defaults to reading from the local AWS config. 245 | region_name (Any[str, None]): 246 | The name of the bucket's region. 247 | Defaults to reading from the local AWS config. 248 | file_acl (str): 249 | The ACL to set on uploaded images. Defaults to "public-read" 250 | 251 | """ 252 | 253 | def __init__( 254 | self, 255 | bucket, 256 | access_key=None, 257 | secret_key=None, 258 | region_name=None, 259 | file_acl='public-read' 260 | ): 261 | if boto3 is None: 262 | raise exc.Boto3ImportError( 263 | "boto3 must be installed for Amazon S3 support. " 264 | "Package found @ https://pypi.python.org/pypi/boto3." 265 | ) 266 | default_session = botocore.session.get_session() 267 | default_credentials = default_session.get_credentials() 268 | 269 | self.bucket_name = bucket 270 | self.access_key = \ 271 | access_key or getattr(default_credentials, 'access_key', None) 272 | self.secret_key = \ 273 | secret_key or getattr(default_credentials, 'secret_key', None) 274 | self.region_name = \ 275 | region_name or default_session.get_config_variable('region') 276 | self.file_acl = 'public-read' 277 | self.s3 = boto3.resource( 278 | 's3', 279 | aws_access_key_id=access_key, 280 | aws_secret_access_key=secret_key, 281 | region_name=region_name, 282 | ) 283 | self._verify_configuration() 284 | 285 | @property 286 | def base_url(self): 287 | """The base URL for the storage's bucket 288 | 289 | Returns: 290 | str: The URL 291 | """ 292 | return '/'.join([self.s3.meta.client._endpoint.host, self.bucket_name]) 293 | 294 | def _verify_configuration(self): 295 | if self.access_key is None: 296 | raise exc.InvalidResizeSettingError( 297 | "Could not find S3 setting for access_key" 298 | ) 299 | if self.secret_key is None: 300 | raise exc.InvalidResizeSettingError( 301 | "Could not find S3 setting for secret_key" 302 | ) 303 | if self.region_name is None: 304 | raise exc.InvalidResizeSettingError( 305 | "Could not find S3 setting for region_name" 306 | ) 307 | 308 | def get(self, relative_path): 309 | """Get binary file data for specified key 310 | 311 | Args: 312 | key (str): The key to get data for 313 | 314 | Returns: 315 | bytes: The file's binary data 316 | """ 317 | if not relative_path: 318 | raise exc.ImageNotFoundError() 319 | obj = self.s3.Object(self.bucket_name, relative_path) 320 | try: 321 | resp = obj.get() 322 | except botocore.exceptions.ClientError as e: 323 | if e.response['Error']['Code'] == 'NoSuchKey': 324 | new_exc = exc.ImageNotFoundError(*e.args) 325 | new_exc.original_exc = e 326 | raise new_exc 327 | else: 328 | raise 329 | return resp['Body'].read() 330 | 331 | def save(self, relative_path, bdata): 332 | """Store binary file data at specified key 333 | 334 | Args: 335 | key (str): The key to store data at 336 | bdata (bytes): The file data 337 | """ 338 | self.s3.meta.client.put_object( 339 | Bucket=self.bucket_name, 340 | Key=relative_path, 341 | Body=bdata, 342 | ACL=self.file_acl 343 | ) 344 | 345 | def exists(self, relative_path): 346 | """Check if the key exists in the backend 347 | 348 | Args: 349 | key (str): The key to check 350 | 351 | Returns: 352 | bool: Whether the key exists or not 353 | """ 354 | if not relative_path: 355 | return False 356 | try: 357 | self.s3.meta.client.head_object( 358 | Bucket=self.bucket_name, 359 | Key=relative_path 360 | ) 361 | except botocore.exceptions.ClientError as e: 362 | if e.response['Error']['Code'] == '404': 363 | return False 364 | else: 365 | raise 366 | else: 367 | return True 368 | 369 | def delete(self, relative_path): 370 | """Delete file at specified key 371 | 372 | Args: 373 | key (str): The key to delete 374 | """ 375 | return self.s3.meta.client.delete_object( 376 | Bucket=self.bucket_name, 377 | Key=relative_path, 378 | ) 379 | 380 | def list_tree(self, subdir): 381 | """ 382 | Recursively yields all keys in a sub-directory of the storage backend 383 | 384 | Args: 385 | subdir (str): 386 | The subdirectory to list keys for 387 | 388 | Returns: 389 | Generator[str, str, None]: 390 | Yields subdirectory's keys 391 | """ 392 | assert subdir, 'Subdir must be specified' 393 | if not subdir.endswith('/'): 394 | subdir += '/' 395 | 396 | kw = dict(Bucket=self.bucket_name, Prefix=subdir) 397 | 398 | while True: 399 | resp = self.s3.meta.client.list_objects_v2(**kw) 400 | for obj in resp.get('Contents', ()): 401 | yield obj['Key'] 402 | if resp.get('NextContinuationToken'): 403 | kw['ContinuationToken'] = resp['NextContinuationToken'] 404 | else: 405 | break 406 | 407 | def delete_tree(self, subdir): 408 | """ 409 | Recursively deletes all keys in specified sub-directory (prefix) 410 | 411 | Args: 412 | subdir (str): 413 | The subdirectory to delete keys in 414 | 415 | Returns: 416 | Generator[str, str, None]: 417 | Yields subdirectory's deleted keys 418 | """ 419 | assert subdir, 'Subdir must be specified' 420 | 421 | for keys in utils.chunked(self.list_tree(subdir), 1000): 422 | self.s3.meta.client.delete_objects( 423 | Bucket=self.bucket_name, 424 | Delete={ 425 | 'Objects': [{'Key': key} for key in keys] 426 | } 427 | ) 428 | for key in keys: 429 | yield key 430 | -------------------------------------------------------------------------------- /flask_resize/utils.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import itertools 3 | import os 4 | 5 | from . import constants, exc 6 | from ._compat import string_types 7 | 8 | 9 | def mkdir_p(path): 10 | """Creates all non-existing directories encountered in the passed in path 11 | 12 | Args: 13 | path (str): 14 | Path containing directories to create 15 | 16 | Raises: 17 | OSError: 18 | If some underlying error occurs when calling :func:`os.makedirs`, 19 | that is not errno.EEXIST. 20 | """ 21 | try: 22 | os.makedirs(path) 23 | except OSError as exc: 24 | if exc.errno == errno.EEXIST and os.path.isdir(path): 25 | pass 26 | else: 27 | raise 28 | 29 | 30 | def parse_dimensions(dimensions): 31 | """Parse the Flask-Resize image dimensions format string/2-tuple 32 | 33 | Args: 34 | dimensions (:class:`str`, Sequence[:class:`int`]): 35 | Can be a string in format ``x``, ````, 36 | ``x``, or ``x``. Or a 2-tuple of ints containg 37 | width and height. 38 | 39 | Raises: 40 | :class:`exc.InvalidDimensionsError`: 41 | If the dimensions couldn't be parsed. 42 | :class:`exc.MissingDimensionsError`: 43 | If no width or height could be parsed from the string. 44 | 45 | Returns: 46 | Tuple[:class:`int`, :class:`int`]: 47 | Width and height. 48 | """ 49 | 50 | if isinstance(dimensions, string_types): 51 | dims = dimensions.split('x') 52 | if len(dims) == 1: 53 | dims.append(None) 54 | dims = [d or None for d in dims] 55 | 56 | else: 57 | dims = [i for i in dimensions] 58 | 59 | if not any(dims) or len(dims) < 2: 60 | raise exc.MissingDimensionsError(dimensions) 61 | if len(dims) > 2: 62 | raise exc.InvalidDimensionsError(dimensions) 63 | 64 | return tuple((int(d) if d else None) for d in dims) 65 | 66 | 67 | def parse_rgb(v, include_number_sign=True): 68 | """Create a hex value color representation of the provided value 69 | 70 | Args: 71 | v (:class:`str`, Sequence[:class:`int`, :class:`int`, :class:`int`]): 72 | A RGB color value in hex representation, may or may not start 73 | with a number sign ("#"). Can be in short CSS format with only 74 | three characters, or the regular six character length. 75 | Can also a 3-tuple of integers, representing Red, Green and Blue. 76 | include_number_sign (bool): 77 | Whether or not to prepend a number sign to the output string. 78 | 79 | Returns: 80 | str: 81 | A full hex representation of the passed in RGB value 82 | """ 83 | if isinstance(v, tuple): 84 | v = ''.join('{:02x}'.format(d) for d in v) 85 | if v.startswith('#'): 86 | v = v[1:] 87 | if len(v) == 3: 88 | v = u''.join(s + s for s in v) 89 | return u'#' + v if include_number_sign else v 90 | 91 | 92 | def parse_format(image_path, format=None): 93 | """Parse and validate format from the supplied image path 94 | 95 | Args: 96 | image_path (str): 97 | Image path to parse format from 98 | format (Optional[:class:`str`]): 99 | This format is assumed if argument is not None 100 | 101 | Raises: 102 | :class:`exc.UnsupportedImageFormatError`: 103 | If an unsupported image format was encountered. 104 | 105 | Returns: 106 | str: 107 | Format of supplied image 108 | """ 109 | if not format: 110 | format = os.path.splitext(image_path)[1][1:] 111 | format = format.upper() 112 | if format in ('JPEG', 'JPG'): 113 | format = constants.JPEG 114 | if format == constants.SVG: 115 | format = constants.PNG 116 | if format not in constants.SUPPORTED_OUTPUT_FILE_FORMATS: 117 | raise exc.UnsupportedImageFormatError( 118 | "JPEG and PNG are the only supported output " 119 | "file formats at the moment." 120 | ) 121 | return format 122 | 123 | 124 | def chunked(iterable, size): 125 | """Split iterable `iter` into one or more `size` sized tuples""" 126 | it = iter(iterable) 127 | return iter(lambda: tuple(itertools.islice(it, size)), ()) 128 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import click 2 | import subprocess as subp 3 | 4 | common_args = ['docs', 'docs/_build/html'] 5 | 6 | 7 | @click.group() 8 | def cli(): 9 | pass 10 | 11 | 12 | @click.command() 13 | @click.option('-p', '--push-tag', help='git push the tag', is_flag=True) 14 | def gittag(push_tag): 15 | from flask_resize import metadata 16 | v = metadata.__version__ 17 | retcode = subp.call(['git', 'tag', '-a', '%s' % v, '-m', 'Version %s' % v]) 18 | if push_tag and retcode == 0: 19 | subp.call(['git', 'push', '--tags']) 20 | 21 | 22 | @click.command() 23 | def release(): 24 | subp.call(['python', 'setup.py', 'sdist', 'bdist_wheel', 'upload']) 25 | 26 | 27 | @click.group(name='docs', chain=True) 28 | def docs(): 29 | pass 30 | 31 | 32 | @click.command() 33 | def clean(): 34 | subp.call(['rm', '-rf', 'docs/_build']) 35 | 36 | 37 | @click.command() 38 | def build(): 39 | subp.call(['sphinx-build'] + common_args) 40 | 41 | 42 | @click.command() 43 | def serve(): 44 | subp.call(['sphinx-autobuild', '-z', 'flask_resize'] + common_args) 45 | 46 | 47 | if __name__ == '__main__': 48 | docs.add_command(clean) 49 | docs.add_command(build) 50 | docs.add_command(serve) 51 | cli.add_command(docs) 52 | 53 | cli.add_command(gittag) 54 | cli.add_command(release) 55 | 56 | cli() 57 | -------------------------------------------------------------------------------- /requirements_docs.txt: -------------------------------------------------------------------------------- 1 | sphinx-argparse>=0.1.17 2 | sphinx-autobuild>=0.6.0 3 | sphinx-rtd-theme>=0.2.4 4 | sphinx>=1.5.3 5 | sphinxcontrib-napoleon>=0.6.1 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Flask-Resize 4 | ------------ 5 | 6 | Flask extension for automating the resizing of images in your code and 7 | templates. Can convert from JPEG|PNG|SVG to JPEG|PNG, resize to fit and crop. 8 | File-based and S3-based storage options are available. 9 | 10 | See https://flask-resize.readthedocs.io/ for documentation. 11 | 12 | """ 13 | from __future__ import print_function 14 | 15 | import sys 16 | 17 | from setuptools import setup, find_packages 18 | 19 | 20 | appname = 'Flask-Resize' 21 | pkgname = appname.lower().replace('-', '_') 22 | 23 | setup( 24 | name=appname, 25 | version='2.0.4', 26 | description='Flask extension for resizing images in code and templates', 27 | long_description=__doc__, 28 | packages=find_packages(), 29 | package_dir={pkgname: pkgname}, 30 | package_data={ 31 | pkgname: [ 32 | 'fonts/*.ttf', 33 | ], 34 | }, 35 | install_requires=[ 36 | 'argh', 37 | 'Flask', 38 | 'pilkit', 39 | 'Pillow', 40 | ], 41 | extras_require={ 42 | 'svg': ['cairosvg'], 43 | 'redis': ['redis'], 44 | 's3': ['boto3'], 45 | 'full': ( 46 | ['redis', 'boto3'] + 47 | (['cairosvg'] if sys.version_info >= (3, 4) else []) 48 | ), 49 | 'test': [ 50 | 'click>=6.7', 51 | 'coverage>=4.2', 52 | 'pytest>=3.0.7', 53 | ], 54 | 'test_s3': ['moto>=0.4.31'], 55 | }, 56 | entry_points={ 57 | 'console_scripts': { 58 | 'flask-resize = flask_resize.bin:parser.dispatch', 59 | }, 60 | }, 61 | author='Jacob Magnusson', 62 | author_email='m@jacobian.se', 63 | url='https://github.com/jmagnusson/Flask-Resize', 64 | license='BSD', 65 | platforms='any', 66 | classifiers=[ 67 | 'Development Status :: 5 - Production/Stable', 68 | 'Environment :: Web Environment', 69 | 'Intended Audience :: Developers', 70 | 'License :: OSI Approved :: BSD License', 71 | 'Operating System :: OS Independent', 72 | 'Programming Language :: Python', 73 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 74 | 'Topic :: Software Development :: Libraries :: Python Modules', 75 | 'Programming Language :: Python :: 2.7', 76 | 'Programming Language :: Python :: 3.4', 77 | 'Programming Language :: Python :: 3.5', 78 | 'Programming Language :: Python :: 3.6', 79 | 'Programming Language :: Python :: Implementation :: CPython', 80 | 'Programming Language :: Python :: Implementation :: PyPy', 81 | ], 82 | ) 83 | -------------------------------------------------------------------------------- /test.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.6.3 2 | FROM python:${PYTHON_VERSION}-alpine 3 | WORKDIR /app 4 | RUN apk add --no-cache\ 5 | curl\ 6 | git\ 7 | # Bash and general build packages 8 | bash make gcc musl-dev linux-headers\ 9 | # For CFFI support 10 | libffi-dev\ 11 | # For cairosvg 12 | cairo-dev\ 13 | # For lxml 14 | libxml2-dev libxslt-dev\ 15 | # PIL/Pillow support 16 | zlib-dev jpeg-dev libwebp-dev freetype-dev lcms2-dev openjpeg-dev\ 17 | # For moto (tests) 18 | openssl-dev 19 | COPY test.docker-entrypoint.sh /docker-entrypoint.sh 20 | RUN chmod +x /docker-entrypoint.sh 21 | COPY setup.py /app 22 | RUN pip install -e .[full,test,test_s3] flake8 isort 23 | COPY conftest.py /app 24 | COPY flask_resize /app/flask_resize 25 | COPY tests /app/tests 26 | RUN pip install -e . 27 | ENTRYPOINT ["/docker-entrypoint.sh"] 28 | -------------------------------------------------------------------------------- /test.docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | trap 'exit' ERR 3 | 4 | lint () { 5 | flake8 flask_resize tests\ 6 | && (isort --recursive --check-only flask_resize tests \ 7 | || (isort --recursive --diff flask_resize tests && false)\ 8 | ) 9 | } 10 | 11 | 12 | pytest_prep () { 13 | echo -n 'Prepping pytest... ' 14 | # Pytest complains about invalid import paths if we don't delete .pyc files 15 | find tests -name "*.pyc" -exec rm -f {} \; &&\ 16 | echo 'Done!' 17 | } 18 | 19 | 20 | pytest_run_default () { 21 | coverage run --source=flask_resize -m py.test && coverage report 22 | } 23 | 24 | 25 | if [[ "$1" = 'lint' ]]; then 26 | lint 27 | elif [[ "$1" = 'test' ]]; then 28 | pytest_prep 29 | pytest_run_default 30 | elif [[ "$1" = 'pytest' ]]; then 31 | pytest_prep 32 | exec "$@" 33 | else 34 | exec "$@" 35 | fi 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsvante/Flask-Resize/7f90ead1ff046874b0ec2ec7b66a8615461c686e/tests/__init__.py -------------------------------------------------------------------------------- /tests/_mocking.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | test_real_s3_keys = ( 4 | 'RESIZE_S3_ACCESS_KEY', 5 | 'RESIZE_S3_SECRET_KEY', 6 | 'RESIZE_S3_BUCKET', 7 | ) 8 | 9 | if 'RESIZE_S3_BUCKET' in os.environ: 10 | def mock_s3(f): 11 | return f 12 | else: 13 | try: 14 | from moto import mock_s3 15 | except ImportError: 16 | def mock_s3(f): 17 | return f 18 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | from flask_resize import Resize 4 | 5 | 6 | def create_resizeapp(RESIZE_ROOT=None, **settings): 7 | app = flask.Flask( 8 | __name__, 9 | static_folder=RESIZE_ROOT, 10 | static_url_path='/', 11 | ) 12 | settings.setdefault('RESIZE_CACHE_STORE', 'noop') 13 | app.config.update(settings, RESIZE_ROOT=RESIZE_ROOT, TESTING=True) 14 | Resize(app) 15 | return app 16 | -------------------------------------------------------------------------------- /tests/decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_resize import _compat 4 | 5 | slow = pytest.mark.skipif( 6 | pytest.config.getoption("--skip-slow"), 7 | reason="--skip-slow passed in, skipping..." 8 | ) 9 | 10 | requires_redis = pytest.mark.skipif( 11 | _compat.redis is None, 12 | reason='Redis is not installed' 13 | ) 14 | 15 | requires_boto3 = pytest.mark.skipif( 16 | _compat.boto3 is None, 17 | reason='`boto3` has to be installed to run this test' 18 | ) 19 | 20 | requires_cairosvg = pytest.mark.skipif( 21 | _compat.cairosvg is None, 22 | reason="CairoSVG 2.0+ is not installed. Only supported in Python 3.4+" 23 | ) 24 | 25 | requires_no_cairosvg = pytest.mark.skipif( 26 | _compat.cairosvg is not None, 27 | reason="Should only test CairoSVG import error when it isn't installed" 28 | ) 29 | -------------------------------------------------------------------------------- /tests/test_bin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import pytest 5 | 6 | import flask_resize 7 | 8 | from .decorators import requires_redis, slow 9 | 10 | 11 | @pytest.fixture 12 | def env(tmpdir, redis_cache): 13 | basedir = tmpdir 14 | conffile = tmpdir.join('flask-resize-conf.py') 15 | conffile.write( 16 | """ 17 | RESIZE_URL = 'https://example.com' 18 | RESIZE_ROOT = '{root}' 19 | RESIZE_REDIS_HOST = '{redis_host}' 20 | RESIZE_REDIS_KEY = '{cache_key}' 21 | """ 22 | .format( 23 | root=str(basedir).replace('\\', '\\\\'), 24 | redis_host=redis_cache._host, 25 | cache_key=redis_cache.key, 26 | ).strip() 27 | ) 28 | env = os.environ.copy() 29 | # env = dict(PATH=os.environ['PATH']) 30 | env.update(FLASK_RESIZE_CONF=str(conffile)) 31 | return env 32 | 33 | 34 | def run(env, *args): 35 | return subprocess.check_output(args, env=env).decode().splitlines() 36 | 37 | 38 | @slow 39 | def test_bin_usage(env): 40 | assert 'usage: flask-resize' in run(env, 'flask-resize', '--help')[0] 41 | 42 | 43 | @slow 44 | def test_bin_list_images_empty(env): 45 | assert run(env, 'flask-resize', 'list', 'images') == [] 46 | 47 | 48 | @slow 49 | def test_bin_list_has_images( 50 | env, 51 | resizetarget_opts, 52 | image1_name, 53 | image1_data, 54 | image1_key 55 | ): 56 | resize_target = flask_resize.ResizeTarget(**resizetarget_opts) 57 | 58 | resize_target.image_store.save(image1_name, image1_data) 59 | resize_target.generate() 60 | assert run(env, 'flask-resize', 'list', 'images') == [image1_key] 61 | 62 | 63 | @requires_redis 64 | @slow 65 | def test_bin_list_cache_empty(env, redis_cache): 66 | assert run(env, 'flask-resize', 'list', 'cache') == [] 67 | 68 | 69 | @requires_redis 70 | @slow 71 | def test_bin_list_has_cache(env, redis_cache): 72 | redis_cache.add('hello') 73 | redis_cache.add('buh-bye') 74 | assert set(run(env, 'flask-resize', 'list', 'cache')) == \ 75 | {'hello', 'buh-bye'} 76 | 77 | 78 | @slow 79 | def test_bin_clear_images( 80 | env, 81 | resizetarget_opts, 82 | image1_name, 83 | image1_data 84 | ): 85 | resize_target = flask_resize.ResizeTarget(**resizetarget_opts) 86 | 87 | resize_target.image_store.save(image1_name, image1_data) 88 | resize_target.generate() 89 | run(env, 'flask-resize', 'clear', 'images') 90 | assert run(env, 'flask-resize', 'list', 'images') == [] 91 | 92 | 93 | @requires_redis 94 | @slow 95 | def test_bin_clear_cache(env, redis_cache): 96 | redis_cache.add('foo bar') 97 | assert run(env, 'flask-resize', 'clear', 'cache') == [] 98 | 99 | 100 | @requires_redis 101 | @slow 102 | def test_bin_sync_cache( 103 | env, 104 | resizetarget_opts, 105 | image1_name, 106 | image1_data, 107 | image1_key, 108 | redis_cache 109 | ): 110 | resize_target = flask_resize.ResizeTarget(**resizetarget_opts) 111 | 112 | resize_target.image_store.save(image1_name, image1_data) 113 | resize_target.generate() 114 | 115 | redis_cache.clear() 116 | 117 | assert run(env, 'flask-resize', 'list', 'cache') == [] 118 | 119 | run(env, 'flask-resize', 'sync', 'cache') 120 | 121 | assert run(env, 'flask-resize', 'list', 'images') == [image1_key] 122 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_resize import exc, resizing 4 | 5 | from .decorators import requires_redis 6 | 7 | 8 | @requires_redis 9 | def test_redis_cache(redis_cache, resizetarget_opts, image1_data, image1_name): 10 | 11 | resizetarget_opts.update(cache_store=redis_cache) 12 | resize_target = resizing.ResizeTarget(**resizetarget_opts) 13 | 14 | assert not redis_cache.exists(resize_target.unique_key) 15 | 16 | with pytest.raises(exc.CacheMiss): 17 | resize_target.get_cached_path() 18 | 19 | resize_target.image_store.save(image1_name, image1_data) 20 | resize_target.generate() 21 | 22 | assert redis_cache.exists(resize_target.unique_key) is True 23 | assert resize_target.get_cached_path() == resize_target.unique_key 24 | 25 | redis_cache.remove(resize_target.unique_key) 26 | assert redis_cache.exists(resize_target.unique_key) is False 27 | -------------------------------------------------------------------------------- /tests/test_concurrency.py: -------------------------------------------------------------------------------- 1 | from multiprocessing.dummy import Pool 2 | 3 | import pytest 4 | 5 | import flask_resize 6 | from flask_resize.configuration import Config 7 | 8 | from .decorators import requires_redis 9 | 10 | 11 | @requires_redis 12 | def test_generate_in_progress( 13 | redis_cache, 14 | resizetarget_opts, 15 | image1_data, 16 | image1_name 17 | ): 18 | resizetarget_opts.update(cache_store=redis_cache) 19 | 20 | resize_target = flask_resize.resizing.ResizeTarget(**resizetarget_opts) 21 | 22 | # Save original file 23 | resize_target.image_store.save(image1_name, image1_data) 24 | 25 | def run(x): 26 | resize_target.generate() 27 | 28 | pool = Pool(2) 29 | 30 | with pytest.raises(flask_resize.exc.GenerateInProgress): 31 | pool.map(run, [None] * 2) 32 | 33 | 34 | @requires_redis 35 | def test_generate_in_progress_resizer_option_true( 36 | redis_cache, 37 | resizetarget_opts, 38 | image1_data, 39 | image1_name, 40 | tmpdir 41 | ): 42 | config = Config( 43 | root=str(tmpdir), 44 | url='/', 45 | redis_host=redis_cache.redis, 46 | raise_on_generate_in_progress=True 47 | ) 48 | resizer = flask_resize.make_resizer(config) 49 | 50 | # Save original file 51 | resizer.storage_backend.save(image1_name, image1_data) 52 | 53 | def run(x): 54 | return resizer(image1_name) 55 | 56 | pool = Pool(2) 57 | 58 | with pytest.raises(flask_resize.exc.GenerateInProgress): 59 | pool.map(run, [None] * 2) 60 | 61 | 62 | @requires_redis 63 | def test_generate_in_progress_resizer_option_false( 64 | redis_cache, 65 | resizetarget_opts, 66 | image1_data, 67 | image1_name, 68 | tmpdir 69 | ): 70 | config = Config( 71 | root=str(tmpdir), 72 | url='/', 73 | redis_host=redis_cache.redis, 74 | raise_on_generate_in_progress=False 75 | ) 76 | resizer = flask_resize.make_resizer(config) 77 | 78 | # Save original file 79 | resizer.storage_backend.save(image1_name, image1_data) 80 | 81 | def run(x): 82 | return resizer(image1_name) 83 | 84 | pool = Pool(2) 85 | data = pool.map(run, [None] * 2) 86 | assert len(data) == 2 87 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import flask 4 | import pytest 5 | 6 | from .base import create_resizeapp 7 | 8 | 9 | def test_resize_settings(): 10 | with pytest.raises(RuntimeError): 11 | create_resizeapp() 12 | with pytest.raises(RuntimeError): 13 | create_resizeapp(RESIZE_URL='http://test.dev') 14 | with pytest.raises(RuntimeError): 15 | create_resizeapp(RESIZE_ROOT=os.sep) 16 | 17 | working_app = create_resizeapp(RESIZE_URL='http://test.dev', 18 | RESIZE_ROOT=os.sep) 19 | assert isinstance(working_app, flask.Flask) 20 | 21 | 22 | def test_resize_noop(): 23 | app = create_resizeapp(RESIZE_NOOP=True) 24 | with app.test_request_context(): 25 | generated_image_path = app.resize('xyz.png', 'WxH') 26 | assert generated_image_path == 'xyz.png' 27 | -------------------------------------------------------------------------------- /tests/test_resize.py: -------------------------------------------------------------------------------- 1 | import io 2 | import re 3 | 4 | import flask 5 | import pytest 6 | from PIL import Image 7 | 8 | from flask_resize import cache, exc, resizing 9 | 10 | from .base import create_resizeapp 11 | from .decorators import requires_cairosvg, requires_no_cairosvg 12 | 13 | 14 | def test_resizetarget_init(filestorage): 15 | original_image_relative_url = 'path/to/my/file.png' 16 | target = resizing.ResizeTarget( 17 | filestorage, 18 | original_image_relative_url, 19 | dimensions='100x50', 20 | format='jpeg', 21 | quality=80, 22 | fill=False, 23 | bgcolor=None, 24 | upscale=True, 25 | progressive=True, 26 | use_placeholder=False, 27 | cache_store=cache.NoopCache(), 28 | ) 29 | assert target.name_hashing_method == 'sha1' 30 | assert target.target_directory == 'resized-images' 31 | 32 | assert( 33 | target._get_generate_unique_key_args() == 34 | [ 35 | original_image_relative_url, 'JPEG', 80, 100, 50, '', 'no-fill', 36 | 'upscale', '' 37 | ] 38 | ) 39 | 40 | assert( 41 | target.unique_key == 42 | 'resized-images/e1307a6b8f166778588914d5130bd92bcd7f20ca.jpg' 43 | ) 44 | 45 | 46 | def test_resizetarget_generate( 47 | resizetarget_opts, 48 | image1_data, 49 | image1_name, 50 | image1_key 51 | ): 52 | resize_target = resizing.ResizeTarget(**resizetarget_opts) 53 | assert resize_target.name_hashing_method == 'sha1' 54 | 55 | with pytest.raises(exc.CacheMiss): 56 | resize_target.get_cached_path() 57 | 58 | with pytest.raises(exc.ImageNotFoundError): 59 | assert resize_target.get_path() 60 | 61 | # Save original file 62 | resize_target.image_store.save(image1_name, image1_data) 63 | 64 | # Generate thumb 65 | resize_target.generate() 66 | 67 | assert resize_target.get_path() == image1_key 68 | 69 | 70 | def test_resizetarget_generate_placeholder(resizetarget_opts, image1_data): 71 | resize_target = resizing.ResizeTarget(**resizetarget_opts) 72 | resize_target.use_placeholder = True 73 | resize_target.generate() 74 | 75 | assert re.match(r'^resized-images/.+\.jpg$', resize_target.get_path()) 76 | 77 | 78 | def test_resize_filter(tmpdir, image1_data, image2_data): 79 | resize_url = 'http://test.dev/' 80 | file1 = tmpdir.join('file1.png') 81 | file1.write_binary(image1_data) 82 | 83 | file2 = tmpdir.join('file2.png') 84 | file2.write_binary(image2_data) 85 | 86 | file1_expected_url = ( 87 | resize_url + 88 | 'resized-images/ac17b732cabcc4eeb783cd994d0e169665b3bb68.png' 89 | ) 90 | 91 | file2_expected_url = ( 92 | resize_url + 93 | 'resized-images/add9a8c8531825a56f58289087b1f892c5e1348f.png' 94 | ) 95 | 96 | app = create_resizeapp( 97 | RESIZE_URL=resize_url, 98 | RESIZE_ROOT=str(tmpdir), 99 | DEBUG=True 100 | ) 101 | template = '' 102 | 103 | @app.route('/') 104 | def start(): 105 | return flask.render_template_string(template, fn='file1.png') 106 | 107 | with app.test_client() as c: 108 | resp = c.get('/') 109 | assert file1_expected_url in resp.get_data(True) 110 | 111 | with app.test_request_context(): 112 | rendered = flask.render_template_string(template, fn='file2.png') 113 | assert file2_expected_url in rendered 114 | 115 | 116 | def test_fill_dimensions(tmpdir, image1_data, resizetarget_opts): 117 | file1 = tmpdir.join('file1.png') 118 | file1.write_binary(image1_data) 119 | 120 | resizetarget_opts.update( 121 | format='png', 122 | source_image_relative_url='file1.png', 123 | dimensions='300x400', 124 | fill=True, 125 | ) 126 | resize_target = resizing.ResizeTarget(**resizetarget_opts) 127 | img_data = resize_target.generate() 128 | generated_img = Image.open(io.BytesIO(img_data)) 129 | assert generated_img.width == 300 130 | assert generated_img.height == 400 131 | assert generated_img.getpixel((0, 0))[3] == 0 # Transparent 132 | 133 | resizetarget_opts.update(dimensions='700x600') 134 | resize_target = resizing.ResizeTarget(**resizetarget_opts) 135 | img_data = resize_target.generate() 136 | generated_img = Image.open(io.BytesIO(img_data)) 137 | assert generated_img.width == 700 138 | assert generated_img.height == 600 139 | assert generated_img.getpixel((0, 0))[3] == 0 # Transparent 140 | 141 | 142 | SVG_DATA = """ 143 | 150 | 151 | 158 | """ 159 | 160 | 161 | @requires_cairosvg 162 | def test_svg_resize(tmpdir, resizetarget_opts): 163 | svg_path = tmpdir.join('test.svg') 164 | svg_path.write(SVG_DATA) 165 | 166 | resizetarget_opts.update( 167 | format='png', 168 | source_image_relative_url='test.svg', 169 | dimensions='50x50', 170 | ) 171 | resize_target = resizing.ResizeTarget(**resizetarget_opts) 172 | img_data = resize_target.generate() 173 | img = Image.open(io.BytesIO(img_data)) 174 | 175 | assert img.width == 50 176 | assert img.height == 50 177 | 178 | expected = (0, 0, 0, 255) 179 | assert img.getpixel((0, 0)) == expected 180 | assert img.getpixel((49, 49)) == expected 181 | 182 | 183 | @requires_no_cairosvg 184 | def test_svg_resize_cairosvgimporterror(tmpdir, resizetarget_opts): 185 | svg_path = tmpdir.join('test.svg') 186 | svg_path.write('content') 187 | 188 | resizetarget_opts.update(source_image_relative_url='test.svg') 189 | resize_target = resizing.ResizeTarget(**resizetarget_opts) 190 | 191 | with pytest.raises(exc.CairoSVGImportError): 192 | resize_target.generate() 193 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | import flask_resize 6 | from flask_resize._compat import boto3 7 | 8 | from ._mocking import mock_s3 9 | from .decorators import requires_boto3 10 | 11 | 12 | def test_file_storage(filestorage, tmpdir): 13 | basepath = tmpdir.mkdir('subdir') 14 | tmpdir.mkdir('subdir/subsubdir') 15 | 16 | filepath1 = basepath.join('file1.txt') 17 | filepath1.write('content') 18 | data = filestorage.get('subdir/file1.txt') 19 | assert data == b'content' 20 | 21 | with pytest.raises(flask_resize.exc.FileExistsError): 22 | filestorage.save('subdir/file1.txt', b'') 23 | 24 | filestorage.delete('subdir/file1.txt') 25 | 26 | with pytest.raises(OSError) as excinfo: 27 | filestorage.delete('subdir/file1.txt') 28 | assert excinfo.value.errno == 2 29 | 30 | filestorage.save('subdir/file2.txt', b'content2') 31 | filestorage.save('subdir/subsubdir/file3.txt', b'content3') 32 | 33 | expected_relpaths = set(['subdir/file2.txt', 'subdir/subsubdir/file3.txt']) 34 | 35 | assert set(filestorage.list_tree('subdir')) == expected_relpaths 36 | assert set(filestorage.delete_tree('subdir')) == expected_relpaths 37 | assert list(filestorage.delete_tree('subdir')) == [] 38 | 39 | 40 | @mock_s3 41 | @requires_boto3 42 | def test_s3_storage(): 43 | real_access_key = os.environ.get('RESIZE_S3_ACCESS_KEY') 44 | real_secret_key = os.environ.get('RESIZE_S3_SECRET_KEY') 45 | real_bucket_name = os.environ.get('RESIZE_S3_BUCKET') 46 | s3_storage = flask_resize.storage.S3Storage( 47 | real_bucket_name or 'test-bucket', 48 | access_key=real_access_key, 49 | secret_key=real_secret_key, 50 | region_name='eu-central-1', 51 | ) 52 | 53 | if not real_bucket_name: 54 | # Create mock bucket 55 | conn = boto3.resource('s3', region_name='eu-central-1') 56 | conn.create_bucket(Bucket='test-bucket') 57 | 58 | assert s3_storage.base_url.startswith('https://s3.') 59 | assert s3_storage.base_url.endswith( 60 | '.amazonaws.com/' + (real_bucket_name or 'test-bucket') 61 | ) 62 | 63 | # In case it already exists 64 | s3_storage.delete('subdir/file1.txt') 65 | 66 | assert s3_storage.exists('subdir/file1.txt') is False 67 | 68 | s3_storage.save('subdir/file1.txt', b'content') 69 | data = s3_storage.get('subdir/file1.txt') 70 | 71 | assert s3_storage.exists('subdir/file1.txt') is True 72 | assert data == b'content' 73 | 74 | # Cleanup 75 | s3_storage.delete('subdir/file1.txt') 76 | 77 | s3_storage.save('subdir/file2.txt', b'content2') 78 | s3_storage.save('subdir/subsubdir/file3.txt', b'content3') 79 | 80 | expected_relpaths = set(['subdir/file2.txt', 'subdir/subsubdir/file3.txt']) 81 | 82 | assert set(s3_storage.list_tree('subdir')) == expected_relpaths 83 | assert set(s3_storage.delete_tree('subdir')) == expected_relpaths 84 | assert list(s3_storage.delete_tree('subdir')) == [] 85 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_resize import exc 4 | from flask_resize.utils import parse_dimensions, parse_rgb 5 | 6 | 7 | def test_parse_dimensions(): 8 | def image_dimensions_generator(): 9 | yield 1 10 | yield 2 11 | 12 | assert parse_dimensions('100x'), (100, None) 13 | assert parse_dimensions('3x4'), (3, 4) 14 | assert parse_dimensions('x2000'), (None, 2000) 15 | assert parse_dimensions((666, None)), (666, None) 16 | assert parse_dimensions([None, 777]), (None, 777) 17 | assert parse_dimensions([123, 456]), (123, 456) 18 | assert parse_dimensions(image_dimensions_generator()), (1, 2) 19 | 20 | invalid_values = ['100px200px', '1x2x3', [1, 2, 3]] 21 | missing_values = ['', [0, 0], [None, None], [1]] 22 | 23 | for invalid in invalid_values + missing_values: 24 | with pytest.raises(exc.InvalidDimensionsError): 25 | parse_dimensions(invalid) 26 | 27 | for missing_value in missing_values: 28 | with pytest.raises(exc.MissingDimensionsError): 29 | parse_dimensions(invalid) 30 | 31 | 32 | def test_parse_rgb(): 33 | assert parse_rgb((0, 0, 0)) == '#000000' 34 | assert parse_rgb((20, 50, 200)) == '#1432c8' 35 | assert parse_rgb('000') == '#000000' 36 | assert parse_rgb('f0c') == '#ff00cc' 37 | assert parse_rgb('1432c8') == '#1432c8' 38 | assert parse_rgb('#feccde') == '#feccde' 39 | assert parse_rgb('#fcd') == '#ffccdd' 40 | 41 | assert parse_rgb((0, 0, 0), include_number_sign=False) == '000000' 42 | assert parse_rgb('#1432c8', include_number_sign=False) == '1432c8' 43 | assert parse_rgb('feccde', include_number_sign=False) == 'feccde' 44 | assert parse_rgb('fcd', include_number_sign=False) == 'ffccdd' 45 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | py{27,34,35,36,py}-release, 9 | py{27,34,35,36,py}-full, 10 | py{27,34,35,36,py}-redis, 11 | py{27,34,35,36,py}-s3, 12 | py{34,35,36}-svg, 13 | lint 14 | 15 | [testenv] 16 | commands = 17 | coverage run --source=flask_resize -m py.test {posargs} 18 | deps = 19 | .[test] 20 | svg: .[svg] 21 | redis: .[redis] 22 | s3: .[s3,test_s3] 23 | full: .[full,test_s3] 24 | passenv = RESIZE_* 25 | 26 | [testenv:lint] 27 | commands = 28 | flake8 flask_resize tests 29 | isort --recursive --diff flask_resize tests 30 | isort --recursive --check-only flask_resize tests 31 | deps = 32 | .[test] 33 | flake8 34 | isort 35 | 36 | [pytest] 37 | norecursedirs = .* *.egg build venv 38 | addopts = --strict 39 | --------------------------------------------------------------------------------