diff --git a/.gitignore b/.gitignore index 4017fe7..46dbf98 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ *backup* db.sqlite3 .vscode +image_optimizer_demo/media/* diff --git a/README.rst b/README.rst index 28f6a44..b807d6f 100644 --- a/README.rst +++ b/README.rst @@ -2,16 +2,16 @@ django-image-optimizer |pypi version| --------------------------------------- .. |pypi version| - image:: https://img.shields.io/pypi/v/django-image-optimizer.svg?style=flat-square + image:: https://img.shields.io/pypi/v/django-image-optimizer.svg :target: https://pypi.python.org/pypi/django-image-optimizer -.. image:: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square +.. image:: https://img.shields.io/badge/license-MIT-blue.svg :target: https://raw.githubusercontent.com/agusmakmun/django-image-optimizer/master/LICENSE -.. image:: https://img.shields.io/pypi/pyversions/django-image-optimizer.svg?style=flat-square +.. image:: https://img.shields.io/pypi/pyversions/django-image-optimizer.svg :target: https://pypi.python.org/pypi/django-image-optimizer -.. image:: https://img.shields.io/badge/Django-1.8,%201.9,%201.10,%201.11,%202.0-green.svg?style=flat-square +.. image:: https://img.shields.io/badge/Django-1.8%20%3E=%203.0-green.svg :target: https://www.djangoproject.com @@ -80,13 +80,51 @@ file. Note: it is a good idea to keep this secret image = OptimizedImageField() + class MyModel2(models.Model): + """ + If you using OPTIMIZED_IMAGE_METHOD = 'pillow' + You can use this optional arguments. + + This model represents a MyModel2 with a few + fields including a `image` field which is an OptimizedImageField + instance with `optimized_image_output_size` and + `optimized_image_resize_method` arguments set. + + This means that image would be a resized + version of the source image, meant to keep a given screen resolution, + in this case (400, 300) pixels. + """ + image = OptimizedImageField( + upload_to="uploads/%Y/%m/%d", + optimized_image_output_size=(400, 300), + optimized_image_resize_method="cover" # "crop", "cover", "contain", "width", "height", "thumbnail" or None + ) + + and saving images into it, the same way you would to a Django ``ImageField``. The optimized image will be saved into the ``url`` field in place of the unoptimized image. +5. Or you can directly use the ``image_optimizer`` function from utils. + +:: + + from image_optimizer.utils import image_optimizer + + + def post_image(request): + image_data = request.FILES.get('image') + image_data = image_optimizer(image_data=image_data, + output_size=(400, 300), + resize_method='cover') + .... + + +**P.S:** + Note about TinyPNG API keys: If you obtain the free TinyPNG API token, you are limited to 500 image optimizations per month, so this function may fail if you have a lot of images. You may either obtain a paid API key, or wait until next month. -This project also taken from: https://github.com/dchukhin/django_optimized_image +This project forked from: https://github.com/dchukhin/django_optimized_image diff --git a/image_optimizer/fields.py b/image_optimizer/fields.py index 702160d..e0414c2 100644 --- a/image_optimizer/fields.py +++ b/image_optimizer/fields.py @@ -1,7 +1,5 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db.models import ImageField +from .utils import image_optimizer class OptimizedImageField(ImageField): @@ -10,9 +8,57 @@ class OptimizedImageField(ImageField): def save_form_data(self, instance, data): """Remove the OptimizedNotOptimized object on clearing the image.""" # Are we updating an image? - updating_image = True if data and getattr(instance, self.name) != data else False + updating_image = ( + True if data and getattr(instance, self.name) != data else False + ) if updating_image: - from .utils import image_optimizer - data = image_optimizer(data) + data = image_optimizer( + data, + self.optimized_image_output_size, + self.optimized_image_resize_method, + ) + super().save_form_data(instance, data) + + def __init__( + self, + optimized_image_output_size=None, + optimized_image_resize_method=None, + *args, + **kwargs + ): + """ + Initialize OptimizedImageField instance. + + set up the `optimized_image_output_size` and + `optimized_image_resize_method` arguments for the current + `OptimizedImageField` instance. + """ + # Set the optimized_image_output_size specified on your + # OptimizedImageField model instances + self.optimized_image_output_size = optimized_image_output_size + + # Set the optimized_image_resize_method specified on your + # OptimizedImageField model instances + self.optimized_image_resize_method = optimized_image_resize_method + + super().__init__(**kwargs) + + def deconstruct(self): + """ + Deconstruct method. + + deconstruct the field, allowing us to handle the field data, useful + in cases where you want to add optional arguments to your custom + field but you need to exclude them from migrations. + """ + name, path, args, kwargs = super().deconstruct() + + if kwargs.get("optimized_image_output_size"): + del kwargs["optimized_image_output_size"] + + if kwargs.get("optimized_image_resize_method"): + del kwargs["optimized_image_resize_method"] + + return name, path, args, kwargs diff --git a/image_optimizer/settings.py b/image_optimizer/settings.py index b5aedff..94cbc22 100644 --- a/image_optimizer/settings.py +++ b/image_optimizer/settings.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.conf import settings -OPTIMIZED_IMAGE_METHOD = getattr(settings, 'OPTIMIZED_IMAGE_METHOD', 'tinypng') -TINYPNG_KEY = getattr(settings, 'TINYPNG_KEY', None) +OPTIMIZED_IMAGE_METHOD = getattr(settings, "OPTIMIZED_IMAGE_METHOD", "pillow") +TINYPNG_KEY = getattr(settings, "TINYPNG_KEY", None) diff --git a/image_optimizer/utils.py b/image_optimizer/utils.py index 0b4bf87..e07f958 100644 --- a/image_optimizer/utils.py +++ b/image_optimizer/utils.py @@ -1,32 +1,146 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +import tinify +import logging +import requests from io import BytesIO from PIL import Image +from resizeimage import resizeimage +from uuid import uuid4 + +from .settings import OPTIMIZED_IMAGE_METHOD, TINYPNG_KEY + + +BACKGROUND_TRANSPARENT = (255, 255, 255, 0) -import tinify -import requests -from .settings import (OPTIMIZED_IMAGE_METHOD, TINYPNG_KEY) +def get_file_name(image_data): + return image_data.name -def image_optimizer(image_data): - """Optimize an image that has not been saved to a file.""" - if OPTIMIZED_IMAGE_METHOD == 'pillow': +def get_file_extension(file_name): + extension = None + # Get image file extension + if file_name.split(".")[-1].lower() != "jpg": + extension = file_name.split(".")[-1].upper() + else: + extension = "JPEG" + return extension + + +def get_image_extension(image): + return image.format + + +def image_optimizer(image_data, output_size=None, resize_method=None): + """ + Optimize an image that has not been saved to a file. + :param `image_data` is image data, e.g from request.FILES['image'] + :param `output_size` is float pixel scale of image (width, height) or None, for example: (400, 300) # noqa: E501 + :param `resize_method` is string resize method, choices are: + None or resizeimage.resize() method argument values, + i.e: "crop", "cover", "contain", "width", "height", "thumbnail" + :return optimized image data. + """ + if OPTIMIZED_IMAGE_METHOD == "pillow": image = Image.open(image_data) bytes_io = BytesIO() - extension = image.format - image.save(bytes_io, format=extension, optimize=True) + + extension = get_image_extension(image) + + # If output_size is set, resize the image with the selected + # resize_method. 'thumbnail' is used by default + if output_size is not None: + if resize_method: + image = resizeimage.resize( + method=resize_method, + image=image, + size=output_size, + ) + + output_image = Image.new( + "RGBA", + output_size, + BACKGROUND_TRANSPARENT, + ) + output_image_center = ( + int((output_size[0] - image.size[0]) / 2), + int((output_size[1] - image.size[1]) / 2), + ) + output_image.paste(image, output_image_center) + else: + # If output_size is None the output_image + # would be the same as source + output_image = image + + # If the file extension is JPEG, convert the output_image to RGB + if extension == "JPEG": + output_image = output_image.convert("RGB") + + output_image.save(bytes_io, format=extension, optimize=True) + image_data.seek(0) image_data.file.write(bytes_io.getvalue()) image_data.file.truncate() - elif OPTIMIZED_IMAGE_METHOD == 'tinypng': + + elif OPTIMIZED_IMAGE_METHOD == "tinypng": # disable warning info requests.packages.urllib3.disable_warnings() + # just info for people + if any([output_size, resize_method]): + message = ( + '[django-image-optimizer] "output_size" and "resize_method" ' + 'only for OPTIMIZED_IMAGE_METHOD="pillow"' + ) + logging.info(message) + tinify.key = TINYPNG_KEY - optimized_buffer = tinify.from_buffer(image_data.file.read()).to_buffer() + optimized_buffer = tinify.from_buffer( + image_data.file.read() + ).to_buffer() # noqa: E501 image_data.seek(0) image_data.file.write(optimized_buffer) image_data.file.truncate() + return image_data + + +def crop_image_on_axis(image, width, height, x, y, extension): + """ + function to crop the image using axis (using Pillow). + :param `image` is image data, e.g from request.FILES['image'] + :param `width` float width of image + :param `height` float height of image + :param `x` is float x axis + :param `y` is float y axis + :param `extension` is string, e.g: ".png" + """ + # Open the passed image + img = Image.open(image) + + # Initialise bytes io + bytes_io = BytesIO() + + # crop the image through axis + img = img.crop((x, y, width + x, height + y)) + + # resize the image and optimise it for file size, + # making smaller as possible + img = img.resize((width, height), Image.ANTIALIAS) + + # This line is optional, for safe side, image name should be unique. + img.name = "{}.{}".format(uuid4().hex, extension) + + # If the file extension is JPEG, convert the output_image to RGB + if extension == "JPEG": + img = image.convert("RGB") + img.save(bytes_io, format=extension, optimize=True) + + # return the image + image.seek(0) + + # Write back new image + image.file.write(bytes_io.getvalue()) + + # truncate the file size + image.file.truncate() + return image diff --git a/image_optimizer_demo/app_demo/admin.py b/image_optimizer_demo/app_demo/admin.py index 40f1668..1d55b8c 100644 --- a/image_optimizer_demo/app_demo/admin.py +++ b/image_optimizer_demo/app_demo/admin.py @@ -1,13 +1,33 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.contrib import admin -from app_demo.models import Post +from app_demo.models import ( + Post, + Collaborator, + OtherImage, +) +from app_demo.forms import CropImageAxisForm class PostAdmin(admin.ModelAdmin): - list_display = ['title', 'created'] - list_filter = ['created'] + list_display = ["title", "created"] + list_filter = ["created"] + + +class CollaboratorAdmin(admin.ModelAdmin): + list_display = ["name", "created"] + list_filter = ["created"] + + +class CropImageAxisAdmin(admin.ModelAdmin): + list_display = ["created", "image"] + list_filter = ["created"] + form = CropImageAxisForm + + def get_form(self, request, *args, **kwargs): + form = super().get_form(request, *args, **kwargs) + form.request = request + return form admin.site.register(Post, PostAdmin) +admin.site.register(Collaborator, CollaboratorAdmin) +admin.site.register(OtherImage, CropImageAxisAdmin) diff --git a/image_optimizer_demo/app_demo/apps.py b/image_optimizer_demo/app_demo/apps.py index 162461a..2fe8dae 100644 --- a/image_optimizer_demo/app_demo/apps.py +++ b/image_optimizer_demo/app_demo/apps.py @@ -1,8 +1,5 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.apps import AppConfig class AppDemoConfig(AppConfig): - name = 'app_demo' + name = "app_demo" diff --git a/image_optimizer_demo/app_demo/forms.py b/image_optimizer_demo/app_demo/forms.py new file mode 100644 index 0000000..6f81d90 --- /dev/null +++ b/image_optimizer_demo/app_demo/forms.py @@ -0,0 +1,38 @@ +from django import forms +from .models import OtherImage +from image_optimizer.utils import crop_image_on_axis, get_file_extension + + +class CropImageAxisForm(forms.ModelForm): + width = forms.IntegerField() + height = forms.IntegerField() + x = forms.FloatField() + y = forms.FloatField() + + def save(self, commit=True): + instance = super().save(commit=False) + request = self.request + + # process on create only + image = request.FILES.get("image") + if image is not None: + width = float(request.POST["width"]) + height = float(request.POST["height"]) + x = float(request.POST["x"]) + y = float(request.POST["y"]) + extension = get_file_extension(image.name) + + try: + image = crop_image_on_axis(image, width, height, x, y, extension) + except ValueError as error: + raise forms.ValidationError(error) + + instance.image = image + instance.save() + return instance + + return super().save(commit) + + class Meta: + model = OtherImage + fields = ["image"] diff --git a/image_optimizer_demo/app_demo/models.py b/image_optimizer_demo/app_demo/models.py index bdc5d3b..e9a6d6f 100644 --- a/image_optimizer_demo/app_demo/models.py +++ b/image_optimizer_demo/app_demo/models.py @@ -1,17 +1,61 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - +"""app_demo models.""" from django.db import models from image_optimizer.fields import OptimizedImageField class Post(models.Model): + """ + Post model. + This model represents a Blog Post with a few fields including a `photo` + field which is an OptimizedImageField instance without any optional + argument. This means that out Post photo would keep source image original + size. + """ + title = models.CharField(max_length=100) - photo = OptimizedImageField(upload_to='uploads/%Y/%m/%d') + photo = OptimizedImageField(upload_to="uploads/posts/%Y/%m/%d") created = models.DateTimeField(auto_now_add=True) def __str__(self): return self.title class Meta: - ordering = ['-created'] + ordering = ["-created"] + + +class Collaborator(models.Model): + """ + Collaborator model. + This model represents a Blog Collaborator(a.k.a. Writter) with a few + fields including a `profile_image` field which is an OptimizedImageField + instance with `optimized_image_output_size` and + `optimized_image_resize_method` arguments set. + This means that our Collaborator profile_image would be a resized + version of the source image, meant to keep a given screen resolution, + in this case (400, 300) pixels. + """ + + name = models.CharField(max_length=100) + profile_image = OptimizedImageField( + upload_to="uploads/collaborators/%Y/%m/%d", + optimized_image_output_size=(400, 300), + optimized_image_resize_method="cover", # 'thumbnail' or 'cover' + ) + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ["-created"] + + +class OtherImage(models.Model): + image = models.ImageField(upload_to="uploads/%Y/%m/%d") + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return str(self.image) + + class Meta: + ordering = ["-created"] diff --git a/image_optimizer_demo/app_demo/views.py b/image_optimizer_demo/app_demo/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/image_optimizer_demo/app_demo/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/image_optimizer_demo/image_optimizer_demo/settings.py b/image_optimizer_demo/image_optimizer_demo/settings.py index 2ad78ed..4acfd54 100644 --- a/image_optimizer_demo/image_optimizer_demo/settings.py +++ b/image_optimizer_demo/image_optimizer_demo/settings.py @@ -20,69 +20,68 @@ # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '*^mmp(4)=8vx5%aksf+$#446gs974xyp4--2&+zp(ezsq0oxxs' +SECRET_KEY = "*^mmp(4)=8vx5%aksf+$#446gs974xyp4--2&+zp(ezsq0oxxs" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = ["*"] -OPTIMIZED_IMAGE_METHOD = 'tinypng' # 'pillow' -TINYPNG_KEY = 'key-key-key-key' +OPTIMIZED_IMAGE_METHOD = "pillow" # 'tinypng' +TINYPNG_KEY = "key-key-key-key" # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'image_optimizer', - 'app_demo', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "image_optimizer", + "app_demo", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'image_optimizer_demo.urls' +ROOT_URLCONF = "image_optimizer_demo.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'image_optimizer_demo.wsgi.application' +WSGI_APPLICATION = "image_optimizer_demo.wsgi.application" # Database # https://docs.djangoproject.com/en/2.0/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -92,16 +91,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -109,9 +108,9 @@ # Internationalization # https://docs.djangoproject.com/en/2.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -123,9 +122,8 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.0/howto/static-files/ -import tempfile -STATIC_URL = '/static/' -MEDIA_URL = '/media/' -STATIC_ROOT = os.path.join(tempfile.gettempdir(), 'image_optimizer_static') -MEDIA_ROOT = os.path.join(tempfile.gettempdir(), 'image_optimizer_media') +STATIC_URL = "/static/" +MEDIA_URL = "/media/" +STATIC_ROOT = os.path.join(BASE_DIR, "static") +MEDIA_ROOT = os.path.join(BASE_DIR, "media") diff --git a/image_optimizer_demo/image_optimizer_demo/urls.py b/image_optimizer_demo/image_optimizer_demo/urls.py index f70fdd0..e33ab67 100644 --- a/image_optimizer_demo/image_optimizer_demo/urls.py +++ b/image_optimizer_demo/image_optimizer_demo/urls.py @@ -1,5 +1,4 @@ """image_optimizer_demo URL Configuration - The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/2.0/topics/http/urls/ Examples: @@ -15,7 +14,13 @@ """ from django.contrib import admin from django.urls import path +from django.conf import settings +from django.conf.urls.static import static -urlpatterns = [ - path('admin/', admin.site.urls), -] +urlpatterns = ( + [ + path("admin/", admin.site.urls), + ] + + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..7253a4c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[flake8] +ignore = E402,E501,W503,W504,E731,E741 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules + +[pycodestyle] +max-line-length = 120 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules diff --git a/setup.py b/setup.py index 1125e5a..7cdf819 100644 --- a/setup.py +++ b/setup.py @@ -1,49 +1,53 @@ # -*- coding: utf-8 -*- -from setuptools import (setup, find_packages) +from setuptools import setup, find_packages -__VERSION__ = '1.0.0' -__AUTHOR__ = 'Agus Makmun (Summon Agus)' -__AUTHOR_EMAIL__ = 'summon.agus@gmail.com' +__VERSION__ = "1.0.3" +__AUTHOR__ = "Agus Makmun (Summon Agus)" +__AUTHOR_EMAIL__ = "summon.agus@gmail.com" install_requires = [ - 'Django', - 'Pillow', - 'requests', - 'tinify' + "Django", + "Pillow", + "requests", + "tinify", + "python-resize-image", ] setup( - name='django-image-optimizer', + name="django-image-optimizer", version=__VERSION__, author=__AUTHOR__, author_email=__AUTHOR_EMAIL__, - description='Django Image Optimizer (Compressor)', + description="Django Image Optimizer (Compressor)", packages=find_packages(exclude=["*demo"]), include_package_data=True, zip_safe=False, - url='https://github.com/agusmakmun/django-image-optimizer', - download_url='https://github.com/agusmakmun/django-image-optimizer/tarball/v%s' % __VERSION__, - keywords=['image optimizer', 'django image optimizer', 'image optimizer'], - long_description=open('README.rst').read(), - license='MIT', + url="https://github.com/agusmakmun/django-image-optimizer", + download_url=( + "https://github.com/agusmakmun/django-image-optimizer/tarball/v%s" % __VERSION__ + ), + keywords=["image optimizer", "django image optimizer", "image optimizer"], + long_description=open("README.rst").read(), + license="MIT", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.0', - 'Intended Audience :: Developers', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.5', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'License :: OSI Approved :: MIT License', + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 1.8", + "Framework :: Django :: 1.9", + "Framework :: Django :: 1.10", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.0", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.7", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", ], install_requires=install_requires, )