pax_global_header00006660000000000000000000000064144575577420014536gustar00rootroot0000000000000052 comment=a2bfe2b5aceaa306e5358b7dea691f17259b0d67 django-ratelimit-4.1.0/000077500000000000000000000000001445755774200147725ustar00rootroot00000000000000django-ratelimit-4.1.0/.github/000077500000000000000000000000001445755774200163325ustar00rootroot00000000000000django-ratelimit-4.1.0/.github/actions/000077500000000000000000000000001445755774200177725ustar00rootroot00000000000000django-ratelimit-4.1.0/.github/actions/test/000077500000000000000000000000001445755774200207515ustar00rootroot00000000000000django-ratelimit-4.1.0/.github/actions/test/action.yml000066400000000000000000000014121445755774200227470ustar00rootroot00000000000000name: test description: 'runs a test matrix' inputs: python-version: required: true django-version: required: true runs: using: "composite" steps: - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ inputs.python-version }} - name: Install dependencies shell: sh run: | python -m pip install --upgrade pip if [[ ${{ inputs.django-version }} != 'main' ]]; then pip install --pre -q "Django>=${{ inputs.django-version }},<${{ inputs.django-version }}.99"; fi if [[ ${{ inputs.django-version }} == 'main' ]]; then pip install https://github.com/django/django/archive/main.tar.gz; fi pip install flake8 django-redis pymemcache - name: Test shell: sh run: | ./run.sh test django-ratelimit-4.1.0/.github/workflows/000077500000000000000000000000001445755774200203675ustar00rootroot00000000000000django-ratelimit-4.1.0/.github/workflows/ci.yml000066400000000000000000000023421445755774200215060ustar00rootroot00000000000000name: ci on: push: branches: - main pull_request: types: [opened, synchronize, reopened, ready_for_review] schedule: - cron: '21 7 * * 0' # run weekly on sundays jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] django: ['3.2', '4.0', '4.1', '4.2', 'main'] exclude: - python-version: '3.7' django: '4.0' - python-version: '3.7' django: '4.1' - python-version: '3.7' django: '4.2' - python-version: '3.7' django: 'main' - python-version: '3.11' django: '3.2' - python-version: '3.11' django: '4.0' steps: - uses: actions/checkout@v3 - uses: ./.github/actions/test with: python-version: ${{ matrix.python-version }} django-version: ${{ matrix.django }} lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v2 with: python-version: '3.11' - name: install flake8 run: pip install flake8 - name: Lint with flake8 run: | ./run.sh flake8 django-ratelimit-4.1.0/.github/workflows/codeql.yml000066400000000000000000000053321445755774200223640ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ "main" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main" ] schedule: - cron: '17 15 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 with: category: "/language:${{matrix.language}}" django-ratelimit-4.1.0/.github/workflows/release.yml000066400000000000000000000021401445755774200225270ustar00rootroot00000000000000name: release on: push: tags: - v* jobs: test: runs-on: ubuntu-latest strategy: fail-fast: true matrix: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] django: ['3.2', '4.0', '4.1'] exclude: - python-version: '3.7' django: '4.0' - python-version: '3.7' django: '4.1' steps: - uses: actions/checkout@v3 - uses: ./.github/actions/test with: python-version: ${{ matrix.python-version }} django-version: ${{ matrix.django }} release: runs-on: ubuntu-latest needs: [test] steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v2 with: python-version: 3.11 - name: install dependencies run: | python -m pip install --upgrade pip pip install build twine - name: build run: ./run.sh build - name: check run: ./run.sh check - name: release uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_DEPLOY_TOKEN }} django-ratelimit-4.1.0/.gitignore000066400000000000000000000000661445755774200167640ustar00rootroot00000000000000*.pyc *.swp dist build *.egg-info docs/_build/* .tox/ django-ratelimit-4.1.0/CHANGELOG000066400000000000000000000071361445755774200162130ustar00rootroot00000000000000========== Change Log ========== UNRELEASED ========== v4.1 ==== Additions: ---------- - Add RATELIMITE_HASH_ALGORITHM setting (#282, #285) Minor changes: -------------- - Fixed links in docs (#277) - Test on Django 4.2 (#284) v4.0 ==== Breaking changes: ----------------- - Renamed the package from ratelimit to django_ratelimit (#226) - Changed the default value of the decorator's block kwarg to True (#271) - Dropped support for Django versions < 3.2 (#263) - Dropped support for Python versions < 3.7 (#263, #254, #266) Additions: ---------- - Add RATELIMIT_IP_META_KEY setting (#218) - Add RATELIMIT_EXCEPTION_CLASS setting (#247) - Add a system check for cache configuration (#268) Minor changes: -------------- - Factor up _get_ip() logic into a single place (#218) - Exception on empty REMOTE_ADDR is clearer (#220) - Moved CI process to GitHub Actions (#219, #225) - Automated release process (#273) v3.0.1 ====== Bug fixes --------- - Fix import path values for rate= argument (#206) v3.0 ==== Breaking changes: ----------------- - Drop Python 2 support (#167) - Drop Django < 2.1 support (#167, #198) - @ratelimit no longer directly supports class methods, use @method_decorator - Drop RatelimitMixin in favor of @method_decorator - Moved is_ratelimited to ratelimit.core from ratelimit.utils - Moved ratelimit.utils.get_usage_count to ratelimit.core.get_usage Additions: ---------- - Made ratelimit.core.get_usage a documented, public method. - Add IP address masking (#178) - Add "Recipes" section to documentation Minor changes: -------------- - Update RatelimitMiddleware to modern style (#168) - Refactor is_ratelimited and get_usage so is_ratelimited is a thinner wrapper v2.0.0 ====== - A number of docs fixes - Fail open when cache is unavailable - Drop support for Django 1.8, 1.9, and 1.10 - Fix Django 2.0 compatibility and update documentation - Test Django 2.1 support v1.1.0 ====== - Test against Django 1.11 and 2.0b - Fix #85, explicitly set cache expiration slightly longer than cache window. - Add Django version classifiers. v1.0.1 ====== - Added Django 1.10 support. v1.0.0 ====== - Allow requests through when cache backend is unavailable. - Add support for Django 1.9, drop support for Django <=1.7. - Fix several small documentation issues. - Fix support for missing headers. v0.6 ==== - Fix CBV inheritance. - Better Django 1.8 support, fixing deprecation warnings and testing. - Clean up some out-of-date docs. - Fix counting behavior around increment and new cache keys. - Correctly pass `group` to callable `key`s. v0.5 ==== - Rates are now counted in fixed—instead of sliding—windows, except for per-second limits. See the Upgrade Notes. - Mixin renamed to `RatelimitMixin` (lowercase `l`) for consistency. - Dramatic rewrite. - `ip`, `field`, and `keys` arguments replaced with `key`. - well-known "key" values support. - Custom callable rate functions. - Support for "not limited" rate. - Replaces ``skip_if`` argument. v0.4 ==== - (Sort of) make @ratelimit decorators stack. - Add RateLimitMixin for CBVs. - Fixes for Python <2.7. - Clean up Travis and tox tests. v0.3 ==== - Drop the 'Backend' concept. - Add settings: RATELIMIT_USE_CACHE and RATELIMIT_CACHE_PREFIX. - Allow custom key functions. - Tests with Django 1.4.x and 1.5.x. - Refactor to simplify tests and development requirements. v0.2 ==== - Added real docs. - Fix unicode field values. - Add real tests. - Use the Ratelimited exception, RatelimitMiddleware, and RATELIMIT_VIEW setting. - Add RATELIMIT_ENABLE setting. - Add the skip_if argument. - Always add request.limited. v0.1 ==== - Initial release. django-ratelimit-4.1.0/CONTRIBUTING.rst000066400000000000000000000011361445755774200174340ustar00rootroot00000000000000============ Contributing ============ For set up, tests, and code standards, see `the documentation`_. Client IP Address ================= Because this comes up frequently: I will not accept a pull request or issue attempting to handle client IP address when Django is behind a proxy. *Ratelimit is the wrong place for this.* There are more details in the `security chapter`_ of the documentation. .. _the documentation: https://django-ratelimit.readthedocs.org/en/latest/contributing.html .. _security chapter: https://django-ratelimit.readthedocs.org/en/latest/security.html#client-ip-address django-ratelimit-4.1.0/LICENSE000066400000000000000000000010551445755774200160000ustar00rootroot00000000000000Copyright (c) 2022, James Socol Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. django-ratelimit-4.1.0/MANIFEST.in000066400000000000000000000001111445755774200165210ustar00rootroot00000000000000include CHANGELOG include LICENSE include MANIFEST.in include README.rst django-ratelimit-4.1.0/README.rst000066400000000000000000000011341445755774200164600ustar00rootroot00000000000000================ Django Ratelimit ================ Django Ratelimit provides a decorator to rate-limit views. Limiting can be based on IP address or a field in the request--either a GET or POST variable. .. image:: https://github.com/jsocol/django-ratelimit/workflows/test/badge.svg?branch=main :target: https://github.com/jsocol/django-ratelimit/actions :Code: https://github.com/jsocol/django-ratelimit :License: Apache Software License 2.0; see LICENSE file :Issues: https://github.com/jsocol/django-ratelimit/issues :Documentation: http://django-ratelimit.readthedocs.io/ django-ratelimit-4.1.0/django_ratelimit/000077500000000000000000000000001445755774200203065ustar00rootroot00000000000000django-ratelimit-4.1.0/django_ratelimit/__init__.py000066400000000000000000000002411445755774200224140ustar00rootroot00000000000000VERSION = (4, 1, 0) __version__ = '.'.join(map(str, VERSION)) ALL = (None,) # Sentinel value for all HTTP methods. UNSAFE = ['DELETE', 'PATCH', 'POST', 'PUT'] django-ratelimit-4.1.0/django_ratelimit/apps.py000066400000000000000000000003261445755774200216240ustar00rootroot00000000000000from django.apps import AppConfig class DjangoRatelimitConfig(AppConfig): name = 'django_ratelimit' label = 'ratelimit' default = True def ready(self): from . import checks # noqa: F401 django-ratelimit-4.1.0/django_ratelimit/checks.py000066400000000000000000000043171445755774200221250ustar00rootroot00000000000000from django.conf import settings from django.core import checks SUPPORTED_CACHE_BACKENDS = [ 'django.core.cache.backends.memcached.PyMemcacheCache', 'django.core.cache.backends.memcached.PyLibMCCache', 'django_redis.cache.RedisCache', ] CACHE_FAKE = 'is not a real cache' CACHE_NOT_SHARED = 'is not a shared cache' CACHE_NOT_ATOMIC = 'does not support atomic increment' KNOWN_BROKEN_CACHE_BACKENDS = { 'django.core.cache.backends.dummy.DummyCache': CACHE_FAKE, 'django.core.cache.backends.locmem.LocMemCache': CACHE_NOT_SHARED, 'django.core.cache.backends.filebased.FileBasedCache': CACHE_NOT_ATOMIC, 'django.core.cache.backends.db.DatabaseCache': CACHE_NOT_ATOMIC, } @checks.register(checks.Tags.caches, 'django_ratelimit') def check_caches(app_configs, **kwargs): errors = [] cache_name = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') caches = getattr(settings, 'CACHES', None) if caches is None: errors.append( checks.Error( 'CACHES is not defined, django_ratelimit will not work', hint='Configure a default cache using memcached or redis.', id='django_ratelimit.E001', ) ) return errors if cache_name not in caches: errors.append( checks.Error( f'RATELIMIT_USE_CACHE value "{cache_name}"" does not ' f'appear in CACHES dictionary', hint='RATELIMIT_USE_CACHE must be set to a valid cache', id='django_ratelimit.E002', ) ) return errors cache_config = caches[cache_name] backend = cache_config['BACKEND'] reason = KNOWN_BROKEN_CACHE_BACKENDS.get(backend, None) if reason is not None: errors.append( checks.Error( f'cache backend {backend} {reason}', hint='Use a supported cache backend', id='django_ratelimit.E003', ) ) if backend not in SUPPORTED_CACHE_BACKENDS: errors.append( checks.Warning( f'cache backend {backend} is not officially supported', id='django_ratelimit.W001', ) ) return errors django-ratelimit-4.1.0/django_ratelimit/core.py000066400000000000000000000174711445755774200216220ustar00rootroot00000000000000import ipaddress import functools import hashlib import re import socket import time import zlib from django.conf import settings from django.core.cache import caches from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from django_ratelimit import ALL, UNSAFE __all__ = ['is_ratelimited', 'get_usage'] _PERIODS = { 's': 1, 'm': 60, 'h': 60 * 60, 'd': 24 * 60 * 60, } # Extend the expiration time by a few seconds to avoid misses. EXPIRATION_FUDGE = 5 def _get_ip(request): ip_meta = getattr(settings, 'RATELIMIT_IP_META_KEY', None) if not ip_meta: ip = request.META['REMOTE_ADDR'] if not ip: raise ImproperlyConfigured( 'IP address in REMOTE_ADDR is empty. This can happen when ' 'using a reverse proxy and connecting to the app server with ' 'Unix sockets. See the documentation for ' 'RATELIMIT_IP_META_KEY: https://bit.ly/3iIpy2x') elif callable(ip_meta): ip = ip_meta(request) elif isinstance(ip_meta, str) and '.' in ip_meta: ip_meta_fn = import_string(ip_meta) ip = ip_meta_fn(request) elif ip_meta in request.META: ip = request.META[ip_meta] else: raise ImproperlyConfigured( 'Could not get IP address from "%s"' % ip_meta) if ':' in ip: # IPv6 mask = getattr(settings, 'RATELIMIT_IPV6_MASK', 64) else: # IPv4 mask = getattr(settings, 'RATELIMIT_IPV4_MASK', 32) network = ipaddress.ip_network(f'{ip}/{mask}', strict=False) return str(network.network_address) def user_or_ip(request): if request.user.is_authenticated: return str(request.user.pk) return _get_ip(request) _SIMPLE_KEYS = { 'ip': lambda r: _get_ip(r), 'user': lambda r: str(r.user.pk), 'user_or_ip': user_or_ip, } def get_header(request, header): key = 'HTTP_' + header.replace('-', '_').upper() return request.META.get(key, '') _ACCESSOR_KEYS = { 'get': lambda r, k: r.GET.get(k, ''), 'post': lambda r, k: r.POST.get(k, ''), 'header': get_header, } def _method_match(request, method=ALL): if method == ALL: return True if not isinstance(method, (list, tuple)): method = [method] return request.method in [m.upper() for m in method] rate_re = re.compile(r'([\d]+)/([\d]*)([smhd])?') def _split_rate(rate): if isinstance(rate, tuple): return rate count, multi, period = rate_re.match(rate).groups() count = int(count) if not period: period = 's' seconds = _PERIODS[period.lower()] if multi: seconds = seconds * int(multi) return count, seconds def _get_window(value, period): """ Given a value, and time period return when the end of the current time period for rate evaluation is. """ ts = int(time.time()) if period == 1: return ts if not isinstance(value, bytes): value = value.encode('utf-8') # This logic determines either the last or current end of a time period. # Subtracting (ts % period) gives us the a consistent edge from the epoch. # We use (zlib.crc32(value) % period) to add a consistent jitter so that # all time periods don't end at the same time. w = ts - (ts % period) + (zlib.crc32(value) % period) if w < ts: return w + period return w def _make_cache_key(group, window, rate, value, methods): count, period = _split_rate(rate) safe_rate = '%d/%ds' % (count, period) parts = [group, safe_rate, value, str(window)] if methods is not None: if methods == ALL: methods = '' elif isinstance(methods, (list, tuple)): methods = ''.join(sorted([m.upper() for m in methods])) parts.append(methods) prefix = getattr(settings, 'RATELIMIT_CACHE_PREFIX', 'rl:') attr = getattr(settings, 'RATELIMIT_HASH_ALGORITHM', hashlib.sha256) algo_cls = (import_string(f'{attr}') if isinstance(attr, str) else attr ) return prefix + algo_cls(''.join(parts).encode('utf-8')).hexdigest() def is_ratelimited(request, group=None, fn=None, key=None, rate=None, method=ALL, increment=False): usage = get_usage(request, group, fn, key, rate, method, increment) if usage is None: return False return usage['should_limit'] def get_usage(request, group=None, fn=None, key=None, rate=None, method=ALL, increment=False): if group is None and fn is None: raise ImproperlyConfigured('get_usage must be called with either ' '`group` or `fn` arguments') if not getattr(settings, 'RATELIMIT_ENABLE', True): return None if not _method_match(request, method): return None if group is None: parts = [] if isinstance(fn, functools.partial): fn = fn.func # Django <2.1 doesn't use a partial. This is ugly and inelegant, but # throwing __qualname__ into the list below helps. if fn.__name__ == 'bound_func': fn = fn.__closure__[0].cell_contents if hasattr(fn, '__module__'): parts.append(fn.__module__) if hasattr(fn, '__self__'): parts.append(fn.__self__.__class__.__name__) parts.append(fn.__qualname__) group = '.'.join(parts) if callable(rate): rate = rate(group, request) elif isinstance(rate, str) and '.' in rate: ratefn = import_string(rate) rate = ratefn(group, request) if rate is None: return None limit, period = _split_rate(rate) if period <= 0: raise ImproperlyConfigured('Ratelimit period must be greater than 0') if not key: raise ImproperlyConfigured('Ratelimit key must be specified') if callable(key): value = key(group, request) elif key in _SIMPLE_KEYS: value = _SIMPLE_KEYS[key](request) elif ':' in key: accessor, k = key.split(':', 1) if accessor not in _ACCESSOR_KEYS: raise ImproperlyConfigured('Unknown ratelimit key: %s' % key) value = _ACCESSOR_KEYS[accessor](request, k) elif '.' in key: keyfn = import_string(key) value = keyfn(group, request) else: raise ImproperlyConfigured( 'Could not understand ratelimit key: %s' % key) window = _get_window(value, period) initial_value = 1 if increment else 0 cache_name = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') cache = caches[cache_name] cache_key = _make_cache_key(group, window, rate, value, method) count = None try: added = cache.add(cache_key, initial_value, period + EXPIRATION_FUDGE) except socket.gaierror: # for redis added = False if added: count = initial_value else: if increment: try: # python3-memcached will throw a ValueError if the server is # unavailable or (somehow) the key doesn't exist. redis, on the # other hand, simply returns None. count = cache.incr(cache_key) except ValueError: pass else: count = cache.get(cache_key, initial_value) # Getting or setting the count from the cache failed if count is None or count is False: if getattr(settings, 'RATELIMIT_FAIL_OPEN', False): return None return { 'count': 0, 'limit': 0, 'should_limit': True, 'time_left': -1, } time_left = window - int(time.time()) return { 'count': count, 'limit': limit, 'should_limit': count > limit, 'time_left': time_left, } is_ratelimited.ALL = ALL is_ratelimited.UNSAFE = UNSAFE get_usage.ALL = ALL get_usage.UNSAFE = UNSAFE django-ratelimit-4.1.0/django_ratelimit/decorators.py000066400000000000000000000021521445755774200230250ustar00rootroot00000000000000from functools import wraps from django.conf import settings from django.utils.module_loading import import_string from django_ratelimit import ALL, UNSAFE from django_ratelimit.exceptions import Ratelimited from django_ratelimit.core import is_ratelimited __all__ = ['ratelimit'] def ratelimit(group=None, key=None, rate=None, method=ALL, block=True): def decorator(fn): @wraps(fn) def _wrapped(request, *args, **kw): old_limited = getattr(request, 'limited', False) ratelimited = is_ratelimited(request=request, group=group, fn=fn, key=key, rate=rate, method=method, increment=True) request.limited = ratelimited or old_limited if ratelimited and block: cls = getattr( settings, 'RATELIMIT_EXCEPTION_CLASS', Ratelimited) raise (import_string(cls) if isinstance(cls, str) else cls)() return fn(request, *args, **kw) return _wrapped return decorator ratelimit.ALL = ALL ratelimit.UNSAFE = UNSAFE django-ratelimit-4.1.0/django_ratelimit/exceptions.py000066400000000000000000000001441445755774200230400ustar00rootroot00000000000000from django.core.exceptions import PermissionDenied class Ratelimited(PermissionDenied): pass django-ratelimit-4.1.0/django_ratelimit/middleware.py000066400000000000000000000010431445755774200227730ustar00rootroot00000000000000from django.conf import settings from django.utils.module_loading import import_string from django_ratelimit.exceptions import Ratelimited class RatelimitMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): return self.get_response(request) def process_exception(self, request, exception): if not isinstance(exception, Ratelimited): return None view = import_string(settings.RATELIMIT_VIEW) return view(request, exception) django-ratelimit-4.1.0/django_ratelimit/models.py000066400000000000000000000000501445755774200221360ustar00rootroot00000000000000# This module intentionally left blank. django-ratelimit-4.1.0/django_ratelimit/tests.py000066400000000000000000000522711445755774200220310ustar00rootroot00000000000000from functools import partial from django.core.cache import cache, InvalidCacheBackendError from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.test.utils import override_settings from django.utils.decorators import method_decorator from django.views.generic import View from django_ratelimit.decorators import ratelimit from django_ratelimit.exceptions import Ratelimited from django_ratelimit.core import (get_usage, is_ratelimited, _split_rate, _get_ip) rf = RequestFactory() class MockUser: def __init__(self, authenticated=False): self.pk = 1 self.is_authenticated = authenticated class RateParsingTests(TestCase): def test_simple(self): tests = ( ('100/s', (100, 1)), ('100/10s', (100, 10)), ('100/10', (100, 10)), ('100/m', (100, 60)), ('400/10m', (400, 600)), ('1000/h', (1000, 3600)), ('800/d', (800, 24 * 60 * 60)), ) for i, o in tests: assert o == _split_rate(i) def callable_rate(group, request): if request.user.is_authenticated: return None return (0, 1) def mykey(group, request): return request.META['REMOTE_ADDR'][::-1] class CustomRatelimitedException(Exception): pass class RatelimitTests(TestCase): def setUp(self): cache.clear() def test_no_key(self): @ratelimit(rate='1/m') def view(request): return True req = rf.get('/') with self.assertRaises(ImproperlyConfigured): view(req) def test_ip(self): @ratelimit(key='ip', rate='1/m', block=False) def view(request): return request.limited assert not view(rf.get('/')), 'First request works.' assert view(rf.get('/')), 'Second request is limited' def test_block(self): @ratelimit(key='ip', rate='1/m') def blocked(request): return request.limited assert not blocked(rf.get('/')), 'First request works.' with self.assertRaises(Ratelimited): blocked(rf.get('/')), 'Second request is blocked.' def test_ratelimit_custom_string_exception_class(self): @ratelimit(key='ip', rate='1/m') def view(request): return request.limited with self.settings( RATELIMIT_EXCEPTION_CLASS=( "django_ratelimit.tests.CustomRatelimitedException" ) ): req = rf.get("") assert not view(req) with self.assertRaises(CustomRatelimitedException): view(req) def test_ratelimit_custom_exception_class(self): @ratelimit(key='ip', rate='1/m') def view(request): return request.limited with self.settings( RATELIMIT_EXCEPTION_CLASS=CustomRatelimitedException ): req = rf.get("") assert not view(req) with self.assertRaises(CustomRatelimitedException): view(req) def test_method(self): @ratelimit(key='ip', method='POST', rate='1/m', group='a', block=False) def limit_post(request): return request.limited assert not limit_post(rf.post('/')), 'Do not limit first POST.' assert limit_post(rf.post('/')), 'Limit second POST.' assert not limit_post(rf.get('/')), 'Do not limit GET.' def test_unsafe_methods(self): @ratelimit(key='ip', method=ratelimit.UNSAFE, rate='0/m', block=False) def limit_unsafe(request): return request.limited assert not limit_unsafe(rf.get('/')) assert not limit_unsafe(rf.head('/')) assert not limit_unsafe(rf.options('/')) assert limit_unsafe(rf.delete('/')) assert limit_unsafe(rf.post('/')) assert limit_unsafe(rf.put('/')) assert limit_unsafe(rf.patch('/')) def test_key_get(self): @ratelimit(key='get:foo', rate='1/m', method='GET', block=False) def view(request): return request.limited assert not view(rf.get('/', {'foo': 'a'})) assert view(rf.get('/', {'foo': 'a'})) assert not view(rf.get('/', {'foo': 'b'})) assert view(rf.get('/', {'foo': 'b'})) def test_key_post(self): @ratelimit(key='post:foo', rate='1/m', block=False) def view(request): return request.limited assert not view(rf.post('/', {'foo': 'a'})) assert view(rf.post('/', {'foo': 'a'})) assert not view(rf.post('/', {'foo': 'b'})) assert view(rf.post('/', {'foo': 'b'})) def test_key_header(self): def _req(): req = rf.post('/') req.META['HTTP_X_REAL_IP'] = '1.2.3.4' return req @ratelimit(key='header:x-real-ip', rate='1/m', block=False) @ratelimit(key='header:x-missing-header', rate='1/m', block=False) def view(request): return request.limited assert not view(_req()) assert view(_req()) def test_rate(self): @ratelimit(key='ip', rate='2/m', block=False) def twice(request): return request.limited assert not twice(rf.post('/')), 'First request is not limited.' assert not twice(rf.post('/')), 'Second request is not limited.' assert twice(rf.post('/')), 'Third request is limited.' def test_zero_rate(self): @ratelimit(key='ip', rate='0/m', block=False) def never(request): return request.limited assert never(rf.post('/')) def test_none_rate(self): @ratelimit(key='ip', rate=None, block=False) def always(request): return request.limited assert not always(rf.post('/')) assert not always(rf.post('/')) assert not always(rf.post('/')) assert not always(rf.post('/')) assert not always(rf.post('/')) assert not always(rf.post('/')) assert not always(rf.post('/')) def test_callable_rate(self): def _req(auth): req = rf.post('/') req.user = MockUser(authenticated=auth) return req def get_rate(group, request): if request.user.is_authenticated: return (2, 60) return (1, 60) @ratelimit(key='user_or_ip', rate=get_rate, block=False) def view(request): return request.limited assert not view(_req(auth=False)) assert view(_req(auth=False)) assert not view(_req(auth=True)) assert not view(_req(auth=True)) assert view(_req(auth=True)) def test_callable_rate_none(self): def _req(never_limit=False): req = rf.post('/') req.never_limit = never_limit return req get_rate = lambda g, r: None if r.never_limit else '1/m' @ratelimit(key='ip', rate=get_rate, block=False) def view(request): return request.limited assert not view(_req()) assert view(_req()) assert not view(_req(never_limit=True)) assert not view(_req(never_limit=True)) def test_callable_rate_zero(self): def _req(auth): req = rf.post('/') req.user = MockUser(authenticated=auth) return req def get_rate(group, request): if request.user.is_authenticated: return '1/m' return '0/m' @ratelimit(key='ip', rate=get_rate, block=False) def view(request): return request.limited assert view(_req(auth=False)) assert not view(_req(auth=True)) assert view(_req(auth=True)) def test_callable_rate_import(self): def _req(auth): req = rf.post('/') req.user = MockUser(authenticated=auth) return req @ratelimit(key='user_or_ip', rate='django_ratelimit.tests.callable_rate', block=False) def view(request): return request.limited assert view(_req(auth=False)) assert not view(_req(auth=True)) def test_user_or_ip(self): """Allow custom functions to set cache keys.""" def _req(auth): req = rf.post('/') req.user = MockUser(authenticated=auth) return req @ratelimit(key='user_or_ip', rate='1/m', block=False) def view(request): return request.limited assert not view(_req(auth=False)) assert view(_req(auth=False)) auth = rf.post('/') auth.user = MockUser(authenticated=True) assert not view(_req(auth=True)) assert view(_req(auth=True)) def test_callable_key_path(self): @ratelimit(key='django_ratelimit.tests.mykey', rate='1/m', block=False) def view(request): return request.limited assert not view(rf.post('/')) assert view(rf.post('/')) def test_callable_key(self): @ratelimit(key=mykey, rate='1/m', block=False) def view(request): return request.limited assert not view(rf.post('/')) assert view(rf.post('/')) def test_stacked_decorator(self): """Allow @ratelimit to be stacked.""" # Put the shorter one first and make sure the second one doesn't # reset request.limited back to False. @ratelimit(rate='1/m', block=False, key=lambda x, y: 'min') @ratelimit(rate='10/d', block=False, key=lambda x, y: 'day') def view(request): return request.limited assert not view(rf.post('/')) assert view(rf.post('/')) def test_stacked_methods(self): """Different methods should result in different counts.""" @ratelimit(rate='1/m', key='ip', method='GET', block=False) @ratelimit(rate='1/m', key='ip', method='POST', block=False) def view(request): return request.limited assert not view(rf.get('/')) assert not view(rf.post('/')) assert view(rf.get('/')) assert view(rf.post('/')) def test_sorted_methods(self): """Order of the methods shouldn't matter.""" @ratelimit(rate='1/m', key='ip', method=['GET', 'POST'], group='a', block=False) def get_post(request): return request.limited @ratelimit(rate='1/m', key='ip', method=['POST', 'GET'], group='a', block=False) def post_get(request): return request.limited assert not get_post(rf.get('/')) assert post_get(rf.get('/')) def test_ratelimit_full_mask_v4(self): @ratelimit(rate='1/m', key='ip', block=False) def view(request): return request.limited with self.settings(RATELIMIT_IPV4_MASK=32): req = rf.get('/') req.META['REMOTE_ADDR'] = '10.1.1.1' assert not view(req) assert view(req) req = rf.get('/') req.META['REMOTE_ADDR'] = '10.1.1.2' assert not view(req) def test_ratelimit_full_mask_v6(self): @ratelimit(rate='1/m', key='ip', block=False) def view(request): return request.limited with self.settings(RATELIMIT_IPV6_MASK=128): req = rf.get('/') req.META['REMOTE_ADDR'] = '2001:db8::1000' assert not view(req) assert view(req) req = rf.get('/') req.META['REMOTE_ADDR'] = '2001:db8::1001' assert not view(req) def test_ratelimit_mask_v4(self): @ratelimit(rate='1/m', key='ip', block=False) def view(request): return request.limited with self.settings(RATELIMIT_IPV4_MASK=16): req = rf.get('/') req.META['REMOTE_ADDR'] = '10.1.1.1' assert not view(req) assert view(req) req = rf.get('/') req.META['REMOTE_ADDR'] = '10.1.0.1' assert view(req) req = rf.get('/') req.META['REMOTE_ADDR'] = '192.168.1.1' assert not view(req) def test_ratelimit_mask_v6(self): @ratelimit(rate='1/m', key='ip', block=False) def view(request): return request.limited with self.settings(RATELIMIT_IPV6_MASK=64): req = rf.get('/') req.META['REMOTE_ADDR'] = '2001:db8::1000' assert not view(req) assert view(req) req = rf.get('/') req.META['REMOTE_ADDR'] = '2001:db8::1001' assert view(req) req = rf.get('/') req.META['REMOTE_ADDR'] = '2001:db9::1000' assert not view(req) class FunctionsTests(TestCase): def setUp(self): cache.clear() def test_is_ratelimited(self): not_increment = partial(is_ratelimited, increment=False, rate='1/m', method=is_ratelimited.ALL, key='ip', group='a') # Does not increment. Count still 0. Does not rate limit # because 0 < 1. assert not not_increment(rf.get('/')) # Does not increment. Count still 1. Not limited because 1 > 1 # is false. assert not not_increment(rf.get('/')) def test_is_ratelimited_increment(self): do_increment = partial(is_ratelimited, increment=True, rate='1/m', method=is_ratelimited.ALL, key='ip', group='a') # Increments. Does not rate limit because 0 < 1. Count now 1. assert not do_increment(rf.get('/')) # Count = 2, 2 > 1. assert do_increment(rf.get('/')) def test_get_usage(self): _get_usage = partial(get_usage, method=get_usage.ALL, key='ip', rate='1/m', group='a') usage = _get_usage(rf.get('/')) self.assertEqual(usage['count'], 0) self.assertEqual(usage['limit'], 1) self.assertLessEqual(usage['time_left'], 60) self.assertFalse(usage['should_limit']) def test_get_usage_increment(self): _get_usage = partial(get_usage, method=get_usage.ALL, key='ip', rate='1/m', group='a', increment=True) _get_usage(rf.get('/')) usage = _get_usage(rf.get('/')) self.assertEqual(usage['count'], 2) self.assertEqual(usage['limit'], 1) self.assertLessEqual(usage['time_left'], 60) self.assertTrue(usage['should_limit']) def test_not_increment_after_increment(self): _get_usage = partial(get_usage, method=get_usage.ALL, key='ip', rate='1/m', group='a') _get_usage(rf.get('/'), increment=True) _get_usage(rf.get('/'), increment=True) usage = _get_usage(rf.get('/')) self.assertEqual(usage['count'], 2) self.assertEqual(usage['limit'], 1) self.assertLessEqual(usage['time_left'], 60) self.assertTrue(usage['should_limit']) def test_get_usage_called_without_group_or_fn(self): with self.assertRaises(ImproperlyConfigured): get_usage(rf.get('/'), key='ip') class RatelimitCBVTests(TestCase): def setUp(self): cache.clear() def test_method_decorator(self): class TestView(View): @method_decorator(ratelimit(key='ip', rate='1/m', block=False)) def post(self, request): return request.limited view = TestView.as_view() assert not view(rf.post('/')) assert view(rf.post('/')) def test_class_decorator(self): @method_decorator(ratelimit(key='ip', rate='1/m', block=False), name='get') class TestView(View): def get(self, request): return request.limited view = TestView.as_view() assert not view(rf.get('/')) assert view(rf.get('/')) def test_wrap_view(self): class TestView(View): def get(self, request): return request.limited view = TestView.as_view() wrapped = ratelimit(key='ip', rate='1/m', block=False)(view) assert not wrapped(rf.get('/')) assert wrapped(rf.get('/')) def test_methods_counted_separately(self): class TestView(View): @method_decorator(ratelimit(key='ip', rate='1/m', method='GET', block=False)) def get(self, request): return request.limited @method_decorator(ratelimit(key='ip', rate='1/m', method='POST', block=False)) def post(self, request): return request.limited view = TestView.as_view() assert not view(rf.get('/')) assert view(rf.get('/')) assert not view(rf.post('/')) def test_views_counted_separately(self): class TestView(View): @method_decorator(ratelimit(key='ip', rate='1/m', method='GET', block=False)) def get(self, request): return request.limited class AnotherTestView(View): @method_decorator(ratelimit(key='ip', rate='1/m', method='GET', block=False)) def get(self, request): return request.limited test_view = TestView.as_view() another_view = AnotherTestView.as_view() assert not test_view(rf.get('/')) assert test_view(rf.get('/')) assert not another_view(rf.get('/')) class CacheFailTests(TestCase): @override_settings(RATELIMIT_USE_CACHE='fake-cache') def test_bad_cache(self): @ratelimit(key='ip', rate='1/m', block=False) def view(request): return request.limited with self.assertRaises(InvalidCacheBackendError): view(rf.post('/')) @override_settings(RATELIMIT_USE_CACHE='connection-errors') def test_limit_on_cache_connection_error(self): @ratelimit(key='ip', rate='10/m', block=False) def view(request): return request.limited assert view(rf.post('/')) @override_settings(RATELIMIT_USE_CACHE='connection-errors', RATELIMIT_FAIL_OPEN=True) def test_fail_open_setting(self): @ratelimit(key='ip', rate='1/m', block=False) def view(request): return request.limited assert not view(rf.get('/')) assert not view(rf.get('/')) @override_settings(RATELIMIT_USE_CACHE='connection-errors') def test_is_ratelimited_cache_connection_error_without_increment(self): def not_increment(request): return is_ratelimited(request, increment=False, method=is_ratelimited.ALL, key='ip', rate='1/m', group='a') assert not not_increment(rf.get('/')) assert not not_increment(rf.get('/')) @override_settings(RATELIMIT_USE_CACHE='connection-errors') def test_is_ratelimited_cache_connection_error_with_increment(self): def do_increment(request): return is_ratelimited(request, increment=True, method=is_ratelimited.ALL, key='ip', rate='1/m', group='a') assert do_increment(rf.get('/')) assert do_increment(rf.get('/')) @override_settings(RATELIMIT_USE_CACHE='connection-errors-redis') def test_is_ratelimited_cache_connection_error_with_increment_redis(self): def do_increment(request): return is_ratelimited(request, increment=True, method=is_ratelimited.ALL, key='ip', rate='1/m', group='a') assert do_increment(rf.get('/')) assert do_increment(rf.get('/')) @override_settings(RATELIMIT_USE_CACHE='instant-expiration') def test_cache_timeout(self): @ratelimit(key='ip', rate='1/m') def view(request): return True assert view(rf.get('/')) assert view(rf.get('/')) def my_ip(req): return req.META['MY_THING'] class IpMetaTests(TestCase): def test_default(self): req = rf.get('/') req.META['REMOTE_ADDR'] = '1.2.3.4' assert '1.2.3.4' == _get_ip(req) @override_settings(RATELIMIT_IP_META_KEY='fake') def test_bad_config(self): req = rf.get('/') req.META['REMOTE_ADDR'] = '1.2.3.4' with self.assertRaises(ImproperlyConfigured): _get_ip(req) @override_settings(RATELIMIT_IP_META_KEY='HTTP_X_CLIENT_IP') def test_alternate_header(self): req = rf.get('/') req.META['REMOTE_ADDR'] = '1.2.3.4' req.META['HTTP_X_CLIENT_IP'] = '5.6.7.8' assert '5.6.7.8' == _get_ip(req) @override_settings(RATELIMIT_IP_META_KEY='django_ratelimit.tests.my_ip') def test_path_to_ip_key_callable(self): req = rf.get('/') req.META['REMOTE_ADDR'] = '1.2.3.4' req.META['MY_THING'] = '5.6.7.8' assert '5.6.7.8' == _get_ip(req) @override_settings(RATELIMIT_IP_META_KEY=my_ip) def test_callable_ip_key(self): req = rf.get('/') req.META['REMOTE_ADDR'] = '1.2.3.4' req.META['MY_THING'] = '5.6.7.8' assert '5.6.7.8' == _get_ip(req) def test_empty_ip(self): req = rf.get('/') req.META['REMOTE_ADDR'] = '' with self.assertRaises(ImproperlyConfigured): _get_ip(req) django-ratelimit-4.1.0/docs/000077500000000000000000000000001445755774200157225ustar00rootroot00000000000000django-ratelimit-4.1.0/docs/Makefile000066400000000000000000000127401445755774200173660ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoRatelimit.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoRatelimit.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoRatelimit" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoRatelimit" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." django-ratelimit-4.1.0/docs/conf.py000066400000000000000000000172231445755774200172260ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Django Ratelimit documentation build configuration file, created by # sphinx-quickstart on Fri Jan 4 15:55:31 2013. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Django Ratelimit' copyright = u'2022, James Socol' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '4.1' # The full version, including alpha/beta/rc tags. release = '4.1.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' highlight_language = 'python' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # 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 = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'DjangoRatelimitdoc' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'DjangoRatelimit.tex', u'Django Ratelimit Documentation', u'James Socol', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'djangoratelimit', u'Django Ratelimit Documentation', [u'James Socol'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'DjangoRatelimit', u'Django Ratelimit Documentation', u'James Socol', 'DjangoRatelimit', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' django-ratelimit-4.1.0/docs/contributing.rst000066400000000000000000000015021445755774200211610ustar00rootroot00000000000000.. _contributing-chapter: ============ Contributing ============ Set Up ====== Create a virtualenv_ and install Django with pip_: .. code-block:: sh $ pip install Django Running the Tests ================= Running the tests is as easy as: .. code-block:: sh $ ./run.sh test You may also run the test on multiple versions of Django using tox. - First install tox: .. code-block:: sh $ pip install tox - Then run the tests with tox: .. code-block:: sh $ tox Code Standards ============== I ask two things for pull requests. * The flake8_ tool must not report any violations. * All tests, including new tests where appropriate, must pass. .. _virtualenv: http://www.virtualenv.org/en/latest/ .. _pip: http://www.pip-installer.org/en/latest/ .. _flake8: https://pypi.python.org/pypi/flake8 django-ratelimit-4.1.0/docs/cookbook/000077500000000000000000000000001445755774200175305ustar00rootroot00000000000000django-ratelimit-4.1.0/docs/cookbook/429.rst000066400000000000000000000077251445755774200206130ustar00rootroot00000000000000.. _recipe-429: ================================= Sending ``429 Too Many Requests`` ================================= `RFC 6585`_ introduced a status code specific to rate-limiting situations: `HTTP 429 Too Many Requests`_. Here's one way to send this status with Django Ratelimit. Create a custom error view ========================== First, create a view that returns the correct type of response (e.g. content-type, shape, information, etc) for your application. For example, a JSON API may return something like ``{"error": "ratelimited"}``, while other applications may return XML, HTML, etc, as needed. Or you may need to decide based on the type of request. Set the status code of the response to 429. .. code-block:: python # myapp/views.py def ratelimited_error(request, exception): # e.g. to return HTML return render(request, 'ratelimited.html', status=429) def ratelimited_error(request, exception): # or other types: return JsonResponse({'error': 'ratelimited'}, status=429) In your app's settings, install the ``RatelimitMiddleware`` :ref:`middleware ` toward the bottom of the list. You must define ``RATELIMIT_VIEW`` as a dotted-path to your error view: .. code-block:: python MIDDLEWARE = ( # ... toward the bottom ... 'ratelimit.middleware.RatelimitMiddleware', # ... ) RATELIMIT_VIEW = 'myapp.views.ratelimited_error' That's it! If you already have :ref:`the decorator ` installed, you're good to go. Otherwise, you'll need to install it in order to trigger the error view. Check the exception type in ``handler403`` ========================================== Alternatively, if you already have a ``handler403`` view defined, you can check the exception type and return a specific status code: .. code-block:: python from django_ratelimit.exceptions import Ratelimited def my_403_handler(request, exception): if isinstance(exception, Ratelimited): return render(request, '429.html', status=429) return render(request, '403.html', status=403) Context ======= **Why doesn't Django Ratelimit handle this itself?** There are a couple of main reasons. The first is that Django has no built-in concept of a ratelimit exception, but it does have ``PermissionDenied``. When a view throws a ``PermissionDenied`` exception, Django has built-in facilities for handling it as a client error (it returns an HTTP 403) instead of a server error (i.e. a 5xx status code). The ``Ratelimited`` exception extends ``PermissionDenied`` so that, if nothing else, there should already be a way to make sure the application is sending a 4xx status code—even if it's not the most-correct status code available. ``Ratelimited`` should not be treated as a server error because the server is working correctly. (NB: That also means that the typical "error"-level logging is not invoked.) There is no way to convince the built-in handler to send any status besides 403. Furthermore, it's impossible for Django Ratelimit to provide a default view that does a better job guessing at the appropriate response type than Django's built-in ``PermissionDenied`` view already does. We could include a default ``429.html`` template with as little information as Django's built-in ``403.html``, but it would only be slightly more correct. The correct response for your users will depend on your application. This means creating the right content-type (e.g. JSON, XML, HTML, etc) and content (whether it's an API error response or a human-readable one). Django Ratelimit can't guess that, so it's up to you to define. Finally, a small historical note. Django Ratelimit actually predates RFC 6585 by about a year. At the time, 403 was as common as any status for ratelimit situations. Others were creating custom statuses, like Twitter's ``420 Enhance Your Calm``. .. _RFC 6585: https://tools.ietf.org/html/rfc6585 .. _HTTP 429 Too Many Requests: https://tools.ietf.org/html/rfc6585#section-4 django-ratelimit-4.1.0/docs/cookbook/index.rst000066400000000000000000000007111445755774200213700ustar00rootroot00000000000000.. _cookbook: ======== Cookbook ======== This section includes suggestions for common patterns that don't make sense to include in the main library because they depend on too many specific about consuming applications. These solutions may be close to copy-pastable, but in generally they are more directional and are provided under the same license as all other code in this repository. Recipes ======= .. toctree:: :maxdepth: 1 429 per-user django-ratelimit-4.1.0/docs/cookbook/per-user.rst000066400000000000000000000035701445755774200220310ustar00rootroot00000000000000.. _recipe-per-user: =================== Per-User Ratelimits =================== One common business strategy includes adjusting rate limits for different types of users, or even different individual users for enterprise sales. With :ref:`callable rates ` it is possible to implement per-user or per-group rate limits. Here is one example of how to implement per-user rates. A ``Ratelimit`` model ===================== This example leverages the database to store per-user rate limits. Keep in mind the additional load this may place on your application's database—which may very well be the resource you intend to protect. Consider caching these types of queries. .. code-block:: python # myapp/models.py class Ratelimit(models.Model): group = models.CharField(db_index=True) user = models.ForeignKey(null=True) # One option for "default" rate = models.CharField() @classmethod def get(cls, group, user=None): # use cache if possible try: return cls.objects.get(group=group, user=user) except cls.DoesNotExist: return cls.objects.get(group=group, user=None) # myapp/ratelimits.py from myapp.models import Ratelimit def per_user(group, request): if request.user.is_authenticated: return Ratelimit.get(group, request.user) return Ratelimit.get(group) # myapp/views.py @login_required @ratelimit(group='search', key='user', rate='myapp.ratelimits.per_user') def search_view(request): # ... It would be important to consider how to handle defaults, cases where the rate is not defined in the database, or the group is new, etc. It would also be important to consider the performance impact of executing such a query as part of the rate limiting process and consider how to store this data. django-ratelimit-4.1.0/docs/index.rst000066400000000000000000000036271445755774200175730ustar00rootroot00000000000000================ Django Ratelimit ================ Project ======= **Django Ratelimit** is a ratelimiting decorator for Django views, storing rate data in the configured `Django cache backend `__. .. image:: https://travis-ci.org/jsocol/django-ratelimit.png?branch=master :target: https://travis-ci.org/jsocol/django-ratelimit :Code: https://github.com/jsocol/django-ratelimit :License: Apache Software License :Issues: https://github.com/jsocol/django-ratelimit/issues :Documentation: http://django-ratelimit.readthedocs.org/ Quickstart ========== .. warning:: `django_ratelimit` requires a Django cache backend that supports `atomic increment`_ operations. The Memcached and Redis backends do, but the database backend does not. More information can be found in :ref:`Installation ` Install: .. code-block:: shell pip install django-ratelimit Use as a decorator in ``views.py``: .. code-block:: python from django_ratelimit.decorators import ratelimit @ratelimit(key='ip') def myview(request): # ... @ratelimit(key='ip', rate='100/h') def secondview(request): # ... Before activating django-ratelimit, you should ensure that your cache backend is setup to be both persistent and work across multiple deployment worker instances (for instance UWSGI workers). Read more in the Django docs on `caching `__. .. _PyPI: http://pypi.python.org/pypi/django-ratelimit .. _atomic increment: https://docs.djangoproject.com/en/4.1/topics/cache/#django.core.caches.cache.incr Contents ======== .. toctree:: :maxdepth: 2 installation settings usage keys rates security upgrading contributing cookbook/index Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` django-ratelimit-4.1.0/docs/installation.rst000066400000000000000000000067351445755774200211700ustar00rootroot00000000000000.. _installation-chapter: ============ Installation ============ .. _installation-cache: Create or use a compatible cache ================================ ``django_ratelimit`` requires a cache backend that #. Is shared across any worker threads, processes, and application servers. Cache backends that use sharding can be used to help scale this. #. Implements *atomic increment*. `Redis`_ and `Memcached`_ backends have these features and are officially supported. Backends like `local memory`_ and `filesystem`_ are not shared across processes or servers. Notably, the `database`_ backend does **not** support atomic increments. If you do not have a compatible cache backend, you'll need to set one up, which is out of scope of this document, and then add it to the ``CACHES`` dictionary in `settings`_. .. warning:: Without atomic increment operations, ``django_ratelimit`` will appear to work, but there is a race condition between reading and writing usage count data that can result in undercounting usage and permitting more traffic than intended. .. _Redis: https://docs.djangoproject.com/en/4.1/topics/cache/#redis .. _Memcached: https://docs.djangoproject.com/en/4.1/topics/cache/#memcached .. _local memory: https://docs.djangoproject.com/en/4.1/topics/cache/#local-memory-caching .. _filesystem: https://docs.djangoproject.com/en/4.1/topics/cache/#filesystem-caching .. _database: https://docs.djangoproject.com/en/4.1/topics/cache/#database-caching .. _settings: https://docs.djangoproject.com/en/4.1/ref/settings/#std-setting-CACHES .. _installation-settings: Configuration ============= ``django_ratelimit`` has reasonable defaults, and if your ``default`` cache is compatible, and your application is not behind a reverse proxy, you can skip this section. For a complete list of configuration options, see :ref:`Settings `. .. _installation-settings-cache: Cache Settings -------------- If you have added an additional ``CACHES`` entry for ratelimiting, you'll need to tell ``django_ratelimit`` to use this via the ``RATELIMIT_USE_CACHE`` setting: .. code-block:: python # your_apps_settings.py CACHES = { 'default': {}, 'cache-for-ratelimiting': {}, } RATELIMIT_USE_CACHE = 'cache-for-ratelimiting' .. _installation-settings-ip: Reverse Proxies and Client IP Address ------------------------------------- ``django_ratelimit`` reads client IP address from ``request.META['REMOTE_ADDR']``. If your application is running behind a reverse proxy such as nginx or HAProxy, you will need to take steps to ensure you have access to the correct client IP address, rather than the address of the proxy. There are security risks for libraries to *assume* how your network is set up, and so ``django_ratelimit`` does not provide any built-in tools to address this. However, the :ref:`Security chapter ` does provide suggestions on how to approach this. .. _installation-enforcing: Enforcing Ratelimits ==================== The most common way to enforce ratelimits is via the ``ratelimit`` :ref:`decorator `: .. code-block:: python from django_ratelimit.decorators import ratelimit @ratelimit(key='user_or_ip', rate='10/m') def myview(request): # limited to 10 req/minute for a given user or client IP # or on class methods class MyView(View): @method_decorator(ratelimit(key='user_or_ip', rate='1/s')) def get(self, request): # limited to 1 req/second django-ratelimit-4.1.0/docs/keys.rst000066400000000000000000000045721445755774200174370ustar00rootroot00000000000000.. _keys-chapter: ============== Ratelimit Keys ============== The ``key=`` argument to the decorator takes either a string or a callable. .. _keys-common: Common keys =========== The following string values for ``key=`` provide shortcuts to commonly used ratelimit keys: - ``'ip'`` - Use the request IP address (i.e. ``request.META['REMOTE_ADDR']``) .. note:: If you are using a reverse proxy, make sure this value is correct or use an appropriate ``header:`` value. See the :ref:`security ` notes. - ``'get:X'`` - Use the value of ``request.GET.get('X', '')``. - ``'post:X'`` - Use the value of ``request.POST.get('X', '')``. - ``'header:x-x'`` - Use the value of ``request.META.get('HTTP_X_X', '')``. .. note:: The value right of the colon will be translated to all-caps and any dashes will be replaced with underscores, e.g.: x-client-ip => X_CLIENT_IP. - ``'user'`` - Use an appropriate value from ``request.user``. Do not use with unauthenticated users. - ``'user_or_ip'`` - Use an appropriate value from ``request.user`` if the user is authenticated, otherwise use ``request.META['REMOTE_ADDR']`` (see the note above about reverse proxies). .. note:: Missing headers, GET, and POST values will all be treated as empty strings, and ratelimited in the same bucket. .. warning:: Using user-supplied data, like data from GET and POST or headers directly from the User-Agent can allow users to trivially opt out of ratelimiting. See the note in :ref:`the security chapter `. .. _keys-strings: String values ============= Other string values not from the list above will be treated as the dotted Python path to a callable. See :ref:`below ` for more on callables. .. _keys-callable: Callable values =============== .. versionadded:: 0.3 .. versionchanged:: 0.5 Added support for python path to callables. .. versionchanged:: 0.6 Callable was mistakenly only passed the ``request``, now also gets ``group`` as documented. If the value of ``key=`` is a callable, or the path to a callable, that callable will be called with two arguments, the :ref:`group ` and the ``request`` object. It should return a bytestring or unicode object, e.g.:: def my_key(group, request): return request.META['REMOTE_ADDR'] + request.user.username django-ratelimit-4.1.0/docs/rates.rst000066400000000000000000000031421445755774200175720ustar00rootroot00000000000000.. _rates-chapter: ===== Rates ===== .. _rates-simple: Simple rates ============ Simple rates are of the form ``X/u`` where ``X`` is a number of requests and ``u`` is a unit from this list: * ``s`` - second * ``m`` - minute * ``h`` - hour * ``d`` - day (For example, you can read ``5/s`` as "five per second.") .. note:: Setting a rate of 0 per any unit of time will disallow requests, e.g. ``0/s`` will prevent any requests to the endpoint. Rates may also be set to ``None``, which indicates "there is no limit." Usage will not be tracked. You may also specify a number of units, i.e.: ``X/Yu`` where ``Y`` is a number of units. If ``u`` is omitted, it is presumed to be seconds. So, the following are equivalent, and all mean "one hundred requests per five minutes": * ``100/5m`` * ``100/300s`` * ``100/300`` .. _rates-callable: Callables ========= .. versionadded:: 0.5 Rates can also be callables (or dotted paths to callables, which are assumed if there is a ``.`` in the value). Callables receive two values, the :ref:`group ` and the ``request`` object. They should return a simple rate string, or a tuple of integers ``(count, seconds)``. For example:: def my_rate(group, request): if request.user.is_authenticated: return '1000/m' return '100/m' Or equivalently:: def my_rate_tuples(group, request): if request.user.is_authenticated: return (1000, 60) return (100, 60) Callables can return ``0`` in the first place to disallow any requests (e.g.: ``0/s``, ``(0, 60)``). They can return ``None`` for "no ratelimit". django-ratelimit-4.1.0/docs/security.rst000066400000000000000000000125241445755774200203270ustar00rootroot00000000000000.. _security-chapter: ======================= Security considerations ======================= .. _security-client-ip: Client IP address ================= IP address is an extremely common rate limit :ref:`key `, so it is important to configure correctly, especially in the equally-common case where Django is behind a load balancer or other reverse proxy. Django-Ratelimit is **not** the correct place to handle reverse proxies and adjust the IP address, and patches dealing with it will not be accepted. There is `too much variation`_ in the wild to handle it safely. This is the same reason `Django dropped`_ ``SetRemoteAddrFromForwardedFor`` middleware in 1.1: no such "mechanism can be made reliable enough for general-purpose use" and it "may lead developers to assume that the value of ``REMOTE_ADDR`` is 'safe'." Risks ----- Mishandling client IP data creates an IP spoofing vector that allows attackers to circumvent IP ratelimiting entirely. Consider an attacker with the real IP address 3.3.3.3 that adds the following to a request:: X-Forwarded-For: 1.2.3.4 A misconfigured web server may pass the header value along, e.g.:: X-Forwarded-For: 3.3.3.3, 1.2.3.4 Alternatively, if the web server sends a different header, like ``X-Cluster-Client-IP`` or ``X-Real-IP``, and passes along the spoofed ``X-Forwarded-For`` header unchanged, a mistake in ratelimit or a misconfiguration in Django could read the spoofed header instead of the intended one. Remediation ----------- There are two options, configuring django-ratelimit or adding global middleware. Which makes sense depends on your setup. Middleware ^^^^^^^^^^ Writing a small middleware class to set ``REMOTE_ADDR`` to the actual client IP address is generally simple:: def reverse_proxy(get_response): def process_request(request): request.META['REMOTE_ADDR'] = # [...] return get_response(request) return process_request where ``# [...]`` depends on your environment. This middleware should be close to the top of the list:: MIDDLEWARE = ( 'path.to.reverse_proxy', # ... ) Then the ``@ratelimit`` decorator can be used with the ``ip`` key:: @ratelimit(key='ip', rate='10/s') Ratelimit keys ^^^^^^^^^^^^^^ Alternatively, if the client IP address is in a simple header (i.e. a header like ``X-Real-IP`` that *only* contains the client IP, unlike ``X-Forwarded-For`` which may contain intermediate proxies) you can use a ``header:`` key:: @ratelimit(key='header:x-real-ip', rate='10/s') .. _too much variation: http://en.wikipedia.org/wiki/Talk:X-Forwarded-For#Variations .. _Django dropped: https://docs.djangoproject.com/en/2.1/releases/1.1/#removed-setremoteaddrfromforwardedfor-middleware .. _security-brute-force: Brute force attacks =================== One of the key uses of ratelimiting is preventing brute force or dictionary attacks against login forms. These attacks generally take one of a few forms: - One IP address trying one username with many passwords. - Many IP addresses trying one username with many passwords. - One IP address trying many usernames with a few common passwords. - Many IP addresses trying many usernames with one or a few common passwords. .. note:: Unfortunately, the fourth case of many IPs trying many usernames can be difficult to distinguish from regular user behavior and requires additional signals, such as a consistent user agent or a common network prefix. Protecting against the single IP address cases is easy:: @ratelimit(key='ip') def login_view(request): pass Also limiting by username provides better protection:: @ratelimit(key='ip') @ratelimit(key='post:username') def login_view(request): pass **Using passwords as key values is not recommended.** Key values are never stored in a raw form, even as cache keys, but they are constructed with a fast hash function. Denial of Service ----------------- However, limiting based on field values may open a `denial of service`_ vector against your users, preventing them from logging in. For pages like login forms, consider implementing a soft blocking mechanism, such as requiring a captcha, rather than a hard block with a ``PermissionDenied`` error. Network Address Translation --------------------------- Depending on your profile of your users, you may have many users behind NAT (e.g. users in schools or in corporate networks). It is reasonable to set a higher limit on a per-IP limit than on a username or password limit. .. _denial of service: http://en.wikipedia.org/wiki/Denial-of-service_attack?oldformat=true .. _security-user-supplied: User-supplied Data ================== Using data from GET (``key='get:X'``) POST (``key='post:X'``) or headers (``key='header:x-x'``) that are provided directly by the browser or other client presents a risk. Unless there is some requirement of the attack that requires the client *not* change the value (for example, attempting to brute force a password requires that the username be consistent) clients can trivially change these values on every request. Headers that are provided by web servers or reverse proxies should be independently audited to ensure they cannot be affected by clients. The ``User-Agent`` header is especially dangerous, since bad actors can change it on every request, and many good actors may share the same value. django-ratelimit-4.1.0/docs/settings.rst000066400000000000000000000057451445755774200203270ustar00rootroot00000000000000.. _settings-chapter: ======== Settings ======== ``RATELIMIT_CACHE_PREFIX`` -------------------------- An optional cache prefix for ratelimit keys (in addition to the ``PREFIX`` value defined on the cache backend). Defaults to ``'rl:'``. ``RATELIMIT_HASH_ALGORITHM`` ----------------------------- An optional functionion to overide the default hashing algorithm used to derive the cache key. Defaults to ``'hashlib.sha256'``. ``RATELIMIT_ENABLE`` -------------------- Set to ``False`` to disable rate-limiting across the board. Defaults to ``True``. May be useful during tests with Django's |override_settings|_ testing tool, for example: .. code-block:: python from django.test import override_settings with override_settings(RATELIMIT_ENABLE=False): result = call_the_view() .. |override_settings| replace:: ``override_settings()`` .. _override_settings: https://docs.djangoproject.com/en/2.0/topics/testing/tools/#django.test.override_settings. ``RATELIMIT_USE_CACHE`` ----------------------- .. warning:: `django_ratelimit` requires a Django cache backend that supports _`atomic increment` operations. The Memcached and Redis backends do, but the database backend does not. The name of the cache (from the ``CACHES`` dict) to use. Defaults to ``'default'``. ``RATELIMIT_VIEW`` ------------------ The string import path to a view to use when a request is ratelimited, in conjunction with ``RatelimitMiddleware``, e.g. ``'myapp.views.ratelimited'``. Has no default - you must set this to use ``RatelimitMiddleware``. ``RATELIMIT_FAIL_OPEN`` ----------------------- Whether to allow requests when the cache backend fails. Defaults to ``False``. ``RATELIMIT_IP_META_KEY`` ------------------------- Set the source of the client IP address in the request.META object. Defaults to ``None``. There are several potential values: ``None`` Use ``request.META['REMOTE_ADDR']`` as the source of the client IP address. A callable object If set to a callable, the callable will be passed the full ``request`` object. The callable must return the client IP address. For example: ``RATELIMIT_IP_META_KEY = lambda r: r.META['HTTP_X_CLIENT_IP']`` A dotted path to a callable Any string containing a ``.`` will be treated as a dotted path to a callable, which will be imported and called on the ``request`` object, as above. Any other string Any other string will be treated as a key for the ``request.META`` object, e.g. ``RATELIMIT_IP_META_KEY = 'HTTP_X_REAL_IP'`` ``RATELIMIT_IPV4_MASK`` ----------------------- IPv4 mask for IP-based rate limit. Defaults to ``32`` (which is no masking) ``RATELIMIT_IPV6_MASK`` ----------------------- IPv6 mask for IP-based rate limit. Defaults to ``64`` (which mask the last 64 bits). Typical end site IPv6 assignment are from /48 to /64. ``RATELIMIT_EXCEPTION_CLASS`` ----------------------------- A custom exception class, or a dotted path to a custom exception class, that will be raised by ratelimit when a limit is exceeded and ``block=True``. django-ratelimit-4.1.0/docs/upgrading.rst000066400000000000000000000250711445755774200204410ustar00rootroot00000000000000.. _upgrading-chapter: ============= Upgrade Notes ============= See also the CHANGELOG_. .. _CHANGELOG: https://github.com/jsocol/django-ratelimit/blob/main/CHANGELOG .. _upgrading-4.0: From 3.x to 4.0 =============== Quickly: - Rename imports from ``from ratelimit`` to ``from django_ratelimit`` - Check all uses of the ``@ratelimit`` decorator. If the ``block`` argument is not set, add ``block=False`` to retain the current behavior. ``block=True`` may optionally be removed. - Django versions below 3.2 and Python versions below 3.7 are no longer supported. Package name changed -------------------- To disambiguate with other ratelimit packages on PyPI and resolve distro packaging issues, the package name has been changed from ``ratelimit`` to ``django_ratelimit``. See `issue 214`_ for more information on this change. When upgrading, import paths need to change to use the new package name. Old: .. code-block:: python from ratelimit.decorators import ratelimit from ratelimit import ALL, UNSAFE New: .. code-block:: python from django_ratelimit.decorators import ratelimit from django_ratelimit import ALL, UNSAFE .. _issue 214: https://github.com/jsocol/django-ratelimit/issues/214 Default decorator behavior changed ---------------------------------- In previous versions, the ``@ratelimit`` decorator did not block traffic that exceeded the rate limits by default. This has been reversed, and now the default behavior *is* to block requests once a rate limit has been exceeded. The old behavior of annotating the request object with a ``.limited`` property can be restored by explicitly setting ``block=False`` on the decorator. Historically, the first use cases Django Ratelimit was built to support were HTML views like login and password-reset pages, rather than APIs. In these cases, rate limiting is often done based on user input like the username or email address. Instead of blocking requests, which could lead to a denial-of-service (DOS) attack against particular users, it is common to trigger some additional security measures to prevent brute-force attacks, like a CAPTCHA, temporary account lock, or even notify those users via email. However, it has become obvious that the majority of views using the ``@ratelimit`` decorator tend to be either specific pages or API endpoints that do not present a DOS attack vector against other users, and that a more intuitive default behavior is to block requests that exceed the limits. Since there tend to only be a couple of pages or routes for uses like authentication, it makes more sense to opt those uses *out* of blocking, than opt all the others *in*. .. _upgrading-3.0: From 2.0 to 3.0 =============== Quickly: - Ratelimit now supports Django >=1.11 and Python >=3.4. - ``@ratelimit`` no longer works directly on class methods, add ``@method_decorator``. - ``RatelimitMixin`` is gone, migrate to ``@method_decorator``. - Moved ``is_ratelimted`` method from ``ratelimit.utils`` to ``ratelimit.core``. ``@ratelimit`` decorator on class methods ----------------------------------------- In 3.0, the decorator has been simplified and must now be used with Django's excellent ``@method_decorator`` utility. Migrating should be relatively straight-forward: .. code-block:: python from django.views.generic import View from ratelimit.decorators import ratelimit class MyView(View): @ratelimit(key='ip', rate='1/m', method='GET') def get(self, request): pass changes to .. code-block:: python from django.utils.decorators import method_decorator from django.views.generic import View from ratelimit.decorators import ratelimit class MyView(View): @method_decorator(ratelimit(key='ip', rate='1/m', method='GET')) def get(self, request): pass ``RatelimitMixin`` ------------------ ``RatelimitMixin`` is a vestige of an older version of Ratelimit that did not support multiple rates per method. As such, it is significantly less powerful than the current ``@ratelimit`` decorator. To migrate to the decorator, use the ``@method_decorator`` from Django: .. code-block:: python class MyView(RatelimitMixin, View): ratelimit_key = 'ip' ratelimit_rate = '10/m' ratelimit_method = 'GET' def get(self, request): pass becomes .. code-block:: python class MyView(View): @method_decorator(ratelimit(key='ip', rate='10/m', method='GET')) def get(self, request): pass The major benefit is that it is now possible to apply multiple limits to the same method, as with :ref:`the decorator `_. .. _upgrading-0.5: From <=0.4 to 0.5 ================= Quickly: - Rate limits are now counted against fixed, instead of sliding, windows. - Rate limits are no longer shared between methods by default. - Change ``ip=True`` to ``key='ip'``. - Drop ``ip=False``. - A key must always be specified. If using without an explicit key, add ``key='ip'``. - Change ``fields='foo'`` to ``post:foo`` or ``get:foo``. - Change ``keys=callable`` to ``key=callable``. - Change ``skip_if`` to a callable ``rate=`` method (see :ref:`Rates `. - Change ``RateLimitMixin`` to ``RatelimitMixin`` (note the lowercase ``l``). - Change ``ratelimit_ip=True`` to ``ratelimit_key='ip'``. - Change ``ratelimit_fields='foo'`` to ``post:foo`` or ``get:foo``. - Change ``ratelimit_keys=callable`` to ``ratelimit_key=callable``. Fixed windows ------------- Before 0.5, rates were counted against a *sliding* window, so if the rate limit was ``1/m``, and three requests came in:: 1.2.3.4 [09/Sep/2014:12:25:03] ... 1.2.3.4 [09/Sep/2014:12:25:53] ... 1.2.3.4 [09/Sep/2014:12:25:59] ... Even though the third request came nearly two minutes after the first request, the second request moved the window. Good actors could easily get caught in this, even trying to implement reasonable back-offs. Starting in 0.5, windows are *fixed*, and staggered throughout a given period based on the key value, so the third request, above would not be rate limited (it's possible neither would the second one). .. warning:: That means that given a rate of ``X/u``, you may see up to ``2 * X`` requests in a short period of time. Make sure to set ``X`` accordingly if this is an issue. This change still limits bad actors while being far kinder to good actors. Staggering windows ^^^^^^^^^^^^^^^^^^ To avoid a situation where all limits expire at the top of the hour, windows are automatically staggered throughout their period based on the key value. So if, for example, two IP addresses are hitting hourly limits, instead of both of those limits expiring at 06:00:00, one might expire at 06:13:41 (and subsequently at 07:13:41, etc) and the other might expire at 06:48:13 (and 07:48:13, etc). Sharing rate limits ------------------- Before 0.5, rate limits were shared between methods based only on their keys. This was very confusing and unintuitive, and is far from the least-surprising_ thing. For example, given these three views:: @ratelimit(ip=True, field='username') def both(request): pass @ratelimit(ip=False, field='username') def field_only(request): pass @ratelimit(ip=True) def ip_only(request): pass The pair ``both`` and ``field_only`` shares one rate limit key based on all requests to either (and any other views) containing the same ``username`` key (in ``GET`` or ``POST``), regardless of IP address. The pair ``both`` and ``ip_only`` shares one rate limit key based on the client IP address, along with all other views. Thus, it's extremely difficult to determine exactly why a request is getting rate limited. In 0.5, methods never share rate limits by default. Instead, limits are based on a combination of the :ref:`group `, rate, key value, and HTTP methods *to which the decorator applies* (i.e. **not** the method of the request). This better supports common use cases and stacking decorators, and still allows decorators to be shared. For example, this implements an hourly rate limit with a per-minute burst rate limit:: @ratelimit(key='ip', rate='100/m') @ratelimit(key='ip', rate='1000/h') def myview(request): pass However, this view is limited *separately* from another view with the same keys and rates:: @ratelimit(key='ip', rate='100/m') @ratelimit(key='ip', rate='1000/h') def anotherview(request): pass To cause the views to share a limit, explicitly set the ``group`` argument:: @ratelimit(group='lists', key='user', rate='100/h') def user_list(request): pass @ratelimit(group='lists', key='user', rate='100/h') def group_list(request): pass You can also stack multiple decorators with different sets of applicable methods:: @ratelimit(key='ip', method='GET', rate='1000/h') @ratelimit(key='ip', method='POST', rate='100/h') def maybe_expensive(request): pass This allows a total of 1,100 requests to this view in one hour, while this would only allow 1000, but still only 100 POSTs:: @ratelimit(key='ip', method=['GET', 'POST'], rate='1000/h') @ratelimit(key='ip', method='POST', rate='100/h') def maybe_expensive(request): pass And these two decorators would not share a rate limit:: @ratelimit(key='ip', method=['GET', 'POST'], rate='100/h') def foo(request): pass @ratelimit(key='ip', method='GET', rate='100/h') def bar(request): pass But these two do share a rate limit:: @ratelimit(group='a', key='ip', method=['GET', 'POST'], rate='1/s') def foo(request): pass @ratelimit(group='a', key='ip', method=['POST', 'GET'], rate='1/s') def bar(request): pass Using multiple decorators ------------------------- A single ``@ratelimit`` decorator used to be able to ratelimit against multiple keys, e.g., before 0.5:: @ratelimit(ip=True, field='username', keys=mykeysfunc) def someview(request): # ... To simplify both the internals and the question of what limits apply, each decorator now tracks exactly one rate, but decorators can be more reliably stacked (c.f. some examples in the section above). The pre-0.5 example above would need to become four decorators:: @ratelimit(key='ip') @ratelimit(key='post:username') @ratelimit(key='get:username') @ratelimit(key=mykeysfunc) def someview(request): # ... As documented above, however, this allows powerful new uses, like burst limits and distinct GET/POST limits. .. _least-surprising: http://en.wikipedia.org/wiki/Principle_of_least_astonishment django-ratelimit-4.1.0/docs/usage.rst000066400000000000000000000241251445755774200175640ustar00rootroot00000000000000.. _usage-chapter: ====================== Using Django Ratelimit ====================== .. _usage-decorator: Use as a decorator ================== .. versionchanged:: 4.0 Import: .. code-block:: python from django_ratelimit.decorators import ratelimit .. py:decorator:: ratelimit(group=None, key=, rate=None, method=ALL, block=True) :arg group: *None* A group of rate limits to count together. Defaults to the dotted name of the view. :arg key: What key to use, see :ref:`Keys `. :arg rate: *'5/m'* The number of requests per unit time allowed. Valid units are: * ``s`` - seconds * ``m`` - minutes * ``h`` - hours * ``d`` - days Also accepts callables. See :ref:`Rates `. A rate of ``0/s`` disallows all requests. A rate of ``None`` means "no limit" and will allow all requests. :arg method: *ALL* Which HTTP method(s) to rate-limit. May be a string, a list/tuple of strings, or the special values for ``ALL`` or ``UNSAFE`` (which includes ``POST``, ``PUT``, ``DELETE`` and ``PATCH``). :arg block: *True* Whether to block the request instead of annotating. HTTP Methods ------------ Each decorator can be limited to one or more HTTP methods. The ``method=`` argument accepts a method name (e.g. ``'GET'``) or a list or tuple of strings (e.g. ``('GET', 'OPTIONS')``). There are two special shortcuts values, both accessible from the ``ratelimit`` decorator or the ``is_ratelimited`` helper, as well as on the root ``ratelimit`` module: .. code-block:: python from django_ratelimit.decorators import ratelimit @ratelimit(key='ip', method=ratelimit.ALL) @ratelimit(key='ip', method=ratelimit.UNSAFE) def myview(request): pass ``ratelimit.ALL`` applies to all HTTP methods. ``ratelimit.UNSAFE`` is a shortcut for ``('POST', 'PUT', 'PATCH', 'DELETE')``. Examples -------- .. code-block:: python @ratelimit(key='ip', rate='5/m', block=False) def myview(request): # Will be true if the same IP makes more than 5 POST # requests/minute. was_limited = getattr(request, 'limited', False) return HttpResponse() @ratelimit(key='ip', rate='5/m', block=True) def myview(request): # If the same IP makes >5 reqs/min, will raise Ratelimited return HttpResponse() @ratelimit(key='post:username', rate='5/m', method=['GET', 'POST'], block=False) def login(request): # If the same username is used >5 times/min, this will be True. # The `username` value will come from GET or POST, determined by the # request method. was_limited = getattr(request, 'limited', False) return HttpResponse() @ratelimit(key='post:username', rate='5/m') @ratelimit(key='post:tenant', rate='5/m') def login(request): # Use multiple keys by stacking decorators. return HttpResponse() @ratelimit(key='get:q', rate='5/m') @ratelimit(key='post:q', rate='5/m') def search(request): # These two decorators combine to form one rate limit: the same search # query can only be tried 5 times a minute, regardless of the request # method (GET or POST) return HttpResponse() @ratelimit(key='ip', rate='4/h') def slow(request): # Allow 4 reqs/hour. return HttpResponse() get_rate = lambda g, r: None if r.user.is_authenticated else '100/h' @ratelimit(key='ip', rate=get_rate) def skipif1(request): # Only rate limit anonymous requests return HttpResponse() @ratelimit(key='user_or_ip', rate='10/s') @ratelimit(key='user_or_ip', rate='100/m') def burst_limit(request): # Implement a separate burst limit. return HttpResponse() @ratelimit(group='expensive', key='user_or_ip', rate='10/h') def expensive_view_a(request): return something_expensive() @ratelimit(group='expensive', key='user_or_ip', rate='10/h') def expensive_view_b(request): # Shares a counter with expensive_view_a return something_else_expensive() @ratelimit(key='header:x-cluster-client-ip') def post(request): # Uses the X-Cluster-Client-IP header value. return HttpResponse() @ratelimit(key=lambda g, r: r.META.get('HTTP_X_CLUSTER_CLIENT_IP', r.META['REMOTE_ADDR']) def myview(request): # Use `X-Cluster-Client-IP` but fall back to REMOTE_ADDR. return HttpResponse() Class-Based Views ----------------- .. versionadded:: 0.5 .. versionchanged:: 3.0 To use the ``@ratelimit`` decorator with class-based views, use the Django ``@method_decorator``: .. code-block:: python from django.utils.decorators import method_decorator from django.views.generic import View class MyView(View): @method_decorator(ratelimit(key='ip', rate='1/m', method='GET')) def get(self, request): pass @method_decorator(ratelimit(key='ip', rate='1/m', method='GET'), name='get') class MyOtherView(View): def get(self, request): pass It is also possible to wrap a whole view later, e.g.: .. code-block:: python from django.urls import path from myapp.views import MyView from django_ratelimit.decorators import ratelimit urlpatterns = [ path('/', ratelimit(key='ip', method='GET', rate='1/m')(MyView.as_view())), ] .. warning:: Make sure the ``method`` argument matches the method decorated. .. note:: Unless given an explicit ``group`` argument, different methods of a class-based view will be limited separately. .. _usage-helper: Core Methods ============ .. versionadded:: 3.0 In some cases the decorator is not flexible enough to, e.g., conditionally apply rate limits. In these cases, you can access the core functionality in ``ratelimit.core``. The two major methods are ``get_usage`` and ``is_ratelimited``. .. code-block:: python from django_ratelimit.core import get_usage, is_ratelimited .. py:function:: get_usage(request, group=None, fn=None, key=None, \ rate=None, method=ALL, increment=False) :arg request: *None* The HTTPRequest object. :arg group: *None* A group of rate limits to count together. Defaults to the dotted name of the view. :arg fn: *None* A view function which can be used to calculate the group as if it was decorated by :ref:`@ratelimit `. :arg key: What key to use, see :ref:`Keys `. :arg rate: *'5/m'* The number of requests per unit time allowed. Valid units are: * ``s`` - seconds * ``m`` - minutes * ``h`` - hours * ``d`` - days Also accepts callables. See :ref:`Rates `. :arg method: *ALL* Which HTTP method(s) to rate-limit. May be a string, a list/tuple, or ``None`` for all methods. :arg increment: *False* Whether to increment the count or just check. :returns dict or None: Either returns None, indicating that ratelimiting was not active for this request (for some reason) or returns a dict including the current count, limit, time left in the window, and whether this request should be limited. .. py:function:: is_ratelimited(request, group=None, fn=None, \ key=None, rate=None, method=ALL, \ increment=False) :arg request: *None* The HTTPRequest object. :arg group: *None* A group of rate limits to count together. Defaults to the dotted name of the view. :arg fn: *None* A view function which can be used to calculate the group as if it was decorated by :ref:`@ratelimit `. :arg key: What key to use, see :ref:`Keys `. :arg rate: *'5/m'* The number of requests per unit time allowed. Valid units are: * ``s`` - seconds * ``m`` - minutes * ``h`` - hours * ``d`` - days Also accepts callables. See :ref:`Rates `. :arg method: *ALL* Which HTTP method(s) to rate-limit. May be a string, a list/tuple, or ``None`` for all methods. :arg increment: *False* Whether to increment the count or just check. :returns bool: Whether this request should be limited or not. ``is_ratelimited`` is a thin wrapper around ``get_usage`` that is maintained for compatibility. It provides strictly less information. .. warning:: ``get_usage`` and ``is_ratelimited`` require either ``group=`` or ``fn=`` to be passed, or they cannot determine the rate limiting state and will throw. .. _usage-exception: Exceptions ========== .. py:class:: ratelimit.exceptions.Ratelimited If a request is ratelimited and ``block`` is set to ``True``, Ratelimit will raise ``ratelimit.exceptions.Ratelimited``. This is a subclass of Django's ``PermissionDenied`` exception, so if you don't need any special handling beyond the built-in 403 processing, you don't have to do anything. If you are setting |handler403|_ in your root URLconf, you can catch this exception in your custom view to return a different response, for example: .. code-block:: python def handler403(request, exception=None): if isinstance(exception, Ratelimited): return HttpResponse('Sorry you are blocked', status=429) return HttpResponseForbidden('Forbidden') .. |handler403| replace:: ``handler403`` .. _handler403: https://docs.djangoproject.com/en/2.1/topics/http/urls/#error-handling .. _usage-middleware: Middleware ========== There is optional middleware to use a custom view to handle ``Ratelimited`` exceptions. To use it, add ``django_ratelimit.middleware.RatelimitMiddleware`` to your ``MIDDLEWARE`` (toward the bottom of the list) and set ``RATELIMIT_VIEW`` to the full path of a view you want to use. The view specified in ``RATELIMIT_VIEW`` will get two arguments, the ``request`` object (after ratelimit processing) and the exception. django-ratelimit-4.1.0/pyproject.toml000066400000000000000000000022571445755774200177140ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.2"] build-backend = "setuptools.build_meta" [project] name = "django-ratelimit" version = "4.1.0" authors = [{name = "James Socol", email = "me@jamessocol.com"}] requires-python = ">= 3.7" license = {file = "LICENSE"} description = "Cache-based rate-limiting for Django." readme = "README.rst" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "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", "Framework :: Django", "Topic :: Software Development :: Libraries :: Python Modules", ] urls = {Homepage = "https://github.com/jsocol/django-ratelimit"} [tool.distutils.bdist_wheel] universal = 1 [tool.setuptools] include-package-data = true [tool.setuptools.packages] find = {namespaces = false} django-ratelimit-4.1.0/run.sh000077500000000000000000000020771445755774200161430ustar00rootroot00000000000000#!/bin/sh export PYTHONPATH=".:$PYTHONPATH" export DJANGO_SETTINGS_MODULE="test_settings" PROG="$0" CMD="$1" shift usage() { echo "USAGE: $PROG [command]" echo " test - run the ratelimit tests" echo " lint - run flake8 (alias: flake8)" echo " shell - open the Django shell" echo " build - build a package for release" echo " check - run twine check on build artifacts" exit 1 } case "$CMD" in "test" ) echo "Django version: $(python -m django --version)" python \ -W error::ResourceWarning \ -W error::DeprecationWarning \ -W error::PendingDeprecationWarning \ -m django \ test \ django_ratelimit \ "$@" ;; "lint"|"flake8" ) echo "Flake8 version: $(flake8 --version)" flake8 "$@" django_ratelimit/ ;; "shell" ) python -m django shell ;; "build" ) rm -rf dist/* python -m build ;; "check" ) twine check dist/* ;; * ) usage ;; esac django-ratelimit-4.1.0/setup.cfg000066400000000000000000000000601445755774200166070ustar00rootroot00000000000000[bdist_wheel] universal=1 [flake8] ignore=E731 django-ratelimit-4.1.0/test_settings.py000066400000000000000000000017201445755774200202430ustar00rootroot00000000000000SECRET_KEY = 'ratelimit' SILENCED_SYSTEM_CHECKS = ['django_ratelimit.E003', 'django_ratelimit.W001'] INSTALLED_APPS = ( 'django_ratelimit', ) RATELIMIT_USE_CACHE = 'default' CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'ratelimit-tests', }, 'connection-errors': { 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', 'LOCATION': 'test-connection-errors', }, 'connection-errors-redis': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': 'redis://test-connection-errors', 'OPTIONS': { 'IGNORE_EXCEPTIONS': True, } }, 'instant-expiration': { 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 'LOCATION': 'test-instant-expiration', }, } DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'test.db', }, } USE_TZ = True django-ratelimit-4.1.0/tox.ini000066400000000000000000000011361445755774200163060ustar00rootroot00000000000000[tox] envlist = py37-django32, py38-django{32,40,41,42,main}, py39-django{32,40,41,42,main}, py310-django{32,40,41,42,main}, py311-django{41,42,main}, pypy39-django{32,40,41,main}, [testenv] allowlist_externals = ./run.sh deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 djangomain: https://github.com/django/django/archive/main.tar.gz pymemcache>=4.0,<5.0 django-redis>=5.2,<6.0 flake8 allowlist_externals = */run.sh commands = ./run.sh test {posargs} ./run.sh flake8