Compare commits

..

No commits in common. "master" and "v0.9.7" have entirely different histories.

20 changed files with 199 additions and 232 deletions

View file

@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v2
with:
python-version: 3.8
@ -33,7 +33,7 @@ jobs:
- name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@master
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}

View file

@ -9,11 +9,11 @@ jobs:
fail-fast: false
max-parallel: 5
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8']
redis-version: [5, 6, 7]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Start Redis
uses: supercharge/redis-github-action@1.5.0
@ -21,7 +21,7 @@ jobs:
redis-version: ${{ matrix.redis-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
@ -31,7 +31,7 @@ jobs:
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
@ -49,6 +49,6 @@ jobs:
tox -v
- name: Upload coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v1
with:
name: Python ${{ matrix.python-version }}

View file

@ -1,35 +0,0 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.12"
# You can also specify other tool versions:
# nodejs: "20"
# rust: "1.70"
# golang: "1.20"
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
# builder: "dirhtml"
# Fail on all warnings to avoid broken references
# fail_on_warning: true
# Optionally build your docs in additional formats such as PDF and ePub
# formats:
# - pdf
# - epub
# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python:
install:
- requirements: requirements.txt

View file

@ -2,18 +2,6 @@
Changes
=======
0.9.8
=====
- Fix watch_login with custom username (#228) [@ron8mcr]
- Replace datetime.now with timezone.now (#232) [@ericls]
- Update tox.ini with Django 4.2, Python 3.11 (#233) [@marius-mather]
- Use redis parse_url method instead of a custom one (#234) [@dkr-sahar]
- Update DEFENDER_REDIS_NAME documentation (#235) [@bennylope]
- Prevent the "Reverse for 'defender_blocks_view' not found" error (#237) [@ataylor32]
- Updated app_index.html (#238) [@ataylor32]
- Improved the "Blocked Logins" page's admin integration (#239) [@ataylor32]
0.9.7
=====

View file

@ -7,7 +7,6 @@ include CODE_OF_CONDUCT.md
include requirements.txt
include tox.ini
include .pre-commit-config.yaml
include .readthedocs.yaml
recursive-include docs *
recursive-include exampleapp *
recursive-include defender/templates *.html

View file

@ -108,8 +108,8 @@ Admin pages
Requirements
------------
* Python: 3.8, 3.9, 3.10, 3.11, 3.12, PyPy
* Django: 3.2, 4.2, 5.0, 5.1, 5.2
* Python: 3.7, 3.8, 3.9, 3.10, PyPy
* Django: 3.x, 4.x
* Redis: 5.x, 6.x, 7.x
@ -381,7 +381,7 @@ These should be defined in your ``settings.py`` file.
(Example with password: ``redis://:mypassword@localhost:6379/0``\ )
* ``DEFENDER_REDIS_PASSWORD_QUOTE``\ : Boolean: if special character in redis password (like '@'), we can quote password ``urllib.parse.quote("password!@#")``, and set to True.
[Default: ``False``\ ]
* ``DEFENDER_REDIS_NAME``\ : String: the name of the cache from ``CACHES`` in your Django settings (e.g. ``"default"``). If set, ``DEFENDER_REDIS_URL`` will be ignored.
* ``DEFENDER_REDIS_NAME``\ : String: the name of your cache client on the CACHES django setting. If set, ``DEFENDER_REDIS_URL`` will be ignored.
[Default: ``None``\ ]
* ``DEFENDER_STORE_ACCESS_ATTEMPTS``\ : Boolean: If you want to store the login
attempt to the database, set to True. If False, it is not saved
@ -534,8 +534,8 @@ Below is a sample ``BasicAuthenticationDefender`` class based on ``rest_framewor
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
from django.contrib.auth.tokens import default_token_generator
from django.utils.http import urlsafe_base64_decode as uid_decoder
from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_str
from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import force_text
from rest_framework import serializers, exceptions, HTTP_HEADER_ENCODING
from rest_framework.exceptions import ValidationError
from defender import utils as defender_utils

View file

@ -1,3 +1,3 @@
VERSION = (0, 9, 8)
VERSION = (0, 9, 7)
__version__ = ".".join((map(str, VERSION)))

View file

@ -7,7 +7,7 @@ from celery import Celery
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
CACHES = {
"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://localhost:6379",}
"default": {"BACKEND": "redis_cache.RedisCache", "LOCATION": "localhost:6379",}
}
SITE_ID = 1

View file

@ -31,5 +31,55 @@ def get_redis_connection():
except AttributeError:
# django_redis.cache.RedisCache case (django-redis package)
return cache.client.get_client(True)
else: # pragma: no cover)
return redis.StrictRedis.from_url(config.DEFENDER_REDIS_URL)
else: # pragma: no cover
redis_config = parse_redis_url(
config.DEFENDER_REDIS_URL, config.DEFENDER_REDIS_PASSWORD_QUOTE)
return redis.StrictRedis(
host=redis_config.get("HOST"),
port=redis_config.get("PORT"),
db=redis_config.get("DB"),
username=redis_config.get("USERNAME"),
password=redis_config.get("PASSWORD"),
ssl=redis_config.get("SSL"),
)
def parse_redis_url(url, password_quote=None):
"""Parses a redis URL."""
# create config with some sane defaults
redis_config = {
"DB": 0,
"PASSWORD": None,
"HOST": "localhost",
"PORT": 6379,
"SSL": False,
}
if not url:
return redis_config
purl = urlparse.urlparse(url)
# Remove query strings.
path = purl.path[1:]
path = path.split("?", 2)[0]
if path:
redis_config.update({"DB": int(path)})
if purl.password:
password = purl.password
if password_quote:
password = urlparse.unquote(password)
redis_config.update({"PASSWORD": password})
if purl.hostname:
redis_config.update({"HOST": purl.hostname})
if purl.username:
redis_config.update({"USERNAME": purl.username})
if purl.port:
redis_config.update({"PORT": int(purl.port)})
if purl.scheme in ["https", "rediss"]:
redis_config.update({"SSL": True})
return redis_config

View file

@ -1,10 +1,8 @@
from datetime import timedelta
from datetime import datetime, timedelta
from defender import config
from defender.connection import get_redis_connection
from .models import AccessAttempt
from django.db.models import Q
from django.utils import timezone
def store_login_attempt(
@ -20,14 +18,6 @@ def store_login_attempt(
login_valid=login_valid,
)
def get_approx_lockouts_cache_key(ip_address, username):
"""get cache key for approximate number of account lockouts"""
return "{0}:approx_lockouts:ip:{1}:user:{2}".format(
config.CACHE_PREFIX, ip_address or "", username.lower() if username else ""
)
def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=None):
"""Get the approximate number of account lockouts in a period of ACCESS_ATTEMPT_EXPIRATION hours.
This is approximate because we do not consider the time between these failed
@ -40,12 +30,16 @@ def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=No
Returns:
int: The minimum of the count of logged failure attempts and the length of the LOCKOUT_COOLOFF_TIMES - 1, or 0 dependant on either configuration or argument parameters (ie. both ip_address and username being None).
"""
# TODO: Possibly add logic to temporarily store this info in the cache
# to help mitigate any potential performance impact this could have.
if not config.STORE_ACCESS_ATTEMPTS or not (ip_address or username):
# If we're not storing login attempts OR both ip_address and username are
# None we should return 0.
return 0
q = Q(attempt_time__gte=timezone.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
q = Q(attempt_time__gte=datetime.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
failure_limit = config.FAILURE_LIMIT
if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \
and not config.DISABLE_IP_LOCKOUT and not config.DISABLE_USERNAME_LOCKOUT
@ -62,15 +56,4 @@ def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=No
# conditions, we're in an inappropriate context.
raise Exception("Invalid state requested")
cache_key = get_approx_lockouts_cache_key(ip_address, username)
redis_client = get_redis_connection()
cached_value = redis_client.get(cache_key)
if cached_value is not None:
return int(cached_value)
lockouts = AccessAttempt.objects.filter(q).count() // failure_limit
redis_client.set(cache_key, int(lockouts), 60)
return lockouts
return AccessAttempt.objects.filter(q).count() // failure_limit

View file

@ -18,10 +18,8 @@ def watch_login(status_code=302, msg="", get_username=utils.get_username_from_re
# if the request is currently under lockout, do not proceed to the
# login function, go directly to lockout url, do not pass go,
# do not collect messages about this login attempt
username = get_username(request)
if utils.is_already_locked(request, username=username):
return utils.lockout_response(request, username=username)
if utils.is_already_locked(request):
return utils.lockout_response(request)
# call the login function
response = func(request, *args, **kwargs)
@ -46,13 +44,13 @@ def watch_login(status_code=302, msg="", get_username=utils.get_username_from_re
# ideally make this background task, but to keep simple,
# keeping it inline for now.
utils.add_login_attempt_to_db(
request, not login_unsuccessful, username=username
request, not login_unsuccessful, get_username
)
if utils.check_request(request, login_unsuccessful, username=username):
if utils.check_request(request, login_unsuccessful, get_username):
return response
return utils.lockout_response(request, username=username)
return utils.lockout_response(request)
return response

View file

@ -1,13 +1,25 @@
{% extends "admin/app_index.html" %}
{% extends "admin/index.html" %}
{% load i18n %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo;
{% for app in app_list %}
{{ app.name }}
{% endfor %}
</div>
{% endblock %}
{% endif %}
{% block sidebar %}{% endblock %}
{% block content %}
{{ block.super }}
{% url 'defender_blocks_view' as blocks_url %}
{% if blocks_url %}
<div class="app-defender module">
<table><tr scope='row'><td colspan='3'>
<h4><a href='{{ blocks_url }}'>Blocked Users</a></h4>
<h4><a href='{% url 'defender_blocks_view' %}'>Blocked Users</a></h4>
</td></tr></table>
</div>
{% endif %}
{% endblock content%}

View file

@ -12,13 +12,13 @@
<div class="breadcrumbs">
<a href="{% url "admin:index" %}">Home</a> &rsaquo;
<a href="{% url "admin:app_list" "defender" %}">Defender</a> &rsaquo;
{{ title }}
</div>
{% endblock breadcrumbs %}
{% block content %}
<div id="content-main">
<h1>Blocked Logins</h1>
<p>Here is a list of IP's and usernames that are blocked</p>
<div class="module">

View file

@ -3,8 +3,11 @@ import string
import time
from unittest.mock import patch
from datetime import datetime, timedelta
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.backends.db import SessionStore
from django.db.models import Q
from django.http import HttpRequest, HttpResponse
from django.test.client import RequestFactory
from django.test.testcases import TestCase
@ -13,7 +16,7 @@ from django.urls import reverse
import redis
from defender.data import get_approx_account_lockouts_from_login_attempts, get_approx_lockouts_cache_key
from defender.data import get_approx_account_lockouts_from_login_attempts
from . import utils
from . import config
@ -23,7 +26,7 @@ from .signals import (
username_block as username_block_signal,
username_unblock as username_unblock_signal,
)
from .connection import get_redis_connection
from .connection import parse_redis_url, get_redis_connection
from .decorators import watch_login
from .models import AccessAttempt
from .test import DefenderTestCase, DefenderTransactionTestCase
@ -475,6 +478,74 @@ class AccessAttemptTest(DefenderTestCase):
self.assertEqual(utils.is_valid_ip("::ffff:192.0.2.128"), True)
self.assertEqual(utils.is_valid_ip("::ffff:8.8.8.8"), True)
def test_parse_redis_url(self):
""" test the parse_redis_url method """
# full regular
conf = parse_redis_url("redis://user:password@localhost2:1234/2", False)
self.assertEqual(conf.get("HOST"), "localhost2")
self.assertEqual(conf.get("DB"), 2)
self.assertEqual(conf.get("PASSWORD"), "password")
self.assertEqual(conf.get("PORT"), 1234)
self.assertEqual(conf.get("USERNAME"), "user")
# full non local
conf = parse_redis_url(
"redis://user:pass@www.localhost.com:1234/2", False)
self.assertEqual(conf.get("HOST"), "www.localhost.com")
self.assertEqual(conf.get("DB"), 2)
self.assertEqual(conf.get("PASSWORD"), "pass")
self.assertEqual(conf.get("PORT"), 1234)
self.assertEqual(conf.get("USERNAME"), "user")
# no user name
conf = parse_redis_url("redis://password@localhost2:1234/2", False)
self.assertEqual(conf.get("HOST"), "localhost2")
self.assertEqual(conf.get("DB"), 2)
self.assertEqual(conf.get("PASSWORD"), None)
self.assertEqual(conf.get("PORT"), 1234)
# no user name 2 with colon
conf = parse_redis_url("redis://:password@localhost2:1234/2", False)
self.assertEqual(conf.get("HOST"), "localhost2")
self.assertEqual(conf.get("DB"), 2)
self.assertEqual(conf.get("PASSWORD"), "password")
self.assertEqual(conf.get("PORT"), 1234)
# Empty
conf = parse_redis_url(None, False)
self.assertEqual(conf.get("HOST"), "localhost")
self.assertEqual(conf.get("DB"), 0)
self.assertEqual(conf.get("PASSWORD"), None)
self.assertEqual(conf.get("PORT"), 6379)
# no db
conf = parse_redis_url("redis://:password@localhost2:1234", False)
self.assertEqual(conf.get("HOST"), "localhost2")
self.assertEqual(conf.get("DB"), 0)
self.assertEqual(conf.get("PASSWORD"), "password")
self.assertEqual(conf.get("PORT"), 1234)
# no password
conf = parse_redis_url("redis://localhost2:1234/0", False)
self.assertEqual(conf.get("HOST"), "localhost2")
self.assertEqual(conf.get("DB"), 0)
self.assertEqual(conf.get("PASSWORD"), None)
self.assertEqual(conf.get("PORT"), 1234)
# password with special character and set the password_quote = True
conf = parse_redis_url("redis://:calmkart%23%40%21@localhost:6379/0", True)
self.assertEqual(conf.get("HOST"), "localhost")
self.assertEqual(conf.get("DB"), 0)
self.assertEqual(conf.get("PASSWORD"), "calmkart#@!")
self.assertEqual(conf.get("PORT"), 6379)
# password without special character and set the password_quote = True
conf = parse_redis_url("redis://:password@localhost2:1234", True)
self.assertEqual(conf.get("HOST"), "localhost2")
self.assertEqual(conf.get("DB"), 0)
self.assertEqual(conf.get("PASSWORD"), "password")
self.assertEqual(conf.get("PORT"), 1234)
@patch("defender.config.DEFENDER_REDIS_NAME", "default")
def test_get_redis_connection_django_conf(self):
""" get the redis connection """
@ -865,41 +936,6 @@ class AccessAttemptTest(DefenderTestCase):
data_out = utils.get_blocked_ips()
self.assertEqual(data_out, [])
@patch("defender.config.USERNAME_FAILURE_LIMIT", 3)
@patch("defender.config.DISABLE_IP_LOCKOUT", True)
def test_login_blocked_for_non_standard_login_views_different_username(self):
"""
Check that a view with custom username blocked correctly
"""
@watch_login(status_code=401, get_username=lambda request: request.POST.get("email"))
def fake_api_401_login_different_username(request):
""" Fake the api login with 401 """
return HttpResponse("Invalid", status=401)
wrong_email = "email@localhost"
request_factory = RequestFactory()
request = request_factory.post("api/login", data={"email": wrong_email})
request.user = AnonymousUser()
request.session = SessionStore()
for _ in range(3):
fake_api_401_login_different_username(request)
data_out = utils.get_blocked_usernames()
self.assertEqual(data_out, [])
fake_api_401_login_different_username(request)
data_out = utils.get_blocked_usernames()
self.assertEqual(data_out, [wrong_email])
# Ensure that `watch_login` correctly extract username from request
# during `is_already_locked` check and don't cause 500 errors
status_code = fake_api_401_login_different_username(request)
self.assertNotEqual(status_code, 500)
@patch("defender.config.ATTEMPT_COOLOFF_TIME", "a")
def test_bad_attempt_cooloff_configuration(self):
self.assertRaises(Exception)
@ -964,19 +1000,6 @@ class AccessAttemptTest(DefenderTestCase):
def test_approx_account_lockout_count_default_case_invalid_args_pt2(self):
with self.assertRaises(Exception):
get_approx_account_lockouts_from_login_attempts(username=VALID_USERNAME)
def test_approx_account_lockout_uses_redis_cache(self):
get_approx_account_lockouts_from_login_attempts(
ip_address="127.0.0.1", username=VALID_USERNAME
)
redis_client = get_redis_connection()
cached_value = redis_client.get(
get_approx_lockouts_cache_key(
ip_address="127.0.0.1", username=VALID_USERNAME
)
)
self.assertIsNotNone(cached_value)
class SignalTest(DefenderTestCase):
@ -1162,16 +1185,6 @@ class TestUtils(DefenderTestCase):
"defender:blocked:username:johndoe", "blocked:username:"),
"defender:blocked:username:johndoe")
def test_whitespace_block_circumvention(self):
username = "johndoe"
req = HttpRequest()
req.POST["username"] = f"{username} " # username with appended whitespace
req.META["HTTP_X_REAL_IP"] = "1.2.3.4"
utils.block_username(username)
self.assertTrue(utils.is_already_locked(req))
class TestRedisConnection(TestCase):
""" Test the redis connection parsing """

View file

@ -12,11 +12,7 @@ from django.utils.module_loading import import_string
from .connection import get_redis_connection
from . import config
from .data import (
get_approx_account_lockouts_from_login_attempts,
get_approx_lockouts_cache_key,
store_login_attempt,
)
from .data import get_approx_account_lockouts_from_login_attempts, store_login_attempt
from .signals import (
send_username_block_signal,
send_ip_block_signal,
@ -67,7 +63,7 @@ def strip_port_number(ip_address_string):
"""
If it's not a valid IP address, we prefer to return
the string as-is instead of returning a potentially
the string as-is instead of returning a potentially
corrupted string:
"""
if is_valid_ip(ip_address):
@ -199,7 +195,7 @@ def increment_key(key):
def username_from_request(request):
""" unloads username from default POST request """
if config.USERNAME_FORM_FIELD in request.POST:
return request.POST[config.USERNAME_FORM_FIELD][:255].strip()
return request.POST[config.USERNAME_FORM_FIELD][:255]
return None
@ -335,10 +331,6 @@ def unblock_ip(ip_address, pipe=None):
pipe.execute()
send_ip_unblock_signal(ip_address)
redis_cache_key = get_approx_lockouts_cache_key(ip_address, None)
redis_client = get_redis_connection()
redis_client.delete(redis_cache_key)
def unblock_username(username, pipe=None):
""" unblock the given Username """
@ -353,10 +345,6 @@ def unblock_username(username, pipe=None):
pipe.execute()
send_username_unblock_signal(username)
redis_cache_key = get_approx_lockouts_cache_key(None, username)
redis_client = get_redis_connection()
redis_client.delete(redis_cache_key)
def reset_failed_attempts(ip_address=None, username=None):
""" reset the failed attempts for these ip's and usernames
@ -369,16 +357,13 @@ def reset_failed_attempts(ip_address=None, username=None):
unblock_ip(ip_address, pipe=pipe)
unblock_username(username, pipe=pipe)
redis_cache_key = get_approx_lockouts_cache_key(ip_address, username)
redis_client = get_redis_connection()
redis_client.delete(redis_cache_key)
pipe.execute()
def lockout_response(request, username):
def lockout_response(request):
""" if we are locked out, here is the response """
ip_address = get_ip(request)
username = get_username_from_request(request)
if config.LOCKOUT_TEMPLATE:
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username)
context = {

View file

@ -1,6 +1,5 @@
from django.shortcuts import render
from django.http import HttpResponseRedirect
from django.contrib import admin
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import reverse
@ -14,12 +13,10 @@ def block_view(request):
blocked_ip_list = get_blocked_ips()
blocked_username_list = get_blocked_usernames()
context = admin.site.index(request).context_data
context.update({
context = {
"blocked_ip_list": blocked_ip_list,
"blocked_username_list": blocked_username_list,
"title": "Blocked logins",
})
}
return render(request, "defender/admin/blocks.html", context)

View file

@ -13,24 +13,16 @@
# import os
# import sys
# sys.path.insert(0, os.path.abspath("."))
try:
from importlib import metadata
except ImportError:
# Running on pre-3.8 Python; use importlib-metadata package
import importlib_metadata as metadata
from pkg_resources import get_distribution
# -- Project information -----------------------------------------------------
project = "django-defender"
copyright = "2024, Ken Cochrane"
copyright = "2014, Ken Cochrane"
author = "Ken Cochrane"
# The full version, including alpha/beta/rc tags.
try:
release = metadata.version("django-defender")
except metadata.PackageNotFoundError:
# package is not installed
release = "0.0.0"
release = get_distribution("django-defender").version
# The short X.Y version.
version = ".".join(release.split(".")[:2])
@ -46,7 +38,7 @@ master_doc = "index"
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = []
templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@ -64,4 +56,4 @@ html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = []
html_static_path = ["_static"]

View file

@ -1,8 +1,6 @@
-e .
coverage
mockredispy
django-redis>=5,<6
redis>=5,<6
django-redis-cache
importlib-metadata<5.0
celery
sphinx_rtd_theme==2.0.0

View file

@ -34,21 +34,16 @@ setup(
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",
"Framework :: Django :: 4.1",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: PyPy",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
@ -64,12 +59,12 @@ setup(
include_package_data=True,
packages=find_packages(),
package_data=get_package_data("defender"),
python_requires="~=3.8",
install_requires=["Django", "redis>=4.0.0"],
python_requires='~=3.7',
install_requires=["Django", "redis"],
tests_require=[
"mockredispy>=2.9.0.11,<3.0",
"coverage",
"celery",
"django-redis",
"django-redis-cache",
],
)

28
tox.ini
View file

@ -1,29 +1,24 @@
[tox]
envlist =
# list of supported Django/Python versions:
py{38,39,py3}-dj{32}
py{38,39,310,311,312}-dj{42}
py{310,311,312}-dj{50,51,52,main}
py312-{lint,docs}
py{37,38,39,py3}-dj{32}
py{38,39,310}-dj{40,41,main}
py38-{lint,docs}
[gh-actions]
python =
3.7: py37
3.8: py38
3.9: py39
3.10: py310
3.11: py311
3.12: py312
pypy3: pypy3
[testenv]
deps =
setuptools
-rrequirements.txt
dj32: django>=3.2,<4.0
dj42: django>=4.2,<5.0
dj50: django>=5.0,<5.1
dj51: django>=5.1,<5.2
dj52: django>=5.2,<5.3
dj40: django>=4.0,<4.1
dj41: django>=4.1,<4.2
djmain: https://github.com/django/django/archive/main.tar.gz
usedevelop = True
commands =
@ -35,22 +30,19 @@ ignore_outcome =
ignore_errors =
djmain: True
[testenv:py312-docs]
basepython = python3.12
[testenv:py38-docs]
basepython = python3.8
deps =
-rrequirements.txt
Sphinx
sphinx_rtd_theme
setuptools
commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html
[testenv:py312-lint]
basepython = python3.12
[testenv:py38-lint]
basepython = python3.8
deps =
twine
check-manifest
setuptools
setuptools_scm
commands =
check-manifest -v
python setup.py sdist