├── .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 |
--------------------------------------------------------------------------------