├── .github └── workflows │ ├── django.yml │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.rst ├── pyproject.toml ├── src └── django_webp │ ├── __init__.py │ ├── context_processors.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── clean_webp_images.py │ ├── middleware.py │ ├── templatetags │ ├── __init__.py │ └── webp.py │ └── utils.py └── tests ├── __init__.py ├── test_WEBPImageConverter.py ├── test_context_processor.py ├── test_main.py ├── test_templatetags.py └── testproj ├── manage.py ├── static └── django_webp │ └── python.png ├── testapp ├── __init__.py ├── admin.py ├── apps.py ├── context_processors.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── clean_webp_images.py ├── middleware.py ├── migrations │ └── __init__.py ├── models.py ├── templates │ └── django_webp │ │ └── index.html ├── templatetags │ ├── __init__.py │ └── webp.py ├── tests.py ├── urls.py ├── utils.py └── views.py └── testproj ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ master, dev ] 6 | pull_request: 7 | branches: [ master, dev ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: ['3.8', '3.x'] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install SO Dependencies 25 | run: | 26 | sudo apt-get install libtiff5-dev libjpeg8-dev zlib1g-dev libfreetype6-dev liblcms2-dev libwebp-dev 27 | python -m pip install --upgrade pip 28 | - name: Install django dependencies 29 | run: pip install -e . 30 | - name: Install coveralls 31 | run: pip install coveralls 32 | - name: Run Tests 33 | run: | 34 | coverage run --source=django_webp manage.py test 35 | coverage lcov 36 | working-directory: tests 37 | - name: Coveralls GitHub Action 38 | uses: coverallsapp/github-action@v2 39 | with: 40 | github-token: ${{ secrets.GITHUB_TOKEN }} 41 | file: ./tests/coverage.lcov 42 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | old/ 5 | .idea/ 6 | .DS_Store 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | venv/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # Installer logs 30 | pip-log.txt 31 | pip-delete-this-directory.txt 32 | 33 | # Unit test / coverage reports 34 | htmlcov/ 35 | .tox/ 36 | .coverage 37 | .cache 38 | nosetests.xml 39 | coverage.xml 40 | coverage.lcov 41 | 42 | # Translations 43 | *.mo 44 | 45 | # Mr Developer 46 | .mr.developer.cfg 47 | .project 48 | .pydevproject 49 | 50 | # Rope 51 | .ropeproject 52 | 53 | # Django stuff: 54 | *.log 55 | *.pot 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # Local 61 | WEBP_CACHE/ 62 | media/ 63 | staticfiles/ 64 | db.sqlite3 65 | pypi.key -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 André Farzat 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. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-webp 2 | =========== 3 | 4 | Speeds up static file load times by generating a webp image to load to a webpage instead of a jpg, gif or png 5 | 6 | |Build Status| |Coverage Status| 7 | 8 | 9 | Usage 10 | ----- 11 | 12 | Load the ``webp`` module in your template and use the ``webp`` 13 | templatetag to point to the image you want to convert. 14 | 15 | .. code:: html 16 | 17 | {% load webp %} 18 | 19 | {# Use webp as you would use static templatetag #} 20 | image 21 | 28 | 29 | Installation 30 | ------------ 31 | 32 | First, if you are using a version of Pillow <= 9.3.0, you must install webp support since earlier versions of Pillow do not 33 | have webp support built-in. In ubuntu, you can install via apt-get: 34 | 35 | .. code:: sh 36 | 37 | apt-get install libwebp-dev 38 | 39 | Please, check `the official guide`_ for the other systems. 40 | 41 | Then, install ``django-webp``. 42 | 43 | .. code:: sh 44 | 45 | pip install django-webp 46 | 47 | add it to ``INSTALLED_APPS`` configuration 48 | 49 | .. code:: python 50 | 51 | INSTALLED_APPS = ( 52 | 'django.contrib.staticfiles', 53 | 'django_webp', 54 | '...', 55 | ) 56 | 57 | edit your installation of Whitenoise to use our slightly modded version of it 58 | 59 | .. code:: python 60 | 61 | MIDDLEWARE = [ 62 | 'django.middleware.security.SecurityMiddleware', 63 | 'django_webp.middleware.ModdedWhiteNoiseMiddleware', 64 | '...', 65 | ] 66 | 67 | add the django\_webp context processor 68 | 69 | .. code:: python 70 | 71 | TEMPLATES = [ 72 | { 73 | '...' 74 | 'OPTIONS': { 75 | 'context_processors': [ 76 | '...', 77 | 'django_webp.context_processors.webp', 78 | ], 79 | }, 80 | }, 81 | ] 82 | 83 | Settings 84 | -------- 85 | 86 | The following Django-level settings affect the behavior of the library 87 | 88 | - ``WEBP_CHECK_URLS`` 89 | 90 | When set to ``True``, urls that link to externally stored images (i.e. images hosted by another site) are checked to confirm if they are valid image links. 91 | Ideally, this should temporarily be set to ``True`` whenever the ``WEBP_CACHE`` has been cleaned or if there has been substantial changes to your project's template files. 92 | This defaults to ``False``. 93 | 94 | 95 | 96 | 97 | 98 | Possible Issues 99 | ----------------- 100 | 101 | - ``django-webp`` uses ``Pillow`` to convert the images. If you’ve installed the ``libwebp-dev`` after already installed ``Pillow``, it’s necessary to uninstall and install it back because it needs to be compiled with it. 102 | - This package was built specifically for production environments that use Whitenoise for serving static files so there currently isn't support for serving files via dedicated servers or through cloud services 103 | 104 | Cleaning the cache 105 | ------------------ 106 | 107 | You can clean the cache running: 108 | 109 | .. code:: sh 110 | 111 | python manage.py clean_webp_images 112 | 113 | .. _the official guide: https://developers.google.com/speed/webp/docs/precompiled 114 | 115 | .. |Build Status| image:: https://github.com/andrefarzat/django-webp/actions/workflows/django.yml/badge.svg?branch=master 116 | :target: https://github.com/andrefarzat/django-webp/actions/workflows/django.yml 117 | .. |Coverage Status| image:: https://coveralls.io/repos/github/andrefarzat/django-webp/badge.svg?branch=master 118 | :target: https://coveralls.io/github/andrefarzat/django-webp?branch=master 119 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | 7 | [project] 8 | name = "django-webp" 9 | version = "3.0.0" 10 | description = "Serves a webp version of static images to browsers instead of jpg, gif or png" 11 | readme = "README.rst" 12 | requires-python = ">=3.5" 13 | keywords = ["django", "webp", "python"] 14 | authors = [ 15 | { name = "Andre Farzat", email = "andrefarzat@gmail.com" }, 16 | { name = "Daniel Opara", email = "daniel.opara@tufts.edu" } 17 | ] 18 | maintainers = [ 19 | { name = "Andre Farzat", email = "andrefarzat@gmail.com" }, 20 | { name = "Daniel Opara", email = "daniel.opara@tufts.edu" } 21 | ] 22 | license = { file = "LICENSE" } 23 | classifiers = [ 24 | "Development Status :: 5 - Production/Stable", 25 | "Intended Audience :: Developers", 26 | 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | 29 | "License :: OSI Approved :: MIT License", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python :: 3 :: Only", 32 | ] 33 | 34 | dependencies = [ 35 | "asgiref>=3.5.0", 36 | "django>=4.0.3", 37 | "pillow>=9.0.1", 38 | "sqlparse>=0.4.2", 39 | "whitenoise>=6.5.0" 40 | ] 41 | 42 | [project.optional-dependencies] 43 | dev = ["pip-tools", "pytest", "black"] 44 | 45 | [project.urls] 46 | Homepage = "http://pypi.python.org/pypi/django-webp/" 47 | 48 | [tool.setuptools] 49 | include-package-data = true 50 | 51 | [tool.setuptools.packages.find] 52 | namespaces = true 53 | where = ["src"] 54 | 55 | -------------------------------------------------------------------------------- /src/django_webp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/src/django_webp/__init__.py -------------------------------------------------------------------------------- /src/django_webp/context_processors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def _check_by_http_accept_header(http_accept): 5 | return "webp" in http_accept 6 | 7 | 8 | def webp(request): 9 | """Adds `supports_webp` value in the context""" 10 | http_accept = request.META.get("HTTP_ACCEPT", "") 11 | 12 | if _check_by_http_accept_header(http_accept): 13 | supports_webp = True 14 | else: 15 | supports_webp = False 16 | 17 | return {"supports_webp": supports_webp} 18 | -------------------------------------------------------------------------------- /src/django_webp/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/src/django_webp/management/__init__.py -------------------------------------------------------------------------------- /src/django_webp/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/src/django_webp/management/commands/__init__.py -------------------------------------------------------------------------------- /src/django_webp/management/commands/clean_webp_images.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | from django_webp.utils import WEBP_STATIC_ROOT 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Removes all cached webp images" 10 | 11 | def handle(self, *args, **options): 12 | try: 13 | shutil.rmtree(WEBP_STATIC_ROOT) 14 | self.stdout.write("Folder %s removed" % WEBP_STATIC_ROOT) # pragma: no cover 15 | except: 16 | raise CommandError("Folder %s was already removed" % WEBP_STATIC_ROOT) 17 | -------------------------------------------------------------------------------- /src/django_webp/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from posixpath import basename 5 | from urllib.parse import urlparse 6 | 7 | from django.conf import settings 8 | from django.contrib.staticfiles import finders 9 | from django.contrib.staticfiles.storage import staticfiles_storage 10 | from django.http import FileResponse 11 | from django.urls import get_script_prefix 12 | 13 | from whitenoise.base import WhiteNoise 14 | from whitenoise.string_utils import ensure_leading_trailing_slash 15 | 16 | __all__ = ["WhiteNoiseMiddleware"] 17 | 18 | class WhiteNoiseFileResponse(FileResponse): 19 | """ 20 | Wrap Django's FileResponse to prevent setting any default headers. For the 21 | most part these just duplicate work already done by WhiteNoise but in some 22 | cases (e.g. the content-disposition header introduced in Django 3.0) they 23 | are actively harmful. 24 | """ 25 | 26 | def set_headers(self, *args, **kwargs): 27 | pass 28 | 29 | 30 | class ModdedWhiteNoiseMiddleware(WhiteNoise): 31 | """ 32 | Wrap WhiteNoise to allow it to function as Django middleware, rather 33 | than WSGI middleware. 34 | """ 35 | 36 | def __init__(self, get_response=None, settings=settings): 37 | self.get_response = get_response 38 | 39 | try: 40 | autorefresh: bool = settings.WHITENOISE_AUTOREFRESH 41 | except AttributeError: 42 | autorefresh = settings.DEBUG 43 | try: 44 | max_age = settings.WHITENOISE_MAX_AGE 45 | except AttributeError: 46 | if settings.DEBUG: 47 | max_age = 0 48 | else: 49 | max_age = 60 50 | try: 51 | allow_all_origins = settings.WHITENOISE_ALLOW_ALL_ORIGINS 52 | except AttributeError: 53 | allow_all_origins = True 54 | try: 55 | charset = settings.WHITENOISE_CHARSET 56 | except AttributeError: 57 | charset = "utf-8" 58 | try: 59 | mimetypes = settings.WHITENOISE_MIMETYPES 60 | except AttributeError: 61 | mimetypes = None 62 | try: 63 | add_headers_function = settings.WHITENOISE_ADD_HEADERS_FUNCTION 64 | except AttributeError: 65 | add_headers_function = None 66 | try: 67 | index_file = settings.WHITENOISE_INDEX_FILE 68 | except AttributeError: 69 | index_file = None 70 | try: 71 | immutable_file_test = settings.WHITENOISE_IMMUTABLE_FILE_TEST 72 | except AttributeError: 73 | immutable_file_test = None 74 | 75 | super().__init__( 76 | application=None, 77 | autorefresh=autorefresh, 78 | max_age=max_age, 79 | allow_all_origins=allow_all_origins, 80 | charset=charset, 81 | mimetypes=mimetypes, 82 | add_headers_function=add_headers_function, 83 | index_file=index_file, 84 | immutable_file_test=immutable_file_test, 85 | ) 86 | 87 | try: 88 | self.use_finders = settings.WHITENOISE_USE_FINDERS 89 | except AttributeError: 90 | self.use_finders = settings.DEBUG 91 | 92 | try: 93 | self.static_prefix = settings.WHITENOISE_STATIC_PREFIX 94 | except AttributeError: 95 | self.static_prefix = urlparse(settings.STATIC_URL or "").path 96 | script_prefix = get_script_prefix().rstrip("/") 97 | if script_prefix: 98 | if self.static_prefix.startswith(script_prefix): 99 | self.static_prefix = self.static_prefix[len(script_prefix) :] 100 | self.static_prefix = ensure_leading_trailing_slash(self.static_prefix) 101 | 102 | self.static_root = settings.STATIC_ROOT 103 | if self.static_root: 104 | self.add_files(self.static_root, prefix=self.static_prefix) 105 | 106 | try: 107 | root = settings.WHITENOISE_ROOT 108 | except AttributeError: 109 | root = None 110 | if root: 111 | self.add_files(root) 112 | 113 | if self.use_finders and not self.autorefresh: 114 | self.add_files_from_finders() 115 | 116 | def __call__(self, request): 117 | if self.autorefresh: 118 | static_file = self.find_file(request.path_info) 119 | else: 120 | static_file = self.files.get(request.path_info) 121 | if static_file is not None: 122 | return self.serve(static_file, request) 123 | if static_file is None: 124 | if self.static_prefix in request.path_info: 125 | # using path info and static root to check the directory of the recently made webp 126 | raw_full_path = os.path.join(self.static_root, (request.path_info).lstrip("/")) 127 | clean_full_path = raw_full_path.replace("\\", "/") 128 | complete_full_path = clean_full_path.replace(self.static_prefix.rstrip("/"), "", 1) 129 | if os.path.exists(complete_full_path): 130 | self.add_files(self.static_root, prefix=self.static_prefix) 131 | static_file = self.files.get(complete_full_path) 132 | if static_file is None: 133 | request.not_serving = request.path_info 134 | return self.serve(static_file, request) 135 | return self.get_response(request) 136 | 137 | @staticmethod 138 | def serve(static_file, request): 139 | try: 140 | response = static_file.get_response(request.method, request.META) 141 | status = int(response.status) 142 | http_response = WhiteNoiseFileResponse(response.file or (), status=status) 143 | # Remove default content-type 144 | del http_response["content-type"] 145 | for key, value in response.headers: 146 | http_response[key] = value 147 | return http_response 148 | except: 149 | http_response = WhiteNoiseFileResponse((), status=500) 150 | http_response['not serving'] = request.not_serving 151 | return http_response 152 | 153 | 154 | def add_files_from_finders(self): 155 | files = {} 156 | for finder in finders.get_finders(): 157 | for path, storage in finder.list(None): 158 | prefix = (getattr(storage, "prefix", None) or "").strip("/") 159 | url = "".join( 160 | ( 161 | self.static_prefix, 162 | prefix, 163 | "/" if prefix else "", 164 | path.replace("\\", "/"), 165 | ) 166 | ) 167 | # Use setdefault as only first matching file should be used 168 | files.setdefault(url, storage.path(path)) 169 | stat_cache = {path: os.stat(path) for path in files.values()} 170 | for url, path in files.items(): 171 | self.add_file_to_dictionary(url, path, stat_cache=stat_cache) 172 | 173 | def candidate_paths_for_url(self, url): 174 | if self.use_finders and url.startswith(self.static_prefix): 175 | path = finders.find(url[len(self.static_prefix) :]) 176 | if path: 177 | yield path 178 | paths = super().candidate_paths_for_url(url) 179 | for path in paths: 180 | yield path 181 | 182 | def immutable_file_test(self, path, url): 183 | """ 184 | Determine whether given URL represents an immutable file (i.e. a 185 | file with a hash of its contents as part of its name) which can 186 | therefore be cached forever 187 | """ 188 | if not url.startswith(self.static_prefix): 189 | return False 190 | name = url[len(self.static_prefix) :] 191 | name_without_hash = self.get_name_without_hash(name) 192 | if name == name_without_hash: 193 | return False 194 | static_url = self.get_static_url(name_without_hash) 195 | # If the static_url function maps the name without hash 196 | # back to the original name, then we know we've got a 197 | # versioned filename 198 | if static_url and basename(static_url) == basename(url): 199 | return True 200 | return False 201 | 202 | def get_name_without_hash(self, filename): 203 | """ 204 | Removes the version hash from a filename e.g, transforms 205 | 'css/application.f3ea4bcc2.css' into 'css/application.css' 206 | 207 | Note: this is specific to the naming scheme used by Django's 208 | CachedStaticFilesStorage. You may have to override this if 209 | you are using a different static files versioning system 210 | """ 211 | name_with_hash, ext = os.path.splitext(filename) 212 | name = os.path.splitext(name_with_hash)[0] 213 | return name + ext 214 | 215 | def get_static_url(self, name): 216 | try: 217 | return staticfiles_storage.url(name) 218 | except ValueError: 219 | return None 220 | -------------------------------------------------------------------------------- /src/django_webp/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/src/django_webp/templatetags/__init__.py -------------------------------------------------------------------------------- /src/django_webp/templatetags/webp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import logging 4 | import requests 5 | from io import BytesIO 6 | from PIL import Image 7 | 8 | from django import template 9 | from django.conf import settings 10 | from django.core.files.base import ContentFile 11 | from django.contrib.staticfiles import finders 12 | from django.core.files.storage import default_storage 13 | from django.templatetags.static import static 14 | from django.core.exceptions import SuspiciousFileOperation 15 | 16 | from whitenoise.middleware import WhiteNoiseMiddleware 17 | from whitenoise.string_utils import ensure_leading_trailing_slash 18 | 19 | from django.conf import settings 20 | from utils import ( 21 | WEBP_STATIC_ROOT, 22 | WEBP_DEBUG, 23 | WEBP_CHECK_URLS, 24 | USING_WHITENOISE 25 | ) 26 | 27 | # if STATIC_ROOT is abs, then we are likely woring in production, if not, likely a testing env 28 | if os.path.isabs(settings.STATIC_ROOT): 29 | base_path = settings.STATIC_ROOT 30 | static_dir_prefix = os.path.relpath(settings.STATIC_ROOT, start=settings.BASE_DIR) 31 | else: 32 | base_path = os.path.join(settings.BASE_DIR, settings.STATIC_ROOT) 33 | static_dir_prefix = settings.STATIC_ROOT 34 | 35 | # getting rid of trailing slash 36 | static_dir_prefix = (static_dir_prefix).rstrip("/") 37 | 38 | register = template.Library() 39 | 40 | 41 | class WEBPImageConverter: 42 | def __init__(self): 43 | self.logger = logging.getLogger(__name__) 44 | 45 | def generate_path(self, image_path): 46 | """creates all folders necessary until reach the file's folder""" 47 | folder_path = os.path.dirname(image_path) 48 | if not os.path.isdir(folder_path): 49 | os.makedirs(folder_path) 50 | 51 | def get_static_image(self, image_url): 52 | if "https://" in image_url: 53 | return image_url 54 | else: 55 | return static(image_url) 56 | 57 | def check_image_dirs(self, generated_path, image_path): 58 | """ 59 | Checks if original image directory is valid and prevents duplicates of 60 | generated images by checking if the already exist in a directory 61 | """ 62 | 63 | # Checking if original image exists 64 | if not os.path.exists(os.path.join(base_path, image_path)) and not "https" in image_path: 65 | self.logger.warn(f"Original image does not exist in static files path: {os.path.join(base_path, image_path)}") 66 | return False 67 | 68 | # Checks if the webp version of the image exists 69 | if os.path.exists(generated_path): 70 | return False 71 | else: 72 | return True 73 | 74 | def is_image_served(self, image_url, timeout_seconds=1): 75 | try: 76 | response = requests.head(image_url, timeout=timeout_seconds) 77 | if response.status_code == requests.codes.ok: 78 | return True 79 | else: 80 | return False 81 | except requests.exceptions.Timeout: 82 | return False 83 | except requests.exceptions.RequestException as e: 84 | return False 85 | 86 | def get_generated_image(self, image_url): 87 | """Returns the url to the webp gerenated image, returns the 88 | original image url if any of the following occur: 89 | 90 | - webp image generation fails 91 | - original image does not exist 92 | """ 93 | 94 | if "https://" in image_url: # pragma: no cover 95 | # Split the text by forward slashes and gets the last part (characters after the last slash) 96 | raw_filename = image_url.split("/")[-1] 97 | real_url = os.path.join( 98 | "online_images/", os.path.splitext(raw_filename)[0] + ".webp" 99 | ) 100 | else: 101 | real_url = os.path.splitext(image_url)[0] + ".webp" 102 | 103 | generated_path = os.path.join(WEBP_STATIC_ROOT, real_url).lstrip("/") 104 | 105 | # Checks if link provided is still valid 106 | # Only bothers to check if the link is valid if WEBP_CHECK_URLS is True 107 | if "https://" in image_url: # pragma: no cover 108 | if WEBP_CHECK_URLS: 109 | try: 110 | response = requests.head(image_url) 111 | if response.status_code == requests.codes.ok: 112 | content_type = response.headers.get("Content-Type", "") 113 | if not content_type.startswith("image/"): 114 | self.logger.warn(f"The following image url is invalid: {image_url}") 115 | except requests.RequestException: 116 | return self.get_static_image(image_url) 117 | 118 | should_generate = self.check_image_dirs(generated_path, image_url) 119 | 120 | if should_generate is True: 121 | if "https://" in image_url: # pragma: no cover 122 | if not self.generate_webp_image(generated_path, image_url): 123 | self.logger.error(f"Failed to generate from URL: {image_url}") 124 | return self.get_static_image(image_url) 125 | else: 126 | # Constructing full image path for original image 127 | image_path = os.path.join(base_path, image_url) 128 | if not self.generate_webp_image(generated_path, image_path): 129 | return self.get_static_image(image_url) 130 | 131 | ## converting generated_path from an absolute path to a relative path 132 | index = generated_path.find(static_dir_prefix) 133 | # Extract the substring starting from static_dir_prefix and replacing any weird backslashes with forward slashes 134 | generated_path = (generated_path[index + len(static_dir_prefix):]).replace("\\", "/") 135 | 136 | 137 | # have to check if image is served bc webp runs before whitenoise can properly hash the image dirs 138 | domain = os.environ.get("HOST", default="http://127.0.0.1:8000/") 139 | if self.is_image_served(domain.rstrip("/") + static(generated_path)): 140 | return static(generated_path) 141 | else: 142 | return self.get_static_image(image_url) 143 | 144 | def generate_webp_image(self, generated_path, image_path): 145 | final_path = os.path.join(str(base_path), generated_path) 146 | 147 | ## Generating images if they do not exist in a directory 148 | # Fetching the image data 149 | if "https://" in image_path: # pragma: no cover 150 | response = requests.get(image_path) 151 | try: 152 | image = Image.open(BytesIO(response.content)) 153 | except: 154 | self.logger.error(f"Error: Failed to read the image file from URL: {image_path}") 155 | return False 156 | else: 157 | try: 158 | image = Image.open(image_path) 159 | except FileNotFoundError: 160 | return False 161 | 162 | # Using the image data to save a webp version of it to static files 163 | try: 164 | self.generate_path(generated_path) 165 | # we use a buffer to store the contents of our conversion before saving to disk 166 | buffer = BytesIO() 167 | image.save(buffer, "WEBP") 168 | content_file = ContentFile(buffer.getvalue()) 169 | default_storage.save(final_path, content_file) 170 | image.close() 171 | return True 172 | except KeyError: # pragma: no cover 173 | self.logger.error("WEBP is not installed in Pillow") 174 | except (IOError, OSError): # pragma: no cover 175 | self.logger.error("WEBP image could not be saved in %s" % generated_path) 176 | except SuspiciousFileOperation: 177 | self.logger.error("SuspiciousFileOperation: the generated image was created outside of the base project path %s" % generated_path) 178 | 179 | return False # pragma: no cover 180 | 181 | 182 | @register.simple_tag(takes_context=True) 183 | def webp(context, value, force_static=WEBP_DEBUG): 184 | converter = WEBPImageConverter() 185 | 186 | supports_webp = context.get("supports_webp", False) 187 | if not supports_webp or not force_static: 188 | return converter.get_static_image(value) 189 | 190 | return converter.get_generated_image(value) 191 | -------------------------------------------------------------------------------- /src/django_webp/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.templatetags.static import static 6 | 7 | BASE_DIR = getattr(settings, "BASE_DIR", "") 8 | 9 | WEBP_CHECK_URLS = getattr(settings, "WEBP_CHECK_URLS", "") or False 10 | 11 | WEBP_STATIC_URL = static(getattr(settings, "WEBP_STATIC_URL", "WEBP_CACHE/")) 12 | if os.path.isabs(settings.STATIC_ROOT): 13 | WEBP_STATIC_ROOT = os.path.join(settings.STATIC_ROOT, "WEBP_CACHE/") 14 | else: 15 | WEBP_STATIC_ROOT = os.path.join(settings.BASE_DIR, settings.STATIC_ROOT, "WEBP_CACHE/") 16 | 17 | WEBP_DEBUG = getattr(settings, "WEBP_DEBUG", settings.DEBUG) 18 | 19 | if not WEBP_STATIC_URL.endswith("/"): # pragma: no cover 20 | raise ImproperlyConfigured("If set, WEBP_STATIC_URL must end with a slash") -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_WEBPImageConverter.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.test import TestCase 4 | from django.conf import settings 5 | 6 | from django_webp.templatetags.webp import WEBPImageConverter 7 | 8 | 9 | class WEBPImageConverterTestCase(TestCase): 10 | 11 | def test_generate_webp_image_when_file_doesnt_exist(self): 12 | image_path = os.path.join(settings.BASE_DIR, 'testapp', 'static', 'django_webp', 'does-not-exist.png') 13 | generated_path = os.path.join(settings.BASE_DIR, 'staticfiles', 'python.webp') 14 | 15 | converter = WEBPImageConverter() 16 | self.assertFalse(converter.generate_webp_image(generated_path, image_path)) 17 | 18 | def test_get_static_image(self): 19 | converter = WEBPImageConverter() 20 | self.assertEqual(converter.get_static_image("https://example.com/image.png"), "https://example.com/image.png") 21 | self.assertEqual(converter.get_static_image("image.png"), "/static/image.png") -------------------------------------------------------------------------------- /tests/test_context_processor.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.http import HttpRequest 4 | 5 | from django_webp.context_processors import webp 6 | 7 | 8 | class ContextProcessorTest(unittest.TestCase): 9 | def setUp(self): 10 | self.request = HttpRequest() 11 | 12 | def test_common_return(self): 13 | """Must return False in the dic""" 14 | result = webp(self.request) 15 | supports_webp = result.get("supports_webp", None) 16 | self.assertFalse(supports_webp) 17 | 18 | 19 | def test_by_http_accept_header(self): 20 | """ 21 | Giving a valid http accept header, shold return True 22 | @see -> https://www.igvita.com/2013/05/01/deploying-webp-via-accept-content-negotiation/ 23 | """ 24 | self.request.META["HTTP_ACCEPT"] = "image/webp" 25 | result = webp(self.request) 26 | supports_webp = result.get("supports_webp", None) 27 | self.assertTrue(supports_webp) 28 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import unittest 4 | from PIL import Image 5 | 6 | from django.test.client import Client 7 | from django.contrib.staticfiles import finders 8 | from django.core.management import call_command 9 | from django.core.management.base import CommandError 10 | 11 | 12 | IMAGE_PNG_PATH = finders.find('django_webp/python.png') 13 | 14 | 15 | class MainTest(unittest.TestCase): 16 | 17 | def test_pillow(self): 18 | """ Checks if current pillow installation 19 | has support to WEBP """ 20 | image = Image.open(IMAGE_PNG_PATH) 21 | try: 22 | image.load() 23 | self.assertTrue(True) 24 | except: 25 | self.assertFail("There is no support for webp") 26 | 27 | 28 | def test_index(self): 29 | client = Client() 30 | response = client.get('/') 31 | 32 | self.assertEqual(response.status_code, 200) 33 | 34 | 35 | def test_clean_webp_images_command(self): 36 | call_command('collectstatic', '--noinput', '--clear') 37 | self.assertRaises(CommandError, call_command, 'clean_webp_images') 38 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import shutil 4 | 5 | from django.conf import settings 6 | from django.core.files.images import ImageFile 7 | from django.core.files.storage import FileSystemStorage 8 | from django.template import Template, Context 9 | from django.test.utils import override_settings 10 | from django.templatetags.static import static 11 | 12 | from django_webp.templatetags.webp import webp 13 | from utils import WEBP_STATIC_URL, WEBP_STATIC_ROOT 14 | 15 | 16 | class TemplateTagTest(unittest.TestCase): 17 | 18 | def setUp(self): 19 | self.supported_url = WEBP_STATIC_URL + 'django_webp/python.webp' 20 | self.unsupported_url = static('django_webp/python.png') 21 | 22 | def tearDown(self): 23 | # cleaning the folder the files here 24 | try: 25 | shutil.rmtree(WEBP_STATIC_ROOT) 26 | except: 27 | pass 28 | 29 | def _assertFile(self, file_path, msg=''): 30 | STATIC_ROOT = settings.STATIC_ROOT if settings.STATIC_ROOT.endswith('/') else settings.STATIC_ROOT + '/' 31 | staticfile_path = file_path.replace(settings.STATIC_URL, STATIC_ROOT) 32 | file_exist = os.path.isfile(staticfile_path) 33 | 34 | msg = msg or ('file doesnt exist: %s' % staticfile_path) 35 | self.assertTrue(file_exist, msg) 36 | 37 | def _get_valid_context(self): 38 | return Context({'supports_webp': True}) 39 | 40 | 41 | def _get_invalid_context(self): 42 | return Context({'supports_webp': False}) 43 | 44 | 45 | def _render_template(self, html, context={}): 46 | return Template(html).render(Context(context)) 47 | 48 | 49 | def test_unexistent_image(self): 50 | """ If the given image doesn't exist, return the static url """ 51 | context = self._get_valid_context() 52 | result = webp(context, 'django_webp/this_image_doesnt_exist.gif') 53 | expected = static('django_webp/this_image_doesnt_exist.gif') 54 | self.assertEqual(result, expected) 55 | 56 | 57 | def test_supports_webp_false(self): 58 | context = self._get_invalid_context() 59 | result = webp(context, 'django_webp/python.png') 60 | expected = static('django_webp/python.png') 61 | self.assertEqual(result, expected) 62 | 63 | 64 | def test_templatetag(self): 65 | """ checks the returned url from the webp function """ 66 | context = self._get_valid_context() 67 | result = webp(context, 'django_webp/python.png') 68 | self.assertEqual(self.supported_url, result) 69 | self._assertFile(result, 'file %s should have been created' % result) 70 | 71 | 72 | def test_templatetag_in_template(self): 73 | html = '{% load webp %}{% webp "django_webp/python.png" %}' 74 | rendered = self._render_template(html, context=self._get_valid_context()) 75 | self.assertEqual(self.supported_url, rendered) 76 | self._assertFile(rendered, 'file %s should have been created' % rendered) 77 | 78 | 79 | def test_debug_true(self): 80 | """ if DEBUG = True, should always return the static url """ 81 | context = self._get_valid_context() 82 | result = webp(context, 'django_webp/python.png', force_static=True) 83 | self.assertEqual(self.unsupported_url, result) 84 | -------------------------------------------------------------------------------- /tests/testproj/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproj.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /tests/testproj/static/django_webp/python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/testproj/static/django_webp/python.png -------------------------------------------------------------------------------- /tests/testproj/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/testproj/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testproj/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /tests/testproj/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestappConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'testapp' 7 | -------------------------------------------------------------------------------- /tests/testproj/testapp/context_processors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def _check_by_http_accept_header(http_accept): 5 | return "webp" in http_accept 6 | 7 | 8 | def webp(request): 9 | """Adds `supports_webp` value in the context""" 10 | http_accept = request.META.get("HTTP_ACCEPT", "") 11 | 12 | if _check_by_http_accept_header(http_accept): 13 | supports_webp = True 14 | else: 15 | supports_webp = False 16 | 17 | return {"supports_webp": supports_webp} 18 | -------------------------------------------------------------------------------- /tests/testproj/testapp/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/testproj/testapp/management/__init__.py -------------------------------------------------------------------------------- /tests/testproj/testapp/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/testproj/testapp/management/commands/__init__.py -------------------------------------------------------------------------------- /tests/testproj/testapp/management/commands/clean_webp_images.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | from django_webp.utils import WEBP_STATIC_ROOT 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Removes all cached webp images" 10 | 11 | def handle(self, *args, **options): 12 | try: 13 | shutil.rmtree(WEBP_STATIC_ROOT) 14 | self.stdout.write("Folder %s removed" % WEBP_STATIC_ROOT) # pragma: no cover 15 | except: 16 | raise CommandError("Folder %s was already removed" % WEBP_STATIC_ROOT) 17 | -------------------------------------------------------------------------------- /tests/testproj/testapp/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from posixpath import basename 5 | from urllib.parse import urlparse 6 | 7 | from django.conf import settings 8 | from django.contrib.staticfiles import finders 9 | from django.contrib.staticfiles.storage import staticfiles_storage 10 | from django.http import FileResponse 11 | from django.urls import get_script_prefix 12 | 13 | from whitenoise.base import WhiteNoise 14 | from whitenoise.string_utils import ensure_leading_trailing_slash 15 | 16 | __all__ = ["WhiteNoiseMiddleware"] 17 | 18 | class WhiteNoiseFileResponse(FileResponse): 19 | """ 20 | Wrap Django's FileResponse to prevent setting any default headers. For the 21 | most part these just duplicate work already done by WhiteNoise but in some 22 | cases (e.g. the content-disposition header introduced in Django 3.0) they 23 | are actively harmful. 24 | """ 25 | 26 | def set_headers(self, *args, **kwargs): 27 | pass 28 | 29 | 30 | class ModdedWhiteNoiseMiddleware(WhiteNoise): 31 | """ 32 | Wrap WhiteNoise to allow it to function as Django middleware, rather 33 | than WSGI middleware. 34 | """ 35 | 36 | def __init__(self, get_response=None, settings=settings): 37 | self.get_response = get_response 38 | 39 | try: 40 | autorefresh: bool = settings.WHITENOISE_AUTOREFRESH 41 | except AttributeError: 42 | autorefresh = settings.DEBUG 43 | try: 44 | max_age = settings.WHITENOISE_MAX_AGE 45 | except AttributeError: 46 | if settings.DEBUG: 47 | max_age = 0 48 | else: 49 | max_age = 60 50 | try: 51 | allow_all_origins = settings.WHITENOISE_ALLOW_ALL_ORIGINS 52 | except AttributeError: 53 | allow_all_origins = True 54 | try: 55 | charset = settings.WHITENOISE_CHARSET 56 | except AttributeError: 57 | charset = "utf-8" 58 | try: 59 | mimetypes = settings.WHITENOISE_MIMETYPES 60 | except AttributeError: 61 | mimetypes = None 62 | try: 63 | add_headers_function = settings.WHITENOISE_ADD_HEADERS_FUNCTION 64 | except AttributeError: 65 | add_headers_function = None 66 | try: 67 | index_file = settings.WHITENOISE_INDEX_FILE 68 | except AttributeError: 69 | index_file = None 70 | try: 71 | immutable_file_test = settings.WHITENOISE_IMMUTABLE_FILE_TEST 72 | except AttributeError: 73 | immutable_file_test = None 74 | 75 | super().__init__( 76 | application=None, 77 | autorefresh=autorefresh, 78 | max_age=max_age, 79 | allow_all_origins=allow_all_origins, 80 | charset=charset, 81 | mimetypes=mimetypes, 82 | add_headers_function=add_headers_function, 83 | index_file=index_file, 84 | immutable_file_test=immutable_file_test, 85 | ) 86 | 87 | try: 88 | self.use_finders = settings.WHITENOISE_USE_FINDERS 89 | except AttributeError: 90 | self.use_finders = settings.DEBUG 91 | 92 | try: 93 | self.static_prefix = settings.WHITENOISE_STATIC_PREFIX 94 | except AttributeError: 95 | self.static_prefix = urlparse(settings.STATIC_URL or "").path 96 | script_prefix = get_script_prefix().rstrip("/") 97 | if script_prefix: 98 | if self.static_prefix.startswith(script_prefix): 99 | self.static_prefix = self.static_prefix[len(script_prefix) :] 100 | self.static_prefix = ensure_leading_trailing_slash(self.static_prefix) 101 | 102 | self.static_root = settings.STATIC_ROOT 103 | if self.static_root: 104 | self.add_files(self.static_root, prefix=self.static_prefix) 105 | 106 | try: 107 | root = settings.WHITENOISE_ROOT 108 | except AttributeError: 109 | root = None 110 | if root: 111 | self.add_files(root) 112 | 113 | if self.use_finders and not self.autorefresh: 114 | self.add_files_from_finders() 115 | 116 | def __call__(self, request): 117 | if self.autorefresh: 118 | static_file = self.find_file(request.path_info) 119 | else: 120 | static_file = self.files.get(request.path_info) 121 | if static_file is not None: 122 | return self.serve(static_file, request) 123 | if static_file is None: 124 | if self.static_prefix in request.path_info: 125 | # using path info and static root to check the directory of the recently made webp 126 | raw_full_path = os.path.join(self.static_root, (request.path_info).lstrip("/")) 127 | clean_full_path = raw_full_path.replace("\\", "/") 128 | complete_full_path = clean_full_path.replace(self.static_prefix.rstrip("/"), "", 1) 129 | if os.path.exists(complete_full_path): 130 | self.add_files(self.static_root, prefix=self.static_prefix) 131 | static_file = self.files.get(complete_full_path) 132 | if static_file is None: 133 | request.not_serving = request.path_info 134 | # print("and this too", request.__dict__) 135 | return self.serve(static_file, request) 136 | return self.get_response(request) 137 | 138 | @staticmethod 139 | def serve(static_file, request): 140 | try: 141 | response = static_file.get_response(request.method, request.META) 142 | status = int(response.status) 143 | http_response = WhiteNoiseFileResponse(response.file or (), status=status) 144 | # Remove default content-type 145 | del http_response["content-type"] 146 | for key, value in response.headers: 147 | http_response[key] = value 148 | return http_response 149 | except: 150 | http_response = WhiteNoiseFileResponse((), status=500) 151 | http_response['not serving'] = request.not_serving 152 | return http_response 153 | 154 | 155 | def add_files_from_finders(self): 156 | files = {} 157 | for finder in finders.get_finders(): 158 | for path, storage in finder.list(None): 159 | prefix = (getattr(storage, "prefix", None) or "").strip("/") 160 | url = "".join( 161 | ( 162 | self.static_prefix, 163 | prefix, 164 | "/" if prefix else "", 165 | path.replace("\\", "/"), 166 | ) 167 | ) 168 | # Use setdefault as only first matching file should be used 169 | files.setdefault(url, storage.path(path)) 170 | stat_cache = {path: os.stat(path) for path in files.values()} 171 | for url, path in files.items(): 172 | self.add_file_to_dictionary(url, path, stat_cache=stat_cache) 173 | 174 | def candidate_paths_for_url(self, url): 175 | if self.use_finders and url.startswith(self.static_prefix): 176 | path = finders.find(url[len(self.static_prefix) :]) 177 | if path: 178 | yield path 179 | paths = super().candidate_paths_for_url(url) 180 | for path in paths: 181 | yield path 182 | 183 | def immutable_file_test(self, path, url): 184 | """ 185 | Determine whether given URL represents an immutable file (i.e. a 186 | file with a hash of its contents as part of its name) which can 187 | therefore be cached forever 188 | """ 189 | if not url.startswith(self.static_prefix): 190 | return False 191 | name = url[len(self.static_prefix) :] 192 | name_without_hash = self.get_name_without_hash(name) 193 | if name == name_without_hash: 194 | return False 195 | static_url = self.get_static_url(name_without_hash) 196 | # If the static_url function maps the name without hash 197 | # back to the original name, then we know we've got a 198 | # versioned filename 199 | if static_url and basename(static_url) == basename(url): 200 | return True 201 | return False 202 | 203 | def get_name_without_hash(self, filename): 204 | """ 205 | Removes the version hash from a filename e.g, transforms 206 | 'css/application.f3ea4bcc2.css' into 'css/application.css' 207 | 208 | Note: this is specific to the naming scheme used by Django's 209 | CachedStaticFilesStorage. You may have to override this if 210 | you are using a different static files versioning system 211 | """ 212 | name_with_hash, ext = os.path.splitext(filename) 213 | name = os.path.splitext(name_with_hash)[0] 214 | return name + ext 215 | 216 | def get_static_url(self, name): 217 | try: 218 | return staticfiles_storage.url(name) 219 | except ValueError: 220 | return None 221 | -------------------------------------------------------------------------------- /tests/testproj/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/testproj/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testproj/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /tests/testproj/testapp/templates/django_webp/index.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load webp %} 3 | 4 | 5 | 6 | 7 | 8 | Django Webp Test Page 9 | 10 | 11 |

static url: {% static 'django_webp/python.png' %}

12 | Python 13 | 14 |

webp url: {% webp 'django_webp/python.png' %}

15 | Python 16 | 17 | -------------------------------------------------------------------------------- /tests/testproj/testapp/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/testproj/testapp/templatetags/__init__.py -------------------------------------------------------------------------------- /tests/testproj/testapp/templatetags/webp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import logging 4 | import requests 5 | from io import BytesIO 6 | from PIL import Image 7 | 8 | from django import template 9 | from django.conf import settings 10 | from django.core.files.base import ContentFile 11 | from django.contrib.staticfiles import finders 12 | from django.core.files.storage import default_storage 13 | from django.templatetags.static import static 14 | from django.core.exceptions import SuspiciousFileOperation 15 | 16 | from whitenoise.middleware import WhiteNoiseMiddleware 17 | from whitenoise.string_utils import ensure_leading_trailing_slash 18 | 19 | from django.conf import settings 20 | from utils import ( 21 | WEBP_STATIC_ROOT, 22 | WEBP_DEBUG, 23 | WEBP_CHECK_URLS, 24 | ) 25 | 26 | # if STATIC_ROOT is abs, then we are likely woring in production, if not, likely a testing env 27 | if os.path.isabs(settings.STATIC_ROOT): 28 | base_path = settings.STATIC_ROOT 29 | static_dir_prefix = os.path.relpath(settings.STATIC_ROOT, start=settings.BASE_DIR) 30 | else: 31 | base_path = os.path.join(settings.BASE_DIR, settings.STATIC_ROOT) 32 | static_dir_prefix = settings.STATIC_ROOT 33 | 34 | # getting rid of trailing slash 35 | static_dir_prefix = (static_dir_prefix).rstrip("/") 36 | 37 | register = template.Library() 38 | 39 | 40 | class WEBPImageConverter: 41 | def __init__(self): 42 | self.logger = logging.getLogger(__name__) 43 | 44 | def generate_path(self, image_path): 45 | """creates all folders necessary until reach the file's folder""" 46 | folder_path = os.path.dirname(image_path) 47 | if not os.path.isdir(folder_path): 48 | os.makedirs(folder_path) 49 | 50 | def get_static_image(self, image_url): 51 | if "https://" in image_url: 52 | return image_url 53 | else: 54 | return static(image_url) 55 | 56 | def check_image_dirs(self, generated_path, image_path): 57 | """ 58 | Checks if original image directory is valid and prevents duplicates of 59 | generated images by checking if the already exist in a directory 60 | """ 61 | 62 | # Checking if original image exists 63 | if not os.path.exists(os.path.join(base_path, image_path)) and not "https" in image_path: 64 | self.logger.warn(f"Original image does not exist in static files path: {os.path.join(base_path, image_path)}") 65 | return False 66 | 67 | # Checks if the webp version of the image exists 68 | if os.path.exists(generated_path): 69 | return False 70 | else: 71 | return True 72 | 73 | def is_image_served(self, image_url, timeout_seconds=1): 74 | try: 75 | response = requests.head(image_url, timeout=timeout_seconds) 76 | if response.status_code == requests.codes.ok: 77 | return True 78 | else: 79 | return False 80 | except requests.exceptions.Timeout: 81 | return False 82 | except requests.exceptions.RequestException as e: 83 | return False 84 | 85 | def get_generated_image(self, image_url): 86 | """Returns the url to the webp gerenated image, returns the 87 | original image url if any of the following occur: 88 | 89 | - webp image generation fails 90 | - original image does not exist 91 | """ 92 | 93 | if "https://" in image_url: # pragma: no cover 94 | # Split the text by forward slashes and gets the last part (characters after the last slash) 95 | raw_filename = image_url.split("/")[-1] 96 | real_url = os.path.join( 97 | "online_images/", os.path.splitext(raw_filename)[0] + ".webp" 98 | ) 99 | else: 100 | real_url = os.path.splitext(image_url)[0] + ".webp" 101 | 102 | generated_path = os.path.join(WEBP_STATIC_ROOT, real_url).lstrip("/") 103 | 104 | # Checks if link provided is still valid 105 | # Only bothers to check if the link is valid if WEBP_CHECK_URLS is True 106 | if "https://" in image_url: # pragma: no cover 107 | if WEBP_CHECK_URLS: 108 | try: 109 | response = requests.head(image_url) 110 | if response.status_code == requests.codes.ok: 111 | content_type = response.headers.get("Content-Type", "") 112 | if not content_type.startswith("image/"): 113 | self.logger.warn(f"The following image url is invalid: {image_url}") 114 | except requests.RequestException: 115 | return self.get_static_image(image_url) 116 | 117 | should_generate = self.check_image_dirs(generated_path, image_url) 118 | 119 | if should_generate is True: 120 | if "https://" in image_url: # pragma: no cover 121 | if not self.generate_webp_image(generated_path, image_url): 122 | self.logger.error(f"Failed to generate from URL: {image_url}") 123 | return self.get_static_image(image_url) 124 | else: 125 | # Constructing full image path for original image 126 | image_path = os.path.join(base_path, image_url) 127 | if not self.generate_webp_image(generated_path, image_path): 128 | return self.get_static_image(image_url) 129 | 130 | ## converting generated_path from an absolute path to a relative path 131 | index = generated_path.find(static_dir_prefix) 132 | # Extract the substring starting from static_dir_prefix and replacing any weird backslashes with forward slashes 133 | generated_path = (generated_path[index + len(static_dir_prefix):]).replace("\\", "/") 134 | 135 | 136 | # have to check if image is served bc webp runs before whitenoise can properly hash the image dirs 137 | domain = os.environ.get("HOST", default="http://127.0.0.1:8000/") 138 | if self.is_image_served(domain.rstrip("/") + static(generated_path)): 139 | return static(generated_path) 140 | else: 141 | return self.get_static_image(image_url) 142 | 143 | def generate_webp_image(self, generated_path, image_path): 144 | final_path = os.path.join(str(base_path), generated_path) 145 | 146 | ## Generating images if they do not exist in a directory 147 | # Fetching the image data 148 | if "https://" in image_path: # pragma: no cover 149 | response = requests.get(image_path) 150 | try: 151 | image = Image.open(BytesIO(response.content)) 152 | except: 153 | self.logger.error(f"Error: Failed to read the image file from URL: {image_path}") 154 | return False 155 | else: 156 | try: 157 | image = Image.open(image_path) 158 | except FileNotFoundError: 159 | return False 160 | 161 | # Using the image data to save a webp version of it to static files 162 | try: 163 | self.generate_path(generated_path) 164 | # we use a buffer to store the contents of our conversion before saving to disk 165 | buffer = BytesIO() 166 | image.save(buffer, "WEBP") 167 | content_file = ContentFile(buffer.getvalue()) 168 | default_storage.save(final_path, content_file) 169 | image.close() 170 | return True 171 | except KeyError: # pragma: no cover 172 | self.logger.error("WEBP is not installed in Pillow") 173 | except (IOError, OSError): # pragma: no cover 174 | self.logger.error("WEBP image could not be saved in %s" % generated_path) 175 | except SuspiciousFileOperation: 176 | self.logger.error("SuspiciousFileOperation: the generated image was created outside of the base project path %s" % generated_path) 177 | 178 | return False # pragma: no cover 179 | 180 | 181 | @register.simple_tag(takes_context=True) 182 | def webp(context, value, force_static=WEBP_DEBUG): 183 | converter = WEBPImageConverter() 184 | 185 | supports_webp = context.get("supports_webp", False) 186 | if not supports_webp or not force_static: 187 | return converter.get_static_image(value) 188 | 189 | return converter.get_generated_image(value) 190 | -------------------------------------------------------------------------------- /tests/testproj/testapp/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /tests/testproj/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.conf import settings 3 | from django.conf.urls.static import static 4 | 5 | from testapp.views import index 6 | 7 | 8 | # Only for test purposes 9 | 10 | urlpatterns = [ 11 | path('', index), 12 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) -------------------------------------------------------------------------------- /tests/testproj/testapp/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.templatetags.static import static 6 | 7 | BASE_DIR = getattr(settings, "BASE_DIR", "") 8 | 9 | WEBP_CHECK_URLS = getattr(settings, "WEBP_CHECK_URLS", "") or False 10 | 11 | WEBP_STATIC_URL = static(getattr(settings, "WEBP_STATIC_URL", "WEBP_CACHE/")) 12 | if os.path.isabs(settings.STATIC_ROOT): 13 | WEBP_STATIC_ROOT = os.path.join(settings.STATIC_ROOT, "WEBP_CACHE/") 14 | else: 15 | WEBP_STATIC_ROOT = os.path.join(settings.BASE_DIR, settings.STATIC_ROOT, "WEBP_CACHE/") 16 | 17 | WEBP_DEBUG = getattr(settings, "WEBP_DEBUG", settings.DEBUG) 18 | 19 | if not WEBP_STATIC_URL.endswith("/"): # pragma: no cover 20 | raise ImproperlyConfigured("If set, WEBP_STATIC_URL must end with a slash") -------------------------------------------------------------------------------- /tests/testproj/testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def index(request): 5 | return render(request, 'django_webp/index.html') -------------------------------------------------------------------------------- /tests/testproj/testproj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-master/django-webp/e3acd34e506add342a6801e7562bbebc4a7f3833/tests/testproj/testproj/__init__.py -------------------------------------------------------------------------------- /tests/testproj/testproj/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for testproj project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproj.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /tests/testproj/testproj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproj project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = 'django-insecure-!tbsxa)lyjo5jwfk0-75%^e+jdr$q7uemx(h!cv&bhhvg_rn9*' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'testapp', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'testproj.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | 'testapp.context_processors.webp', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'testproj.wsgi.application' 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 78 | 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.sqlite3', 82 | 'NAME': BASE_DIR / 'db.sqlite3', 83 | } 84 | } 85 | 86 | 87 | # Password validation 88 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 89 | 90 | AUTH_PASSWORD_VALIDATORS = [ 91 | { 92 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 93 | }, 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 102 | }, 103 | ] 104 | 105 | 106 | # Internationalization 107 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 108 | 109 | LANGUAGE_CODE = 'en-us' 110 | 111 | TIME_ZONE = 'UTC' 112 | 113 | USE_I18N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 120 | 121 | STATIC_URL = 'static/' 122 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") 123 | STATICFILES_DIRS = [ 124 | os.path.join(BASE_DIR, "static"), 125 | ] 126 | 127 | 128 | # Default primary key field type 129 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 130 | 131 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 132 | 133 | WEBP_DEBUG = False 134 | -------------------------------------------------------------------------------- /tests/testproj/testproj/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from django.conf import settings 3 | from django.conf.urls.static import static 4 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 5 | 6 | 7 | # Only for test purposes 8 | 9 | urlpatterns = [ 10 | path('', include('testapp.urls')), 11 | ] -------------------------------------------------------------------------------- /tests/testproj/testproj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproj project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproj.settings') 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------