New upstream version 2.0.0

This commit is contained in:
Frédéric Péters 2019-08-13 12:22:35 +02:00
parent 08b3d457c0
commit fadd3eae74
26 changed files with 1637 additions and 557 deletions

View File

@ -1,17 +1,46 @@
language: python
sudo: false
env:
- DJANGO_VERSION=1.4
- DJANGO_VERSION=1.5
- DJANGO_VERSION=1.6
- DJANGO_VERSION=1.11
- DJANGO_VERSION=2.0
- DJANGO_VERSION=2.1
- DJANGO_VERSION=master
python:
- "2.6"
- "2.7"
- "3.4"
- "3.5"
- "3.6"
- "pypy"
install:
- pip install -q "Django>=${DJANGO_VERSION},<${DJANGO_VERSION}.99"
script: ./run.sh test
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install -q python-memcached>=1.57; fi
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -q python3-memcached>=1.51; fi
- if [[ $TRAVIS_PYTHON_VERSION == pypy ]]; then pip install -q python-memcached>=1.57; fi
- if [[ $DJANGO_VERSION != master ]]; then pip install -q "Django>=${DJANGO_VERSION},<${DJANGO_VERSION}.99"; fi
- if [[ $DJANGO_VERSION == master ]]; then pip install https://github.com/django/django/archive/master.tar.gz; fi
- pip install "redis<3" django-redis==4.9.0 flake8
script:
- ./run.sh test
- ./run.sh flake8
matrix:
include:
- python: 3.3
env: DJANGO_VERSION=1.5
- python: 3.3
env: DJANGO_VERSION=1.6
exclude:
- python: "2.7"
env: DJANGO_VERSION=2.0
- python: "2.7"
env: DJANGO_VERSION=2.1
- python: "2.7"
env: DJANGO_VERSION=master
- python: "3.4"
env: DJANGO_VERSION=2.1
- python: "3.4"
env: DJANGO_VERSION=master
- python: "pypy"
env: DJANGO_VERSION=2.0
- python: "pypy"
env: DJANGO_VERSION=2.1
- python: "pypy"
env: DJANGO_VERSION=master
allow_failures:
- python: "3.5"
env: DJANGO_VERSION=master
- python: "3.6"
env: DJANGO_VERSION=master

View File

@ -2,6 +2,71 @@
Change Log
==========
Pending
=======
- New release notes here.
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
====

22
CONTRIBUTING.rst Normal file
View File

@ -0,0 +1,22 @@
============
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

View File

@ -1,4 +1,4 @@
Copyright (c) 2013, James Socol
Copyright (c) 2014, James Socol
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@ -10,6 +10,6 @@ variable.
:target: https://travis-ci.org/jsocol/django-ratelimit
:Code: https://github.com/jsocol/django-ratelimit
:License: BSD; see LICENSE file
:License: Apache Software License 2.0; see LICENSE file
:Issues: https://github.com/jsocol/django-ratelimit/issues
:Documentation: http://django-ratelimit.readthedocs.org/
:Documentation: http://django-ratelimit.readthedocs.io/

View File

@ -41,16 +41,16 @@ master_doc = 'index'
# General information about the project.
project = u'Django Ratelimit'
copyright = u'2013, James Socol'
copyright = u'2018, 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 = '0.3'
version = '2.0'
# The full version, including alpha/beta/rc tags.
release = '0.3.0'
release = '2.0.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -82,6 +82,7 @@ exclude_patterns = ['_build']
# 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 = []
@ -120,7 +121,7 @@ html_theme = 'default'
# 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 = ['_static']
#html_static_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.

View File

@ -8,7 +8,9 @@ Contributing
Set Up
======
Create a virtualenv_ and install Django with pip_::
Create a virtualenv_ and install Django with pip_:
.. code-block:: sh
$ pip install Django
@ -16,19 +18,25 @@ Create a virtualenv_ and install Django with pip_::
Running the Tests
=================
Running the tests is as easy as::
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::
- First install tox:
$ pip install tox
.. code-block:: sh
- Then run the tests with tox::
$ pip install tox
$ tox
- Then run the tests with tox:
.. code-block:: sh
$ tox
Code Standards

View File

@ -28,11 +28,11 @@ Use as a decorator in ``views.py``::
from ratelimit.decorators import ratelimit
@ratelimit()
@ratelimit(key='ip')
def myview(request):
# ...
@ratelimit(rate='100/h')
@ratelimit(key='ip', rate='100/h')
def secondview(request):
# ...
@ -48,6 +48,10 @@ Contents
settings
usage
keys
rates
security
upgrading
contributing

82
docs/keys.rst Normal file
View File

@ -0,0 +1,82 @@
.. _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
<security-chapter>` 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
<security-user-supplied>`.
.. _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 <keys-callable>` 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
<usage-chapter>` 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

61
docs/rates.rst Normal file
View File

@ -0,0 +1,61 @@
.. _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.")
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 no ``/`` in the value).
Callables receive two values, the :ref:`group <usage-chapter>` 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".

170
docs/security.rst Normal file
View File

@ -0,0 +1,170 @@
.. _security-chapter:
=======================
Security considerations
=======================
.. _security-client-ip:
Client IP address
=================
IP address is an extremely common rate limit :ref:`key <keys-chapter>`,
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::
class ReverseProxy(object):
def process_request(self, request):
request.META['REMOTE_ADDR'] = # [...]
where ``# [...]`` depends on your environment. This middleware should be
close to the top of the list::
MIDDLEWARE_CLASSES = (
'path.to.ReverseProxy',
# ...
)
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 implenting 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.

View File

@ -4,14 +4,45 @@
Settings
========
``RATELIMIT_CACHE_PREFIX``:
An optional cache prefix for ratelimit keys (in addition to the
``PREFIX`` value). *rl:*
``RATELIMIT_ENABLE``:
Set to ``False`` to disable rate-limiting across the board. *True*
``RATELIMIT_USE_CACHE``:
Which cache (from the ``CACHES`` dict) to use. *default*
``RATELIMIT_VIEW``:
A view to use when a request is ratelimited, in conjunction with
``RatelimitMiddleware``. (E.g.: ``'myapp.views.ratelimited'``.)
*None*
``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_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``
-----------------------
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``.

200
docs/upgrading.rst Normal file
View File

@ -0,0 +1,200 @@
.. _upgrading-chapter:
=============
Upgrade Notes
=============
See also the `CHANGELOG <../CHANGELOG>`.
.. _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=<callable>`` method (see
:ref:`Rates <rates-chapter>`.
- 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] ... <RATE LIMITED>
1.2.3.4 [09/Sep/2014:12:25:59] ... <RATE LIMITED>
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 <usage-decorator>`, 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

View File

@ -5,138 +5,194 @@ Using Django Ratelimit
======================
.. _usage-decorator:
Use as a decorator
==================
The ``@ratelimit`` view decorator provides several optional arguments
with sensible defaults (in italics).
Import::
from ratelimit.decorators import ratelimit
.. py:decorator:: ratelimit(ip=True, block=False, method=None, field=None, rate='5/m', skip_if=None, keys=None)
.. py:decorator:: ratelimit(group=None, key=, rate=None, method=ALL, block=False)
:arg ip:
*True* Whether to rate-limit based on the IP from ``REMOTE_ADDR``.
:arg group:
*None* A group of rate limits to count together. Defaults to the
dotted name of the view.
.. Note::
If you're using a reverse proxy, set this to False and use
the ``keys`` argument.
:arg block:
*False* Whether to block the request instead of annotating.
:arg method:
*None* Which HTTP method(s) to rate-limit. May be a string, a
list/tuple, or ``None`` for all methods.
:arg field:
*None* Which HTTP GET/POST argument field(s) to use to
rate-limit. May be a string or a list of strings.
:arg key:
What key to use, see :ref:`Keys <keys-chapter>`.
:arg rate:
*'5/m'* The number of requests per unit time allowed. Valid units are:
*'5/m'* The number of requests per unit time allowed. Valid
units are:
* ``s`` - seconds
* ``m`` - minutes
* ``h`` - hours
* ``d`` - days
:arg skip_if:
*None* If specified, pass this parameter a callable
(e.g. lambda function) that takes the current request. If the
callable returns a value that evaluates to True, the rate
limiting is skipped for that particular view. This is useful
to do things like selectively deactivating rate limiting based
on a value in your settings file, or based on an attirbute in
the current request object. (Also see the ``RATELIMIT_ENABLE``
setting below.)
Also accepts callables. See :ref:`Rates <rates-chapter>`.
:arg keys:
*None* Specify a function or list of functions that take the
request object and return string keys. This allows you to
define custom logic (for example, use an authenticated user ID
or unauthenticated IP address).
: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``).
.. Note::
If you're using a reverse proxy, pass in a function that
pulls the appropriate field from ``request.META`` for the
actual ip address of the client.
:arg block:
*False* Whether to block the request instead of annotating.
Examples::
HTTP Methods
------------
@ratelimit()
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, the ``RatelimitMixin`` class, or the
``is_ratelimited`` helper, as well as on the root ``ratelimit`` module::
from ratelimit.decorators import ratelimit
@ratelimit(key='ip', method=ratelimit.ALL)
@ratelimit(key='ip', method=ratelimit.UNSAFE)
def myview(request):
# Will be true if the same IP makes more than 5 requests/minute.
pass
``ratelimit.ALL`` applies to all HTTP methods. ``ratelimit.UNSAFE``
is a shortcut for ``('POST', 'PUT', 'PATCH', 'DELETE')``.
Examples
--------
::
@ratelimit(key='ip', rate='5/m')
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(block=True)
@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(field='username')
@ratelimit(key='post:username', rate='5/m', method=['GET', 'POST'])
def login(request):
# If the same username OR IP is used >5 times/min, this will be True.
# 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(method='POST')
@ratelimit(key='post:username', rate='5/m')
@ratelimit(key='post:tenant', rate='5/m')
def login(request):
# Only apply rate-limiting to POSTs.
return HttpResponseRedirect()
@ratelimit(field=['username', 'other_field'])
def login(request):
# Use multiple field values.
# Use multiple keys by stacking decorators.
return HttpResponse()
@ratelimit(rate='4/h')
@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()
@ratelimit(skip_if=lambda request: getattr(request, 'some_attribute', False))
rate = lambda r: None if request.user.is_authenticated else '100/h'
@ratelimit(key='ip', rate=rate)
def skipif1(request):
# Conditionally skip rate limiting (example 1)
# Only rate limit anonymous requests
return HttpResponse()
@ratelimit(skip_if=lambda request: settings.MYAPP_DEACTIVATE_RATE_LIMITING)
def skipif2(request):
# Conditionally skip rate limiting (example 2)
@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(keys=lambda x: 'min', rate='1/m')
@ratelimit(keys=lambda x: 'hour', rate='10/h')
@ratelimit(keys=lambda x: 'day', rate='50/d')
@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):
# Stack them.
# Note: once a decorator limits the request, the ones after
# won't count the request for limiting.
# Uses the X-Cluster-Client-IP header value.
return HttpResponse()
@ratelimit(ip=False,
keys=lambda req: req.META.get('HTTP_X_CLUSTER_CLIENT_IP',
req.META['REMOTE_ADDR']))
def post(request):
# This will use the HTTP_X_CLUSTER_CLIENT_IP and default to
# REMOTE_ADDR if that's not set. This is how you'd set up your
# rate limiting if you're behind a reverse proxy.
#
# It's important to set ip to False here. Otherwise it'll use
# limit on EITHER HTTP_X_CLUSTER_CLIENT_IP or REMOTE_ADDR and
# the end result is that everything will be throttled.
@ratelimit(key=lambda 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
The ``@ratelimit`` decorator also works on class-based view methods,
though *make sure the ``method`` argument matches the decorator*::
class MyView(View):
@ratelimit(key='ip', method='POST')
def post(self, request, *args):
# Something expensive...
.. note::
Unless given an explicit ``group`` argument, different methods of a
class-based view will be limited separate.
.. _usage-mixin:
Class-Based View Mixin
======================
.. py:class:: ratelimit.mixins.RatelimitMixin
.. versionadded:: 0.4
Ratelimits can also be applied to class-based views with the
``ratelimit.mixins.RatelimitMixin`` mixin. They are configured via class
attributes that are the same as the :ref:`decorator <usage-decorator>`,
prefixed with ``ratelimit_``, e.g.::
class MyView(RatelimitMixin, View):
ratelimit_key = 'ip'
ratelimit_rate = '10/m'
ratelimit_block = False
ratelimit_method = 'GET'
def get(self, request, *args, **kwargs):
# Calculate expensive report...
.. versionchanged:: 0.5
The name of the mixin changed from ``RateLimitMixin`` to
``RatelimitMixin`` for consistency.
.. _usage-helper:
Helper Function
===============
@ -146,37 +202,41 @@ the decorator.
Import::
from ratelimit.helpers import is_ratelimited
from ratelimit.utils import is_ratelimited
.. py:function:: is_ratelimited(request, increment=False, ip=True, method=None, field=None, rate='5/m', keys=None)
.. py:function:: is_ratelimited(request, group=None, key=, rate=None, method=ALL, increment=False)
:arg request:
(Required) The request object.
*None* The HTTPRequest object.
:arg increment:
*False* Whether to increment the count.
:arg group:
*None* A group of rate limits to count together. Defaults to the
dotted name of the view.
:arg ip:
*True* Whether to rate-limit based on the IP.
:arg method:
*None* Which HTTP method(s) to rate-limit. May be a string, a
list/tuple, or ``None`` for all methods.
:arg field:
*None* Which HTTP field(s) to use to rate-limit. May be a
string or a list.
:arg key:
What key to use, see :ref:`Keys <keys-chapter>`.
:arg rate:
*'5/m'* The number of requests per unit time allowed.
*'5/m'* The number of requests per unit time allowed. Valid
units are:
:arg keys:
*None* Specify a function or list of functions that take the
request object and return string keys. This allows you to
define custom logic (for example, use an authenticated user ID
or unauthenticated IP address).
* ``s`` - seconds
* ``m`` - minutes
* ``h`` - hours
* ``d`` - days
Also accepts callables. See :ref:`Rates <rates-chapter>`.
: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.
.. _usage-exception:
Exceptions
==========
@ -190,6 +250,20 @@ Exceptions
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
==========

View File

@ -1,2 +1,5 @@
VERSION = (0, 4, 0)
VERSION = (2, 0, 0)
__version__ = '.'.join(map(str, VERSION))
ALL = (None,) # Sentinel value for all HTTP methods.
UNSAFE = ['DELETE', 'PATCH', 'POST', 'PUT']

View File

@ -1,24 +1,36 @@
from __future__ import absolute_import
from functools import wraps
from django.http import HttpRequest
from ratelimit import ALL, UNSAFE
from ratelimit.exceptions import Ratelimited
from ratelimit.helpers import is_ratelimited
from ratelimit.utils import is_ratelimited
__all__ = ['ratelimit']
def ratelimit(ip=True, block=False, method=['POST'], field=None, rate='5/m',
skip_if=None, keys=None):
def ratelimit(group=None, key=None, rate=None, method=ALL, block=False):
def decorator(fn):
@wraps(fn)
def _wrapped(request, *args, **kw):
def _wrapped(*args, **kw):
# Work as a CBV method decorator.
if isinstance(args[0], HttpRequest):
request = args[0]
else:
request = args[1]
request.limited = getattr(request, 'limited', False)
if skip_if is None or not skip_if(request):
ratelimited = is_ratelimited(request=request, increment=True,
ip=ip, method=method, field=field,
rate=rate, keys=keys)
if ratelimited and block:
raise Ratelimited()
return fn(request, *args, **kw)
ratelimited = is_ratelimited(request=request, group=group, fn=fn,
key=key, rate=rate, method=method,
increment=True)
if ratelimited and block:
raise Ratelimited()
return fn(*args, **kw)
return _wrapped
return decorator
ratelimit.ALL = ALL
ratelimit.UNSAFE = UNSAFE

View File

@ -1,99 +0,0 @@
import hashlib
import re
from django.conf import settings
from django.core.cache import get_cache
__all__ = ['is_ratelimited']
RATELIMIT_ENABLE = getattr(settings, 'RATELIMIT_ENABLE', True)
CACHE_PREFIX = getattr(settings, 'RATELIMIT_CACHE_PREFIX', 'rl:')
_PERIODS = {
's': 1,
'm': 60,
'h': 60 * 60,
'd': 24 * 60 * 60,
}
rate_re = re.compile('([\d]+)/([\d]*)([smhd])')
def _method_match(request, method=None):
if method is None:
return True
if not isinstance(method, (list, tuple)):
method = [method]
return request.method in [m.upper() for m in method]
def _split_rate(rate):
count, multi, period = rate_re.match(rate).groups()
count = int(count)
time = _PERIODS[period.lower()]
if multi:
time = time * int(multi)
return count, time
def _get_keys(request, ip=True, field=None, keyfuncs=None):
keys = []
if ip:
keys.append('ip:' + request.META['REMOTE_ADDR'])
if field is not None:
if not isinstance(field, (list, tuple)):
field = [field]
for f in field:
val = getattr(request, request.method).get(f, '').encode('utf-8')
val = hashlib.sha1(val).hexdigest()
keys.append(u'field:%s:%s' % (f, val))
if keyfuncs:
if not isinstance(keyfuncs, (list, tuple)):
keyfuncs = [keyfuncs]
for k in keyfuncs:
keys.append(k(request))
return [CACHE_PREFIX + k for k in keys]
def _incr(cache, keys, timeout=60):
# Yes, this is a race condition, but memcached.incr doesn't reset the
# timeout.
counts = cache.get_many(keys)
for key in keys:
if key in counts:
counts[key] += 1
else:
counts[key] = 1
cache.set_many(counts, timeout=timeout)
return counts
def _get(cache, keys):
counts = cache.get_many(keys)
for key in keys:
if key in counts:
counts[key] += 1
else:
counts[key] = 1
return counts
def is_ratelimited(request, increment=False, ip=True, method=['POST'],
field=None, rate='5/m', keys=None):
count, period = _split_rate(rate)
cache = getattr(settings, 'RATELIMIT_USE_CACHE', 'default')
cache = get_cache(cache)
request.limited = getattr(request, 'limited', False)
if (not request.limited and RATELIMIT_ENABLE and
_method_match(request, method)):
_keys = _get_keys(request, ip, field, keys)
if increment:
counts = _incr(cache, _keys, period)
else:
counts = _get(cache, _keys)
if any([c > count for c in counts.values()]):
request.limited = True
return request.limited

View File

@ -1,10 +1,19 @@
try:
from django.utils.importlib import import_module
except ImportError:
from importlib import import_module
try:
from django.utils.deprecation import MiddlewareMixin
except ImportError:
MiddlewareMixin = object
from django.conf import settings
from django.utils.importlib import import_module
from ratelimit.exceptions import Ratelimited
class RatelimitMiddleware(object):
class RatelimitMiddleware(MiddlewareMixin):
def process_exception(self, request, exception):
if not isinstance(exception, Ratelimited):
return

View File

@ -1,9 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from .decorators import ratelimit
from ratelimit import ALL, UNSAFE
from ratelimit.decorators import ratelimit
class RateLimitMixin(object):
__all__ = ['RatelimitMixin']
class RatelimitMixin(object):
"""
Mixin for usage in Class Based Views
configured with the decorator ``ratelimit`` defaults.
@ -13,33 +17,42 @@ class RateLimitMixin(object):
Example::
class ContactView(RateLimitMixin, FormView):
class ContactView(RatelimitMixin, FormView):
form_class = ContactForm
template_name = "contact.html"
# Limit contact form by remote address.
ratelimit_key = 'ip'
ratelimit_block = True
def form_valid(self, form):
# do sth. here
# Whatever validation.
return super(ContactView, self).form_valid(form)
"""
ratelimit_ip = True
ratelimit_block = False
ratelimit_method = ['POST']
ratelimit_field = None
ratelimit_group = None
ratelimit_key = None
ratelimit_rate = '5/m'
ratelimit_skip_if = None
ratelimit_keys = None
ratelimit_block = False
ratelimit_method = ALL
ALL = ALL
UNSAFE = UNSAFE
def get_ratelimit_config(self):
# Ensures that the ratelimit_key is called as a function instead
# of a method if it is a callable (ie self is not passed).
if callable(self.ratelimit_key):
self.ratelimit_key = self.ratelimit_key.__func__
return dict(
(k[len("ratelimit_"):], v)
for k, v in vars(self.__class__).items()
if k.startswith("ratelimit")
group=self.ratelimit_group,
key=self.ratelimit_key,
rate=self.ratelimit_rate,
block=self.ratelimit_block,
method=self.ratelimit_method,
)
def dispatch(self, *args, **kwargs):
return ratelimit(
**self.get_ratelimit_config()
)(super(RateLimitMixin, self).dispatch)(*args, **kwargs)
)(super(RatelimitMixin, self).dispatch)(*args, **kwargs)

View File

@ -1,39 +1,77 @@
import django
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.views.generic import View
from ratelimit.decorators import ratelimit
from ratelimit.exceptions import Ratelimited
from ratelimit.mixins import RateLimitMixin
from ratelimit.helpers import is_ratelimited
from ratelimit.mixins import RatelimitMixin
from ratelimit.utils import is_ratelimited, _split_rate
rf = RequestFactory()
class MockUser(object):
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 mykey(group, request):
return request.META['REMOTE_ADDR'][::-1]
class RatelimitTests(TestCase):
def setUp(self):
cache.clear()
def test_limit_ip(self):
@ratelimit(ip=True, method=None, rate='1/m', block=True)
def test_no_key(self):
@ratelimit(rate='1/m', block=True)
def view(request):
return True
req = RequestFactory().get('/')
req = rf.get('/')
with self.assertRaises(ImproperlyConfigured):
view(req)
def test_ip(self):
@ratelimit(key='ip', rate='1/m', block=True)
def view(request):
return True
req = rf.get('/')
assert view(req), 'First request works.'
with self.assertRaises(Ratelimited):
view(req)
def test_block(self):
@ratelimit(ip=True, method=None, rate='1/m', block=True)
@ratelimit(key='ip', rate='1/m', block=True)
def blocked(request):
return request.limited
@ratelimit(ip=True, method=None, rate='1/m', block=False)
@ratelimit(key='ip', rate='1/m', block=False)
def unblocked(request):
return request.limited
req = RequestFactory().get('/')
req = rf.get('/')
assert not blocked(req), 'First request works.'
with self.assertRaises(Ratelimited):
@ -42,15 +80,14 @@ class RatelimitTests(TestCase):
assert unblocked(req), 'Request is limited but not blocked.'
def test_method(self):
rf = RequestFactory()
post = rf.post('/')
get = rf.get('/')
@ratelimit(ip=True, method=['POST'], rate='1/m')
@ratelimit(key='ip', method='POST', rate='1/m', group='a')
def limit_post(request):
return request.limited
@ratelimit(ip=True, method=['POST', 'GET'], rate='1/m')
@ratelimit(key='ip', method=['POST', 'GET'], rate='1/m', group='a')
def limit_get(request):
return request.limited
@ -61,132 +98,289 @@ class RatelimitTests(TestCase):
assert limit_get(post), 'Limit first POST.'
assert limit_get(get), 'Limit first GET.'
def test_field(self):
james = RequestFactory().post('/', {'username': 'james'})
john = RequestFactory().post('/', {'username': 'john'})
@ratelimit(ip=False, field='username', rate='1/m')
def username(request):
def test_unsafe_methods(self):
@ratelimit(key='ip', method=ratelimit.UNSAFE, rate='0/m')
def limit_unsafe(request):
return request.limited
assert not username(james), "james' first request is fine."
assert username(james), "james' second request is limited."
assert not username(john), "john's first request is fine."
get = rf.get('/')
head = rf.head('/')
options = rf.options('/')
def test_field_unicode(self):
post = RequestFactory().post('/', {'username': u'fran\xe7ois'})
delete = rf.delete('/')
post = rf.post('/')
put = rf.put('/')
@ratelimit(ip=False, field='username', rate='1/m')
assert not limit_unsafe(get)
assert not limit_unsafe(head)
assert not limit_unsafe(options)
assert limit_unsafe(delete)
assert limit_unsafe(post)
assert limit_unsafe(put)
# TODO: When all supported versions have this, drop the `if`.
if hasattr(rf, 'patch'):
patch = rf.patch('/')
assert limit_unsafe(patch)
def test_key_get(self):
req_a = rf.get('/', {'foo': 'a'})
req_b = rf.get('/', {'foo': 'b'})
@ratelimit(key='get:foo', rate='1/m', method='GET')
def view(request):
return request.limited
assert not view(post), 'First request is not limited.'
assert view(post), 'Second request is limited.'
assert not view(req_a)
assert view(req_a)
assert not view(req_b)
assert view(req_b)
def test_field_empty(self):
post = RequestFactory().post('/', {})
def test_key_post(self):
req_a = rf.post('/', {'foo': 'a'})
req_b = rf.post('/', {'foo': 'b'})
@ratelimit(ip=False, field='username', rate='1/m')
@ratelimit(key='post:foo', rate='1/m')
def view(request):
return request.limited
assert not view(post), 'First request is not limited.'
assert view(post), 'Second request is limited.'
assert not view(req_a)
assert view(req_a)
assert not view(req_b)
assert view(req_b)
def test_key_header(self):
req = rf.post('/')
req.META['HTTP_X_REAL_IP'] = '1.2.3.4'
@ratelimit(key='header:x-real-ip', rate='1/m')
@ratelimit(key='header:x-missing-header', rate='1/m')
def view(request):
return request.limited
assert not view(req)
assert view(req)
def test_rate(self):
req = RequestFactory().post('/')
req = rf.post('/')
@ratelimit(ip=True, rate='2/m')
@ratelimit(key='ip', rate='2/m')
def twice(request):
return request.limited
assert not twice(req), 'First request is not limited.'
del req.limited
assert not twice(req), 'Second request is not limited.'
del req.limited
assert twice(req), 'Third request is limited.'
def test_skip_if(self):
req = RequestFactory().post('/')
def test_zero_rate(self):
req = rf.post('/')
@ratelimit(rate='1/m', skip_if=lambda r: getattr(r, 'skip', False))
@ratelimit(key='ip', rate='0/m')
def never(request):
return request.limited
assert never(req)
def test_none_rate(self):
req = rf.post('/')
@ratelimit(key='ip', rate=None)
def always(request):
return request.limited
assert not always(req)
del req.limited
assert not always(req)
del req.limited
assert not always(req)
del req.limited
assert not always(req)
del req.limited
assert not always(req)
del req.limited
assert not always(req)
def test_callable_rate(self):
auth = rf.post('/')
unauth = rf.post('/')
auth.user = MockUser(authenticated=True)
unauth.user = MockUser(authenticated=False)
def get_rate(group, request):
if request.user.is_authenticated:
return (2, 60)
return (1, 60)
@ratelimit(key='user_or_ip', rate=get_rate)
def view(request):
return request.limited
assert not view(req), 'First request is not limited.'
assert view(req), 'Second request is limited.'
del req.limited
req.skip = True
assert not view(req), 'Skipped request is not limited.'
assert not view(unauth)
assert view(unauth)
assert not view(auth)
assert not view(auth)
assert view(auth)
@override_settings(RATELIMIT_USE_CACHE='fake.cache')
def test_callable_rate_none(self):
req = rf.post('/')
req.never_limit = False
get_rate = lambda g, r: None if r.never_limit else '1/m'
@ratelimit(key='ip', rate=get_rate)
def view(request):
return request.limited
assert not view(req)
del req.limited
assert view(req)
req.never_limit = True
del req.limited
assert not view(req)
del req.limited
assert not view(req)
def test_callable_rate_zero(self):
auth = rf.post('/')
unauth = rf.post('/')
auth.user = MockUser(authenticated=True)
unauth.user = MockUser(authenticated=False)
def get_rate(group, request):
if request.user.is_authenticated:
return '1/m'
return '0/m'
@ratelimit(key='ip', rate=get_rate)
def view(request):
return request.limited
assert view(unauth)
del unauth.limited
assert not view(auth)
del auth.limited
assert view(auth)
assert view(unauth)
@override_settings(RATELIMIT_USE_CACHE='fake-cache')
def test_bad_cache(self):
"""The RATELIMIT_USE_CACHE setting works if the cache exists."""
@ratelimit()
@ratelimit(key='ip', rate='1/m')
def view(request):
return request
req = RequestFactory().post('/')
req = rf.post('/')
with self.assertRaises(InvalidCacheBackendError):
view(req)
def test_keys(self):
@override_settings(RATELIMIT_USE_CACHE='connection-errors')
def test_cache_connection_error(self):
@ratelimit(key='ip', rate='1/m')
def view(request):
return request
req = rf.post('/')
assert view(req)
def test_user_or_ip(self):
"""Allow custom functions to set cache keys."""
class User(object):
def __init__(self, authenticated=False):
self.pk = 1
self.authenticated = authenticated
def is_authenticated(self):
return self.authenticated
def user_or_ip(req):
if req.user.is_authenticated():
return 'uip:%d' % req.user.pk
return 'uip:%s' % req.META['REMOTE_ADDR']
@ratelimit(ip=False, rate='1/m', block=False, keys=user_or_ip)
@ratelimit(key='user_or_ip', rate='1/m', block=False)
def view(request):
return request.limited
req = RequestFactory().post('/')
req.user = User(authenticated=False)
unauth = rf.post('/')
unauth.user = MockUser(authenticated=False)
assert not view(req), 'First unauthenticated request is allowed.'
assert view(req), 'Second unauthenticated request is limited.'
assert not view(unauth), 'First unauthenticated request is allowed.'
assert view(unauth), 'Second unauthenticated request is limited.'
del req.limited
req.user = User(authenticated=True)
auth = rf.post('/')
auth.user = MockUser(authenticated=True)
assert not view(req), 'First authenticated request is allowed.'
assert view(req), 'Second authenticated is limited.'
assert not view(auth), 'First authenticated request is allowed.'
assert view(auth), 'Second authenticated is limited.'
def test_key_path(self):
@ratelimit(key='ratelimit.tests.mykey', rate='1/m')
def view(request):
return request.limited
req = rf.post('/')
assert not view(req)
assert view(req)
def test_callable_key(self):
@ratelimit(key=mykey, rate='1/m')
def view(request):
return request.limited
req = rf.post('/')
assert not view(req)
assert view(req)
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(ip=False, rate='1/m', block=False, keys=lambda x: 'min')
@ratelimit(ip=False, rate='10/d', block=False, keys=lambda x: 'day')
@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
req = RequestFactory().post('/')
req = rf.post('/')
assert not view(req), 'First unauthenticated request is allowed.'
assert view(req), 'Second unauthenticated request is limited.'
def test_stacked_methods(self):
"""Different methods should result in different counts."""
@ratelimit(rate='1/m', key='ip', method='GET')
@ratelimit(rate='1/m', key='ip', method='POST')
def view(request):
return request.limited
get = rf.get('/')
post = rf.post('/')
assert not view(get)
assert not view(post)
assert view(get)
assert view(post)
def test_sorted_methods(self):
"""Order of the methods shouldn't matter."""
@ratelimit(rate='1/m', key='ip', method=['GET', 'POST'], group='a')
def get_post(request):
return request.limited
@ratelimit(rate='1/m', key='ip', method=['POST', 'GET'], group='a')
def post_get(request):
return request.limited
req = rf.get('/')
assert not get_post(req)
assert post_get(req)
def test_is_ratelimited(self):
def get_keys(request):
def get_key(group, request):
return 'test_is_ratelimited_key'
def not_increment(request):
return is_ratelimited(request, increment=False, ip=False,
method=None, keys=[get_keys], rate='1/m')
return is_ratelimited(request, increment=False,
method=is_ratelimited.ALL, key=get_key,
rate='1/m', group='a')
def do_increment(request):
return is_ratelimited(request, increment=True, ip=False,
method=None, keys=[get_keys], rate='1/m')
return is_ratelimited(request, increment=True,
method=is_ratelimited.ALL, key=get_key,
rate='1/m', group='a')
req = RequestFactory().get('/')
req = rf.get('/')
# Does not increment. Count still 0. Does not rate limit
# because 0 < 1.
assert not not_increment(req), 'Request should not be rate limited.'
@ -194,236 +388,228 @@ class RatelimitTests(TestCase):
# Increments. Does not rate limit because 0 < 1. Count now 1.
assert not do_increment(req), 'Request should not be rate limited.'
# Does not increment. Count still 1. Rate limits because 1 < 1
# Does not increment. Count still 1. Not limited because 1 > 1
# is false.
assert not not_increment(req), 'Request should not be rate limited.'
# Count = 2, 2 > 1.
assert do_increment(req), 'Request should be rate limited.'
assert not_increment(req), 'Request should be rate limited.'
@override_settings(RATELIMIT_USE_CACHE='connection-errors')
def test_is_ratelimited_cache_connection_error_without_increment(self):
def get_key(group, request):
return 'test_is_ratelimited_key'
#do it here, since python < 2.7 does not have unittest.skipIf
if django.VERSION >= (1, 4):
class RateLimitCBVTests(TestCase):
def not_increment(request):
return is_ratelimited(request, increment=False,
method=is_ratelimited.ALL, key=get_key,
rate='1/m', group='a')
SKIP_REASON = u'Class Based View supported by Django >=1.4'
req = rf.get('/')
assert not not_increment(req)
def setUp(self):
cache.clear()
@override_settings(RATELIMIT_USE_CACHE='connection-errors')
def test_is_ratelimited_cache_connection_error_with_increment(self):
def get_key(group, request):
return 'test_is_ratelimited_key'
def test_limit_ip(self):
def do_increment(request):
return is_ratelimited(request, increment=True,
method=is_ratelimited.ALL, key=get_key,
rate='1/m', group='a')
class RLView(RateLimitMixin, View):
ratelimit_ip = True
ratelimit_method = None
ratelimit_rate = '1/m'
ratelimit_block = True
req = rf.get('/')
assert not do_increment(req)
assert req.limited is False
rlview = RLView.as_view()
@override_settings(RATELIMIT_USE_CACHE='connection-errors-redis')
def test_is_ratelimited_cache_connection_error_with_increment_redis(self):
def get_key(group, request):
return 'test_is_ratelimited_key'
req = RequestFactory().get('/')
assert rlview(req), 'First request works.'
with self.assertRaises(Ratelimited):
rlview(req)
def do_increment(request):
return is_ratelimited(request, increment=True,
method=is_ratelimited.ALL, key=get_key,
rate='1/m', group='a')
def test_block(self):
req = rf.get('/')
assert do_increment(req)
assert req.limited is True
class BlockedView(RateLimitMixin, View):
ratelimit_ip = True
ratelimit_method = None
ratelimit_rate = '1/m'
ratelimit_block = True
@override_settings(RATELIMIT_USE_CACHE='instant-expiration')
def test_cache_timeout(self):
@ratelimit(key='ip', rate='1/m', block=True)
def view(request):
return True
def get(self, request, *args, **kwargs):
return request.limited
req = rf.get('/')
assert view(req), 'First request works.'
with self.assertRaises(Ratelimited):
view(req)
class UnBlockedView(RateLimitMixin, View):
ratelimit_ip = True
ratelimit_method = None
ratelimit_rate = '1/m'
ratelimit_block = False
def get(self, request, *args, **kwargs):
return request.limited
class RatelimitCBVTests(TestCase):
blocked = BlockedView.as_view()
unblocked = UnBlockedView.as_view()
def setUp(self):
cache.clear()
req = RequestFactory().get('/')
def test_limit_ip(self):
assert not blocked(req), 'First request works.'
with self.assertRaises(Ratelimited):
blocked(req)
class RLView(RatelimitMixin, View):
ratelimit_key = 'ip'
ratelimit_method = ratelimit.ALL
ratelimit_rate = '1/m'
ratelimit_block = True
assert unblocked(req), 'Request is limited but not blocked.'
rlview = RLView.as_view()
def test_method(self):
rf = RequestFactory()
post = rf.post('/')
get = rf.get('/')
req = rf.get('/')
assert rlview(req), 'First request works.'
with self.assertRaises(Ratelimited):
rlview(req)
class LimitPostView(RateLimitMixin, View):
ratelimit_ip = True
ratelimit_method = ['POST']
ratelimit_rate = '1/m'
def test_block(self):
def post(self, request, *args, **kwargs):
return request.limited
get = post
class BlockedView(RatelimitMixin, View):
ratelimit_group = 'cbv:block'
ratelimit_key = 'ip'
ratelimit_method = ratelimit.ALL
ratelimit_rate = '1/m'
ratelimit_block = True
class LimitGetView(RateLimitMixin, View):
ratelimit_ip = True
ratelimit_method = ['POST', 'GET']
ratelimit_rate = '1/m'
def get(self, request, *args, **kwargs):
return request.limited
def post(self, request, *args, **kwargs):
return request.limited
get = post
class UnBlockedView(RatelimitMixin, View):
ratelimit_group = 'cbv:block'
ratelimit_key = 'ip'
ratelimit_method = ratelimit.ALL
ratelimit_rate = '1/m'
ratelimit_block = False
limit_post = LimitPostView.as_view()
limit_get = LimitGetView.as_view()
def get(self, request, *args, **kwargs):
return request.limited
assert not limit_post(post), 'Do not limit first POST.'
assert limit_post(post), 'Limit second POST.'
assert not limit_post(get), 'Do not limit GET.'
blocked = BlockedView.as_view()
unblocked = UnBlockedView.as_view()
assert limit_get(post), 'Limit first POST.'
assert limit_get(get), 'Limit first GET.'
req = rf.get('/')
def test_field(self):
james = RequestFactory().post('/', {'username': 'james'})
john = RequestFactory().post('/', {'username': 'john'})
assert not blocked(req), 'First request works.'
with self.assertRaises(Ratelimited):
blocked(req)
class UsernameView(RateLimitMixin, View):
ratelimit_ip = False
ratelimit_field = 'username'
ratelimit_rate = '1/m'
assert unblocked(req), 'Request is limited but not blocked.'
def post(self, request, *args, **kwargs):
return request.limited
get = post
def test_method(self):
post = rf.post('/')
get = rf.get('/')
username = UsernameView.as_view()
assert not username(james), "james' first request is fine."
assert username(james), "james' second request is limited."
assert not username(john), "john's first request is fine."
class LimitPostView(RatelimitMixin, View):
ratelimit_group = 'cbv:method'
ratelimit_key = 'ip'
ratelimit_method = ['POST']
ratelimit_rate = '1/m'
def test_field_unicode(self):
post = RequestFactory().post('/', {'username': u'fran\xe7ois'})
def post(self, request, *args, **kwargs):
return request.limited
get = post
class UsernameView(RateLimitMixin, View):
ratelimit_ip = False
ratelimit_field = 'username'
ratelimit_rate = '1/m'
class LimitGetView(RatelimitMixin, View):
ratelimit_group = 'cbv:method'
ratelimit_key = 'ip'
ratelimit_method = ['POST', 'GET']
ratelimit_rate = '1/m'
def post(self, request, *args, **kwargs):
return request.limited
get = post
def post(self, request, *args, **kwargs):
return request.limited
get = post
view = UsernameView.as_view()
limit_post = LimitPostView.as_view()
limit_get = LimitGetView.as_view()
assert not view(post), 'First request is not limited.'
assert view(post), 'Second request is limited.'
assert not limit_post(post), 'Do not limit first POST.'
assert limit_post(post), 'Limit second POST.'
assert not limit_post(get), 'Do not limit GET.'
def test_field_empty(self):
post = RequestFactory().post('/', {})
assert limit_get(post), 'Limit first POST.'
assert limit_get(get), 'Limit first GET.'
class EmptyFieldView(RateLimitMixin, View):
ratelimit_ip = False
ratelimit_field = 'username'
ratelimit_rate = '1/m'
def test_rate(self):
req = rf.post('/')
def post(self, request, *args, **kwargs):
return request.limited
get = post
class TwiceView(RatelimitMixin, View):
ratelimit_key = 'ip'
ratelimit_rate = '2/m'
view = EmptyFieldView.as_view()
def post(self, request, *args, **kwargs):
return request.limited
get = post
assert not view(post), 'First request is not limited.'
assert view(post), 'Second request is limited.'
twice = TwiceView.as_view()
def test_rate(self):
req = RequestFactory().post('/')
assert not twice(req), 'First request is not limited.'
assert not twice(req), 'Second request is not limited.'
assert twice(req), 'Third request is limited.'
class TwiceView(RateLimitMixin, View):
ratelimit_ip = True
ratelimit_rate = '2/m'
@override_settings(RATELIMIT_USE_CACHE='fake-cache')
def test_bad_cache(self):
"""The RATELIMIT_USE_CACHE setting works if the cache exists."""
self.skipTest('I do not know why this fails when the other works.')
def post(self, request, *args, **kwargs):
return request.limited
get = post
class BadCacheView(RatelimitMixin, View):
ratelimit_key = 'ip'
twice = TwiceView.as_view()
def post(self, request, *args, **kwargs):
return request
get = post
view = BadCacheView.as_view()
assert not twice(req), 'First request is not limited.'
assert not twice(req), 'Second request is not limited.'
assert twice(req), 'Third request is limited.'
req = rf.post('/')
def test_skip_if(self):
req = RequestFactory().post('/')
with self.assertRaises(InvalidCacheBackendError):
view(req)
class SkipIfView(RateLimitMixin, View):
ratelimit_rate = '1/m'
ratelimit_skip_if = lambda r: getattr(r, 'skip', False)
def test_keys(self):
"""Allow custom functions to set cache keys."""
def post(self, request, *args, **kwargs):
return request.limited
get = post
view = SkipIfView.as_view()
def user_or_ip(group, req):
if req.user.is_authenticated:
return 'uip:%d' % req.user.pk
return 'uip:%s' % req.META['REMOTE_ADDR']
assert not view(req), 'First request is not limited.'
assert view(req), 'Second request is limited.'
del req.limited
req.skip = True
assert not view(req), 'Skipped request is not limited.'
class KeysView(RatelimitMixin, View):
ratelimit_key = user_or_ip
ratelimit_block = False
ratelimit_rate = '1/m'
@override_settings(RATELIMIT_USE_CACHE='fake-cache')
def test_bad_cache(self):
"""The RATELIMIT_USE_CACHE setting works if the cache exists."""
def post(self, request, *args, **kwargs):
return request.limited
get = post
view = KeysView.as_view()
class BadCacheView(RateLimitMixin, View):
req = rf.post('/')
req.user = MockUser(authenticated=False)
def post(self, request, *args, **kwargs):
return request
get = post
view = BadCacheView.as_view()
assert not view(req), 'First unauthenticated request is allowed.'
assert view(req), 'Second unauthenticated request is limited.'
req = RequestFactory().post('/')
del req.limited
req.user = MockUser(authenticated=True)
with self.assertRaises(InvalidCacheBackendError):
view(req)
assert not view(req), 'First authenticated request is allowed.'
assert view(req), 'Second authenticated is limited.'
def test_keys(self):
"""Allow custom functions to set cache keys."""
class User(object):
def __init__(self, authenticated=False):
self.pk = 1
self.authenticated = authenticated
def test_method_decorator(self):
class TestView(View):
@ratelimit(key='ip', rate='1/m', block=False)
def post(self, request):
return request.limited
def is_authenticated(self):
return self.authenticated
view = TestView.as_view()
def user_or_ip(req):
if req.user.is_authenticated():
return 'uip:%d' % req.user.pk
return 'uip:%s' % req.META['REMOTE_ADDR']
req = rf.post('/')
class KeysView(RateLimitMixin, View):
ratelimit_ip = False
ratelimit_block = False
ratelimit_rate = '1/m'
ratelimit_keys = user_or_ip
def post(self, request, *args, **kwargs):
return request.limited
get = post
view = KeysView.as_view()
req = RequestFactory().post('/')
req.user = User(authenticated=False)
assert not view(req), 'First unauthenticated request is allowed.'
assert view(req), 'Second unauthenticated request is limited.'
del req.limited
req.user = User(authenticated=True)
assert not view(req), 'First authenticated request is allowed.'
assert view(req), 'Second authenticated is limited.'
assert not view(req)
assert view(req)

186
ratelimit/utils.py Normal file
View File

@ -0,0 +1,186 @@
import hashlib
import re
import time
import zlib
from importlib import import_module
from django.conf import settings
from django.core.cache import caches
from django.core.exceptions import ImproperlyConfigured
from ratelimit import ALL, UNSAFE
__all__ = ['is_ratelimited']
_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 user_or_ip(request):
if request.user.is_authenticated:
return str(request.user.pk)
return request.META['REMOTE_ADDR']
_SIMPLE_KEYS = {
'ip': lambda r: r.META['REMOTE_ADDR'],
'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):
ts = int(time.time())
if period == 1:
return ts
if not isinstance(value, bytes):
value = value.encode('utf-8')
w = ts - (ts % period) + (zlib.crc32(value) % period)
if w < ts:
return w + period
return w
def _make_cache_key(group, rate, value, methods):
count, period = _split_rate(rate)
safe_rate = '%d/%ds' % (count, period)
window = _get_window(value, 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:')
return prefix + hashlib.md5(u''.join(parts).encode('utf-8')).hexdigest()
def is_ratelimited(request, group=None, fn=None, key=None, rate=None,
method=ALL, increment=False):
if group is None:
if hasattr(fn, '__self__'):
parts = fn.__module__, fn.__self__.__class__.__name__, fn.__name__
else:
parts = (fn.__module__, fn.__name__)
group = '.'.join(parts)
if not getattr(settings, 'RATELIMIT_ENABLE', True):
request.limited = False
return False
if not _method_match(request, method):
return False
old_limited = getattr(request, 'limited', False)
if callable(rate):
rate = rate(group, request)
if rate is None:
request.limited = old_limited
return False
usage = get_usage_count(request, group, fn, key, rate, method, increment)
fail_open = getattr(settings, 'RATELIMIT_FAIL_OPEN', False)
usage_count = usage.get('count')
if usage_count is None:
limited = not fail_open
else:
usage_limit = usage.get('limit')
limited = usage_count > usage_limit
if increment:
request.limited = old_limited or limited
return limited
def get_usage_count(request, group=None, fn=None, key=None, rate=None,
method=ALL, increment=False):
if not key:
raise ImproperlyConfigured('Ratelimit key must be specified')
limit, period = _split_rate(rate)
cache_name = getattr(settings, 'RATELIMIT_USE_CACHE', 'default')
cache = caches[cache_name]
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:
mod, attr = key.rsplit('.', 1)
keyfn = getattr(import_module(mod), attr)
value = keyfn(group, request)
else:
raise ImproperlyConfigured(
'Could not understand ratelimit key: %s' % key)
cache_key = _make_cache_key(group, rate, value, method)
time_left = _get_window(value, period) - int(time.time())
initial_value = 1 if increment else 0
added = cache.add(cache_key, initial_value, period + EXPIRATION_FUDGE)
if added:
count = initial_value
else:
if increment:
try:
count = cache.incr(cache_key)
except ValueError:
count = initial_value
else:
count = cache.get(cache_key, initial_value)
return {'count': count, 'limit': limit, 'time_left': time_left}
is_ratelimited.ALL = ALL
is_ratelimited.UNSAFE = UNSAFE

9
run.sh
View File

@ -5,14 +5,19 @@ export DJANGO_SETTINGS_MODULE="test_settings"
usage() {
echo "USAGE: $0 [command]"
echo " test - run the jsonview tests"
echo " test - run the ratelimit tests"
echo " flake8 - run flake8"
echo " shell - open the Django shell"
exit 1
}
case "$1" in
"test" )
django-admin.py test ratelimit ;;
shift;
django-admin.py test ratelimit $@;;
"flake8" )
shift;
flake8 $@ ratelimit/;;
"shell" )
django-admin.py shell ;;
* )

5
setup.cfg Normal file
View File

@ -0,0 +1,5 @@
[bdist_wheel]
universal=1
[flake8]
ignore=E731

View File

@ -9,21 +9,32 @@ setup(
description='Cache-based rate-limiting for Django.',
long_description=open('README.rst').read(),
author='James Socol',
author_email='james@mozilla.com',
url='http://github.com/jsocol/django-ratelimit',
author_email='me@jamessocol.com',
url='https://github.com/jsocol/django-ratelimit',
license='Apache Software License',
packages=find_packages(exclude=['test_settings']),
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
include_package_data=True,
package_data = {'': ['README.rst']},
package_data={'': ['README.rst']},
classifiers=[
'Development Status :: 4 - Beta',
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
'Environment :: Web Environment :: Mozilla',
'Framework :: Django',
'Framework :: Django :: 1.11',
'Framework :: Django :: 2.0',
'Framework :: Django :: 2.1',
'Intended Audience :: Developers',
'License :: OSI Approved :: Apache Software License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Software Development :: Libraries :: Python Modules',
]
)

View File

@ -11,6 +11,22 @@ CACHES = {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'ratelimit-tests',
},
'connection-errors': {
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
'LOCATION': 'test-connection-errors',
},
'connection-errors-redis': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'test-connection-errors',
'OPTIONS': {
'IGNORE_EXCEPTIONS': True,
}
},
'instant-expiration': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'test-instant-expiration',
'TIMEOUT': 0,
},
}
DATABASES = {
@ -19,3 +35,6 @@ DATABASES = {
'NAME': 'test.db',
},
}
# silence system check about unset `MIDDLEWARE_CLASSES`
SILENCED_SYSTEM_CHECKS = ['1_7.W001']

59
tox.ini
View File

@ -1,40 +1,23 @@
[tox]
envlist =
py27-django111,
py34-django{111,20},
py35-django{111,20,21,master},
py36-django{111,20,21,master},
py37-django{20,21,master},
pypy-django111
[testenv]
commands = ./run.sh test
deps =
py{27,py}: python-memcached>=1.57
py{34,35,36,37}: python3-memcached>=1.51
django111: Django>=1.11,<1.12
django20: Django>=2.0,<2.1
django21: Django>=2.1,<2.2
djangomaster: https://github.com/django/django/archive/master.tar.gz
django-redis==4.9.0
flake8
# python 3.3
[testenv:py33-1.6]
basepython = python3.3
deps = Django>=1.6,<1.6.99
[testenv:py33-1.5]
basepython = python3.3
deps = Django>=1.5,<1.5.99
# python 2.7
[testenv:py27-1.6]
basepython = python2.7
deps = Django>=1.6,<1.6.99
[testenv:py27-1.5]
basepython = python2.7
deps = Django>=1.5,<1.5.99
[testenv:py27-1.4]
basepython = python2.7
deps = Django>=1.4,<1.4.99
# python 2.6
[testenv:py26-1.6]
basepython = python2.6
deps = Django>=1.6,<1.6.99
[testenv:py26-1.5]
basepython = python2.6
deps = Django>=1.5,<1.5.99
[testenv:py26-1.4]
basepython = python2.6
deps = Django>=1.4,<1.4.99
commands =
./run.sh test
./run.sh flake8