From 9880ff76256f7db2c0453f213836ba32bbcc40fd Mon Sep 17 00:00:00 2001 From: Laurent Savaete Date: Wed, 19 Feb 2020 13:36:13 +0000 Subject: [PATCH] First version --- LICENSE | 8 +- README.md | 99 +++++ poetry.lock | 686 ++++++++++++++++++++++++++++++ pyproject.toml | 32 ++ slowapi/__init__.py | 1 + slowapi/errors.py | 27 ++ slowapi/extension.py | 650 ++++++++++++++++++++++++++++ slowapi/util.py | 26 ++ slowapi/wrappers.py | 89 ++++ tests/__init__.py | 36 ++ tests/test_base.py | 2 + tests/test_fastapi_extension.py | 50 +++ tests/test_starlette_extension.py | 170 ++++++++ 13 files changed, 1872 insertions(+), 4 deletions(-) create mode 100644 README.md create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 slowapi/__init__.py create mode 100644 slowapi/errors.py create mode 100644 slowapi/extension.py create mode 100644 slowapi/util.py create mode 100644 slowapi/wrappers.py create mode 100644 tests/__init__.py create mode 100644 tests/test_base.py create mode 100644 tests/test_fastapi_extension.py create mode 100644 tests/test_starlette_extension.py diff --git a/LICENSE b/LICENSE index dea1739..0f78683 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..01d6856 --- /dev/null +++ b/README.md @@ -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. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..6240c77 --- /dev/null +++ b/poetry.lock @@ -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"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..90f4c2a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.poetry] +name = "slowapi" +version = "0.1.0" +description = "A rate limiting extension for Starlette and Fastapi" +authors = ["Laurent Savaete "] +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" diff --git a/slowapi/__init__.py b/slowapi/__init__.py new file mode 100644 index 0000000..4438295 --- /dev/null +++ b/slowapi/__init__.py @@ -0,0 +1 @@ +from .extension import Limiter, _rate_limit_exceeded_handler diff --git a/slowapi/errors.py b/slowapi/errors.py new file mode 100644 index 0000000..f30a49c --- /dev/null +++ b/slowapi/errors.py @@ -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) diff --git a/slowapi/extension.py b/slowapi/extension.py new file mode 100644 index 0000000..cf35733 --- /dev/null +++ b/slowapi/extension.py @@ -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, + ) diff --git a/slowapi/util.py b/slowapi/util.py new file mode 100644 index 0000000..7a454ab --- /dev/null +++ b/slowapi/util.py @@ -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" diff --git a/slowapi/wrappers.py b/slowapi/wrappers.py new file mode 100644 index 0000000..5dda55d --- /dev/null +++ b/slowapi/wrappers.py @@ -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, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..54d71ff --- /dev/null +++ b/tests/__init__.py @@ -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 diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..6740832 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,2 @@ +def test_import(): + import slowapi diff --git a/tests/test_fastapi_extension.py b/tests/test_fastapi_extension.py new file mode 100644 index 0000000..d943f61 --- /dev/null +++ b/tests/test_fastapi_extension.py @@ -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 + ) diff --git a/tests/test_starlette_extension.py b/tests/test_starlette_extension.py new file mode 100644 index 0000000..14db222 --- /dev/null +++ b/tests/test_starlette_extension.py @@ -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