First version

This commit is contained in:
Laurent Savaete
2020-02-19 13:36:13 +00:00
parent a12edf5365
commit 9880ff7625
13 changed files with 1872 additions and 4 deletions

View File

@@ -4,10 +4,10 @@ Copyright (c) 2020 Laurent Savaete
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
in the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# SlowApi
A rate limiting library for Starlette and FastAPI adapted from [flask-limiter](http://github.com/alisaifee/flask-limiter).
Note: this is alpha quality code still, the API may change, and things may fall apart while you try it.
# Quick start
## Starlette
```python
from starlette.applications import Starlette
from slowapi import Limiter, _rate_limit_exceeded_handler
limiter = Limiter(key_func=get_remote_address)
app = Starlette()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@limiter.limit("5/minute")
async def homepage(request: Request):
return PlainTextResponse("test")
app.add_route("/home", homepage)
```
The above app will have a route `t1` that will accept up to 5 requests per minute. Requests beyond this limit will be answered with an HTTP 429 error, and the body of the view will not run.
## FastAPI
```python
from fastapi import FastAPI
from slowapi import Limiter, _rate_limit_exceeded_handler
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.get("/home")
@limiter.limit("5/minute")
async def homepage(request: Request):
return PlainTextResponse("test")
```
This will provide the same result, but with a FastAPI app.
# Features
Most feature are coming from (will come from) FlaskLimiter and the underlying [limits](https://limits.readthedocs.io/).
Supported now:
- Single and multiple `limit` decorator on endpoint functions to apply limits
- redis, memcached and memory backends to track your limits (memory as a fallback)
- support for sync and async HTTP endpoints
- Support for shared limits across a set of routes
# Limitations and known issues
* There is no support for default limits yet (in other words, the only default limit supported is "unlimited")
* The `request` argument must be explicitly passed to your endpoint, or `slowapi` won't be able to hook into it. In other words, write:
```python
@limiter.limit("5/minute")
async def myendpoint(request: Request)
pass
```
and not:
```python
@limiter.limit("5/minute")
async def myendpoint()
pass
```
* `websocket` endpoints are not supported yet.
# Developing and contributing
PRs are more than welcome! Please include tests for your changes :)
The package uses [poetry](https://python-poetry.org) to manage dependencies. To setup your dev env:
```bash
$ poetry install
```
To run the tests:
```bash
$ pytest
```
# Credits
Credits go to [flask-limiter](https://github.com/alisaifee/flask-limiter) of which SlowApi is a (still partial) adaptation to Starlette and FastAPI.
It's also important to mention that the actual rate limiting work is done be [limits](https://github.com/alisaifee/limits/), `slowapi` is just a wrapper around it.

686
poetry.lock generated Normal file
View File

@@ -0,0 +1,686 @@
[[package]]
category = "dev"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
name = "appdirs"
optional = false
python-versions = "*"
version = "1.4.3"
[[package]]
category = "dev"
description = "Atomic file writes."
marker = "sys_platform == \"win32\""
name = "atomicwrites"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.3.0"
[[package]]
category = "dev"
description = "Classes Without Boilerplate"
name = "attrs"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "19.3.0"
[package.extras]
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
docs = ["sphinx", "zope.interface"]
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
[[package]]
category = "dev"
description = "The uncompromising code formatter."
name = "black"
optional = false
python-versions = ">=3.6"
version = "19.10b0"
[package.dependencies]
appdirs = "*"
attrs = ">=18.1.0"
click = ">=6.5"
pathspec = ">=0.6,<1"
regex = "*"
toml = ">=0.9.4"
typed-ast = ">=1.4.0"
[package.extras]
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
[[package]]
category = "dev"
description = "Python package for providing Mozilla's CA Bundle."
name = "certifi"
optional = false
python-versions = "*"
version = "2019.11.28"
[[package]]
category = "dev"
description = "Universal encoding detector for Python 2 and 3"
name = "chardet"
optional = false
python-versions = "*"
version = "3.0.4"
[[package]]
category = "dev"
description = "Composable command line interface toolkit"
name = "click"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "7.0"
[[package]]
category = "dev"
description = "Cross-platform colored terminal text."
marker = "sys_platform == \"win32\""
name = "colorama"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.4.3"
[[package]]
category = "dev"
description = "A backport of the dataclasses module for Python 3.6"
marker = "python_version < \"3.7\""
name = "dataclasses"
optional = false
python-versions = "*"
version = "0.6"
[[package]]
category = "dev"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
name = "fastapi"
optional = false
python-versions = ">=3.6"
version = "0.49.0"
[package.dependencies]
pydantic = ">=0.32.2,<2.0.0"
starlette = "0.12.9"
[package.extras]
all = ["requests", "aiofiles", "jinja2", "python-multipart", "itsdangerous", "pyyaml", "graphene", "ujson", "email-validator", "uvicorn", "async-exit-stack", "async-generator"]
dev = ["pyjwt", "passlib", "autoflake", "flake8", "uvicorn", "graphene"]
doc = ["mkdocs", "mkdocs-material", "markdown-include"]
test = ["pytest (>=4.0.0)", "pytest-cov", "mypy", "black", "isort", "requests", "email-validator", "sqlalchemy", "peewee", "databases", "orjson", "async-exit-stack", "async-generator"]
[[package]]
category = "dev"
description = "time manipulation utilities for python"
name = "hiro"
optional = false
python-versions = "*"
version = "0.5.1"
[package.dependencies]
mock = "*"
six = ">=1.4.1"
[[package]]
category = "dev"
description = "Internationalized Domain Names in Applications (IDNA)"
name = "idna"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.9"
[[package]]
category = "dev"
description = "Read metadata from Python packages"
marker = "python_version < \"3.8\""
name = "importlib-metadata"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
version = "1.5.0"
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "rst.linker"]
testing = ["packaging", "importlib-resources"]
[[package]]
category = "dev"
description = "A Python utility / library to sort Python imports."
name = "isort"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "4.3.21"
[package.extras]
pipfile = ["pipreqs", "requirementslib"]
pyproject = ["toml"]
requirements = ["pipreqs", "pip-api"]
xdg_home = ["appdirs (>=1.4.0)"]
[[package]]
category = "main"
description = "Rate limiting utilities"
name = "limits"
optional = false
python-versions = "*"
version = "1.5"
[package.dependencies]
six = ">=1.4.1"
[[package]]
category = "dev"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
name = "lxml"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
version = "4.5.0"
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html5 = ["html5lib"]
htmlsoup = ["beautifulsoup4"]
source = ["Cython (>=0.29.7)"]
[[package]]
category = "dev"
description = "Rolling backport of unittest.mock for all Pythons"
name = "mock"
optional = false
python-versions = ">=3.6"
version = "4.0.1"
[package.extras]
build = ["twine", "wheel", "blurb"]
docs = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]]
category = "dev"
description = "More routines for operating on iterables, beyond itertools"
name = "more-itertools"
optional = false
python-versions = ">=3.5"
version = "8.2.0"
[[package]]
category = "dev"
description = "Optional static typing for Python"
name = "mypy"
optional = false
python-versions = ">=3.5"
version = "0.761"
[package.dependencies]
mypy-extensions = ">=0.4.3,<0.5.0"
typed-ast = ">=1.4.0,<1.5.0"
typing-extensions = ">=3.7.4"
[package.extras]
dmypy = ["psutil (>=4.0)"]
[[package]]
category = "dev"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
name = "mypy-extensions"
optional = false
python-versions = "*"
version = "0.4.3"
[[package]]
category = "dev"
description = "Core utilities for Python packages"
name = "packaging"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "20.1"
[package.dependencies]
pyparsing = ">=2.0.2"
six = "*"
[[package]]
category = "dev"
description = "Utility library for gitignore style pattern matching of file paths."
name = "pathspec"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.7.0"
[[package]]
category = "dev"
description = "plugin and hook calling mechanisms for python"
name = "pluggy"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "0.13.1"
[package.dependencies]
[package.dependencies.importlib-metadata]
python = "<3.8"
version = ">=0.12"
[package.extras]
dev = ["pre-commit", "tox"]
[[package]]
category = "dev"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
name = "py"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.8.1"
[[package]]
category = "dev"
description = "Data validation and settings management using python 3.6 type hinting"
name = "pydantic"
optional = false
python-versions = ">=3.6"
version = "1.4"
[package.dependencies]
[package.dependencies.dataclasses]
python = "<3.7"
version = ">=0.6"
[package.extras]
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
typing_extensions = ["typing-extensions (>=3.7.2)"]
[[package]]
category = "dev"
description = "Python parsing module"
name = "pyparsing"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
version = "2.4.6"
[[package]]
category = "dev"
description = "pytest: simple powerful testing with Python"
name = "pytest"
optional = false
python-versions = ">=3.5"
version = "5.3.5"
[package.dependencies]
atomicwrites = ">=1.0"
attrs = ">=17.4.0"
colorama = "*"
more-itertools = ">=4.0.0"
packaging = "*"
pluggy = ">=0.12,<1.0"
py = ">=1.5.0"
wcwidth = "*"
[package.dependencies.importlib-metadata]
python = "<3.8"
version = ">=0.12"
[package.extras]
checkqa-mypy = ["mypy (v0.761)"]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
[[package]]
category = "main"
description = "Python client for Redis key-value store"
name = "redis"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "3.4.1"
[package.extras]
hiredis = ["hiredis (>=0.1.3)"]
[[package]]
category = "dev"
description = "Alternative regular expression module, to replace re."
name = "regex"
optional = false
python-versions = "*"
version = "2020.2.20"
[[package]]
category = "dev"
description = "Python HTTP for Humans."
name = "requests"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "2.23.0"
[package.dependencies]
certifi = ">=2017.4.17"
chardet = ">=3.0.2,<4"
idna = ">=2.5,<3"
urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
[package.extras]
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
[[package]]
category = "main"
description = "Python 2 and 3 compatibility utilities"
name = "six"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.14.0"
[[package]]
category = "dev"
description = "The little ASGI library that shines."
name = "starlette"
optional = false
python-versions = ">=3.6"
version = "0.12.9"
[package.extras]
full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"]
[[package]]
category = "dev"
description = "Python Library for Tom's Obvious, Minimal Language"
name = "toml"
optional = false
python-versions = "*"
version = "0.10.0"
[[package]]
category = "dev"
description = "a fork of Python 2 and 3 ast modules with type comment support"
name = "typed-ast"
optional = false
python-versions = "*"
version = "1.4.1"
[[package]]
category = "dev"
description = "Backported and Experimental Type Hints for Python 3.5+"
name = "typing-extensions"
optional = false
python-versions = "*"
version = "3.7.4.1"
[[package]]
category = "dev"
description = "HTTP library with thread-safe connection pooling, file post, and more."
name = "urllib3"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
version = "1.25.8"
[package.extras]
brotli = ["brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
[[package]]
category = "dev"
description = "Measures number of Terminal column cells of wide-character codes"
name = "wcwidth"
optional = false
python-versions = "*"
version = "0.1.8"
[[package]]
category = "dev"
description = "Backport of pathlib-compatible object wrapper for zip files"
marker = "python_version < \"3.8\""
name = "zipp"
optional = false
python-versions = ">=3.6"
version = "3.0.0"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
testing = ["jaraco.itertools", "func-timeout"]
[metadata]
content-hash = "cc0599a8890bd327b78c53f145ff1e0c9ff29cd945a4d2c21423b7e0d09054fd"
python-versions = "^3.6"
[metadata.files]
appdirs = [
{file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"},
{file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"},
]
atomicwrites = [
{file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"},
{file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"},
]
attrs = [
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
]
black = [
{file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
]
certifi = [
{file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"},
{file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"},
]
chardet = [
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
]
click = [
{file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"},
{file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"},
]
colorama = [
{file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
{file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
]
dataclasses = [
{file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"},
{file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"},
]
fastapi = [
{file = "fastapi-0.49.0-py3-none-any.whl", hash = "sha256:717dbd2871c270970c70406ef4e550c3504525a7941df817f3a1318de0857c13"},
{file = "fastapi-0.49.0.tar.gz", hash = "sha256:c9296e05a011a53c5b4f0a12f06c261b95b7199685b3af986486e41a27545081"},
]
hiro = [
{file = "hiro-0.5.1-py3.7.egg", hash = "sha256:8fb52fac61a360a4bd955a2e0c975483da28aba99196febb99d5dda38b5f48e0"},
{file = "hiro-0.5.1.tar.gz", hash = "sha256:d10e3b7f27b36673b4fa1283cd38d610326ba1ff1291260d0275152f15ae4bc7"},
]
idna = [
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
]
importlib-metadata = [
{file = "importlib_metadata-1.5.0-py2.py3-none-any.whl", hash = "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"},
{file = "importlib_metadata-1.5.0.tar.gz", hash = "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302"},
]
isort = [
{file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
{file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
]
limits = [
{file = "limits-1.5-py2-none-any.whl", hash = "sha256:d05ba12c94ea8ab7a54cfa9c55ec46882790fae7e389743c0abea8e58fa1f886"},
{file = "limits-1.5.tar.gz", hash = "sha256:19bc9e6f6c64e8edfc7d18734a89486cdadbad816a81673c35669a1d1cac6d5d"},
]
lxml = [
{file = "lxml-4.5.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c"},
{file = "lxml-4.5.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd"},
{file = "lxml-4.5.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261"},
{file = "lxml-4.5.0-cp27-cp27m-win32.whl", hash = "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89"},
{file = "lxml-4.5.0-cp27-cp27m-win_amd64.whl", hash = "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a"},
{file = "lxml-4.5.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128"},
{file = "lxml-4.5.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca"},
{file = "lxml-4.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb"},
{file = "lxml-4.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8"},
{file = "lxml-4.5.0-cp35-cp35m-win32.whl", hash = "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77"},
{file = "lxml-4.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081"},
{file = "lxml-4.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9"},
{file = "lxml-4.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717"},
{file = "lxml-4.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15"},
{file = "lxml-4.5.0-cp36-cp36m-win32.whl", hash = "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7"},
{file = "lxml-4.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012"},
{file = "lxml-4.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6"},
{file = "lxml-4.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679"},
{file = "lxml-4.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc"},
{file = "lxml-4.5.0-cp37-cp37m-win32.whl", hash = "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a"},
{file = "lxml-4.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8"},
{file = "lxml-4.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72"},
{file = "lxml-4.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1"},
{file = "lxml-4.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a"},
{file = "lxml-4.5.0-cp38-cp38-win32.whl", hash = "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f"},
{file = "lxml-4.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3"},
{file = "lxml-4.5.0.tar.gz", hash = "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60"},
]
mock = [
{file = "mock-4.0.1-py3-none-any.whl", hash = "sha256:5e48d216809f6f393987ed56920305d8f3c647e6ed35407c1ff2ecb88a9e1151"},
{file = "mock-4.0.1.tar.gz", hash = "sha256:2a572b715f09dd2f0a583d8aeb5bb67d7ed7a8fd31d193cf1227a99c16a67bc3"},
]
more-itertools = [
{file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"},
{file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"},
]
mypy = [
{file = "mypy-0.761-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6"},
{file = "mypy-0.761-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36"},
{file = "mypy-0.761-cp35-cp35m-win_amd64.whl", hash = "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72"},
{file = "mypy-0.761-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2"},
{file = "mypy-0.761-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0"},
{file = "mypy-0.761-cp36-cp36m-win_amd64.whl", hash = "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474"},
{file = "mypy-0.761-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a"},
{file = "mypy-0.761-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749"},
{file = "mypy-0.761-cp37-cp37m-win_amd64.whl", hash = "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1"},
{file = "mypy-0.761-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7"},
{file = "mypy-0.761-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"},
{file = "mypy-0.761-cp38-cp38-win_amd64.whl", hash = "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b"},
{file = "mypy-0.761-py3-none-any.whl", hash = "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217"},
{file = "mypy-0.761.tar.gz", hash = "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
packaging = [
{file = "packaging-20.1-py2.py3-none-any.whl", hash = "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73"},
{file = "packaging-20.1.tar.gz", hash = "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"},
]
pathspec = [
{file = "pathspec-0.7.0-py2.py3-none-any.whl", hash = "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424"},
{file = "pathspec-0.7.0.tar.gz", hash = "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"},
]
pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
]
py = [
{file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"},
{file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"},
]
pydantic = [
{file = "pydantic-1.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04"},
{file = "pydantic-1.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752"},
{file = "pydantic-1.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f"},
{file = "pydantic-1.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac"},
{file = "pydantic-1.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11"},
{file = "pydantic-1.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf"},
{file = "pydantic-1.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f"},
{file = "pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df"},
{file = "pydantic-1.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab"},
{file = "pydantic-1.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3"},
{file = "pydantic-1.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21"},
{file = "pydantic-1.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed"},
{file = "pydantic-1.4-py36.py37.py38-none-any.whl", hash = "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d"},
{file = "pydantic-1.4.tar.gz", hash = "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f"},
]
pyparsing = [
{file = "pyparsing-2.4.6-py2.py3-none-any.whl", hash = "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"},
{file = "pyparsing-2.4.6.tar.gz", hash = "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f"},
]
pytest = [
{file = "pytest-5.3.5-py3-none-any.whl", hash = "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"},
{file = "pytest-5.3.5.tar.gz", hash = "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d"},
]
redis = [
{file = "redis-3.4.1-py2.py3-none-any.whl", hash = "sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833"},
{file = "redis-3.4.1.tar.gz", hash = "sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f"},
]
regex = [
{file = "regex-2020.2.20-cp27-cp27m-win32.whl", hash = "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb"},
{file = "regex-2020.2.20-cp27-cp27m-win_amd64.whl", hash = "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74"},
{file = "regex-2020.2.20-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400"},
{file = "regex-2020.2.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0"},
{file = "regex-2020.2.20-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc"},
{file = "regex-2020.2.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"},
{file = "regex-2020.2.20-cp36-cp36m-win32.whl", hash = "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69"},
{file = "regex-2020.2.20-cp36-cp36m-win_amd64.whl", hash = "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b"},
{file = "regex-2020.2.20-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e"},
{file = "regex-2020.2.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242"},
{file = "regex-2020.2.20-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce"},
{file = "regex-2020.2.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab"},
{file = "regex-2020.2.20-cp37-cp37m-win32.whl", hash = "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431"},
{file = "regex-2020.2.20-cp37-cp37m-win_amd64.whl", hash = "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1"},
{file = "regex-2020.2.20-cp38-cp38-manylinux1_i686.whl", hash = "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045"},
{file = "regex-2020.2.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26"},
{file = "regex-2020.2.20-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2"},
{file = "regex-2020.2.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70"},
{file = "regex-2020.2.20-cp38-cp38-win32.whl", hash = "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d"},
{file = "regex-2020.2.20-cp38-cp38-win_amd64.whl", hash = "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa"},
{file = "regex-2020.2.20.tar.gz", hash = "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5"},
]
requests = [
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
]
six = [
{file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"},
{file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"},
]
starlette = [
{file = "starlette-0.12.9.tar.gz", hash = "sha256:c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411"},
]
toml = [
{file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"},
{file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"},
{file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"},
]
typed-ast = [
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
]
typing-extensions = [
{file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"},
{file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"},
{file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"},
]
urllib3 = [
{file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"},
{file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"},
]
wcwidth = [
{file = "wcwidth-0.1.8-py2.py3-none-any.whl", hash = "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603"},
{file = "wcwidth-0.1.8.tar.gz", hash = "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"},
]
zipp = [
{file = "zipp-3.0.0-py3-none-any.whl", hash = "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2"},
{file = "zipp-3.0.0.tar.gz", hash = "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a"},
]

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[tool.poetry]
name = "slowapi"
version = "0.1.0"
description = "A rate limiting extension for Starlette and Fastapi"
authors = ["Laurent Savaete <laurent@where.tf>"]
license = "MIT"
readme = "README.md"
repository = "https://github.com/laurents/slowapi"
homepage = "https://github.com/laurents/slowapi"
[tool.poetry.dependencies]
python = "^3.6"
limits = "^1.5"
redis = "^3.4.1"
[tool.poetry.dev-dependencies]
isort = "^4.3.21"
mypy = "^0.761"
black = "^19.10b0"
fastapi = "^0.49.0"
lxml = "^4.5.0"
starlette = "^0.12.9"
mock = "^4.0.1"
hiro = "^0.5.1"
requests = "^2.22.0"
pytest = "^5.3.5"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

1
slowapi/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .extension import Limiter, _rate_limit_exceeded_handler

27
slowapi/errors.py Normal file
View File

@@ -0,0 +1,27 @@
"""
errors and exceptions
"""
from starlette.exceptions import HTTPException
from .wrappers import Limit
class RateLimitExceeded(HTTPException):
"""
exception raised when a rate limit is hit.
"""
limit = None
def __init__(self, limit: Limit) -> None:
self.limit = limit
if limit.error_message:
description: str = (
limit.error_message
if not callable(limit.error_message)
else limit.error_message()
)
else:
description = str(limit.limit)
super(RateLimitExceeded, self).__init__(status_code=429, detail=description)

650
slowapi/extension.py Normal file
View File

@@ -0,0 +1,650 @@
"""
The starlette extension to rate-limit requests
"""
import asyncio
import datetime
import functools
import inspect
import itertools
import json
import logging
import sys
import time
import warnings
from email.utils import formatdate, parsedate_to_datetime
from functools import wraps
from typing import (Any, Callable, Dict, List, Optional, Set, Tuple, Type,
TypeVar, Union)
from limits import RateLimitItem # type: ignore
from limits.errors import ConfigurationError # type: ignore
from limits.storage import Storage # type: ignore
from limits.storage import MemoryStorage, storage_from_string
from limits.strategies import STRATEGIES, RateLimiter # type: ignore
from starlette.applications import Starlette
from starlette.config import Config
from starlette.exceptions import HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from .errors import RateLimitExceeded
from .util import get_ipaddr
from .wrappers import Limit, LimitGroup
# used to annotate get_app_config method
T = TypeVar("T")
class C:
ENABLED = "RATELIMIT_ENABLED"
HEADERS_ENABLED = "RATELIMIT_HEADERS_ENABLED"
STORAGE_URL = "RATELIMIT_STORAGE_URL"
STORAGE_OPTIONS = "RATELIMIT_STORAGE_OPTIONS"
STRATEGY = "RATELIMIT_STRATEGY"
GLOBAL_LIMITS = "RATELIMIT_GLOBAL"
DEFAULT_LIMITS = "RATELIMIT_DEFAULT"
APPLICATION_LIMITS = "RATELIMIT_APPLICATION"
HEADER_LIMIT = "RATELIMIT_HEADER_LIMIT"
HEADER_REMAINING = "RATELIMIT_HEADER_REMAINING"
HEADER_RESET = "RATELIMIT_HEADER_RESET"
SWALLOW_ERRORS = "RATELIMIT_SWALLOW_ERRORS"
IN_MEMORY_FALLBACK = "RATELIMIT_IN_MEMORY_FALLBACK"
IN_MEMORY_FALLBACK_ENABLED = "RATELIMIT_IN_MEMORY_FALLBACK_ENABLED"
HEADER_RETRY_AFTER = "RATELIMIT_HEADER_RETRY_AFTER"
HEADER_RETRY_AFTER_VALUE = "RATELIMIT_HEADER_RETRY_AFTER_VALUE"
KEY_PREFIX = "RATELIMIT_KEY_PREFIX"
class HEADERS:
RESET = 1
REMAINING = 2
LIMIT = 3
RETRY_AFTER = 4
MAX_BACKEND_CHECKS = 5
def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> Response:
"""
Build a simple JSON response that includes the details of the rate limit
that was hit. If no limit is hit, the countdown is added to headers.
"""
response = JSONResponse(
{"error": f"Rate limit exceeded: {exc.limit}"}, status_code=429
)
response = request.app.state.limiter._inject_headers(
response, request.state.view_rate_limit
)
return response
class Limiter:
"""
:param app: :class:`Starlette/FastAPI` instance to initialize the extension
with.
:param list default_limits: a variable list of strings or callables returning strings denoting global
limits to apply to all routes. :ref:`ratelimit-string` for more details.
:param list application_limits: a variable list of strings or callables returning strings for limits that
are applied to the entire application (i.e a shared limit for all routes)
:param function key_func: a callable that returns the domain to rate limit by.
:param bool headers_enabled: whether ``X-RateLimit`` response headers are written.
:param str strategy: the strategy to use. refer to :ref:`ratelimit-strategy`
:param str storage_uri: the storage location. refer to :ref:`ratelimit-conf`
:param dict storage_options: kwargs to pass to the storage implementation upon
instantiation.
:param bool auto_check: whether to automatically check the rate limit in the before_request
chain of the application. default ``True``
:param bool swallow_errors: whether to swallow errors when hitting a rate limit.
An exception will still be logged. default ``False``
:param list in_memory_fallback: a variable list of strings or callables returning strings denoting fallback
limits to apply when the storage is down.
:param bool in_memory_fallback_enabled: simply falls back to in memory storage
when the main storage is down and inherits the original limits.
:param str key_prefix: prefix prepended to rate limiter keys.
:param Optional[str] config_filename: name of the config file for Starlette from which to load settings
for the rate limiter. Defaults to ".env".
"""
def __init__(
self,
# app: Starlette = None,
key_func=Callable[..., str],
default_limits: List[Union[str, Callable[..., str]]] = [],
application_limits: List[Union[str, Callable[..., str]]] = [],
headers_enabled: bool = False,
strategy: Optional[str] = None,
storage_uri: Optional[str] = None,
storage_options: Dict = {},
auto_check: bool = True,
swallow_errors: bool = False,
in_memory_fallback: List = [],
in_memory_fallback_enabled: bool = False,
retry_after=None,
key_prefix: str = "",
enabled: bool = True,
config_filename: Optional[str] = None,
):
"""
Configure the rate limiter at app level
"""
# assert app is not None, "Passing the app instance to the limiter is required"
# self.app = app
# app.state.limiter = self
self.logger = logging.getLogger("slowapi")
self.app_config = Config(
config_filename if config_filename is not None else ".env"
)
self.enabled = enabled
self._default_limits = []
self._application_limits = []
self._in_memory_fallback = []
self._in_memory_fallback_enabled = (
in_memory_fallback_enabled or len(in_memory_fallback) > 0
)
self._exempt_routes: Set = set()
self._request_filters: List = []
self._headers_enabled = headers_enabled
self._header_mapping: Dict[int, str] = {}
self._retry_after = retry_after
self._strategy = strategy
self._storage_uri = storage_uri
self._storage_options = storage_options
self._auto_check = auto_check
self._swallow_errors = swallow_errors
self._key_func = key_func
self._key_prefix = key_prefix
for limit in set(default_limits):
self._default_limits.extend(
[LimitGroup(limit, self._key_func, None, False, None, None, None)]
)
for limit in application_limits:
self._application_limits.extend(
[LimitGroup(limit, self._key_func, "global", False, None, None, None)]
)
for limit in in_memory_fallback:
self._in_memory_fallback.extend(
[LimitGroup(limit, self._key_func, None, False, None, None, None)]
)
self._route_limits: Dict = {}
self._dynamic_route_limits: Dict = {}
# a flag to note if the storage backend is dead (not available)
self._storage_dead: bool = False
self._fallback_limiter = None
self.__check_backend_count = 0
self.__last_check_backend = time.time()
self.__marked_for_limiting: Dict = {}
class BlackHoleHandler(logging.StreamHandler):
def emit(*_):
return
self.logger.addHandler(BlackHoleHandler())
self.enabled = self.get_app_config(C.ENABLED, self.enabled)
self._swallow_errors = self.get_app_config(
C.SWALLOW_ERRORS, self._swallow_errors
)
self._headers_enabled = self._headers_enabled or self.get_app_config(
C.HEADERS_ENABLED, False
)
self._storage_options.update(self.get_app_config(C.STORAGE_OPTIONS, {}))
self._storage: Storage = storage_from_string(
self._storage_uri or self.get_app_config(C.STORAGE_URL, "memory://"),
**self._storage_options,
)
strategy = self._strategy or self.get_app_config(C.STRATEGY, "fixed-window")
if strategy not in STRATEGIES:
raise ConfigurationError("Invalid rate limiting strategy %s" % strategy)
self._limiter: RateLimiter = STRATEGIES[strategy](self._storage)
self._header_mapping.update(
{
HEADERS.RESET: self._header_mapping.get(
HEADERS.RESET,
self.get_app_config(C.HEADER_RESET, "X-RateLimit-Reset"),
),
HEADERS.REMAINING: self._header_mapping.get(
HEADERS.REMAINING,
self.get_app_config(C.HEADER_REMAINING, "X-RateLimit-Remaining"),
),
HEADERS.LIMIT: self._header_mapping.get(
HEADERS.LIMIT,
self.get_app_config(C.HEADER_LIMIT, "X-RateLimit-Limit"),
),
HEADERS.RETRY_AFTER: self._header_mapping.get(
HEADERS.RETRY_AFTER,
self.get_app_config(C.HEADER_RETRY_AFTER, "Retry-After"),
),
}
)
self._retry_after = self._retry_after or self.get_app_config(
C.HEADER_RETRY_AFTER_VALUE
)
self._key_prefix = self._key_prefix or self.get_app_config(C.KEY_PREFIX)
app_limits = self.get_app_config(C.APPLICATION_LIMITS, None)
if not self._application_limits and app_limits:
self._application_limits = [
LimitGroup(
app_limits, self._key_func, "global", False, None, None, None
)
]
conf_limits = self.get_app_config(C.DEFAULT_LIMITS, None)
if not self._default_limits and conf_limits:
self._default_limits = [
LimitGroup(conf_limits, self._key_func, None, False, None, None, None)
]
fallback_enabled = self.get_app_config(C.IN_MEMORY_FALLBACK_ENABLED, False)
fallback_limits = self.get_app_config(C.IN_MEMORY_FALLBACK, None)
if not self._in_memory_fallback and fallback_limits:
self._in_memory_fallback = [
LimitGroup(
fallback_limits, self._key_func, None, False, None, None, None
)
]
if not self._in_memory_fallback_enabled:
self._in_memory_fallback_enabled = (
fallback_enabled or len(self._in_memory_fallback) > 0
)
if self._in_memory_fallback_enabled:
self._fallback_storage = MemoryStorage()
self._fallback_limiter = STRATEGIES[strategy](self._fallback_storage)
def slowapi_startup(self):
"""
Starlette startup event handler that links the app with the Limiter instance.
"""
print("STARTUP")
app.state.limiter = self
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
def get_app_config(self, key: str, default_value: T = None) -> T:
"""
Place holder until we find a better way to load config from app
"""
return self.app_config(key, default=default_value, cast=type(default_value))
def __should_check_backend(self) -> bool:
if self.__check_backend_count > MAX_BACKEND_CHECKS:
self.__check_backend_count = 0
if time.time() - self.__last_check_backend > pow(2, self.__check_backend_count):
self.__last_check_backend = time.time()
self.__check_backend_count += 1
return True
return False
def reset(self) -> None:
"""
resets the storage if it supports being reset
"""
try:
self._storage.reset()
self.logger.info("Storage has been reset and all limits cleared")
except NotImplementedError:
self.logger.warning("This storage type does not support being reset")
@property
def limiter(self) -> RateLimiter:
"""
The backend that keeps track of consumption of endpoints vs limits
"""
if self._storage_dead and self._in_memory_fallback_enabled:
return self._fallback_limiter
else:
return self._limiter
def _inject_headers(
self, response: Response, current_limit: Tuple[RateLimitItem, List[str]]
) -> Response:
if self.enabled and self._headers_enabled and current_limit is not None:
try:
window_stats: Tuple[int, int] = self.limiter.get_window_stats(
current_limit[0], *current_limit[1]
)
reset_in = 1 + window_stats[0]
response.headers.append(
self._header_mapping[HEADERS.LIMIT], str(current_limit[0].amount)
)
response.headers.append(
self._header_mapping[HEADERS.REMAINING], str(window_stats[1])
)
response.headers.append(
self._header_mapping[HEADERS.RESET], str(reset_in)
)
# response may have an existing retry after
print(response.headers)
existing_retry_after_header = response.headers.get("Retry-After")
if existing_retry_after_header is not None:
# might be in http-date format
retry_after = parsedate_to_datetime(existing_retry_after_header)
# parse_date failure returns None
if retry_after is None:
retry_after = time.time() + int(existing_retry_after_header)
if isinstance(retry_after, datetime.datetime):
retry_after_int: int = int(time.mktime(retry_after.timetuple()))
reset_in = max(retry_after_int, reset_in)
response.headers[self._header_mapping[HEADERS.RETRY_AFTER]] = (
formatdate(reset_in)
if self._retry_after == "http-date"
else str(int(reset_in - time.time()))
)
except:
if self._in_memory_fallback and not self._storage_dead:
self.logger.warn(
"Rate limit storage unreachable - falling back to"
" in-memory storage"
)
self._storage_dead = True
response = self._inject_headers(response, current_limit)
if self._swallow_errors:
self.logger.exception(
"Failed to update rate limit headers. Swallowing error"
)
else:
raise
return response
def __evaluate_limits(
self, request: Request, endpoint: str, limits: List[Limit]
) -> None:
failed_limit = None
limit_for_header = None
for lim in limits:
limit_scope = lim.scope or endpoint
if lim.is_exempt:
continue
if lim.methods is not None and request.method.lower() not in lim.methods:
continue
if lim.per_method:
limit_scope += ":%s" % request.method
if "request" in inspect.signature(lim.key_func).parameters.keys():
limit_key = lim.key_func(request)
else:
limit_key = lim.key_func()
args = [limit_key, limit_scope]
if all(args):
if self._key_prefix:
args = [self._key_prefix] + args
if not limit_for_header or lim.limit < limit_for_header[0]:
limit_for_header = (lim.limit, args)
if not self.limiter.hit(lim.limit, *args):
self.logger.warning(
"ratelimit %s (%s) exceeded at endpoint: %s",
lim.limit,
limit_key,
limit_scope,
)
failed_limit = lim
limit_for_header = (lim.limit, args)
break
else:
self.logger.error(
"Skipping limit: %s. Empty value found in parameters.", lim.limit
)
continue
# keep track of which limit was hit, to be picked up for the response header
request.state.view_rate_limit = limit_for_header
if failed_limit:
raise RateLimitExceeded(failed_limit)
def __check_request_limit(
self, request: Request, endpoint_func: Callable, in_middleware: bool = True
) -> None:
"""
Determine if the request is within limits
"""
endpoint = request["path"] or ""
# view_func = current_app.view_functions.get(endpoint, None)
view_func = endpoint_func
name = "%s.%s" % (view_func.__module__, view_func.__name__) if view_func else ""
# cases where we don't need to check the limits
if (
not endpoint
or not self.enabled
# or we are sending a static file
# or view_func == current_app.send_static_file
or name in self._exempt_routes
or any(fn() for fn in self._request_filters)
):
return
limits: List = []
dynamic_limits: List = []
if not in_middleware:
limits = name in self._route_limits and self._route_limits[name] or []
dynamic_limits = []
if name in self._dynamic_route_limits:
for lim in self._dynamic_route_limits[name]:
try:
dynamic_limits.extend(list(lim))
except ValueError as e:
self.logger.error(
"failed to load ratelimit for view function %s (%s)",
name,
e,
)
try:
all_limits: List = []
if self._storage_dead and self._fallback_limiter:
if in_middleware and name in self.__marked_for_limiting:
pass
else:
if self.__should_check_backend() and self._storage.check():
self.logger.info("Rate limit storage recovered")
self._storage_dead = False
self.__check_backend_count = 0
else:
all_limits = list(itertools.chain(*self._in_memory_fallback))
if not all_limits:
route_limits = limits + dynamic_limits
all_limits = (
list(itertools.chain(*self._application_limits))
if in_middleware
else []
)
all_limits += route_limits
if not route_limits and not (
in_middleware and name in self.__marked_for_limiting
):
all_limits += list(itertools.chain(*self._default_limits))
# actually check the limits, so far we've only computed the list of limits to check
self.__evaluate_limits(request, endpoint, all_limits)
except Exception as e: # no qa
if isinstance(e, RateLimitExceeded):
raise
if self._in_memory_fallback_enabled and not self._storage_dead:
self.logger.warn(
"Rate limit storage unreachable - falling back to"
" in-memory storage"
)
self._storage_dead = True
self.__check_request_limit(request, endpoint_func, in_middleware)
else:
if self._swallow_errors:
self.logger.exception("Failed to rate limit. Swallowing error")
else:
raise
def __limit_decorator(
self,
limit_value: Union[str, Callable[..., str]],
key_func: Optional[Callable[..., str]] = None,
shared: bool = False,
scope: Optional[Union[str, Callable[..., str]]] = None,
per_method: bool = False,
methods: Optional[List] = None,
error_message: Optional[str] = None,
exempt_when: Optional[Callable[..., bool]] = None,
) -> Callable:
_scope = scope if shared else None
def decorator(func: Callable) -> Callable:
keyfunc = key_func or self._key_func
name = f"{func.__module__}.{func.__name__}"
dynamic_limit, static_limits = None, []
if callable(limit_value):
dynamic_limit = LimitGroup(
limit_value,
keyfunc,
_scope,
per_method,
methods,
error_message,
exempt_when,
)
else:
try:
static_limits = list(
LimitGroup(
limit_value,
keyfunc,
_scope,
per_method,
methods,
error_message,
exempt_when,
)
)
except ValueError as e:
self.logger.error(
"Failed to configure throttling for %s (%s)", name, e,
)
self.__marked_for_limiting.setdefault(name, []).append(func)
if dynamic_limit:
self._dynamic_route_limits.setdefault(name, []).append(dynamic_limit)
else:
self._route_limits.setdefault(name, []).extend(static_limits)
connection_type: Optional[str] = None
sig = inspect.signature(func)
for idx, parameter in enumerate(sig.parameters.values()):
if parameter.name == "request" or parameter.name == "websocket":
connection_type = parameter.name
break
else:
raise Exception(
f'No "request" or "websocket" argument on function "{func}"'
)
if asyncio.iscoroutinefunction(func):
# Handle async request/response functions.
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Response:
# get the request object from the decorated endpoint function
request = kwargs.get("request", args[idx] if args else None)
assert isinstance(request, Request)
if self._auto_check and not getattr(
request.state, "_rate_limiting_complete", False
):
self.__check_request_limit(request, func, False)
request.state._rate_limiting_complete = True
response = await func(*args, **kwargs)
self._inject_headers(response, request.state.view_rate_limit)
return response
return async_wrapper
else:
# Handle sync request/response functions.
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Response:
# get the request object from the decorated endpoint function
request = kwargs.get("request", args[idx] if args else None)
assert isinstance(request, Request)
if self._auto_check and not getattr(
request.state, "_rate_limiting_complete", False
):
self.__check_request_limit(request, func, False)
request.state._rate_limiting_complete = True
response = func(*args, **kwargs)
self._inject_headers(response, request.state.view_rate_limit)
return response
return sync_wrapper
return decorator
def limit(
self,
limit_value: Union[str, Callable[[str], str]],
key_func: Optional[Callable[..., str]] = None,
per_method: bool = False,
methods: Optional[List] = None,
error_message: Optional[str] = None,
exempt_when=None,
):
"""
decorator to be used for rate limiting individual routes.
:param limit_value: rate limit string or a callable that returns a string.
:ref:`ratelimit-string` for more details.
:param function key_func: function/lambda to extract the unique identifier for
the rate limit. defaults to remote address of the request.
:param bool per_method: whether the limit is sub categorized into the http
method of the request.
:param list methods: if specified, only the methods in this list will be rate
limited (default: None).
:param error_message: string (or callable that returns one) to override the
error message used in the response.
:param exempt_when:
:return:
"""
return self.__limit_decorator(
limit_value,
key_func,
per_method=per_method,
methods=methods,
error_message=error_message,
exempt_when=exempt_when,
)
def shared_limit(
self,
limit_value: Union[str, Callable[[str], str]],
scope: Union[str, Callable[..., str]],
key_func: Optional[Callable[..., str]] = None,
error_message: Optional[str] = None,
exempt_when=None,
):
"""
decorator to be applied to multiple routes sharing the same rate limit.
:param limit_value: rate limit string or a callable that returns a string.
:ref:`ratelimit-string` for more details.
:param scope: a string or callable that returns a string
for defining the rate limiting scope.
:param function key_func: function/lambda to extract the unique identifier for
the rate limit. defaults to remote address of the request.
:param error_message: string (or callable that returns one) to override the
error message used in the response.
:param exempt_when:
"""
return self.__limit_decorator(
limit_value,
key_func,
True,
scope,
error_message=error_message,
exempt_when=exempt_when,
)

26
slowapi/util.py Normal file
View File

@@ -0,0 +1,26 @@
from datetime import datetime, timedelta
from email.utils import parsedate_tz
from typing import Optional
from starlette.requests import Request
def get_ipaddr(request: Request) -> str:
"""
:return: the ip address for the current request (or 127.0.0.1 if none found)
based on the X-Forwarded-For headers.
Note that a more robust method for determining IP address of the client is
provided by uvicorn's ProxyHeadersMiddleware.
"""
if "X_FORWARDED_FOR" in request.headers:
r = request.headers["X_FORWARDED_FOR"]
return r
else:
return request.client.host or "127.0.0.1"
def get_remote_address(request: Request) -> str:
"""
:return: the ip address for the current request (or 127.0.0.1 if none found)
"""
return request.client.host or "127.0.0.1"

89
slowapi/wrappers.py Normal file
View File

@@ -0,0 +1,89 @@
from typing import Callable, Iterator, List, Optional, Union
from limits import RateLimitItem, parse_many # type: ignore
class Limit(object):
"""
simple wrapper to encapsulate limits and their context
"""
def __init__(
self,
limit: RateLimitItem,
key_func: Callable[..., str],
scope: Optional[Union[str, Callable[..., str]]],
per_method: bool,
methods: Optional[List[str]],
error_message: Optional[Union[str, Callable[..., str]]],
exempt_when: Optional[Callable[..., bool]],
) -> None:
self.limit = limit
self.key_func = key_func
self.__scope = scope
self.per_method = per_method
self.methods = methods
self.error_message = error_message
self.exempt_when = exempt_when
@property
def is_exempt(self) -> bool:
"""
Check if the limit is exempt.
Return True to exempt the route from the limit.
"""
return self.exempt_when() if self.exempt_when is not None else False
@property
def scope(self) -> str:
# flack.request.endpoint is the name of the function for the endpoint
# FIXME: how to get the request here?
if self.__scope is None:
return ""
else:
return (
self.__scope(request.endpoint)
if callable(self.__scope)
else self.__scope
)
class LimitGroup(object):
"""
represents a group of related limits either from a string or a callable that returns one
"""
def __init__(
self,
limit_provider: Union[str, Callable[..., str]],
key_function: Callable[..., str],
scope: Optional[Union[str, Callable[..., str]]],
per_method: bool,
methods: Optional[List[str]],
error_message: Optional[Union[str, Callable[..., str]]],
exempt_when: Optional[Callable[..., bool]],
):
self.__limit_provider = limit_provider
self.__scope = scope
self.key_function = key_function
self.per_method = per_method
self.methods = methods and [m.lower() for m in methods] or methods
self.error_message = error_message
self.exempt_when = exempt_when
def __iter__(self) -> Iterator[Limit]:
limit_items: List[RateLimitItem] = parse_many(
self.__limit_provider()
if callable(self.__limit_provider)
else self.__limit_provider
)
for limit in limit_items:
yield Limit(
limit,
self.key_function,
self.__scope,
self.per_method,
self.methods,
self.error_message,
self.exempt_when,
)

36
tests/__init__.py Normal file
View File

@@ -0,0 +1,36 @@
import logging
import platform
import unittest
from functools import wraps
import redis
from fastapi import FastAPI
from mock import mock
from starlette.applications import Starlette
from slowapi.errors import RateLimitExceeded
from slowapi.extension import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
class TestSlowapi:
def build_starlette_app(self, config={}, **limiter_args):
limiter_args.setdefault("key_func", get_remote_address)
limiter = Limiter(**limiter_args)
app = Starlette(debug=True)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
mock_handler = mock.Mock()
mock_handler.level = logging.INFO
limiter.logger.addHandler(mock_handler)
return app, limiter
def build_fastapi_app(self, config={}, **limiter_args):
limiter_args.setdefault("key_func", get_remote_address)
limiter = Limiter(**limiter_args)
app = FastAPI()
mock_handler = mock.Mock()
mock_handler.level = logging.INFO
limiter.logger.addHandler(mock_handler)
return app, limiter

2
tests/test_base.py Normal file
View File

@@ -0,0 +1,2 @@
def test_import():
import slowapi

View File

@@ -0,0 +1,50 @@
import hiro
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient
from slowapi.extension import Limiter
from slowapi.util import get_ipaddr
from tests import TestSlowapi
class TestDecorators(TestSlowapi):
def test_single_decorator(self):
app, limiter = self.build_fastapi_app(key_func=get_ipaddr)
@app.get("/t1")
@limiter.limit("5/minute")
async def t1(request: Request):
return PlainTextResponse("test")
client = TestClient(app)
for i in range(0, 10):
response = client.get("/t1")
assert response.status_code == 200 if i < 5 else 429
def test_multiple_decorators(self):
app, limiter = self.build_fastapi_app(key_func=get_ipaddr)
@app.get("/t1")
@limiter.limit(
"100 per minute", lambda: "test"
) # effectively becomes a limit for all users
@limiter.limit("50/minute") # per ip as per default key_func
async def t1(request: Request):
return PlainTextResponse("test")
with hiro.Timeline().freeze() as timeline:
cli = TestClient(app)
for i in range(0, 100):
response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"})
assert response.status_code == 200 if i < 50 else 429
for i in range(50):
assert cli.get("/t1").status_code == 200
assert cli.get("/t1").status_code == 429
assert (
cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code
== 429
)

View File

@@ -0,0 +1,170 @@
import time
import hiro # type: ignore
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient
from slowapi.util import get_ipaddr, get_remote_address
from tests import TestSlowapi
class TestDecorators(TestSlowapi):
def test_single_decorator_async(self):
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
@limiter.limit("5/minute")
async def t1(request: Request):
return PlainTextResponse("test")
app.add_route("/t1", t1)
client = TestClient(app)
for i in range(0, 10):
response = client.get("/t1")
assert response.status_code == 200 if i < 5 else 429
if i < 5:
assert response.text == "test"
def test_single_decorator_sync(self):
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
@limiter.limit("5/minute")
def t1(request: Request):
return PlainTextResponse("test")
app.add_route("/t1", t1)
client = TestClient(app)
for i in range(0, 10):
response = client.get("/t1")
assert response.status_code == 200 if i < 5 else 429
if i < 5:
assert response.text == "test"
def test_shared_decorator(self):
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
shared_lim = limiter.shared_limit("5/minute", "somescope")
@shared_lim
def t1(request: Request):
return PlainTextResponse("test")
@shared_lim
def t2(request: Request):
return PlainTextResponse("test")
app.add_route("/t1", t1)
app.add_route("/t2", t2)
client = TestClient(app)
for i in range(0, 10):
response = client.get("/t1")
assert response.status_code == 200 if i < 5 else 429
# the shared limit has already been hit via t1
assert client.get("/t2").status_code == 429
def test_multiple_decorators(self):
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
@limiter.limit("100 per minute", lambda: "test")
@limiter.limit("50/minute") # per ip as per default key_func
async def t1(request: Request):
return PlainTextResponse("test")
app.add_route("/t1", t1)
with hiro.Timeline().freeze() as timeline:
cli = TestClient(app)
for i in range(0, 100):
response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"})
assert response.status_code == 200 if i < 50 else 429
for i in range(50):
assert cli.get("/t1").status_code == 200
assert cli.get("/t1").status_code == 429
assert (
cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code
== 429
)
def test_headers_no_breach(self):
app, limiter = self.build_starlette_app(
headers_enabled=True, key_func=get_remote_address
)
@app.route("/t1")
@limiter.limit("10/minute")
def t1(request: Request):
return PlainTextResponse("test")
@app.route("/t2")
@limiter.limit("2/second; 5 per minute; 10/hour")
def t2(request: Request):
return PlainTextResponse("test")
with hiro.Timeline().freeze():
with TestClient(app) as cli:
resp = cli.get("/t1")
assert resp.headers.get("X-RateLimit-Limit") == "10"
assert resp.headers.get("X-RateLimit-Remaining") == "9"
assert resp.headers.get("X-RateLimit-Reset") == str(
int(time.time() + 61)
)
assert resp.headers.get("Retry-After") == str(60)
resp = cli.get("/t2")
assert resp.headers.get("X-RateLimit-Limit") == "2"
assert resp.headers.get("X-RateLimit-Remaining") == "1"
assert resp.headers.get("X-RateLimit-Reset") == str(
int(time.time() + 2)
)
assert resp.headers.get("Retry-After") == str(1)
def test_headers_breach(self):
app, limiter = self.build_starlette_app(
headers_enabled=True, key_func=get_remote_address
)
@app.route("/t1")
@limiter.limit("2/second; 10 per minute; 20/hour")
def t(request: Request):
return PlainTextResponse("test")
with hiro.Timeline().freeze() as timeline:
with TestClient(app) as cli:
for i in range(11):
resp = cli.get("/t1")
timeline.forward(1)
print(resp.headers)
assert resp.headers.get("X-RateLimit-Limit") == "10"
assert resp.headers.get("X-RateLimit-Remaining") == "0"
assert resp.headers.get("X-RateLimit-Reset") == str(
int(time.time() + 50)
)
assert resp.headers.get("Retry-After") == str(int(50))
def test_retry_after(self):
# FIXME: this test is not actually running!
app, limiter = self.build_starlette_app(
headers_enabled=True, key_func=get_remote_address
)
@app.route("/t1")
@limiter.limit("1/minute")
def t(request: Request):
return PlainTextResponse("test")
with hiro.Timeline().freeze() as timeline:
with TestClient(app) as cli:
resp = cli.get("/t1")
retry_after = int(resp.headers.get("Retry-After"))
assert retry_after > 0
timeline.forward(retry_after)
resp = cli.get("/t1")
assert resp.status_code == 200