├── .github └── workflows │ ├── build.yml │ ├── lint.yml │ └── pypi.yml ├── .gitignore ├── .yamllint ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── flask_thumbnails ├── __init__.py ├── storage_backends.py ├── thumbnail.py └── utils.py ├── pyproject.toml ├── requirements-dev.txt ├── setup.py └── tests ├── __init__.py ├── core_tests.py ├── storage_backends_tests.py └── utils_tests.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "build" 3 | 4 | on: # yamllint disable-line rule:truthy 5 | pull_request: 6 | push: 7 | branches: master 8 | 9 | jobs: 10 | build: 11 | name: Python ${{ matrix.python-version }} | Flask ${{ matrix.flask-version }} | Ubuntu 12 | runs-on: ubuntu-20.04 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - python-version: 3.6 18 | flask-version: "0.12.*" 19 | - python-version: 3.6 20 | flask-version: "1.0.*" 21 | - python-version: 3.6 22 | flask-version: "1.1.*" 23 | - python-version: 3.6 24 | flask-version: "2.0.*" 25 | 26 | - python-version: 3.7 27 | flask-version: "2.0.*" 28 | - python-version: 3.7 29 | flask-version: "2.1.*" 30 | - python-version: 3.7 31 | flask-version: "2.2.*" 32 | 33 | - python-version: 3.8 34 | flask-version: "2.2.*" 35 | 36 | - python-version: 3.9 37 | flask-version: "2.2.*" 38 | coverage: true 39 | 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v3 43 | 44 | - name: Setup Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v4 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | 49 | - name: Install Flask ${{ matrix.flask-version }} 50 | run: | 51 | pip install Flask==${{ matrix.flask-version }} 52 | pip install pillow 53 | pip install coverage 54 | pip install mock 55 | 56 | - name: Run tests 57 | run: | 58 | make test 59 | 60 | - if: ${{ matrix.coverage }} 61 | run: | 62 | make coverage 63 | 64 | - if: ${{ matrix.coverage }} 65 | name: Upload coverage to Codecov 66 | uses: codecov/codecov-action@v3 67 | with: 68 | token: ${{ secrets.CODECOV_TOKEN }} 69 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Lint" 3 | 4 | on: # yamllint disable-line rule:truthy 5 | pull_request: 6 | push: 7 | branches: master 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Python 3.9 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.9 22 | 23 | - name: Upgrade Setuptools 24 | run: pip install --upgrade setuptools wheel 25 | 26 | - name: Install requirements 27 | run: pip install -r requirements-dev.txt 28 | 29 | - name: Run lint 30 | run: make lint 31 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "PyPI Release" 3 | 4 | on: # yamllint disable-line rule:truthy 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | publish: 11 | name: PyPI Release 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Python 3.9 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.9 22 | 23 | - name: Upgrade Setuptools 24 | run: pip install --upgrade setuptools wheel 25 | 26 | - name: Build Distribution 27 | run: python setup.py sdist bdist_wheel --universal 28 | 29 | - name: Publish to PyPI 30 | uses: pypa/gh-action-pypi-publish@master 31 | with: 32 | user: __token__ 33 | password: ${{ secrets.pypi_password }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | local_settings.py 54 | 55 | # Flask stuff: 56 | instance/ 57 | .webassets-cache 58 | 59 | # Scrapy stuff: 60 | .scrapy 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # IPython Notebook 69 | .ipynb_checkpoints 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # celery beat schedule file 75 | celerybeat-schedule 76 | 77 | # dotenv 78 | .env 79 | 80 | # virtualenv 81 | venv/ 82 | ENV/ 83 | 84 | # Spyder project settings 85 | .spyderproject 86 | 87 | # Rope project settings 88 | .ropeproject 89 | 90 | # OS 91 | .DS_Store -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | line-length: disable 6 | 7 | ignore: | 8 | *.venv/ 9 | *.mypy_cache/ 10 | *.eggs/ 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [1.1.1] - 2021-05-15 4 | ### Fix 5 | - Fix image rotation according to EXIF information (thx @jonashoechst) 6 | 7 | ## [1.1.0] - 2021-05-15 8 | ### Added 9 | - Flask 2.0 compatibility 10 | 11 | ## [1.0.7] - 2020-12-07 12 | ### Added 13 | - Flask 1.1 compatibility 14 | 15 | ## [1.0.6] - 2018-07-07 16 | ### Added 17 | - Flask 1.0 compatibility 18 | 19 | ## [1.0.4] - 2018-04-15 20 | ### Fix 21 | - Update setup.py 22 | 23 | ## [1.0.2] - 2017-06-07 24 | ### Fix 25 | - Fix "mkdirs" py2/py3 compatibility 26 | 27 | ## [1.0.1] - 2017-05-07 28 | ### Add 29 | - Support for single integer size values for square thumbnails 30 | - Fix the event someone uses the Thumbnail constructor without passing in the app 31 | 32 | ## [1.0.0] - 2017-04-07 33 | ### Add 34 | - Add custom thumbnail storage 35 | - Support python 3.4+ 36 | - Test code 37 | 38 | [Unreleased]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.1.1...HEAD 39 | [1.1.1]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.1.0...v1.1.1 40 | [1.1.0]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.0.7...v1.1.0 41 | [1.0.7]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.0.6...v1.0.7 42 | [1.0.6]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.0.5...v1.0.6 43 | [1.0.4]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.0.3...v1.0.4 44 | [1.0.2]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.0.1...v1.0.2 45 | [1.0.1]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.0.0...v1.0.1 46 | [1.0.0]: https://github.com/silentsokolov/flask-thumbnails/compare/v1.0.0...v1.0.0 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Dmitriy Sokolov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE CHANGES README.rst flask_thumbnails/*.py -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check-black check-isort check-pylint static-analysis test sdist wheel release pre-release clean 2 | 3 | sdist: 4 | python setup.py sdist 5 | 6 | wheel: 7 | python setup.py bdist_wheel --universal 8 | 9 | release: clean sdist wheel 10 | twine upload dist/* 11 | 12 | pre-release: sdist wheel 13 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 14 | 15 | clean: 16 | find . | grep -E '(__pycache__|\.pyc|\.pyo$)' | xargs rm -rf 17 | rm -rf build 18 | rm -rf dist 19 | rm -rf *.egg-info 20 | 21 | check-black: 22 | @echo "--> Running black checks" 23 | @black --check --diff . 24 | 25 | check-isort: 26 | @echo "--> Running isort checks" 27 | @isort --check-only . 28 | 29 | check-pylint: 30 | @echo "--> Running pylint checks" 31 | @pylint `git ls-files '*.py'` 32 | 33 | check-yamllint: 34 | @echo "--> Running yamllint checks" 35 | @yamllint . 36 | 37 | lint: check-black check-isort check-pylint check-yamllint 38 | 39 | # Format code 40 | .PHONY: fmt 41 | 42 | fmt: 43 | @echo "--> Running isort" 44 | @isort . 45 | @echo "--> Running black" 46 | @black . 47 | 48 | # Test 49 | .PHONY: test 50 | 51 | test: 52 | @echo "--> Running tests" 53 | PYTHONPATH=".:tests:${PYTHONPATH}" python setup.py test 54 | 55 | coverage: 56 | @echo "--> Running coverage" 57 | PYTHONPATH=".:tests:${PYTHONPATH}" coverage run --omit='setup.py' --omit='tests/*' --source='.' setup.py test 58 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/silentsokolov/flask-thumbnails/workflows/build/badge.svg?branch=master 2 | :target: https://github.com/silentsokolov/flask-thumbnails/actions?query=workflow%3Abuild+branch%3Amaster 3 | 4 | .. image:: https://codecov.io/gh/silentsokolov/flask-thumbnails/branch/master/graph/badge.svg 5 | :target: https://codecov.io/gh/silentsokolov/flask-thumbnails 6 | 7 | flask-thumbnails 8 | ================ 9 | 10 | A simple extension to create a thumbs for the Flask 11 | 12 | 13 | Installation 14 | ------------ 15 | 16 | Use your favorite Python package manager to install the app from PyPI, e.g. 17 | 18 | Example: 19 | 20 | ``pip install flask-thumbnails`` 21 | 22 | 23 | Add ``Thumbnail`` to your extension file: 24 | 25 | .. code:: python 26 | 27 | from flask import Flask 28 | from flask_thumbnails import Thumbnail 29 | 30 | app = Flask(__name__) 31 | 32 | thumb = Thumbnail(app) 33 | 34 | Add ``THUMBNAIL_MEDIA_ROOT`` and ``THUMBNAIL_MEDIA_URL`` in your settings: 35 | 36 | .. code:: python 37 | 38 | app.config['THUMBNAIL_MEDIA_ROOT'] = '/home/www/media' 39 | app.config['THUMBNAIL_MEDIA_URL'] = '/media/' 40 | 41 | 42 | Example usage 43 | ------------- 44 | 45 | Use in Jinja2 template: 46 | 47 | .. code:: html 48 | 49 | 50 | 51 | 52 | 53 | Options 54 | ~~~~~~~ 55 | 56 | ``crop='fit'`` returns a sized and cropped version of the image, cropped to the requested aspect ratio and size, `read more `_. 57 | 58 | ``quality=XX`` changes the quality of the output JPEG thumbnail, default ``90``. 59 | 60 | 61 | Develop and Production 62 | ---------------------- 63 | 64 | Production 65 | ~~~~~~~~~~ 66 | 67 | In production, you need to add media directory in you web server. 68 | 69 | 70 | Develop 71 | ~~~~~~~ 72 | 73 | To service the uploaded files need a helper function, where ``/media/`` your settings ``app.config['THUMBNAIL_MEDIA_URL']``: 74 | 75 | .. code:: python 76 | 77 | from flask import send_from_directory 78 | 79 | @app.route('/media/') 80 | def media_file(filename): 81 | return send_from_directory(app.config['THUMBNAIL_MEDIA_THUMBNAIL_ROOT'], filename) 82 | 83 | 84 | Option settings 85 | --------------- 86 | 87 | If you want to store the thumbnail in a folder other than the ``THUMBNAIL_MEDIA_THUMBNAIL_ROOT``, you need to set it manually: 88 | 89 | .. code:: python 90 | 91 | app.config['THUMBNAIL_MEDIA_THUMBNAIL_ROOT'] = '/home/www/media/cache' 92 | app.config['THUMBNAIL_MEDIA_THUMBNAIL_URL'] = '/media/cache/' 93 | app.config['THUMBNAIL_STORAGE_BACKEND'] = 'flask_thumbnails.storage_backends.FilesystemStorageBackend' 94 | app.config['THUMBNAIL_DEFAULT_FORMAT'] = 'JPEG' 95 | 96 | 97 | Migrate 0.X to 1.X 98 | ------------------ 99 | 100 | Since version 1.X all settings have a prefix ``THUMBNAIL_``. Example: ``MEDIA_ROOT`` -> ``THUMBNAIL_MEDIA_ROOT``. 101 | -------------------------------------------------------------------------------- /flask_thumbnails/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Dmitriy Sokolov" 2 | __version__ = "1.1.1" 3 | 4 | from .thumbnail import Thumbnail 5 | -------------------------------------------------------------------------------- /flask_thumbnails/storage_backends.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | from abc import ABC, abstractmethod 4 | 5 | 6 | class BaseStorageBackend(ABC): 7 | def __init__(self, app=None): 8 | self.app = app 9 | 10 | @abstractmethod 11 | def read(self, filepath, mode="rb", **kwargs): 12 | raise NotImplementedError 13 | 14 | @abstractmethod 15 | def exists(self, filepath): 16 | raise NotImplementedError 17 | 18 | @abstractmethod 19 | def save(self, filepath, data): 20 | raise NotImplementedError 21 | 22 | 23 | class FilesystemStorageBackend(BaseStorageBackend): 24 | def read(self, filepath, mode="rb", **kwargs): 25 | with open(filepath, mode) as f: # pylint: disable=unspecified-encoding 26 | return f.read() 27 | 28 | def exists(self, filepath): 29 | return os.path.exists(filepath) 30 | 31 | def save(self, filepath, data): 32 | directory = os.path.dirname(filepath) 33 | 34 | if not os.path.exists(directory): 35 | try: 36 | os.makedirs(directory) 37 | except OSError as e: 38 | if e.errno != errno.EEXIST: 39 | raise 40 | 41 | if not os.path.isdir(directory): 42 | raise IOError("{} is not a directory".format(directory)) 43 | 44 | with open(filepath, "wb") as f: 45 | f.write(data) 46 | -------------------------------------------------------------------------------- /flask_thumbnails/thumbnail.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import BytesIO 3 | 4 | try: 5 | from PIL import Image, ImageOps 6 | except ImportError: 7 | raise RuntimeError( # pylint: disable=raise-missing-from 8 | "Get Pillow at https://pypi.python.org/pypi/Pillow or run command 'pip install pillow'." 9 | ) 10 | 11 | from .utils import aspect_to_string, generate_filename, import_from_string, parse_size 12 | 13 | 14 | class Thumbnail: 15 | def __init__(self, app=None, configure_jinja=True): 16 | self.app = app 17 | self._configure_jinja = configure_jinja 18 | self._default_root_directory = "media" 19 | self._default_thumbnail_directory = "media" 20 | self._default_root_url = "/" 21 | self._default_thumbnail_root_url = "/" 22 | self._default_format = "JPEG" 23 | self._default_storage_backend = "flask_thumbnails.storage_backends.FilesystemStorageBackend" 24 | 25 | if app is not None: 26 | self.init_app(app) 27 | 28 | def init_app(self, app): 29 | if self.app is None: 30 | self.app = app 31 | app.thumbnail_instance = self 32 | 33 | if not hasattr(app, "extensions"): 34 | app.extensions = {} 35 | 36 | if "thumbnail" in app.extensions: 37 | raise RuntimeError("Flask-thumbnail extension already initialized") 38 | 39 | app.extensions["thumbnail"] = self 40 | 41 | app.config.setdefault("THUMBNAIL_MEDIA_ROOT", self._default_root_directory) 42 | app.config.setdefault("THUMBNAIL_MEDIA_THUMBNAIL_ROOT", self._default_thumbnail_directory) 43 | app.config.setdefault("THUMBNAIL_MEDIA_URL", self._default_root_url) 44 | app.config.setdefault("THUMBNAIL_MEDIA_THUMBNAIL_URL", self._default_thumbnail_root_url) 45 | app.config.setdefault("THUMBNAIL_STORAGE_BACKEND", self._default_storage_backend) 46 | app.config.setdefault("THUMBNAIL_DEFAULT_FORMAT", self._default_format) 47 | 48 | if self._configure_jinja: 49 | app.jinja_env.filters.update( 50 | thumbnail=self.get_thumbnail, 51 | ) 52 | 53 | @property 54 | def root_directory(self): 55 | path = self.app.config["THUMBNAIL_MEDIA_ROOT"] 56 | 57 | if os.path.isabs(path): 58 | return path 59 | else: 60 | return os.path.join(self.app.root_path, path) 61 | 62 | @property 63 | def thumbnail_directory(self): 64 | path = self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"] 65 | 66 | if os.path.isabs(path): 67 | return path 68 | else: 69 | return os.path.join(self.app.root_path, path) 70 | 71 | @property 72 | def root_url(self): 73 | return self.app.config["THUMBNAIL_MEDIA_URL"] 74 | 75 | @property 76 | def thumbnail_url(self): 77 | return self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_URL"] 78 | 79 | @property 80 | def storage_backend(self): 81 | return self.app.config["THUMBNAIL_STORAGE_BACKEND"] 82 | 83 | def get_storage_backend(self): 84 | backend_class = import_from_string(self.storage_backend) 85 | return backend_class(app=self.app) 86 | 87 | def get_thumbnail(self, original, size, **options): 88 | storage = self.get_storage_backend() 89 | crop = options.get("crop", "fit") 90 | background = options.get("background") 91 | quality = options.get("quality", 90) 92 | thumbnail_size = parse_size(size) 93 | 94 | original_path, original_filename = os.path.split(original) 95 | thumbnail_filename = generate_filename( 96 | original_filename, aspect_to_string(size), crop, background, quality 97 | ) 98 | 99 | original_filepath = os.path.join(self.root_directory, original_path, original_filename) 100 | thumbnail_filepath = os.path.join( 101 | self.thumbnail_directory, original_path, thumbnail_filename 102 | ) 103 | thumbnail_url = os.path.join(self.thumbnail_url, original_path, thumbnail_filename) 104 | 105 | if storage.exists(thumbnail_filepath): 106 | return thumbnail_url 107 | 108 | image = Image.open(BytesIO(storage.read(original_filepath))) 109 | try: 110 | image.load() 111 | except (IOError, OSError): 112 | self.app.logger.warning("Thumbnail not load image: %s", original_filepath) 113 | return thumbnail_url 114 | 115 | # get original image format 116 | options["format"] = options.get("format", image.format) 117 | 118 | image = self._create_thumbnail(image, thumbnail_size, crop, background=background) 119 | 120 | raw_data = self.get_raw_data(image, **options) 121 | storage.save(thumbnail_filepath, raw_data) 122 | 123 | return thumbnail_url 124 | 125 | def get_raw_data(self, image, **options): 126 | data = { 127 | "format": self._get_format(image, **options), 128 | "quality": options.get("quality", 90), 129 | } 130 | 131 | _file = BytesIO() 132 | image.save(_file, **data) 133 | return _file.getvalue() 134 | 135 | @staticmethod 136 | def colormode(image, colormode="RGB"): 137 | if colormode == "RGB" or colormode == "RGBA": 138 | if image.mode == "RGBA": 139 | return image 140 | if image.mode == "LA": 141 | return image.convert("RGBA") 142 | return image.convert(colormode) 143 | 144 | if colormode == "GRAY": 145 | return image.convert("L") 146 | 147 | return image.convert(colormode) 148 | 149 | @staticmethod 150 | def background(original_image, color=0xFF): 151 | size = (max(original_image.size),) * 2 152 | image = Image.new("L", size, color) 153 | image.paste( 154 | original_image, 155 | tuple(map(lambda x: (x[0] - x[1]) / 2, zip(size, original_image.size))), 156 | ) 157 | 158 | return image 159 | 160 | def _get_format(self, image, **options): 161 | if options.get("format"): 162 | return options.get("format") 163 | if image.format: 164 | return image.format 165 | 166 | return self.app.config["THUMBNAIL_DEFAULT_FORMAT"] 167 | 168 | def _create_thumbnail(self, image, size, crop="fit", background=None): 169 | try: 170 | resample = Image.Resampling.LANCZOS 171 | except AttributeError: # pylint: disable=raise-missing-from 172 | resample = Image.ANTIALIAS 173 | 174 | image = ImageOps.exif_transpose(image) 175 | 176 | if crop == "fit": 177 | image = ImageOps.fit(image, size, resample) 178 | else: 179 | image = image.copy() 180 | image.thumbnail(size, resample=resample) 181 | 182 | if background is not None: 183 | image = self.background(image) 184 | 185 | image = self.colormode(image) 186 | 187 | return image 188 | -------------------------------------------------------------------------------- /flask_thumbnails/utils.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | 4 | 5 | def import_from_string(path): 6 | path_bits = path.split(".") 7 | class_name = path_bits.pop() 8 | module_path = ".".join(path_bits) 9 | module_itself = importlib.import_module(module_path) 10 | 11 | if not hasattr(module_itself, class_name): 12 | raise ImportError("The Python module '%s' has no '%s' class." % (module_path, class_name)) 13 | 14 | return getattr(module_itself, class_name) 15 | 16 | 17 | def generate_filename(original_filename, *options): 18 | name, ext = os.path.splitext(original_filename) 19 | for v in options: 20 | if v: 21 | name += "_%s" % v 22 | name += ext 23 | 24 | return name 25 | 26 | 27 | def parse_size(size): 28 | if isinstance(size, int): 29 | # If the size parameter is a single number, assume square aspect. 30 | return [size, size] 31 | 32 | if isinstance(size, (tuple, list)): 33 | if len(size) == 1: 34 | # If single value tuple/list is provided, exand it to two elements 35 | return size + type(size)(size) 36 | return size 37 | 38 | try: 39 | thumbnail_size = [int(x) for x in size.lower().split("x", 1)] 40 | except ValueError: 41 | raise ValueError( # pylint: disable=raise-missing-from 42 | "Bad thumbnail size format. Valid format is INTxINT." 43 | ) 44 | 45 | if len(thumbnail_size) == 1: 46 | # If the size parameter only contains a single integer, assume square aspect. 47 | thumbnail_size.append(thumbnail_size[0]) 48 | 49 | return thumbnail_size 50 | 51 | 52 | def aspect_to_string(size): 53 | if isinstance(size, str): 54 | return size 55 | 56 | return "x".join(map(str, size)) 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | target-version = ['py38', 'py39'] 4 | exclude = '(\.eggs|\.git|\.mypy_cache|\.venv|venv|env|_build|build|build|dist|eggs)' 5 | 6 | [tool.isort] 7 | line_length = 100 8 | profile = "black" 9 | use_parentheses = true 10 | skip = '.eggs/,.mypy_cache/,.venv/,venv/,env/,eggs/' 11 | 12 | [tool.pylint] 13 | [tool.pylint.master] 14 | py-version = 3.9 15 | 16 | [tool.pylint.messages-control] 17 | disable=[ 18 | 'C', 19 | 'R', 20 | 21 | # Redundant with mypy 22 | 'typecheck', 23 | 24 | # There are many places where we want to catch a maximally generic exception. 25 | 'bare-except', 26 | 'broad-except', 27 | 28 | # Pylint is by default very strict on logging string interpolations, but the 29 | # (performance-motivated) rules do not make sense for infrequent log messages (like error reports) 30 | # and make messages less readable. 31 | 'logging-fstring-interpolation', 32 | 'logging-format-interpolation', 33 | 'logging-not-lazy', 34 | 'too-many-arguments', 35 | 'duplicate-code', 36 | ] 37 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | Flask==2.3.2 2 | Pillow==10.2.0 3 | mock==4.0.3 4 | black==22.3.0 5 | isort==5.10.1 6 | pylint==2.14.4 7 | yamllint==1.26.3 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | from os.path import dirname, join 7 | 8 | from setuptools import setup 9 | 10 | 11 | def get_version(package): 12 | init_py = open(os.path.join(package, "__init__.py"), encoding="utf-8").read() 13 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 14 | 15 | 16 | def get_packages(package): 17 | return [ 18 | dirpath 19 | for dirpath, dirnames, filenames in os.walk(package) 20 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 21 | ] 22 | 23 | 24 | setup( 25 | name="flask-thumbnails", 26 | version=get_version("flask_thumbnails"), 27 | url="https://github.com/silentsokolov/flask-thumbnails", 28 | license="MIT", 29 | description="A simple extension to create a thumbs for the Flask", 30 | long_description_content_type="text/x-rst", 31 | long_description=open(join(dirname(__file__), "README.rst"), encoding="utf-8").read(), 32 | author="Dmitriy Sokolov", 33 | author_email="silentsokolov@gmail.com", 34 | packages=get_packages("flask_thumbnails"), 35 | include_package_data=True, 36 | install_requires=[], 37 | python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*", 38 | zip_safe=False, 39 | platforms="any", 40 | test_suite="tests", 41 | classifiers=[ 42 | "Development Status :: 5 - Production/Stable", 43 | "Environment :: Web Environment", 44 | "Framework :: Flask", 45 | "Intended Audience :: Developers", 46 | "License :: OSI Approved :: MIT License", 47 | "Operating System :: OS Independent", 48 | "Programming Language :: Python", 49 | "Programming Language :: Python :: 2", 50 | "Programming Language :: Python :: 2.7", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.5", 53 | "Programming Language :: Python :: 3.6", 54 | "Programming Language :: Python :: 3.7", 55 | "Programming Language :: Python :: 3.8", 56 | "Programming Language :: Python :: 3.9", 57 | "Topic :: Utilities", 58 | ], 59 | ) 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silentsokolov/flask-thumbnails/4549cf7ddf6aa4ac0ad662e16b56050edae6df00/tests/__init__.py -------------------------------------------------------------------------------- /tests/core_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import unittest 4 | from io import BytesIO 5 | 6 | import mock 7 | from flask import Flask 8 | from PIL import Image 9 | 10 | from flask_thumbnails import Thumbnail 11 | from flask_thumbnails.storage_backends import FilesystemStorageBackend 12 | 13 | 14 | class CoreTestCase(unittest.TestCase): 15 | def setUp(self): 16 | self.app = Flask(__name__) 17 | self.thumbnail = Thumbnail(app=self.app) 18 | self.client = self.app.test_client() 19 | 20 | self.image = Image.new("RGB", (100, 100), "black") 21 | 22 | def test_root_directory(self): 23 | self.app.config["THUMBNAIL_MEDIA_ROOT"] = "media" 24 | self.assertEqual( 25 | self.thumbnail.root_directory, 26 | os.path.join(self.app.root_path, self.app.config["THUMBNAIL_MEDIA_ROOT"]), 27 | ) 28 | 29 | self.app.config["THUMBNAIL_MEDIA_ROOT"] = "/tmp/media" 30 | self.assertEqual(self.thumbnail.root_directory, self.app.config["THUMBNAIL_MEDIA_ROOT"]) 31 | 32 | def test_thumbnail_directory(self): 33 | self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"] = "media" 34 | self.assertEqual( 35 | self.thumbnail.thumbnail_directory, 36 | os.path.join(self.app.root_path, self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"]), 37 | ) 38 | 39 | self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"] = "/tmp/media" 40 | self.assertEqual( 41 | self.thumbnail.thumbnail_directory, 42 | self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"], 43 | ) 44 | 45 | def test_root_url(self): 46 | self.app.config["THUMBNAIL_MEDIA_URL"] = "/media" 47 | self.assertEqual(self.thumbnail.root_url, self.app.config["THUMBNAIL_MEDIA_URL"]) 48 | 49 | def test_thumbnail_url(self): 50 | self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_URL"] = "/media" 51 | self.assertEqual( 52 | self.thumbnail.thumbnail_url, 53 | self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_URL"], 54 | ) 55 | 56 | def test_storage_backend(self): 57 | self.assertEqual( 58 | self.thumbnail.storage_backend, self.app.config["THUMBNAIL_STORAGE_BACKEND"] 59 | ) 60 | 61 | def test_get_storage_backend(self): 62 | self.assertIsInstance(self.thumbnail.get_storage_backend(), FilesystemStorageBackend) 63 | 64 | def test_colormode(self): 65 | image = Image.new("L", (10, 10)) 66 | new_image = self.thumbnail.colormode(image) 67 | 68 | self.assertEqual(new_image.mode, "RGB") 69 | 70 | image = Image.new("LA", (10, 10)) 71 | new_image = self.thumbnail.colormode(image) 72 | 73 | self.assertEqual(new_image.mode, "RGBA") 74 | 75 | image = Image.new("RGBA", (10, 10)) 76 | new_image = self.thumbnail.colormode(image) 77 | 78 | self.assertEqual(new_image.mode, "RGBA") 79 | 80 | image = Image.new("RGB", (10, 10)) 81 | new_image = self.thumbnail.colormode(image) 82 | 83 | self.assertEqual(new_image.mode, "RGB") 84 | 85 | def test_get_format(self): 86 | image = Image.new("RGB", (10, 10)) 87 | new_image = self.thumbnail.colormode(image) 88 | 89 | self.assertEqual(new_image.mode, "RGB") 90 | 91 | options = {"format": "PNG"} 92 | self.assertEqual( 93 | self.thumbnail._get_format(image, **options), "PNG" # pylint: disable=protected-access 94 | ) 95 | 96 | options = {} 97 | self.assertEqual( 98 | self.thumbnail._get_format(image, **options), # pylint: disable=protected-access 99 | self.app.config["THUMBNAIL_DEFAULT_FORMAT"], 100 | ) 101 | 102 | def test_get_raw_data(self): 103 | image = Image.new("L", (10, 10)) 104 | 105 | options = {"format": "JPEG"} 106 | data = self.thumbnail.get_raw_data(image, **options) 107 | 108 | new_image = Image.open(BytesIO(data)) 109 | self.assertEqual(image.mode, new_image.mode) 110 | self.assertEqual(image.size, new_image.size) 111 | self.assertEqual(new_image.format, "JPEG") 112 | 113 | def test_create_thumbnail(self): 114 | image = Image.new("L", (100, 100)) 115 | 116 | new_image = self.thumbnail._create_thumbnail( # pylint: disable=protected-access 117 | image, size=(50, 50) 118 | ) 119 | 120 | self.assertEqual(new_image.size, (50, 50)) 121 | 122 | new_image = self.thumbnail._create_thumbnail( # pylint: disable=protected-access 123 | image, size=(50, 50), crop=None 124 | ) 125 | 126 | self.assertEqual(new_image.size, (50, 50)) 127 | 128 | @mock.patch("flask_thumbnails.utils.generate_filename") 129 | def test_get_thumbnail(self, mock_thumb_name): 130 | with tempfile.NamedTemporaryFile(suffix=".jpg") as original: 131 | with tempfile.NamedTemporaryFile(suffix=".jpg") as thumb: 132 | mock_thumb_name.return_value = os.path.basename(thumb.name) 133 | self.app.config["THUMBNAIL_MEDIA_ROOT"] = os.path.dirname(original.name) 134 | self.app.config["THUMBNAIL_MEDIA_THUMBNAIL_ROOT"] = os.path.dirname(thumb.name) 135 | 136 | image = Image.new("RGB", (100, 100), "black") 137 | image.save(original.name) 138 | 139 | thumb_url = self.thumbnail.get_thumbnail(os.path.basename(original.name), "200x200") 140 | 141 | self.assertTrue(thumb_url) 142 | -------------------------------------------------------------------------------- /tests/storage_backends_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | import unittest 5 | from io import BytesIO 6 | 7 | from PIL import Image 8 | 9 | from flask_thumbnails.storage_backends import FilesystemStorageBackend 10 | 11 | 12 | class FilesystemStorageBackendTestCase(unittest.TestCase): 13 | def setUp(self): 14 | image = Image.new("RGB", (100, 100), "black") 15 | tmp_file = tempfile.NamedTemporaryFile(suffix=".jpg") 16 | image.save(tmp_file) 17 | tmp_file.seek(0) 18 | 19 | self.tmp_file = tmp_file 20 | self.backend = FilesystemStorageBackend() 21 | 22 | def test_read(self): 23 | image = Image.open(BytesIO(self.backend.read(self.tmp_file.name))) 24 | image.load() 25 | self.assertEqual(image.size, (100, 100)) 26 | 27 | def test_exists(self): 28 | self.assertTrue(self.backend.exists(os.path.join(os.getcwd(), "setup.py"))) 29 | self.assertFalse(self.backend.exists(os.path.join(os.getcwd(), "stup.py"))) 30 | 31 | def test_save(self): 32 | with tempfile.NamedTemporaryFile() as tmp_file: 33 | self.backend.save(tmp_file.name, b"123") 34 | self.assertTrue(os.path.exists(tmp_file.name)) 35 | 36 | def test_save_with_missing_dir(self): 37 | directory = tempfile.mkdtemp() 38 | filepath = os.path.join(directory, "test_dir/more_test_dir", "img.jpg") 39 | 40 | try: 41 | self.assertFalse(os.path.exists(os.path.dirname(filepath))) 42 | self.backend.save(filepath, b"123") 43 | self.assertTrue(os.path.exists(os.path.dirname(filepath))) 44 | finally: 45 | shutil.rmtree(directory) 46 | 47 | def tearDown(self): 48 | self.tmp_file.close() 49 | -------------------------------------------------------------------------------- /tests/utils_tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from flask_thumbnails.utils import ( 4 | aspect_to_string, 5 | generate_filename, 6 | import_from_string, 7 | parse_size, 8 | ) 9 | 10 | 11 | class UtilsTestCase(unittest.TestCase): 12 | def test_import_from_string(self): 13 | c = import_from_string("unittest.TestCase") 14 | self.assertEqual(c, unittest.TestCase) 15 | 16 | def test_import_from_string_none(self): 17 | with self.assertRaises(ImportError): 18 | import_from_string("django.test.NonModel") 19 | 20 | def test_generate_filename(self): 21 | name = generate_filename("test.jpg", "200x200", "fit", "100") 22 | self.assertEqual(name, "test_200x200_fit_100.jpg") 23 | 24 | def test_parse_size(self): 25 | size = parse_size("200x200") 26 | self.assertEqual(size, [200, 200]) 27 | 28 | size = parse_size("200") 29 | self.assertEqual(size, [200, 200]) 30 | 31 | size = parse_size(200) 32 | self.assertEqual(size, [200, 200]) 33 | 34 | size = parse_size((200, 200)) 35 | self.assertEqual(size, (200, 200)) 36 | 37 | size = parse_size((200,)) 38 | self.assertEqual(size, (200, 200)) 39 | 40 | size = parse_size( 41 | [ 42 | 200, 43 | ] 44 | ) 45 | self.assertEqual(size, [200, 200]) 46 | 47 | with self.assertRaises(ValueError): 48 | parse_size("this_is_invalid") 49 | 50 | with self.assertRaises(ValueError): 51 | parse_size("") 52 | 53 | def test_aspect_to_string(self): 54 | size = aspect_to_string("200x200") 55 | self.assertEqual(size, "200x200") 56 | 57 | size = aspect_to_string([200, 200]) 58 | self.assertEqual(size, "200x200") 59 | 60 | size = aspect_to_string((200, 200)) 61 | self.assertEqual(size, "200x200") 62 | --------------------------------------------------------------------------------