Merge pull request #167 from PetrDlouhy/django40

add support for Django 4.0 and 4.1, Python 3.10, drop support of Django 1.11
This commit is contained in:
Corey Oordt 2022-09-21 11:14:01 -05:00 committed by GitHub
commit 68abe74741
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 96 additions and 70 deletions

View file

@ -13,7 +13,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: [3.10]
fail-fast: false
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View file

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ 3.6, 3.7, 3.8, 3.9 ] python-version: [ 3.6, 3.7, 3.8, 3.9, '3.10' ]
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View file

@ -5,7 +5,7 @@ repos:
- id: isort - id: isort
additional_dependencies: [toml] additional_dependencies: [toml]
- repo: https://github.com/python/black - repo: https://github.com/python/black
rev: 21.11b1 rev: 22.8.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks

View file

@ -1,7 +1,7 @@
"""Admin interface classes.""" """Admin interface classes."""
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .base import CategoryBaseAdmin, CategoryBaseAdminForm from .base import CategoryBaseAdmin, CategoryBaseAdminForm
from .genericcollection import GenericCollectionTabularInline from .genericcollection import GenericCollectionTabularInline

View file

@ -6,8 +6,8 @@ It provides customizable metadata and its own name space.
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.db import models from django.db import models
from django.utils.encoding import force_text from django.utils.encoding import force_str
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from mptt.fields import TreeForeignKey from mptt.fields import TreeForeignKey
from mptt.managers import TreeManager from mptt.managers import TreeManager
from mptt.models import MPTTModel from mptt.models import MPTTModel
@ -77,7 +77,7 @@ class CategoryBase(MPTTModel):
def __str__(self): def __str__(self):
ancestors = self.get_ancestors() ancestors = self.get_ancestors()
return " > ".join( return " > ".join(
[force_text(i.name) for i in ancestors] [force_str(i.name) for i in ancestors]
+ [ + [
self.name, self.name,
] ]

View file

@ -5,7 +5,7 @@ from django.contrib.admin.utils import lookup_field
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import models from django.db import models
from django.template import Library from django.template import Library
from django.utils.encoding import force_text, smart_text from django.utils.encoding import force_str, smart_str
from django.utils.html import conditional_escape, escape, escapejs, format_html from django.utils.html import conditional_escape, escape, escapejs, format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -53,7 +53,7 @@ def items_for_tree_result(cl, result, form):
allow_tags = True allow_tags = True
result_repr = _boolean_icon(value) result_repr = _boolean_icon(value)
else: else:
result_repr = smart_text(value) result_repr = smart_str(value)
# Strip HTML tags in the resulting text, except if the # Strip HTML tags in the resulting text, except if the
# function has an "allow_tags" attribute set to True. # function has an "allow_tags" attribute set to True.
if not allow_tags: if not allow_tags:
@ -80,7 +80,7 @@ def items_for_tree_result(cl, result, form):
except (AttributeError, ObjectDoesNotExist): except (AttributeError, ObjectDoesNotExist):
pass pass
if force_text(result_repr) == "": if force_str(result_repr) == "":
result_repr = mark_safe(" ") result_repr = mark_safe(" ")
# If list_display_links not defined, add the link tag to the first field # If list_display_links not defined, add the link tag to the first field
if (first and not cl.list_display_links) or field_name in cl.list_display_links: if (first and not cl.list_display_links) or field_name in cl.list_display_links:
@ -97,12 +97,12 @@ def items_for_tree_result(cl, result, form):
else: else:
attr = pk attr = pk
value = result.serializable_value(attr) value = result.serializable_value(attr)
result_id = repr(force_text(value))[1:] result_id = repr(force_str(value))[1:]
first = False first = False
result_id = escapejs(value) result_id = escapejs(value)
yield mark_safe( yield mark_safe(
format_html( format_html(
smart_text('<{}{}><a href="{}"{}>{}</a></{}>'), smart_str('<{}{}><a href="{}"{}>{}</a></{}>'),
table_tag, table_tag,
row_class, row_class,
url, url,
@ -123,12 +123,12 @@ def items_for_tree_result(cl, result, form):
# can provide fields on a per request basis # can provide fields on a per request basis
if form and field_name in form.fields: if form and field_name in form.fields:
bf = form[field_name] bf = form[field_name]
result_repr = mark_safe(force_text(bf.errors) + force_text(bf)) result_repr = mark_safe(force_str(bf.errors) + force_str(bf))
else: else:
result_repr = conditional_escape(result_repr) result_repr = conditional_escape(result_repr)
yield mark_safe(smart_text("<td%s>%s</td>" % (row_class, result_repr))) yield mark_safe(smart_str("<td%s>%s</td>" % (row_class, result_repr)))
if form and not form[cl.model._meta.pk.name].is_hidden: if form and not form[cl.model._meta.pk.name].is_hidden:
yield mark_safe(smart_text("<td>%s</td>" % force_text(form[cl.model._meta.pk.name]))) yield mark_safe(smart_str("<td>%s</td>" % force_str(form[cl.model._meta.pk.name])))
class TreeList(list): class TreeList(list):

View file

@ -8,7 +8,7 @@ from django.contrib.admin.views.main import ChangeList
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from . import settings from . import settings
@ -150,8 +150,8 @@ class TreeEditor(admin.ModelAdmin):
"""The 'change list' admin view for this model.""" """The 'change list' admin view for this model."""
from django.contrib.admin.views.main import ERROR_FLAG from django.contrib.admin.views.main import ERROR_FLAG
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.utils.encoding import force_text from django.utils.encoding import force_str
from django.utils.translation import ungettext from django.utils.translation import ngettext
opts = self.model._meta opts = self.model._meta
app_label = opts.app_label app_label = opts.app_label
@ -199,6 +199,22 @@ class TreeEditor(admin.ModelAdmin):
self.list_editable, self.list_editable,
self, self,
) )
elif django.VERSION < (4, 0):
params = (
request,
self.model,
list_display,
self.list_display_links,
self.list_filter,
self.date_hierarchy,
self.search_fields,
self.list_select_related,
self.list_per_page,
self.list_max_show_all,
self.list_editable,
self,
self.sortable_by,
)
else: else:
params = ( params = (
request, request,
@ -214,6 +230,7 @@ class TreeEditor(admin.ModelAdmin):
self.list_editable, self.list_editable,
self, self,
self.sortable_by, self.sortable_by,
self.search_help_text,
) )
cl = TreeChangeList(*params) cl = TreeChangeList(*params)
except IncorrectLookupParameters: except IncorrectLookupParameters:
@ -256,17 +273,14 @@ class TreeEditor(admin.ModelAdmin):
if changecount: if changecount:
if changecount == 1: if changecount == 1:
name = force_text(opts.verbose_name) name = force_str(opts.verbose_name)
else: else:
name = force_text(opts.verbose_name_plural) name = force_str(opts.verbose_name_plural)
msg = ( msg = ngettext(
ungettext( "%(count)s %(name)s was changed successfully.",
"%(count)s %(name)s was changed successfully.", "%(count)s %(name)s were changed successfully.",
"%(count)s %(name)s were changed successfully.", changecount,
changecount, ) % {"count": changecount, "name": name, "obj": force_str(obj)}
)
% {"count": changecount, "name": name, "obj": force_text(obj)}
)
self.message_user(request, msg) self.message_user(request, msg)
return HttpResponseRedirect(request.get_full_path()) return HttpResponseRedirect(request.get_full_path())
@ -303,11 +317,11 @@ class TreeEditor(admin.ModelAdmin):
if django.VERSION[0] == 1 and django.VERSION[1] < 4: if django.VERSION[0] == 1 and django.VERSION[1] < 4:
context["root_path"] = self.admin_site.root_path context["root_path"] = self.admin_site.root_path
elif django.VERSION[0] == 1 or (django.VERSION[0] == 2 and django.VERSION[1] < 1): elif django.VERSION[0] == 1 or (django.VERSION[0] == 2 and django.VERSION[1] < 1):
selection_note_all = ungettext("%(total_count)s selected", "All %(total_count)s selected", cl.result_count) selection_note_all = ngettext("%(total_count)s selected", "All %(total_count)s selected", cl.result_count)
context.update( context.update(
{ {
"module_name": force_text(opts.verbose_name_plural), "module_name": force_str(opts.verbose_name_plural),
"selection_note": _("0 of %(cnt)s selected") % {"cnt": len(cl.result_list)}, "selection_note": _("0 of %(cnt)s selected") % {"cnt": len(cl.result_list)},
"selection_note_all": selection_note_all % {"total_count": cl.result_count}, "selection_note_all": selection_note_all % {"total_count": cl.result_count},
} }

View file

@ -10,7 +10,7 @@ class Command(BaseCommand):
help = "Alter the tables for all registered models, or just specified models" help = "Alter the tables for all registered models, or just specified models"
args = "[appname ...]" args = "[appname ...]"
can_import_settings = True can_import_settings = True
requires_system_checks = False requires_system_checks = []
def add_arguments(self, parser): def add_arguments(self, parser):
"""Add app_names argument to the command.""" """Add app_names argument to the command."""

View file

@ -10,7 +10,7 @@ class Command(BaseCommand):
help = "Drop the given field from the given model's table" help = "Drop the given field from the given model's table"
args = "appname modelname fieldname" args = "appname modelname fieldname"
can_import_settings = True can_import_settings = True
requires_system_checks = False requires_system_checks = []
def add_arguments(self, parser): def add_arguments(self, parser):
"""Add app_name, model_name, and field_name arguments to the command.""" """Add app_name, model_name, and field_name arguments to the command."""

View file

@ -1,7 +1,7 @@
"""Adds and removes category relations on the database.""" """Adds and removes category relations on the database."""
from django.apps import apps from django.apps import apps
from django.db import connection, transaction from django.db import DatabaseError, connection, transaction
from django.db.utils import ProgrammingError from django.db.utils import OperationalError, ProgrammingError
def table_exists(table_name): def table_exists(table_name):
@ -25,7 +25,10 @@ def field_exists(app_name, model_name, field_name):
field = model._meta.get_field(field_name) field = model._meta.get_field(field_name)
if hasattr(field, "m2m_db_table"): if hasattr(field, "m2m_db_table"):
m2m_table_name = field.m2m_db_table() m2m_table_name = field.m2m_db_table()
m2m_field_info = connection.introspection.get_table_description(cursor, m2m_table_name) try:
m2m_field_info = connection.introspection.get_table_description(cursor, m2m_table_name)
except DatabaseError: # Django >= 4.1 throws DatabaseError
m2m_field_info = []
if m2m_field_info: if m2m_field_info:
return True return True
@ -68,7 +71,9 @@ def migrate_app(sender, *args, **kwargs):
schema_editor.add_field(model, registry._field_registry[fld]) schema_editor.add_field(model, registry._field_registry[fld])
if sid: if sid:
transaction.savepoint_commit(sid) transaction.savepoint_commit(sid)
except ProgrammingError: # Django 4.1 with sqlite3 has for some reason started throwing OperationalError
# instead of ProgrammingError, so we need to catch both.
except (ProgrammingError, OperationalError):
if sid: if sid:
transaction.savepoint_rollback(sid) transaction.savepoint_rollback(sid)
continue continue

View file

@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.files.images import get_image_dimensions from django.core.files.images import get_image_dimensions
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_text from django.utils.encoding import force_str
try: try:
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
@ -13,7 +13,7 @@ except ImportError:
from django.contrib.contenttypes.generic import GenericForeignKey from django.contrib.contenttypes.generic import GenericForeignKey
from django.core.files.storage import get_storage_class from django.core.files.storage import get_storage_class
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .base import CategoryBase from .base import CategoryBase
from .settings import ( from .settings import (
@ -72,7 +72,7 @@ class Category(CategoryBase):
ancestors = list(self.get_ancestors()) + [ ancestors = list(self.get_ancestors()) + [
self, self,
] ]
return prefix + "/".join([force_text(i.slug) for i in ancestors]) + "/" return prefix + "/".join([force_str(i.slug) for i in ancestors]) + "/"
if RELATION_MODELS: if RELATION_MODELS:

View file

@ -3,13 +3,13 @@ These functions handle the adding of fields to other models.
""" """
from typing import Optional, Type, Union from typing import Optional, Type, Union
import collections from collections.abc import Iterable
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db.models import CASCADE, ForeignKey, ManyToManyField from django.db.models import CASCADE, ForeignKey, ManyToManyField
# from settings import self._field_registry, self._model_registry # from settings import self._field_registry, self._model_registry
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from . import fields from . import fields
@ -26,9 +26,7 @@ class Registry(object):
self._field_registry = {} self._field_registry = {}
self._model_registry = {} self._model_registry = {}
def register_model( def register_model(self, app: str, model_name, field_type: str, field_definitions: Union[str, Iterable]):
self, app: str, model_name, field_type: str, field_definitions: Union[str, collections.Iterable]
):
""" """
Registration process for Django 1.7+. Registration process for Django 1.7+.
@ -41,15 +39,13 @@ class Registry(object):
Raises: Raises:
ImproperlyConfigured: For incorrect parameter types or missing model. ImproperlyConfigured: For incorrect parameter types or missing model.
""" """
import collections
from django.apps import apps from django.apps import apps
app_label = app app_label = app
if isinstance(field_definitions, str): if isinstance(field_definitions, str):
field_definitions = [field_definitions] field_definitions = [field_definitions]
elif not isinstance(field_definitions, collections.Iterable): elif not isinstance(field_definitions, Iterable):
raise ImproperlyConfigured( raise ImproperlyConfigured(
_("Field configuration for %(app)s should be a string or iterable") % {"app": app} _("Field configuration for %(app)s should be a string or iterable") % {"app": app}
) )

View file

@ -3,7 +3,7 @@ import collections
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
"ALLOW_SLUG_CHANGE": False, "ALLOW_SLUG_CHANGE": False,

View file

@ -43,12 +43,12 @@
<p>{{ field.contents }}</p> <p>{{ field.contents }}</p>
{% else %} {% else %}
{{ field.field.errors.as_ul }} {{ field.field.errors.as_ul }}
{% ifequal field.field.name inline_admin_formset.formset.ct_fk_field %} {% if field.field.name == inline_admin_formset.formset.ct_fk_field %}
{{ field.field }} {{ field.field }}
<a id="lookup_id_{{field.field.html_name}}" class="related-lookup" onclick="return showGenericRelatedObjectLookupPopup(this, {{ inline_admin_formset.formset.content_types }});" href="#"> <a id="lookup_id_{{field.field.html_name}}" class="related-lookup" onclick="return showGenericRelatedObjectLookupPopup(this, {{ inline_admin_formset.formset.content_types }});" href="#">
<img width="16" height="16" alt="Lookup" src="{% static 'img/admin/selector-search.gif' %}"/> <img width="16" height="16" alt="Lookup" src="{% static 'img/admin/selector-search.gif' %}"/>
</a> </a>
{% else %}{{ field.field }} {% endifequal %} {% else %}{{ field.field }} {% endif %}
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}

View file

@ -4,8 +4,8 @@
{% if structure.new_level %}<ul><li> {% if structure.new_level %}<ul><li>
{% else %}</li><li> {% else %}</li><li>
{% endif %} {% endif %}
{% ifequal node category %}<strong>{{ node.name }}</strong> {% if node == category %}<strong>{{ node.name }}</strong>
{% else %}<a href="{{ node.get_absolute_url }}">{{ node.name }}</a> {% else %}<a href="{{ node.get_absolute_url }}">{{ node.name }}</a>
{% endifequal %} {% endif %}
{% for level in structure.closed_levels %}</li></ul>{% endfor %} {% for level in structure.closed_levels %}</li></ul>{% endfor %}
{% endfor %}</li></ul> {% endfor %}</li></ul>

View file

@ -4,8 +4,8 @@
{% if structure.new_level %}<ul><li> {% if structure.new_level %}<ul><li>
{% else %}</li><li> {% else %}</li><li>
{% endif %} {% endif %}
{% ifequal node category %}<strong>{{ node.name }}</strong> {% if node == category %}<strong>{{ node.name }}</strong>
{% else %}<a href="{{ node.get_absolute_url }}">{{ node.name }}</a> {% else %}<a href="{{ node.get_absolute_url }}">{{ node.name }}</a>
{% endifequal %} {% endif %}
{% for level in structure.closed_levels %}</li></ul>{% endfor %} {% for level in structure.closed_levels %}</li></ul>{% endfor %}
{% endfor %}</li></ul>{% endspaceless %} {% endfor %}</li></ul>{% endspaceless %}

View file

@ -1,7 +1,7 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import smart_text from django.utils.encoding import smart_str
from categories.models import Category from categories.models import Category
@ -16,7 +16,7 @@ class TestCategoryAdmin(TestCase):
url = reverse("admin:categories_category_add") url = reverse("admin:categories_category_add")
data = { data = {
"parent": "", "parent": "",
"name": smart_text("Parent Catégory"), "name": smart_str("Parent Catégory"),
"thumbnail": "", "thumbnail": "",
"filename": "", "filename": "",
"active": "on", "active": "on",
@ -38,7 +38,7 @@ class TestCategoryAdmin(TestCase):
self.assertEqual(1, Category.objects.count()) self.assertEqual(1, Category.objects.count())
# update parent # update parent
data.update({"name": smart_text("Parent Catégory (Changed)")}) data.update({"name": smart_str("Parent Catégory (Changed)")})
resp = self.client.post(reverse("admin:categories_category_change", args=(1,)), data=data) resp = self.client.post(reverse("admin:categories_category_change", args=(1,)), data=data)
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 302)
self.assertEqual(1, Category.objects.count()) self.assertEqual(1, Category.objects.count())
@ -47,8 +47,8 @@ class TestCategoryAdmin(TestCase):
data.update( data.update(
{ {
"parent": "1", "parent": "1",
"name": smart_text("Child Catégory"), "name": smart_str("Child Catégory"),
"slug": smart_text("child-category"), "slug": smart_str("child-category"),
} }
) )
resp = self.client.post(url, data=data) resp = self.client.post(url, data=data)

View file

@ -1,5 +1,5 @@
"""URL patterns for the categories app.""" """URL patterns for the categories app."""
from django.conf.urls import url from django.urls import path, re_path
from django.views.generic import ListView from django.views.generic import ListView
from . import views from . import views
@ -7,6 +7,6 @@ from .models import Category
categorytree_dict = {"queryset": Category.objects.filter(level=0)} categorytree_dict = {"queryset": Category.objects.filter(level=0)}
urlpatterns = (url(r"^$", ListView.as_view(**categorytree_dict), name="categories_tree_list"),) urlpatterns = (path("", ListView.as_view(**categorytree_dict), name="categories_tree_list"),)
urlpatterns += (url(r"^(?P<path>.+)/$", views.category_detail, name="categories_category"),) urlpatterns += (re_path(r"^(?P<path>.+)/$", views.category_detail, name="categories_category"),)

View file

@ -4,7 +4,7 @@ from typing import Optional
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.template.loader import select_template from django.template.loader import select_template
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from .models import Category from .models import Category

View file

@ -1,8 +1,9 @@
"""URL patterns for the example project.""" """URL patterns for the example project."""
import os import os
from django.conf.urls import include, url from django.conf.urls import include
from django.contrib import admin from django.contrib import admin
from django.urls import path, re_path
from django.views.static import serve from django.views.static import serve
admin.autodiscover() admin.autodiscover()
@ -17,12 +18,14 @@ urlpatterns = (
# to INSTALLED_APPS to enable admin documentation: # to INSTALLED_APPS to enable admin documentation:
# (r'^admin/doc/', include('django.contrib.admindocs.urls')), # (r'^admin/doc/', include('django.contrib.admindocs.urls')),
# Uncomment the next line to enable the admin: # Uncomment the next line to enable the admin:
url(r"^admin/", admin.site.urls), path("admin/", admin.site.urls),
url(r"^categories/", include("categories.urls")), path("categories/", include("categories.urls")),
# r'^cats/', include('categories.urls')), # r'^cats/', include('categories.urls')),
url(r"^static/categories/(?P<path>.*)$", serve, {"document_root": ROOT_PATH + "/categories/media/categories/"}), re_path(
r"^static/categories/(?P<path>.*)$", serve, {"document_root": ROOT_PATH + "/categories/media/categories/"}
),
# (r'^static/editor/(?P<path>.*)$', 'django.views.static.serve', # (r'^static/editor/(?P<path>.*)$', 'django.views.static.serve',
# {'document_root': ROOT_PATH + '/editor/media/editor/', # {'document_root': ROOT_PATH + '/editor/media/editor/',
# 'show_indexes':True}), # 'show_indexes':True}),
url(r"^static/(?P<path>.*)$", serve, {"document_root": os.path.join(ROOT_PATH, "example", "static")}), re_path(r"^static/(?P<path>.*)$", serve, {"document_root": os.path.join(ROOT_PATH, "example", "static")}),
) )

View file

@ -47,4 +47,5 @@ requirements = parse_reqs("requirements.txt")
setup( setup(
install_requires=requirements, install_requires=requirements,
py_modules=[],
) )

10
tox.ini
View file

@ -2,7 +2,10 @@
envlist = envlist =
begin begin
py36-lint py36-lint
py{36,37,38,39}-django{111,2,21,22,3,31} py{36,37,38,39}-django{2}
py{36,37,38,39,310}-django{21,22,3,31,32}
py{38,39,310}-django{40}
py{38,39,310}-django{41}
coverage-report coverage-report
[gh-actions] [gh-actions]
@ -11,6 +14,7 @@ python =
3.7: py37 3.7: py37
3.8: py38 3.8: py38
3.9: py39 3.9: py39
3.10: py310
[testenv] [testenv]
passenv = GITHUB_* passenv = GITHUB_*
@ -21,7 +25,9 @@ deps=
django22: Django>=2.2,<2.3 django22: Django>=2.2,<2.3
django3: Django>=3.0,<3.1 django3: Django>=3.0,<3.1
django31: Django>=3.1,<3.2 django31: Django>=3.1,<3.2
django111: Django>=1.11,<1.12 django32: Django>=3.2,<4.0
django40: Django>=4.0,<4.1
django41: Django>=4.1,<5.0
coverage[toml] coverage[toml]
pillow pillow
ipdb ipdb